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());
+ }
+ }
+}