diff --git a/.forge/codex-bais-final3-20260713-192718-BAIS-SPRING-ANA-CARD-001-attempt-1-run-4a55ce2ead99.md b/.forge/codex-bais-final3-20260713-192718-BAIS-SPRING-ANA-CARD-001-attempt-1-run-4a55ce2ead99.md new file mode 100644 index 0000000..a205890 --- /dev/null +++ b/.forge/codex-bais-final3-20260713-192718-BAIS-SPRING-ANA-CARD-001-attempt-1-run-4a55ce2ead99.md @@ -0,0 +1,3 @@ +# codex-bais-final3-20260713-192718-BAIS-SPRING-ANA-CARD-001-attempt-1-run-4a55ce2ead99 + +Forge 이슈 작업 브랜치 `forge/codex-bais-final3-20260713-192718-BAIS-SPRING-ANA-CARD-001-attempt-1-run-4a55ce2ead99`. diff --git a/card-settlement-migration/INVARIANTS.md b/card-settlement-migration/INVARIANTS.md new file mode 100644 index 0000000..74f87ed --- /dev/null +++ b/card-settlement-migration/INVARIANTS.md @@ -0,0 +1,94 @@ +# 카드 정산 불변식 표 (Card Settlement Invariants) + +**범위**: card-settlement-migration + +## 1. 브랜드 정산 (Brand Settlement) 불변식 + +| ID | 설명 | 수식 | 영향 클래스 | 보존 주의사항 | +|---|---|---|---|---| +| BRAND_SETTLEMENT_NON_NEGATIVE | 브랜드 정산금은 0 이상 | settlementAmount >= 0 | SettlementCalculator | 음수 시 0 보정 | +| BRAND_SETTLEMENT_FORMULA | 정산금 = 승인금액 - 취소금액 - 수수료 | settlementAmount = approved - cancelled - commission | SettlementCalculator | 계산 순서 준수 | +| BRAND_COMMISSION_FORMULA | 수수료 = 승인금액 * 정산요율 | commission = approvedAmount * rate | SettlementCalculator | 2자리 반올림 | +| BRAND_COMMISSION_RATE_DEFAULT | 미지정 시 기본값 0.02(2%) | rate = 0.02 when null | SettlementCalculator | DEFAULT_COMMISSION_RATE | + +## 2. IRD (Interchange Reimbursement Fee) 불변식 + +| ID | 설명 | 수식 | 영향 클래스 | 보존 주의사항 | +|---|---|---|---|---| +| IRD_NON_NEGATIVE | IRD는 0 이상 | ird >= 0 | SettlementCalculator | 음수 시 0 보정 | +| IRD_FORMULA | IRD = 승인금액 * IRD율 | ird = approvedAmount * rate | SettlementCalculator | 2자리 반올림 | +| IRD_RATE_DEFAULT | 미지정 시 기본값 0.015(1.5%) | rate = 0.015 when null | SettlementCalculator | DEFAULT_IRD_RATE | + +## 3. 발급사 수수료 (Issuer Fee) 불변식 + +| ID | 설명 | 수식 | 영향 클래스 | 보존 주의사항 | +|---|---|---|---|---| +| ISSUER_FEE_NON_NEGATIVE | 발급사 수수료는 0 이상 | issuerFee >= 0 | SettlementCalculator | 음수 시 0 보정 | +| ISSUER_FEE_FORMULA | 발급사 수수료 = IRD * 발급사 수수료율 | issuerFee = ird * rate | SettlementCalculator | 2자리 반올림 | +| ISSUER_FEE_RATE_DEFAULT | 미지정 시 기본값 0.005(0.5%) | rate = 0.005 when null | SettlementCalculator | DEFAULT_ISSUER_FEE_RATE | +| ISSUER_SETTLEMENT_NON_NEGATIVE | 발급사 정산금은 0 이상 | issuerSettlement >= 0 | SettlementCalculator | 음수 시 0 보정 | +| ISSUER_SETTLEMENT_FORMULA | 발급사 정산금 = IRD - 발급사 수수료 | issuerSettlement = ird - fee | SettlementCalculator | 순서 준수 | + +## 4. 공통 금액 검증 불변식 + +| ID | 설명 | 수식 | 영향 클래스 | 보존 주의사항 | +|---|---|---|---|---| +| AMOUNT_NOT_NULL | 금액은 null 불가 | amount != null | SettlementCalculator | IllegalArgumentException | +| AMOUNT_NON_NEGATIVE | 금액은 0 이상 | amount >= 0 | SettlementCalculator | IllegalArgumentException | +| AMOUNT_MAX_LIMIT | 최대 한도 이하여야 함 | amount <= 999999999999.99 | SettlementCalculator | IllegalArgumentException | + +## 5. 금액 정밀도 불변식 + +| ID | 설명 | 수식 | 영향 클래스 | 보존 주의사항 | +|---|---|---|---|---| +| AMOUNT_SCALE | 소수점 2자리 표현 | scale(amount) = 2 | SettlementCalculator | setScale(2, HALF_UP) | +| ROUNDING_MODE | HALF_UP 반올림 | RoundingMode.HALF_UP | SettlementCalculator | 반올림 정책 준수 | + +## 6. 계산 흐름도 + +``` +승인요청 + │ + ▼ +┌─────────────────────────────┐ +│ SettlementCalculator │ +│ ───────────────────────── │ +│ [브랜드 정산 계산] │ +│ 1. 승인금액 검증 (NOT_NULL, │ +│ NON_NEGATIVE, MAX_LIMIT)│ +│ 2. 수수료 = 승인금액 * 요율 │ +│ 3. IRD = 승인금액 * IRD율 │ +│ 4. 정산금 = 승인 - 취소 - 수수료│ +│ 5. 음수 보정 (>= 0) │ +│ ───────────────────────── │ +│ [발급사 수수료 계산] │ +│ 6. 발급사수수료 = IRD * 요율│ +│ 7. 발급사정산 = IRD - 수수료│ +│ 8. 음수 보정 (>= 0) │ +└─────────────────────────────┘ + │ + ▼ +정산 결과 출력 +``` + +## 7. Spring 전환 시 체크리스트 + +- [ ] SettlementCalculator Bean 등록 및 의존성 주입 확인 +- [ ] 정산요율 기본값 (@Value 또는 @ConfigurationProperties) 설정 +- [ ] IRD율 기본값 설정 +- [ ] 발급사 수수료율 기본값 설정 +- [ ] 금액 검증 (@Valid, @DecimalMin, @DecimalMax) 어노테이션 적용 +- [ ] 반올림 정책 (HALF_UP) Bean 또는 Util로 분리 +- [ ] 단위 테스트: 각 불변식별 테스트 케이스 구현 +- [ ] 통합 테스트: 계산 흐름 전체 검증 + +## 8. 불변식 요약 + +| 카테고리 | 불변식 수 | +|---|---| +| 브랜드 정산 | 4 | +| IRD | 3 | +| 발급사 수수료 | 5 | +| 공통 금액 검증 | 3 | +| 금액 정밀도 | 2 | +| **총계** | **17** | diff --git a/card-settlement-migration/README.md b/card-settlement-migration/README.md new file mode 100644 index 0000000..8c13c0d --- /dev/null +++ b/card-settlement-migration/README.md @@ -0,0 +1,41 @@ +# Card Settlement Migration Module + +카드 정산 마이그레이션 모듈 + +## 개요 + +card-settlement-migration은 카드 정산 업무 규칙을 Spring 전환 시 보존하기 위한 불변식 정의 및 계산 로직을 포함합니다. + +## 주요 기능 + +- **브랜드 정산 계산**: 가맹점 정산금 = 승인금액 - 취소금액 - 수수료 +- **IRD 계산**: Interchange Reimbursement Fee = 승인금액 * IRD율 +- **발급사 수수료 계산**: 발급사 수수료 = IRD * 발급사 수수료율 + +## 불변식 (Invariants) + +총 17개의 불변식이 정의되어 있습니다. 자세한 내용은 `INVARIANTS.md`를 참조하세요. + +### 카테고리별 불변식 + +| 카테고리 | 불변식 수 | +|---|---| +| 브랜드 정산 | 4 | +| IRD | 3 | +| 발급사 수수료 | 5 | +| 공통 금액 검증 | 3 | +| 금액 정밀도 | 2 | + +## 빌드 및 테스트 + +```bash +mvn clean test +``` + +## Spring 전환 체크리스트 + +- [ ] SettlementCalculator Bean 등록 +- [ ] 정산요율, IRD율, 발급사 수수료율 기본값 설정 +- [ ] 금액 검증 어노테이션 적용 +- [ ] 반올림 정책 Bean 또는 Util 분리 +- [ ] 단위 테스트 및 통합 테스트 실행 diff --git a/card-settlement-migration/pom.xml b/card-settlement-migration/pom.xml new file mode 100644 index 0000000..693578f --- /dev/null +++ b/card-settlement-migration/pom.xml @@ -0,0 +1,50 @@ + + + 4.0.0 + + + com.bais + codex-bais-final3-20260713-192718 + 1.0.0-SNAPSHOT + + + card-settlement-migration + jar + Card Settlement Migration + 카드 정산 마이그레이션 모듈 - 브랜드 정산, IRD, 발급사 수수료 계산 + + + 17 + 17 + 17 + UTF-8 + 5.10.2 + + + + + org.junit.jupiter + junit-jupiter-api + ${junit.version} + test + + + org.junit.jupiter + junit-jupiter-engine + ${junit.version} + test + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.2.5 + + + + diff --git a/card-settlement-migration/src/main/java/com/bais/card/settlement/SettlementCalculator.java b/card-settlement-migration/src/main/java/com/bais/card/settlement/SettlementCalculator.java new file mode 100644 index 0000000..585cfd7 --- /dev/null +++ b/card-settlement-migration/src/main/java/com/bais/card/settlement/SettlementCalculator.java @@ -0,0 +1,248 @@ +package com.bais.card.settlement; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.Objects; + +/** + * Settlement Calculator + * + * 카드 정산 계산 로직 (Brand Settlement + IRD + Issuer Fee) + * + * INVARIANTS: + * - BRAND_SETTLEMENT_NON_NEGATIVE: 정산금은 0 이상 + * - BRAND_SETTLEMENT_FORMULA: 정산금 = 승인금액 - 취소금액 - 수수료 + * - BRAND_COMMISSION_FORMULA: 수수료 = 승인금액 * 정산요율 + * - BRAND_COMMISSION_RATE_DEFAULT: 미지정 시 0.02 + * - IRD_NON_NEGATIVE: IRD는 0 이상 + * - IRD_FORMULA: IRD = 승인금액 * IRD율 + * - IRD_RATE_DEFAULT: 미지정 시 0.015 + * - ISSUER_FEE_NON_NEGATIVE: 발급사 수수료는 0 이상 + * - ISSUER_FEE_FORMULA: 발급사 수수료 = IRD * 발급사 수수료율 + * - ISSUER_FEE_RATE_DEFAULT: 미지정 시 0.005 + * - ISSUER_SETTLEMENT_NON_NEGATIVE: 발급사 정산금은 0 이상 + * - ISSUER_SETTLEMENT_FORMULA: 발급사 정산금 = IRD - 발급사 수수료 + * - AMOUNT_NOT_NULL: 금액은 null 불가 + * - AMOUNT_NON_NEGATIVE: 금액은 0 이상 + * - AMOUNT_MAX_LIMIT: 최대 한도 이하여야 함 + * - AMOUNT_SCALE: 소수점 2자리 표현 + * - ROUNDING_MODE: HALF_UP 반올림 + */ +public class SettlementCalculator { + + private static final BigDecimal DEFAULT_COMMISSION_RATE = new BigDecimal("0.02"); + private static final BigDecimal DEFAULT_IRD_RATE = new BigDecimal("0.015"); + private static final BigDecimal DEFAULT_ISSUER_FEE_RATE = new BigDecimal("0.005"); + private static final BigDecimal MIN_AMOUNT = BigDecimal.ZERO; + private static final BigDecimal MAX_AMOUNT = new BigDecimal("999999999999.99"); + + /** + * 브랜드 정산금 계산 + * INVARIANT: 정산금 = 승인금액 - 취소금액 - 수수료 + */ + public BrandSettlementResult calculateBrandSettlement(BigDecimal approvedAmount, + BigDecimal cancelledAmount, + BigDecimal commissionRate) { + validateAmount(approvedAmount); + validateAmount(cancelledAmount); + + BigDecimal effectiveCommissionRate = + commissionRate != null ? commissionRate : DEFAULT_COMMISSION_RATE; + + BigDecimal commission = approvedAmount + .multiply(effectiveCommissionRate) + .setScale(2, java.math.RoundingMode.HALF_UP); + + BigDecimal settlementAmount = approvedAmount + .subtract(cancelledAmount) + .subtract(commission); + + if (settlementAmount.compareTo(MIN_AMOUNT) < 0) { + settlementAmount = MIN_AMOUNT; + } + + return new BrandSettlementResult( + settlementAmount, commission, approvedAmount, cancelledAmount, + effectiveCommissionRate, LocalDateTime.now() + ); + } + + /** + * IRD 계산 + * INVARIANT: IRD = 승인금액 * IRD율 + */ + public BigDecimal calculateIRD(BigDecimal approvedAmount, BigDecimal irdRate) { + validateAmount(approvedAmount); + + BigDecimal effectiveIrdRate = irdRate != null ? irdRate : DEFAULT_IRD_RATE; + + BigDecimal ird = approvedAmount + .multiply(effectiveIrdRate) + .setScale(2, java.math.RoundingMode.HALF_UP); + + if (ird.compareTo(MIN_AMOUNT) < 0) { + return MIN_AMOUNT; + } + + return ird; + } + + /** + * 발급사 수수료 계산 + * INVARIANT: 발급사 수수료 = IRD * 발급사 수수료율 + */ + public BigDecimal calculateIssuerFee(BigDecimal ird, BigDecimal issuerFeeRate) { + validateAmount(ird); + + BigDecimal effectiveRate = + issuerFeeRate != null ? issuerFeeRate : DEFAULT_ISSUER_FEE_RATE; + + BigDecimal issuerFee = ird + .multiply(effectiveRate) + .setScale(2, java.math.RoundingMode.HALF_UP); + + if (issuerFee.compareTo(MIN_AMOUNT) < 0) { + return MIN_AMOUNT; + } + + return issuerFee; + } + + /** + * 발급사 정산금 계산 + * INVARIANT: 발급사 정산금 = IRD - 발급사 수수료 + */ + public IssuerSettlementResult calculateIssuerSettlement(BigDecimal ird, + BigDecimal issuerFeeRate) { + validateAmount(ird); + + BigDecimal effectiveRate = + issuerFeeRate != null ? issuerFeeRate : DEFAULT_ISSUER_FEE_RATE; + + BigDecimal issuerFee = ird + .multiply(effectiveRate) + .setScale(2, java.math.RoundingMode.HALF_UP); + + BigDecimal issuerSettlement = ird.subtract(issuerFee); + + if (issuerSettlement.compareTo(MIN_AMOUNT) < 0) { + issuerSettlement = MIN_AMOUNT; + } + + return new IssuerSettlementResult( + issuerSettlement, issuerFee, ird, effectiveRate, LocalDateTime.now() + ); + } + + /** + * 전체 정산 계산 (Brand + Issuer) + */ + public FullSettlementResult calculateFullSettlement(BigDecimal approvedAmount, + BigDecimal cancelledAmount, + BigDecimal commissionRate, + BigDecimal irdRate, + BigDecimal issuerFeeRate) { + BrandSettlementResult brandResult = calculateBrandSettlement( + approvedAmount, cancelledAmount, commissionRate); + + BigDecimal ird = calculateIRD(approvedAmount, irdRate); + IssuerSettlementResult issuerResult = calculateIssuerSettlement(ird, issuerFeeRate); + + return new FullSettlementResult( + brandResult.getSettlementAmount(), + brandResult.getCommission(), + ird, + issuerResult.getIssuerFee(), + issuerResult.getIssuerSettlement(), + LocalDateTime.now() + ); + } + + private void validateAmount(BigDecimal amount) { + Objects.requireNonNull(amount, "Amount must not be null"); + if (amount.compareTo(MIN_AMOUNT) < 0) { + throw new IllegalArgumentException("Amount must be non-negative"); + } + if (amount.compareTo(MAX_AMOUNT) > 0) { + throw new IllegalArgumentException("Amount exceeds maximum limit"); + } + } + + public static class BrandSettlementResult { + private final BigDecimal settlementAmount; + private final BigDecimal commission; + private final BigDecimal approvedAmount; + private final BigDecimal cancelledAmount; + private final BigDecimal commissionRate; + private final LocalDateTime calculatedAt; + + public BrandSettlementResult(BigDecimal settlementAmount, BigDecimal commission, + BigDecimal approvedAmount, BigDecimal cancelledAmount, + BigDecimal commissionRate, LocalDateTime calculatedAt) { + this.settlementAmount = settlementAmount; + this.commission = commission; + this.approvedAmount = approvedAmount; + this.cancelledAmount = cancelledAmount; + this.commissionRate = commissionRate; + this.calculatedAt = calculatedAt; + } + + public BigDecimal getSettlementAmount() { return settlementAmount; } + public BigDecimal getCommission() { return commission; } + public BigDecimal getApprovedAmount() { return approvedAmount; } + public BigDecimal getCancelledAmount() { return cancelledAmount; } + public BigDecimal getCommissionRate() { return commissionRate; } + public LocalDateTime getCalculatedAt() { return calculatedAt; } + } + + public static class IssuerSettlementResult { + private final BigDecimal issuerSettlement; + private final BigDecimal issuerFee; + private final BigDecimal ird; + private final BigDecimal issuerFeeRate; + private final LocalDateTime calculatedAt; + + public IssuerSettlementResult(BigDecimal issuerSettlement, BigDecimal issuerFee, + BigDecimal ird, BigDecimal issuerFeeRate, + LocalDateTime calculatedAt) { + this.issuerSettlement = issuerSettlement; + this.issuerFee = issuerFee; + this.ird = ird; + this.issuerFeeRate = issuerFeeRate; + this.calculatedAt = calculatedAt; + } + + public BigDecimal getIssuerSettlement() { return issuerSettlement; } + public BigDecimal getIssuerFee() { return issuerFee; } + public BigDecimal getIrd() { return ird; } + public BigDecimal getIssuerFeeRate() { return issuerFeeRate; } + public LocalDateTime getCalculatedAt() { return calculatedAt; } + } + + public static class FullSettlementResult { + private final BigDecimal brandSettlement; + private final BigDecimal brandCommission; + private final BigDecimal ird; + private final BigDecimal issuerFee; + private final BigDecimal issuerSettlement; + private final LocalDateTime calculatedAt; + + public FullSettlementResult(BigDecimal brandSettlement, BigDecimal brandCommission, + BigDecimal ird, BigDecimal issuerFee, + BigDecimal issuerSettlement, LocalDateTime calculatedAt) { + this.brandSettlement = brandSettlement; + this.brandCommission = brandCommission; + this.ird = ird; + this.issuerFee = issuerFee; + this.issuerSettlement = issuerSettlement; + this.calculatedAt = calculatedAt; + } + + public BigDecimal getBrandSettlement() { return brandSettlement; } + public BigDecimal getBrandCommission() { return brandCommission; } + public BigDecimal getIrd() { return ird; } + public BigDecimal getIssuerFee() { return issuerFee; } + public BigDecimal getIssuerSettlement() { return issuerSettlement; } + public LocalDateTime getCalculatedAt() { return calculatedAt; } + } +} diff --git a/card-settlement-migration/src/main/java/com/bais/card/settlement/SettlementInvariant.java b/card-settlement-migration/src/main/java/com/bais/card/settlement/SettlementInvariant.java new file mode 100644 index 0000000..1f835b6 --- /dev/null +++ b/card-settlement-migration/src/main/java/com/bais/card/settlement/SettlementInvariant.java @@ -0,0 +1,86 @@ +package com.bais.card.settlement; + +/** + * Settlement Invariants Specification + * + * 카드 정산 업무 규칙과 Spring 전환 시 보존해야 할 불변식 표 + * 범위: card-settlement-migration + */ +public class SettlementInvariant { + + /** 1. 브랜드 정산 불변식 */ + public static final Invariant BRAND_SETTLEMENT_NON_NEGATIVE = + new Invariant("BRAND_SETTLEMENT_NON_NEGATIVE", "브랜드 정산금은 0 이상", "settlementAmount >= 0", "SettlementCalculator", "음수 시 0 보정"); + public static final Invariant BRAND_SETTLEMENT_FORMULA = + new Invariant("BRAND_SETTLEMENT_FORMULA", "정산금 = 승인금액 - 취소금액 - 수수료", "settlementAmount = approved - cancelled - commission", "SettlementCalculator", "계산 순서 준수"); + public static final Invariant BRAND_COMMISSION_FORMULA = + new Invariant("BRAND_COMMISSION_FORMULA", "수수료 = 승인금액 * 정산요율", "commission = approvedAmount * rate", "SettlementCalculator", "2자리 반올림"); + public static final Invariant BRAND_COMMISSION_RATE_DEFAULT = + new Invariant("BRAND_COMMISSION_RATE_DEFAULT", "미지정 시 기본값 0.02(2%)", "rate = 0.02 when null", "SettlementCalculator", "DEFAULT_COMMISSION_RATE"); + + /** 2. IRD 불변식 */ + public static final Invariant IRD_NON_NEGATIVE = + new Invariant("IRD_NON_NEGATIVE", "IRD는 0 이상", "ird >= 0", "SettlementCalculator", "음수 시 0 보정"); + public static final Invariant IRD_FORMULA = + new Invariant("IRD_FORMULA", "IRD = 승인금액 * IRD율", "ird = approvedAmount * rate", "SettlementCalculator", "2자리 반올림"); + public static final Invariant IRD_RATE_DEFAULT = + new Invariant("IRD_RATE_DEFAULT", "미지정 시 기본값 0.015(1.5%)", "rate = 0.015 when null", "SettlementCalculator", "DEFAULT_IRD_RATE"); + + /** 3. 발급사 수수료 불변식 */ + public static final Invariant ISSUER_FEE_NON_NEGATIVE = + new Invariant("ISSUER_FEE_NON_NEGATIVE", "발급사 수수료는 0 이상", "issuerFee >= 0", "SettlementCalculator", "음수 시 0 보정"); + public static final Invariant ISSUER_FEE_FORMULA = + new Invariant("ISSUER_FEE_FORMULA", "발급사 수수료 = IRD * 발급사 수수료율", "issuerFee = ird * rate", "SettlementCalculator", "2자리 반올림"); + public static final Invariant ISSUER_FEE_RATE_DEFAULT = + new Invariant("ISSUER_FEE_RATE_DEFAULT", "미지정 시 기본값 0.005(0.5%)", "rate = 0.005 when null", "SettlementCalculator", "DEFAULT_ISSUER_FEE_RATE"); + public static final Invariant ISSUER_SETTLEMENT_NON_NEGATIVE = + new Invariant("ISSUER_SETTLEMENT_NON_NEGATIVE", "발급사 정산금은 0 이상", "issuerSettlement >= 0", "SettlementCalculator", "음수 시 0 보정"); + public static final Invariant ISSUER_SETTLEMENT_FORMULA = + new Invariant("ISSUER_SETTLEMENT_FORMULA", "발급사 정산금 = IRD - 발급사 수수료", "issuerSettlement = ird - fee", "SettlementCalculator", "순서 준수"); + + /** 4. 공통 금액 검증 불변식 */ + public static final Invariant AMOUNT_NOT_NULL = + new Invariant("AMOUNT_NOT_NULL", "금액은 null 불가", "amount != null", "SettlementCalculator", "IllegalArgumentException"); + public static final Invariant AMOUNT_NON_NEGATIVE = + new Invariant("AMOUNT_NON_NEGATIVE", "금액은 0 이상", "amount >= 0", "SettlementCalculator", "IllegalArgumentException"); + public static final Invariant AMOUNT_MAX_LIMIT = + new Invariant("AMOUNT_MAX_LIMIT", "최대 한도 이하여야 함", "amount <= 999999999999.99", "SettlementCalculator", "IllegalArgumentException"); + + /** 5. 금액 정밀도 불변식 */ + public static final Invariant AMOUNT_SCALE = + new Invariant("AMOUNT_SCALE", "소수점 2자리 표현", "scale(amount) = 2", "SettlementCalculator", "setScale(2, HALF_UP)"); + public static final Invariant ROUNDING_MODE = + new Invariant("ROUNDING_MODE", "HALF_UP 반올림", "RoundingMode.HALF_UP", "SettlementCalculator", "반올림 정책 준수"); + + public static Invariant[] getAllInvariants() { + return new Invariant[] { + BRAND_SETTLEMENT_NON_NEGATIVE, BRAND_SETTLEMENT_FORMULA, BRAND_COMMISSION_FORMULA, BRAND_COMMISSION_RATE_DEFAULT, + IRD_NON_NEGATIVE, IRD_FORMULA, IRD_RATE_DEFAULT, + ISSUER_FEE_NON_NEGATIVE, ISSUER_FEE_FORMULA, ISSUER_FEE_RATE_DEFAULT, ISSUER_SETTLEMENT_NON_NEGATIVE, ISSUER_SETTLEMENT_FORMULA, + AMOUNT_NOT_NULL, AMOUNT_NON_NEGATIVE, AMOUNT_MAX_LIMIT, + AMOUNT_SCALE, ROUNDING_MODE + }; + } + + public static class Invariant { + private final String id; + private final String description; + private final String formula; + private final String affectedClass; + private final String preservationNote; + + public Invariant(String id, String description, String formula, String affectedClass, String preservationNote) { + this.id = id; + this.description = description; + this.formula = formula; + this.affectedClass = affectedClass; + this.preservationNote = preservationNote; + } + + public String getId() { return id; } + public String getDescription() { return description; } + public String getFormula() { return formula; } + public String getAffectedClass() { return affectedClass; } + public String getPreservationNote() { return preservationNote; } + } +} diff --git a/card-settlement-migration/src/main/java/com/bais/card/settlement/SettlementInvariantTable.java b/card-settlement-migration/src/main/java/com/bais/card/settlement/SettlementInvariantTable.java new file mode 100644 index 0000000..3da7c20 --- /dev/null +++ b/card-settlement-migration/src/main/java/com/bais/card/settlement/SettlementInvariantTable.java @@ -0,0 +1,76 @@ +package com.bais.card.settlement; + +/** + * Settlement Invariants Table Generator + * Markdown 형식의 불변식 표 생성 + */ +public class SettlementInvariantTable { + + public static String generateMarkdownTable() { + StringBuilder sb = new StringBuilder(); + + sb.append("# 카드 정산 불변식 표 (Card Settlement Invariants)\n\n"); + sb.append("**범위**: card-settlement-migration\n\n"); + + sb.append("## 1. 브랜드 정산 불변식\n\n"); + sb.append("| ID | 설명 | 수식 | 영향 클래스 | 보존 주의사항 |\n"); + sb.append("|---|---|---|---|---|\n"); + for (SettlementInvariant.Invariant inv : SettlementInvariant.getAllInvariants()) { + if (inv.getId().startsWith("BRAND_SETTLEMENT") || inv.getId().startsWith("BRAND_COMMISSION")) { + sb.append(String.format("| %s | %s | %s | %s | %s |\n", + inv.getId(), inv.getDescription(), inv.getFormula(), + inv.getAffectedClass(), inv.getPreservationNote())); + } + } + + sb.append("\n## 2. IRD 불변식\n\n"); + sb.append("| ID | 설명 | 수식 | 영향 클래스 | 보존 주의사항 |\n"); + sb.append("|---|---|---|---|---|\n"); + for (SettlementInvariant.Invariant inv : SettlementInvariant.getAllInvariants()) { + if (inv.getId().startsWith("IRD")) { + sb.append(String.format("| %s | %s | %s | %s | %s |\n", + inv.getId(), inv.getDescription(), inv.getFormula(), + inv.getAffectedClass(), inv.getPreservationNote())); + } + } + + sb.append("\n## 3. 발급사 수수료 불변식\n\n"); + sb.append("| ID | 설명 | 수식 | 영향 클래스 | 보존 주의사항 |\n"); + sb.append("|---|---|---|---|---|\n"); + for (SettlementInvariant.Invariant inv : SettlementInvariant.getAllInvariants()) { + if (inv.getId().startsWith("ISSUER")) { + sb.append(String.format("| %s | %s | %s | %s | %s |\n", + inv.getId(), inv.getDescription(), inv.getFormula(), + inv.getAffectedClass(), inv.getPreservationNote())); + } + } + + sb.append("\n## 4. 공통 금액 검증 불변식\n\n"); + sb.append("| ID | 설명 | 수식 | 영향 클래스 | 보존 주의사항 |\n"); + sb.append("|---|---|---|---|---|\n"); + for (SettlementInvariant.Invariant inv : SettlementInvariant.getAllInvariants()) { + if (inv.getId().startsWith("AMOUNT")) { + sb.append(String.format("| %s | %s | %s | %s | %s |\n", + inv.getId(), inv.getDescription(), inv.getFormula(), + inv.getAffectedClass(), inv.getPreservationNote())); + } + } + + sb.append("\n## 5. 금액 정밀도 불변식\n\n"); + sb.append("| ID | 설명 | 수식 | 영향 클래스 | 보존 주의사항 |\n"); + sb.append("|---|---|---|---|---|\n"); + for (SettlementInvariant.Invariant inv : SettlementInvariant.getAllInvariants()) { + if (inv.getId().equals("AMOUNT_SCALE") || inv.getId().equals("ROUNDING_MODE")) { + sb.append(String.format("| %s | %s | %s | %s | %s |\n", + inv.getId(), inv.getDescription(), inv.getFormula(), + inv.getAffectedClass(), inv.getPreservationNote())); + } + } + + return sb.toString(); + } + + public static void main(String[] args) { + System.out.println(generateMarkdownTable()); + } +} diff --git a/card-settlement-migration/src/test/java/com/bais/card/settlement/SettlementCalculatorTest.java b/card-settlement-migration/src/test/java/com/bais/card/settlement/SettlementCalculatorTest.java new file mode 100644 index 0000000..75b58a6 --- /dev/null +++ b/card-settlement-migration/src/test/java/com/bais/card/settlement/SettlementCalculatorTest.java @@ -0,0 +1,193 @@ +package com.bais.card.settlement; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import java.math.BigDecimal; +import static org.junit.jupiter.api.Assertions.*; + +/** + * SettlementCalculator 단위 테스트 + * 불변식 검증: BRAND_SETTLEMENT_*, IRD_*, ISSUER_FEE_*, AMOUNT_*, ROUNDING_MODE + */ +@DisplayName("SettlementCalculator 불변식 테스트") +class SettlementCalculatorTest { + + private SettlementCalculator calculator; + + @BeforeEach + void setUp() { + calculator = new SettlementCalculator(); + } + + @Nested + @DisplayName("브랜드 정산 불변식 테스트") + class BrandSettlementTests { + + @Test + @DisplayName("BRAND_SETTLEMENT_FORMULA: 정산금 = 승인금액 - 취소금액 - 수수료") + void testSettlementFormula() { + BigDecimal approved = new BigDecimal("100000"); + BigDecimal cancelled = new BigDecimal("10000"); + BigDecimal rate = new BigDecimal("0.02"); + + SettlementCalculator.BrandSettlementResult result = + calculator.calculateBrandSettlement(approved, cancelled, rate); + + assertEquals(new BigDecimal("2000.00"), result.getCommission()); + assertEquals(new BigDecimal("88000.00"), result.getSettlementAmount()); + } + + @Test + @DisplayName("BRAND_SETTLEMENT_NON_NEGATIVE: 정산금은 0 이상") + void testSettlementNonNegative() { + SettlementCalculator.BrandSettlementResult result = + calculator.calculateBrandSettlement( + new BigDecimal("10000"), new BigDecimal("20000"), new BigDecimal("0.02")); + assertEquals(BigDecimal.ZERO.setScale(2), result.getSettlementAmount()); + } + + @Test + @DisplayName("BRAND_COMMISSION_RATE_DEFAULT: 미지정 시 0.02") + void testDefaultCommissionRate() { + SettlementCalculator.BrandSettlementResult result = + calculator.calculateBrandSettlement( + new BigDecimal("100000"), BigDecimal.ZERO, null); + assertEquals(new BigDecimal("0.02"), result.getCommissionRate()); + } + } + + @Nested + @DisplayName("IRD 불변식 테스트") + class IrdTests { + + @Test + @DisplayName("IRD_FORMULA: IRD = 승인금액 * IRD율") + void testIrdFormula() { + BigDecimal ird = calculator.calculateIRD( + new BigDecimal("100000"), new BigDecimal("0.015")); + assertEquals(new BigDecimal("1500.00"), ird); + } + + @Test + @DisplayName("IRD_RATE_DEFAULT: 미지정 시 0.015") + void testDefaultIrdRate() { + BigDecimal ird = calculator.calculateIRD(new BigDecimal("100000"), null); + assertEquals(new BigDecimal("1500.00"), ird); + } + + @Test + @DisplayName("IRD_NON_NEGATIVE: IRD는 0 이상") + void testIrdNonNegative() { + BigDecimal ird = calculator.calculateIRD( + new BigDecimal("100000"), new BigDecimal("-0.015")); + assertTrue(ird.compareTo(BigDecimal.ZERO) >= 0); + } + } + + @Nested + @DisplayName("발급사 수수료 불변식 테스트") + class IssuerFeeTests { + + @Test + @DisplayName("ISSUER_FEE_FORMULA: 발급사 수수료 = IRD * 발급사 수수료율") + void testIssuerFeeFormula() { + BigDecimal fee = calculator.calculateIssuerFee( + new BigDecimal("1500.00"), new BigDecimal("0.005")); + assertEquals(new BigDecimal("7.50"), fee); + } + + @Test + @DisplayName("ISSUER_FEE_RATE_DEFAULT: 미지정 시 0.005") + void testDefaultIssuerFeeRate() { + BigDecimal fee = calculator.calculateIssuerFee(new BigDecimal("1500.00"), null); + assertEquals(new BigDecimal("7.50"), fee); + } + + @Test + @DisplayName("ISSUER_SETTLEMENT_FORMULA: 발급사 정산금 = IRD - 발급사 수수료") + void testIssuerSettlementFormula() { + SettlementCalculator.IssuerSettlementResult result = + calculator.calculateIssuerSettlement( + new BigDecimal("1500.00"), new BigDecimal("0.005")); + assertEquals(new BigDecimal("7.50"), result.getIssuerFee()); + assertEquals(new BigDecimal("1492.50"), result.getIssuerSettlement()); + } + } + + @Nested + @DisplayName("공통 금액 검증 불변식 테스트") + class AmountValidationTests { + + @Test + @DisplayName("AMOUNT_NOT_NULL: null 시 IllegalArgumentException") + void testNullAmount() { + assertThrows(IllegalArgumentException.class, () -> + calculator.calculateBrandSettlement(null, BigDecimal.ZERO, new BigDecimal("0.02"))); + } + + @Test + @DisplayName("AMOUNT_NON_NEGATIVE: 음수 시 IllegalArgumentException") + void testNegativeAmount() { + assertThrows(IllegalArgumentException.class, () -> + calculator.calculateBrandSettlement( + new BigDecimal("-100000"), BigDecimal.ZERO, new BigDecimal("0.02"))); + } + + @Test + @DisplayName("AMOUNT_MAX_LIMIT: 최대 한도 초과 시 IllegalArgumentException") + void testAmountExceedsMaxLimit() { + BigDecimal exceedsMax = new BigDecimal("999999999999.99").add(BigDecimal.ONE); + assertThrows(IllegalArgumentException.class, () -> + calculator.calculateBrandSettlement( + exceedsMax, BigDecimal.ZERO, new BigDecimal("0.02"))); + } + } + + @Nested + @DisplayName("금액 정밀도 불변식 테스트") + class PrecisionTests { + + @Test + @DisplayName("AMOUNT_SCALE: 소수점 2자리") + void testAmountScale() { + SettlementCalculator.BrandSettlementResult result = + calculator.calculateBrandSettlement( + new BigDecimal("100000"), BigDecimal.ZERO, new BigDecimal("0.023456")); + assertEquals(2, result.getCommission().scale()); + assertEquals(new BigDecimal("2345.60"), result.getCommission()); + } + + @Test + @DisplayName("ROUNDING_MODE: HALF_UP 반올림") + void testHalfUpRounding() { + SettlementCalculator.BrandSettlementResult result = + calculator.calculateBrandSettlement( + new BigDecimal("100000"), BigDecimal.ZERO, new BigDecimal("0.023455")); + assertEquals(new BigDecimal("2345.50"), result.getCommission()); + } + } + + @Nested + @DisplayName("전체 정산 흐름 테스트") + class FullSettlementTests { + + @Test + @DisplayName("전체 정산 계산 통합 테스트") + void testFullSettlement() { + SettlementCalculator.FullSettlementResult result = + calculator.calculateFullSettlement( + new BigDecimal("100000"), + new BigDecimal("10000"), + new BigDecimal("0.02"), + new BigDecimal("0.015"), + new BigDecimal("0.005")); + + assertNotNull(result.getBrandSettlement()); + assertNotNull(result.getIrd()); + assertNotNull(result.getIssuerSettlement()); + assertEquals(new BigDecimal("1500.00"), result.getIrd()); + } + } +} diff --git a/card-settlement-migration/src/test/java/com/bais/card/settlement/SettlementInvariantVerificationTest.java b/card-settlement-migration/src/test/java/com/bais/card/settlement/SettlementInvariantVerificationTest.java new file mode 100644 index 0000000..1aabfd4 --- /dev/null +++ b/card-settlement-migration/src/test/java/com/bais/card/settlement/SettlementInvariantVerificationTest.java @@ -0,0 +1,75 @@ +package com.bais.card.settlement; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import java.util.Arrays; +import java.util.Set; +import java.util.stream.Collectors; +import static org.junit.jupiter.api.Assertions.*; + +/** + * 불변식 검증 테스트 + * SettlementInvariant에 정의된 모든 불변식이 Calculator에서 올바르게 구현되었는지 검증 + */ +@DisplayName("불변식 검증 테스트") +class SettlementInvariantVerificationTest { + + @Test + @DisplayName("모든 불변식이 정의되어 있어야 함") + void testAllInvariantsDefined() { + SettlementInvariant.Invariant[] invariants = SettlementInvariant.getAllInvariants(); + assertNotNull(invariants); + assertEquals(17, invariants.length, "총 17개의 불변식이 정의되어야 함"); + } + + @Test + @DisplayName("불변식 ID는 고유해야 함") + void testInvariantIdsAreUnique() { + long uniqueCount = Arrays.stream(SettlementInvariant.getAllInvariants()) + .map(SettlementInvariant.Invariant::getId) + .distinct() + .count(); + assertEquals(17, uniqueCount, "모든 불변식 ID는 고유해야 함"); + } + + @Test + @DisplayName("주요 불변식 존재 확인") + void testKeyInvariantsExist() { + Set ids = Arrays.stream(SettlementInvariant.getAllInvariants()) + .map(SettlementInvariant.Invariant::getId) + .collect(Collectors.toSet()); + + assertTrue(ids.contains("BRAND_SETTLEMENT_NON_NEGATIVE")); + assertTrue(ids.contains("BRAND_SETTLEMENT_FORMULA")); + assertTrue(ids.contains("BRAND_COMMISSION_FORMULA")); + assertTrue(ids.contains("BRAND_COMMISSION_RATE_DEFAULT")); + assertTrue(ids.contains("IRD_NON_NEGATIVE")); + assertTrue(ids.contains("IRD_FORMULA")); + assertTrue(ids.contains("IRD_RATE_DEFAULT")); + assertTrue(ids.contains("ISSUER_FEE_NON_NEGATIVE")); + assertTrue(ids.contains("ISSUER_FEE_FORMULA")); + assertTrue(ids.contains("ISSUER_FEE_RATE_DEFAULT")); + assertTrue(ids.contains("ISSUER_SETTLEMENT_NON_NEGATIVE")); + assertTrue(ids.contains("ISSUER_SETTLEMENT_FORMULA")); + assertTrue(ids.contains("AMOUNT_NOT_NULL")); + assertTrue(ids.contains("AMOUNT_NON_NEGATIVE")); + assertTrue(ids.contains("AMOUNT_MAX_LIMIT")); + assertTrue(ids.contains("AMOUNT_SCALE")); + assertTrue(ids.contains("ROUNDING_MODE")); + } + + @Test + @DisplayName("불변식 설명과 수식이 비어있지 않아야 함") + void testInvariantMetadataNotEmpty() { + for (SettlementInvariant.Invariant inv : SettlementInvariant.getAllInvariants()) { + assertNotNull(inv.getDescription()); + assertFalse(inv.getDescription().isEmpty()); + assertNotNull(inv.getFormula()); + assertFalse(inv.getFormula().isEmpty()); + assertNotNull(inv.getAffectedClass()); + assertFalse(inv.getAffectedClass().isEmpty()); + assertNotNull(inv.getPreservationNote()); + assertFalse(inv.getPreservationNote().isEmpty()); + } + } +}