Merge branch 'dev'
CI / build (push) Failing after 12m10s
Deploy / deploy (push) Failing after 15m27s

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