diff --git a/src/main/java/com/sb/web/account/domain/AccountEntry.java b/src/main/java/com/sb/web/account/domain/AccountEntry.java index 22ec4cc..48d7271 100644 --- a/src/main/java/com/sb/web/account/domain/AccountEntry.java +++ b/src/main/java/com/sb/web/account/domain/AccountEntry.java @@ -29,6 +29,7 @@ public class AccountEntry implements Serializable { private String walletName; // 조인 표시용 private Long toWalletId; // 이체 입금 계좌 private String toWalletName; + private Integer installmentMonths; // 카드 할부 개월수(2~24, 일시불은 null) private LocalDateTime createdAt; private LocalDateTime updatedAt; } diff --git a/src/main/java/com/sb/web/account/domain/InvestTrade.java b/src/main/java/com/sb/web/account/domain/InvestTrade.java index 2899243..6c6fc12 100644 --- a/src/main/java/com/sb/web/account/domain/InvestTrade.java +++ b/src/main/java/com/sb/web/account/domain/InvestTrade.java @@ -5,6 +5,7 @@ import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; +import java.math.BigDecimal; import java.time.LocalDate; import java.time.LocalDateTime; @@ -22,7 +23,7 @@ public class InvestTrade { private Long holdingId; private String tradeType; // BUY / SELL private LocalDate tradeDate; - private Long quantity; // 수량(주) + private BigDecimal quantity; // 수량(주, 소수점 매매 지원) private Long price; // 단가(원) private Long fee; // 수수료/세금(원) private LocalDateTime createdAt; diff --git a/src/main/java/com/sb/web/account/dto/AccountEntryRequest.java b/src/main/java/com/sb/web/account/dto/AccountEntryRequest.java index 3030abe..7363b7c 100644 --- a/src/main/java/com/sb/web/account/dto/AccountEntryRequest.java +++ b/src/main/java/com/sb/web/account/dto/AccountEntryRequest.java @@ -1,5 +1,7 @@ package com.sb.web.account.dto; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Pattern; @@ -39,6 +41,11 @@ public class AccountEntryRequest { /** 이체 입금 계좌 ID (TRANSFER 시 필수) */ private Long toWalletId; + /** 카드 할부 개월수 (2~24, 일시불은 null). EXPENSE + 카드 결제에만 의미 있음 */ + @Min(value = 2, message = "할부는 2개월 이상이어야 합니다.") + @Max(value = 24, message = "할부는 최대 24개월까지 가능합니다.") + private Integer installmentMonths; + /** 선택한 태그 ID 목록 (태그 관리에 등록된 태그, 선택) */ private List tagIds; } diff --git a/src/main/java/com/sb/web/account/dto/AccountEntryResponse.java b/src/main/java/com/sb/web/account/dto/AccountEntryResponse.java index c542a49..ebe091c 100644 --- a/src/main/java/com/sb/web/account/dto/AccountEntryResponse.java +++ b/src/main/java/com/sb/web/account/dto/AccountEntryResponse.java @@ -24,6 +24,7 @@ public class AccountEntryResponse { private String walletName; private Long toWalletId; private String toWalletName; + private Integer installmentMonths; private List tags; public static AccountEntryResponse from(AccountEntry e, List tags) { @@ -38,6 +39,7 @@ public class AccountEntryResponse { .walletName(e.getWalletName()) .toWalletId(e.getToWalletId()) .toWalletName(e.getToWalletName()) + .installmentMonths(e.getInstallmentMonths()) .tags(tags) .build(); } diff --git a/src/main/java/com/sb/web/account/dto/HoldingResponse.java b/src/main/java/com/sb/web/account/dto/HoldingResponse.java index bbaca70..1ab4ba4 100644 --- a/src/main/java/com/sb/web/account/dto/HoldingResponse.java +++ b/src/main/java/com/sb/web/account/dto/HoldingResponse.java @@ -3,6 +3,8 @@ package com.sb.web.account.dto; import lombok.Builder; import lombok.Data; +import java.math.BigDecimal; + /** * 보유 종목 응답 (매매이력으로 산출한 집계 포함). */ @@ -16,7 +18,7 @@ public class HoldingResponse { private String ticker; private Long currentPrice; // 현재가(수동, null 가능) - private Long quantity; // 보유수량 (매수−매도) + private BigDecimal quantity; // 보유수량 (매수−매도, 소수점 가능) private Long avgPrice; // 평균매입단가 (보유분, 반올림 표시값) private Long costBasis; // 보유분 매입원가 합(수수료 포함) private Long evalValue; // 평가금액 = 수량 × (현재가 ?? 평단) diff --git a/src/main/java/com/sb/web/account/dto/TradeRequest.java b/src/main/java/com/sb/web/account/dto/TradeRequest.java index b6f2040..eebacbd 100644 --- a/src/main/java/com/sb/web/account/dto/TradeRequest.java +++ b/src/main/java/com/sb/web/account/dto/TradeRequest.java @@ -6,6 +6,7 @@ import jakarta.validation.constraints.Positive; import jakarta.validation.constraints.PositiveOrZero; import lombok.Data; +import java.math.BigDecimal; import java.time.LocalDate; /** @@ -20,8 +21,8 @@ public class TradeRequest { @NotNull(message = "거래일을 입력하세요.") private LocalDate tradeDate; - @Positive(message = "수량은 1 이상이어야 합니다.") - private Long quantity; + @Positive(message = "수량은 0보다 커야 합니다.") + private BigDecimal quantity; @PositiveOrZero(message = "단가는 0 이상이어야 합니다.") private Long price; diff --git a/src/main/java/com/sb/web/account/dto/TradeResponse.java b/src/main/java/com/sb/web/account/dto/TradeResponse.java index 9d73a71..815f54b 100644 --- a/src/main/java/com/sb/web/account/dto/TradeResponse.java +++ b/src/main/java/com/sb/web/account/dto/TradeResponse.java @@ -4,6 +4,8 @@ import com.sb.web.account.domain.InvestTrade; import lombok.Builder; import lombok.Data; +import java.math.BigDecimal; +import java.math.RoundingMode; import java.time.LocalDate; /** @@ -17,12 +19,16 @@ public class TradeResponse { private Long holdingId; private String tradeType; private LocalDate tradeDate; - private Long quantity; + private BigDecimal quantity; private Long price; private Long fee; - private Long amount; // 거래금액 = 수량 × 단가 (수수료 제외) + private Long amount; // 거래금액 = 수량 × 단가 (수수료 제외, 원 단위 반올림) public static TradeResponse from(InvestTrade t) { + long amount = t.getQuantity() + .multiply(BigDecimal.valueOf(t.getPrice())) + .setScale(0, RoundingMode.HALF_UP) + .longValue(); return TradeResponse.builder() .id(t.getId()) .holdingId(t.getHoldingId()) @@ -31,7 +37,7 @@ public class TradeResponse { .quantity(t.getQuantity()) .price(t.getPrice()) .fee(t.getFee()) - .amount(t.getQuantity() * t.getPrice()) + .amount(amount) .build(); } } diff --git a/src/main/java/com/sb/web/account/service/AccountService.java b/src/main/java/com/sb/web/account/service/AccountService.java index 21de589..b589443 100644 --- a/src/main/java/com/sb/web/account/service/AccountService.java +++ b/src/main/java/com/sb/web/account/service/AccountService.java @@ -168,6 +168,7 @@ public class AccountService { .memo(req.getMemo()) .walletId(req.getWalletId()) .toWalletId(transfer ? req.getToWalletId() : null) + .installmentMonths(installmentOf(req)) .build(); mapper.insert(entry); applyTags(entry.getId(), req.getTagIds(), memberId); @@ -186,6 +187,7 @@ public class AccountService { entry.setMemo(req.getMemo()); entry.setWalletId(req.getWalletId()); entry.setToWalletId(transfer ? req.getToWalletId() : null); + entry.setInstallmentMonths(installmentOf(req)); mapper.update(entry); mapper.deleteEntryTagsByEntryId(id); @@ -448,6 +450,15 @@ public class AccountService { return AccountEntryResponse.from(entry, mapper.findTagNamesByEntryId(entry.getId())); } + /** 할부 개월수 정규화: 지출(EXPENSE)이고 2개월 이상일 때만 저장, 그 외(일시불·수입·이체)는 null */ + private Integer installmentOf(AccountEntryRequest req) { + Integer m = req.getInstallmentMonths(); + if (!"EXPENSE".equals(req.getType()) || m == null || m < 2) { + return null; + } + return m; + } + /** 선택된 태그 ID 중 해당 사용자 소유로 존재하는 것만 연결 */ private void applyTags(Long entryId, List tagIds, Long memberId) { if (tagIds == null || tagIds.isEmpty()) { diff --git a/src/main/java/com/sb/web/account/service/InvestService.java b/src/main/java/com/sb/web/account/service/InvestService.java index 919889c..062f4f7 100644 --- a/src/main/java/com/sb/web/account/service/InvestService.java +++ b/src/main/java/com/sb/web/account/service/InvestService.java @@ -15,6 +15,8 @@ import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.math.BigDecimal; +import java.math.RoundingMode; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -86,9 +88,9 @@ public class InvestService { String type = "SELL".equals(req.getTradeType()) ? "SELL" : "BUY"; if ("SELL".equals(type)) { Calc c = calc(investMapper.findTradesByHolding(holdingId, memberId)); - if (req.getQuantity() > c.quantity()) { + if (req.getQuantity().compareTo(c.quantity()) > 0) { throw new ApiException(HttpStatus.BAD_REQUEST, - "매도 수량이 보유수량(" + c.quantity() + ")을 초과합니다."); + "매도 수량이 보유수량(" + c.quantity().stripTrailingZeros().toPlainString() + ")을 초과합니다."); } } InvestTrade t = InvestTrade.builder() @@ -124,7 +126,7 @@ public class InvestService { long evalPrice = h.getCurrentPrice() != null ? h.getCurrentPrice() : c.avgPrice(); WalletInvest wi = result.computeIfAbsent(h.getWalletId(), k -> new WalletInvest()); wi.cashDelta += cashDelta(trades); - wi.stockEval += c.quantity() * evalPrice; + wi.stockEval += amountOf(c.quantity(), evalPrice); } return result; } @@ -145,25 +147,28 @@ public class InvestService { return map; } - /** 매매이력(시간순)으로 보유수량·원가·실현손익 산출 */ + /** 매매이력(시간순)으로 보유수량·원가·실현손익 산출. 수량은 소수점, 금액은 원 단위 정수. */ private Calc calc(List trades) { - long qty = 0, costBasis = 0, realized = 0; + BigDecimal qty = BigDecimal.ZERO; + long costBasis = 0, realized = 0; for (InvestTrade t : trades) { - long amount = t.getQuantity() * t.getPrice(); + long amount = amountOf(t.getQuantity(), t.getPrice()); long fee = t.getFee() != null ? t.getFee() : 0L; if ("BUY".equals(t.getTradeType())) { - qty += t.getQuantity(); + qty = qty.add(t.getQuantity()); costBasis += amount + fee; } else { // SELL - long costRemoved = (qty > 0 && t.getQuantity() < qty) - ? Math.round(costBasis * (double) t.getQuantity() / qty) - : costBasis; // 전량 매도 시 잔여 원가 전부 제거(반올림 잔차 방지) + // 부분 매도 시 보유원가를 수량 비율로 제거, 전량 매도 시 잔여 원가 전부 제거(반올림 잔차 방지) + boolean partial = qty.signum() > 0 && t.getQuantity().compareTo(qty) < 0; + long costRemoved = partial + ? Math.round(costBasis * t.getQuantity().doubleValue() / qty.doubleValue()) + : costBasis; long proceeds = amount - fee; realized += proceeds - costRemoved; costBasis -= costRemoved; - qty -= t.getQuantity(); - if (qty <= 0) { - qty = 0; + qty = qty.subtract(t.getQuantity()); + if (qty.signum() <= 0) { + qty = BigDecimal.ZERO; costBasis = 0; } } @@ -171,17 +176,24 @@ public class InvestService { return new Calc(qty, costBasis, realized); } - private record Calc(long quantity, long costBasis, long realized) { + private record Calc(BigDecimal quantity, long costBasis, long realized) { long avgPrice() { - return quantity > 0 ? Math.round(costBasis / (double) quantity) : 0; + return quantity.signum() > 0 ? Math.round(costBasis / quantity.doubleValue()) : 0; } } + /** 거래금액(원) = 수량 × 단가, 원 단위 반올림 */ + private static long amountOf(BigDecimal quantity, long price) { + return quantity.multiply(BigDecimal.valueOf(price)) + .setScale(0, RoundingMode.HALF_UP) + .longValue(); + } + /** 매매로 인한 예수금 증감 = −Σ매수(금액+수수료) + Σ매도(금액−수수료) */ private long cashDelta(List trades) { long cashDelta = 0; for (InvestTrade t : trades) { - long amount = t.getQuantity() * t.getPrice(); + long amount = amountOf(t.getQuantity(), t.getPrice()); long fee = t.getFee() != null ? t.getFee() : 0L; cashDelta += "BUY".equals(t.getTradeType()) ? -(amount + fee) : (amount - fee); } @@ -192,7 +204,7 @@ public class InvestService { Calc c = calc(trades); long avgPrice = c.avgPrice(); long evalPrice = h.getCurrentPrice() != null ? h.getCurrentPrice() : avgPrice; - long evalValue = c.quantity() * evalPrice; + long evalValue = amountOf(c.quantity(), evalPrice); long evalGain = evalValue - c.costBasis(); Double returnPct = c.costBasis() > 0 ? Math.round(evalGain * 1000.0 / c.costBasis()) / 10.0 : null; return HoldingResponse.builder() diff --git a/src/main/resources/db/account.sql b/src/main/resources/db/account.sql index 104f3f5..1b3ae8b 100644 --- a/src/main/resources/db/account.sql +++ b/src/main/resources/db/account.sql @@ -39,6 +39,7 @@ CREATE TABLE IF NOT EXISTS account_entry ( memo VARCHAR(255) NULL, wallet_id BIGINT NULL COMMENT '발생/출금 계좌(wallet.id)', to_wallet_id BIGINT NULL COMMENT '이체 입금 계좌(TRANSFER)', + installment_months INT NULL COMMENT '카드 할부 개월수(2~24, 일시불은 NULL)', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), @@ -48,6 +49,7 @@ CREATE TABLE IF NOT EXISTS account_entry ( -- 기존 account_entry 컬럼 추가 (멱등) ALTER TABLE account_entry ADD COLUMN IF NOT EXISTS wallet_id BIGINT NULL; ALTER TABLE account_entry ADD COLUMN IF NOT EXISTS to_wallet_id BIGINT NULL; +ALTER TABLE account_entry ADD COLUMN IF NOT EXISTS installment_months INT NULL; -- 가계부 태그 (사용자별 관리 — 게시판 태그와 분리) CREATE TABLE IF NOT EXISTS account_tag ( @@ -155,7 +157,7 @@ CREATE TABLE IF NOT EXISTS invest_trade ( holding_id BIGINT NOT NULL COMMENT 'invest_holding.id', trade_type VARCHAR(10) NOT NULL COMMENT 'BUY / SELL', trade_date DATE NOT NULL, - quantity BIGINT NOT NULL COMMENT '수량(주)', + quantity DECIMAL(18,6) NOT NULL COMMENT '수량(주, 소수점 매매 지원)', price BIGINT NOT NULL COMMENT '단가(원)', fee BIGINT NOT NULL DEFAULT 0 COMMENT '수수료/세금(원)', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, @@ -163,3 +165,6 @@ CREATE TABLE IF NOT EXISTS invest_trade ( KEY idx_trade_member_holding (member_id, holding_id), KEY idx_trade_date (holding_id, trade_date, id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- 소수점 매매(예: 해외주식 소수점 매수) 지원: 기존 BIGINT → DECIMAL +ALTER TABLE invest_trade MODIFY COLUMN quantity DECIMAL(18,6) NOT NULL COMMENT '수량(주, 소수점 매매 지원)'; diff --git a/src/main/resources/mapper/AccountEntryMapper.xml b/src/main/resources/mapper/AccountEntryMapper.xml index a834c87..50d56ec 100644 --- a/src/main/resources/mapper/AccountEntryMapper.xml +++ b/src/main/resources/mapper/AccountEntryMapper.xml @@ -15,6 +15,7 @@ + @@ -28,6 +29,7 @@ e.id, e.member_id, e.entry_date, e.type, e.category, e.amount, e.memo, e.wallet_id, w.name AS wallet_name, e.to_wallet_id, tw.name AS to_wallet_name, + e.installment_months, e.created_at, e.updated_at @@ -125,15 +127,16 @@ INSERT INTO account_entry (member_id, entry_date, type, category, amount, memo, - wallet_id, to_wallet_id, created_at, updated_at) + wallet_id, to_wallet_id, installment_months, created_at, updated_at) VALUES (#{memberId}, #{entryDate}, #{type}, #{category}, #{amount}, #{memo}, - #{walletId}, #{toWalletId}, NOW(), NOW()) + #{walletId}, #{toWalletId}, #{installmentMonths}, NOW(), NOW()) UPDATE account_entry SET entry_date = #{entryDate}, type = #{type}, category = #{category}, - amount = #{amount}, memo = #{memo}, wallet_id = #{walletId}, to_wallet_id = #{toWalletId} + amount = #{amount}, memo = #{memo}, wallet_id = #{walletId}, to_wallet_id = #{toWalletId}, + installment_months = #{installmentMonths} WHERE id = #{id} AND member_id = #{memberId}