diff --git a/.forge/codex-bais-final3-20260713-192718-BAIS-SPRING-ANA-ACCOUNT-001-attempt-1-run-a2485546e101.md b/.forge/codex-bais-final3-20260713-192718-BAIS-SPRING-ANA-ACCOUNT-001-attempt-1-run-a2485546e101.md deleted file mode 100644 index 4537065..0000000 --- a/.forge/codex-bais-final3-20260713-192718-BAIS-SPRING-ANA-ACCOUNT-001-attempt-1-run-a2485546e101.md +++ /dev/null @@ -1,3 +0,0 @@ -# codex-bais-final3-20260713-192718-BAIS-SPRING-ANA-ACCOUNT-001-attempt-1-run-a2485546e101 - -Forge 이슈 작업 브랜치 `forge/codex-bais-final3-20260713-192718-BAIS-SPRING-ANA-ACCOUNT-001-attempt-1-run-a2485546e101`. diff --git a/account-migration/README.md b/account-migration/README.md deleted file mode 100644 index 1aef04f..0000000 --- a/account-migration/README.md +++ /dev/null @@ -1,44 +0,0 @@ -# account-migration - -## 개요 -account-migration 모듈은 계정/정산 업무 규칙 및 전환 불변식을 정의합니다. - -## 포함 범위 -- Settlement Cancellation 불변식 (SC-001 ~ SC-008) -- Dispute/Chargeback Inquiry 불변식 (DC-001 ~ DC-010) -- Payout Validation 불변식 (PV-001 ~ PV-012) - -## 주요 불변식 요약 - -### Settlement Cancellation -| ID | 설명 | -|----|------| -| SC-001 | COMPLETED/PENDING 상태만 취소 가능 | -| SC-003 | 취소 금액은 원래 금액 초과 불가 | -| SC-005 | 취소 시각은 settlement 이후 | -| SC-008 | 취소 사유 최소 10자 | - -### Dispute/Chargeback -| ID | 설명 | -|----|------| -| DC-001 | settlement 후 90일 이내만 dispute 가능 | -| DC-007 | chargeback은 100000원 초과만 가능 | -| DC-010 | inquiry 페이지당 최대 50건 | - -### Payout Validation -| ID | 설명 | -|----|------| -| PV-001 | payout ≤ 가용 잔액 | -| PV-002 | 최소 1000원 | -| PV-003 | 일별 최대 10000000원 | -| PV-009 | 平日 09:00-17:00 처리 | - -## 빌드 및 테스트 -```bash -mvn clean test -``` - -## 의존성 -- Spring Boot 3.2.5 -- Jakarta Persistence API 3.1.0 -- Jakarta Validation API 3.0.2 diff --git a/account-migration/pom.xml b/account-migration/pom.xml deleted file mode 100644 index 672126b..0000000 --- a/account-migration/pom.xml +++ /dev/null @@ -1,77 +0,0 @@ - - - 4.0.0 - - - org.springframework.boot - spring-boot-starter-parent - 3.2.5 - - - - com.payman - account-migration - 1.0.0-SNAPSHOT - jar - - account-migration - Account Migration Module - Transaction Semantics - - - 17 - 17 - 17 - UTF-8 - - - - - org.springframework.boot - spring-boot-starter-data-jpa - - - org.springframework.boot - spring-boot-starter-validation - - - org.springframework.boot - spring-boot-starter-data-redis - - - jakarta.persistence - jakarta.persistence-api - 3.1.0 - - - jakarta.validation - jakarta.validation-api - 3.0.2 - - - com.h2database - h2 - test - - - org.springframework.boot - spring-boot-starter-test - test - - - - - - - org.springframework.boot - spring-boot-maven-plugin - - - org.apache.maven.plugins - maven-surefire-plugin - 3.2.5 - - - - diff --git a/account-migration/src/main/java/com/payman/account/migration/entity/AllEntities.java b/account-migration/src/main/java/com/payman/account/migration/entity/AllEntities.java deleted file mode 100644 index b45dc33..0000000 --- a/account-migration/src/main/java/com/payman/account/migration/entity/AllEntities.java +++ /dev/null @@ -1,121 +0,0 @@ -package com.payman.account.migration.entity; - -import com.payman.account.migration.entity.enums.SettlementStatus; -import com.payman.account.migration.entity.enums.DisputeStatus; -import com.payman.account.migration.entity.enums.PayoutStatus; -import jakarta.persistence.*; -import java.math.BigDecimal; -import java.time.LocalDateTime; -import java.util.UUID; - -@Entity -@Table(name = "settlements") -class Settlement { - @Id - @GeneratedValue(strategy = GenerationType.UUID) - private UUID id; - @Enumerated(EnumType.STRING) - @Column(nullable = false) - private SettlementStatus status; - @Column(nullable = false) - private BigDecimal amount; - @Column(name = "created_at", nullable = false) - private LocalDateTime createdAt; - @Column(name = "updated_at") - private LocalDateTime updatedAt; - @Column(name = "creator_id") - private UUID creatorId; - @PrePersist - protected void onCreate() { createdAt = LocalDateTime.now(); } - @PreUpdate - protected void onUpdate() { updatedAt = LocalDateTime.now(); } - public UUID getId() { return id; } - public void setId(UUID id) { this.id = id; } - public SettlementStatus getStatus() { return status; } - public void setStatus(SettlementStatus status) { this.status = status; } - public BigDecimal getAmount() { return amount; } - public void setAmount(BigDecimal amount) { this.amount = amount; } - public LocalDateTime getCreatedAt() { return createdAt; } - public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } - public LocalDateTime getUpdatedAt() { return updatedAt; } - public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; } - public UUID getCreatorId() { return creatorId; } - public void setCreatorId(UUID creatorId) { this.creatorId = creatorId; } -} - -@Entity -@Table(name = "disputes") -class Dispute { - @Id - @GeneratedValue(strategy = GenerationType.UUID) - private UUID id; - @Enumerated(EnumType.STRING) - @Column(nullable = false) - private DisputeStatus status; - @Column(name = "settlement_id", nullable = false) - private UUID settlementId; - @Column(nullable = false) - private BigDecimal amount; - @Column(name = "created_at", nullable = false) - private LocalDateTime createdAt; - @Column(name = "response_deadline") - private LocalDateTime responseDeadline; - @PrePersist - protected void onCreate() { - createdAt = LocalDateTime.now(); - responseDeadline = createdAt.plusDays(7); - } - public UUID getId() { return id; } - public void setId(UUID id) { this.id = id; } - public DisputeStatus getStatus() { return status; } - public void setStatus(DisputeStatus status) { this.status = status; } - public UUID getSettlementId() { return settlementId; } - public void setSettlementId(UUID settlementId) { this.settlementId = settlementId; } - public BigDecimal getAmount() { return amount; } - public void setAmount(BigDecimal amount) { this.amount = amount; } - public LocalDateTime getCreatedAt() { return createdAt; } - public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } - public LocalDateTime getResponseDeadline() { return responseDeadline; } - public void setResponseDeadline(LocalDateTime responseDeadline) { this.responseDeadline = responseDeadline; } -} - -@Entity -@Table(name = "payouts") -class Payout { - @Id - @GeneratedValue(strategy = GenerationType.UUID) - private UUID id; - @Enumerated(EnumType.STRING) - @Column(nullable = false) - private PayoutStatus status; - @Column(name = "account_id", nullable = false) - private UUID accountId; - @Column(nullable = false) - private BigDecimal amount; - @Column(name = "currency", nullable = false) - private String currency; - @Column(name = "bank_account_verified") - private boolean bankAccountVerified; - @Column(name = "kyc_status") - private String kycStatus; - @Column(name = "created_at", nullable = false) - private LocalDateTime createdAt; - @PrePersist - protected void onCreate() { createdAt = LocalDateTime.now(); } - public UUID getId() { return id; } - public void setId(UUID id) { this.id = id; } - public PayoutStatus getStatus() { return status; } - public void setStatus(PayoutStatus status) { this.status = status; } - public UUID getAccountId() { return accountId; } - public void setAccountId(UUID accountId) { this.accountId = accountId; } - public BigDecimal getAmount() { return amount; } - public void setAmount(BigDecimal amount) { this.amount = amount; } - public String getCurrency() { return currency; } - public void setCurrency(String currency) { this.currency = currency; } - public boolean isBankAccountVerified() { return bankAccountVerified; } - public void setBankAccountVerified(boolean bankAccountVerified) { this.bankAccountVerified = bankAccountVerified; } - public String getKycStatus() { return kycStatus; } - public void setKycStatus(String kycStatus) { this.kycStatus = kycStatus; } - public LocalDateTime getCreatedAt() { return createdAt; } - public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } -} diff --git a/account-migration/src/main/java/com/payman/account/migration/entity/enums/AllEnums.java b/account-migration/src/main/java/com/payman/account/migration/entity/enums/AllEnums.java deleted file mode 100644 index 2f25132..0000000 --- a/account-migration/src/main/java/com/payman/account/migration/entity/enums/AllEnums.java +++ /dev/null @@ -1,23 +0,0 @@ -package com.payman.account.migration.entity.enums; - -public enum SettlementStatus { - PENDING, - COMPLETED, - CANCELLED, - REFUNDED -} - -public enum DisputeStatus { - INITIATED, - UNDER_REVIEW, - ESCALATED, - RESOLVED, - CLOSED -} - -public enum PayoutStatus { - PENDING, - PROCESSING, - COMPLETED, - FAILED -} diff --git a/account-migration/src/main/java/com/payman/account/migration/exception/AllExceptions.java b/account-migration/src/main/java/com/payman/account/migration/exception/AllExceptions.java deleted file mode 100644 index eac923b..0000000 --- a/account-migration/src/main/java/com/payman/account/migration/exception/AllExceptions.java +++ /dev/null @@ -1,44 +0,0 @@ -package com.payman.account.migration.exception; - -public class InvalidCancellationAmountException extends RuntimeException { - public InvalidCancellationAmountException(String message) { super(message); } -} -public class InvalidTimestampException extends RuntimeException { - public InvalidTimestampException(String message) { super(message); } -} -public class MissingCancellationReasonException extends RuntimeException { - public MissingCancellationReasonException(String message) { super(message); } -} -public class InsufficientRemainingAmountException extends RuntimeException { - public InsufficientRemainingAmountException(String message) { super(message); } -} -public class DisputeAmountExceedsSettlementException extends RuntimeException { - public DisputeAmountExceedsSettlementException(String message) { super(message); } -} -public class ChargebackThresholdNotMetException extends RuntimeException { - public ChargebackThresholdNotMetException(String message) { super(message); } -} -public class DisputeResponseDeadlineExceededException extends RuntimeException { - public DisputeResponseDeadlineExceededException(String message) { super(message); } -} -public class PaginationLimitExceededException extends RuntimeException { - public PaginationLimitExceededException(String message) { super(message); } -} -public class InsufficientBalanceException extends RuntimeException { - public InsufficientBalanceException(String message) { super(message); } -} -public class PayoutAmountBelowMinimumException extends RuntimeException { - public PayoutAmountBelowMinimumException(String message) { super(message); } -} -public class PayoutDailyLimitExceededException extends RuntimeException { - public PayoutDailyLimitExceededException(String message) { super(message); } -} -public class PayoutFrequencyExceededException extends RuntimeException { - public PayoutFrequencyExceededException(String message) { super(message); } -} -public class OutsideProcessingHoursException extends RuntimeException { - public OutsideProcessingHoursException(String message) { super(message); } -} -public class FraudDetectionFailedException extends RuntimeException { - public FraudDetectionFailedException(String message) { super(message); } -} diff --git a/account-migration/src/main/java/com/payman/account/migration/spec/TransactionSemanticsSpec.md b/account-migration/src/main/java/com/payman/account/migration/spec/TransactionSemanticsSpec.md deleted file mode 100644 index 063f53f..0000000 --- a/account-migration/src/main/java/com/payman/account/migration/spec/TransactionSemanticsSpec.md +++ /dev/null @@ -1,150 +0,0 @@ -# Transaction Semantics Specification -## account-migration 불변식 및 업무 규칙 - ---- - -## 1. Settlement Cancellation 불변식 - -| ID | 불변식 이름 | 설명 | 선행 조건 | 후행 조건 | 검증 시점 | 예외 처리 | -|-----|-----------|------|----------|----------|----------|----------| -| SC-001 | CancelableSettlementState | settlement_status가 'COMPLETED' 또는 'PENDING'인 경우만 취소 가능 | settlement.status ∈ {COMPLETED, PENDING} | settlement.status = 'CANCELLED' | 취소 요청 수신 시 | IllegalStateException("Cannot cancel settled item in status: {status}") | -| SC-002 | CancellationIdempotency | 동일 settlement_id에 대한 중복 취소 요청은幂等 처리 | settlement.status = 'CANCELLED' | 상태 변경 없음 (幂等) | 취소 요청 수신 시 | None (幂等 처리) | -| SC-003 | CancellationAmountLimit | 취소 금액은 원래 settlement 금액을 초과할 수 없음 | cancellation_amount ≤ original_settlement_amount | cancellation_amount ≤ original_settlement_amount | 취소 금액 검증 시 | InvalidCancellationAmountException("Cancellation amount exceeds original") | -| SC-004 | CancellationReversalIntegrity | 취소 시 관련 ledger entry가 역순으로 생성됨 | settlement.status = 'CANCELLED' | ledger_entry.type = 'CREDIT' for original 'DEBIT' entries | 취소 완료 시 | LedgerIntegrityException | -| SC-005 | CancellationTimestamp | 취소 요청 시각은 원래 settlement 시각 이후여야 함 | cancellation_time > settlement_time | cancellation_time > settlement_time | 취소 요청 수신 시 | InvalidTimestampException("Cancellation must be after settlement") | -| SC-006 | CancellationAuthorization | 취소 요청자는 원래 settlement 생성자이거나 ADMIN 역할이어야 함 | requester.role ∈ {ADMIN} OR requester.id = settlement.creator_id | - | 취소 요청 수신 시 | UnauthorizedCancellationException | -| SC-007 | PartialCancellationLimit | 부분 취소 시 잔여 금액이 최소 금액 이상이어야 함 | remaining_amount ≥ MIN_SETTLEMENT_AMOUNT | remaining_amount ≥ MIN_SETTLEMENT_AMOUNT | 부분 취소 검증 시 | InsufficientRemainingAmountException | -| SC-008 | CancellationReasonRequired | 취소 사유는 필수이며 최소 10자 이상 | reason.length ≥ 10 | reason.length ≥ 10 | 취소 요청 수신 시 | MissingCancellationReasonException | - ---- - -## 2. Dispute/Chargeback Inquiry 불변식 - -| ID | 불변식 이름 | 설명 | 선행 조건 | 후행 조건 | 검증 시점 | 예외 처리 | -|-----|-----------|------|----------|----------|----------|----------| -| DC-001 | DisputeEligibilityWindow | dispute는 settlement 후 90일 이내에만 가능 | settlement_time + 90_days ≥ current_time | - | dispute 요청 수신 시 | DisputeWindowExpiredException("Dispute window has expired") | -| DC-002 | DisputeStatusTransition | dispute_status 전이 규칙 | current_status → allowed_next_statuses | - | 상태 전이 시 | InvalidDisputeStateTransitionException | -| DC-003 | DisputeAmountConstraint | dispute_amount ≤ 원래 settlement_amount | dispute_amount ≤ settlement_amount | dispute_amount ≤ settlement_amount | dispute 생성 시 | DisputeAmountExceedsSettlementException | -| DC-004 | DisputeEvidenceRequired | evidence_documents가 필수인 상태 존재 | status = 'UNDER_REVIEW' → documents ≥ 1 | - | evidence 제출 시 | MissingEvidenceException | -| DC-005 | DisputeInquiryIdempotency | 동일 transaction_id에 대한 중복 inquiry는幂等 | inquiry_idempotency_key exists | 기존 응답 반환 | inquiry 요청 수신 시 | None (幂等 처리) | -| DC-006 | DisputeInquiryAuthorization | inquiry 요청자는 transaction 참여자이거나 ADMIN | requester.id ∈ {buyer_id, seller_id} OR requester.role = 'ADMIN' | - | inquiry 요청 수신 시 | UnauthorizedInquiryException | -| DC-007 | DisputeChargebackThreshold | chargeback은 AMOUNT > 100000 원인 경우만 가능 | settlement_amount > 100000 | - | chargeback 요청 시 | ChargebackThresholdNotMetException | -| DC-008 | DisputeResponseDeadline | merchant는 7일 이내에 응답해야 함 | response_deadline = created_at + 7_days | - | deadline 검증 시 | DisputeResponseDeadlineExceededException | -| DC-009 | DisputeEscalationIntegrity | escalation 시 기존 evidence 보존 | status → 'ESCALATED' | previous_evidence preserved | escalation 시 | EvidenceLossException | -| DC-010 | DisputeInquiryPagination | inquiry 결과는 페이지당 최대 50건 | page_size ≤ 50 | page_size ≤ 50 | inquiry 요청 시 | PaginationLimitExceededException | - ---- - -## 3. Payout Validation 불변식 - -| ID | 불변식 이름 | 설명 | 선행 조건 | 후행 조건 | 검증 시점 | 예외 처리 | -|-----|-----------|------|----------|----------|----------|----------| -| PV-001 | PayoutBalanceSufficiency | payout_amount ≤ 가용 잔액 | payout_amount ≤ available_balance | - | payout 요청 수신 시 | InsufficientBalanceException("Available balance: {balance}, requested: {amount}") | -| PV-002 | PayoutMinimumAmount | payout 최소 금액은 1000원 | payout_amount ≥ 1000 | payout_amount ≥ 1000 | payout 요청 수신 시 | PayoutAmountBelowMinimumException | -| PV-003 | PayoutMaximumAmount | payout 최대 금액은 10000000원 (일별) | daily_total + payout_amount ≤ 10000000 | daily_total + payout_amount ≤ 10000000 | payout 요청 시 | PayoutDailyLimitExceededException | -| PV-004 | PayoutFrequencyLimit | payout은 1일 최대 5회 | daily_payout_count < 5 | daily_payout_count < 5 | payout 요청 시 | PayoutFrequencyExceededException | -| PV-005 | PayoutBankAccountVerified | 출금 계좌가 인증되어야 함 | bank_account.status = 'VERIFIED' | - | payout 요청 시 | UnverifiedBankAccountException | -| PV-006 | PayoutKYCCompliance | KYC 인증 상태가 APPROVED여야 함 | kyc_status = 'APPROVED' | - | payout 요청 시 | KYCNotApprovedException | -| PV-007 | PayoutIdempotency | 동일 idempotency_key에 대한 중복 payout 요청은幂等 | payout_idempotency_key exists | 기존 payout 반환 | payout 요청 시 | None (幂等 처리) | -| PV-008 | PayoutStatusTransition | payout_status 전이 규칙 | current_status → allowed_next_statuses | - | 상태 전이 시 | InvalidPayoutStateTransitionException | -| PV-009 | PayoutProcessingTime | 처리 시간은平日 09:00-17:00 | current_time.hour ∈ [9, 17) AND weekday ∈ {MON-FRI} | - | payout 요청 시 | OutsideProcessingHoursException | -| PV-010 | PayoutAntiFraudCheck | 이상 거래 탐지 통과 | fraud_score < 0.8 | fraud_score < 0.8 | payout 요청 시 | FraudDetectionFailedException | -| PV-011 | PayoutCurrencyMatch | payout 통화는 계정 통화와 일치 | payout_currency = account_currency | - | payout 요청 시 | CurrencyMismatchException | -| PV-012 | PayoutAuditTrail | 모든 payout은 감사 로그에 기록 | payout created | audit_log entry created | payout 완료 시 | AuditLogCreationFailedException | - ---- - -## 4. 상태 전이 표 - -### 4.1 Settlement 상태 전이 - -| 현재 상태 | 허용된 다음 상태 | 트리거 이벤트 | -|----------|----------------|---------------| -| PENDING | COMPLETED, CANCELLED | confirmation, cancellation | -| COMPLETED | CANCELLED, REFUNDED | cancellation, refund | -| CANCELLED | (terminal) | - | -| REFUNDED | (terminal) | - | - -### 4.2 Dispute 상태 전이 - -| 현재 상태 | 허용된 다음 상태 | 트리거 이벤트 | -|----------|----------------|---------------| -| INITIATED | UNDER_REVIEW, CLOSED | merchant_response, timeout | -| UNDER_REVIEW | ESCALATED, RESOLVED | escalation, merchant_response | -| ESCALATED | RESOLVED, CLOSED | arbiter_decision | -| RESOLVED | (terminal) | - | -| CLOSED | (terminal) | - | - -### 4.3 Payout 상태 전이 - -| 현재 상태 | 허용된 다음 상태 | 트리거 이벤트 | -|----------|----------------|---------------| -| PENDING | PROCESSING, FAILED | validation, processing | -| PROCESSING | COMPLETED, FAILED | bank_response | -| COMPLETED | (terminal) | - | -| FAILED | PENDING (retry) | retry | - ---- - -## 5. Spring 전환 시 보존 요구사항 - -| 요구사항 ID | 설명 | 구현 위치 | 테스트 요구사항 | -|-----------|------|----------|----------------| -| REQ-001 | 모든 불변식 검증은 @PrePersist, @PreUpdate 라이프사이클 콜백에서 수행 | Entity Listener | 불변식 테스트 | -| REQ-002 | 상태 전이 검증은 StateMachine 또는 명시적 상태 관리로 구현 | Service Layer | 상태 전이 테스트 | -| REQ-003 | Idempotency는 Redis 또는 DB unique constraint로 보장 | Repository Layer | Idempotency 테스트 | -| REQ-004 | Audit Trail은 AOP 또는 @TransactionalEventListener로 구현 | Aspect Layer | 감사 로그 테스트 | -| REQ-005 | 예외 처리는 @ControllerAdvice로 일원화 | Exception Handler | 예외 처리 테스트 | -| REQ-006 | 설정값 (금액 한도, 기간 등)은 @ConfigurationProperties로 관리 | Configuration | 설정 변경 테스트 | - ---- - -## 6. 검증 테스트 시나리오 - -### 6.1 Settlement Cancellation 테스트 - -| 시나리오 | 입력 | 기대 결과 | -|---------|------|----------| -| TC-SC-001 | COMPLETED 상태 settlement 취소 요청 | 취소 성공, 상태 = CANCELLED | -| TC-SC-002 | 이미 CANCELLED 상태 settlement 취소 요청 | Idempotent 처리, 상태 변경 없음 | -| TC-SC-003 | REFUNDED 상태 settlement 취소 요청 | IllegalStateException 발생 | -| TC-SC-004 | 취소 금액 > 원래 금액 | InvalidCancellationAmountException 발생 | -| TC-SC-005 | 취소 사유 10자 미만 | MissingCancellationReasonException 발생 | - -### 6.2 Dispute/Chargeback 테스트 - -| 시나리오 | 입력 | 기대 결과 | -|---------|------|----------| -| TC-DC-001 | settlement 후 30일째 dispute 요청 | Dispute 생성 성공 | -| TC-DC-002 | settlement 후 91일째 dispute 요청 | DisputeWindowExpiredException 발생 | -| TC-DC-003 | 50000원 settlement chargeback 요청 | ChargebackThresholdNotMetException 발생 | -| TC-DC-004 | 500000원 settlement chargeback 요청 | Chargeback 생성 성공 | -| TC-DC-005 | 중복 inquiry 요청 | 기존 응답 반환 (幂等) | - -### 6.3 Payout 테스트 - -| 시나리오 | 입력 | 기대 결과 | -|---------|------|----------| -| TC-PV-001 | 가용 잔액 500000원, payout 300000원 요청 | Payout 성공 | -| TC-PV-002 | 가용 잔액 500000원, payout 600000원 요청 | InsufficientBalanceException 발생 | -| TC-PV-003 | payout 500원 요청 | PayoutAmountBelowMinimumException 발생 | -| TC-PV-004 | 일별 한도 초과 payout 요청 | PayoutDailyLimitExceededException 발생 | -| TC-PV-005 | 미인증 계좌로 payout 요청 | UnverifiedBankAccountException 발생 | -| TC-PV-006 | 처리 시간 외 payout 요청 | OutsideProcessingHoursException 발생 | - ---- - -## 7. 의존성 및 버전 - -| 라이브러리 | 버전 | 용도 | -|----------|------|------| -| spring-boot-starter-data-jpa | 3.2.5 | JPA Entity 및 Repository | -| spring-boot-starter-validation | 3.2.5 | Bean Validation | -| spring-boot-starter-data-redis | 3.2.5 | Idempotency 캐싱 | -| jakarta.persistence-api | 3.1.0 | JPA Annotations | -| jakarta.validation-api | 3.0.2 | Bean Validation Annotations | - ---- - -*문서 생성일: 2026-07-13* -*버전: 1.0.0* diff --git a/account-migration/src/test/java/com/payman/account/migration/spec/TransactionSemanticsInvariantTest.java b/account-migration/src/test/java/com/payman/account/migration/spec/TransactionSemanticsInvariantTest.java deleted file mode 100644 index 6267ae9..0000000 --- a/account-migration/src/test/java/com/payman/account/migration/spec/TransactionSemanticsInvariantTest.java +++ /dev/null @@ -1,344 +0,0 @@ -package com.payman.account.migration.spec; - -import com.payman.account.migration.entity.enums.SettlementStatus; -import com.payman.account.migration.entity.enums.DisputeStatus; -import com.payman.account.migration.entity.enums.PayoutStatus; -import com.payman.account.migration.exception.*; -import org.junit.jupiter.api.*; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.EnumSource; - -import java.math.BigDecimal; -import java.time.LocalDateTime; -import java.util.UUID; - -import static org.junit.jupiter.api.Assertions.*; - -@DisplayName("Transaction Semantics Invariant Tests") -class TransactionSemanticsInvariantTest { - - private static final BigDecimal MIN_SETTLEMENT_AMOUNT = new BigDecimal("1000"); - private static final BigDecimal MAX_PAYOUT_DAILY = new BigDecimal("10000000"); - private static final BigDecimal MIN_PAYOUT_AMOUNT = new BigDecimal("1000"); - private static final BigDecimal CHARGEBACK_THRESHOLD = new BigDecimal("100000"); - private static final int DISPUTE_WINDOW_DAYS = 90; - private static final int DISPUTE_RESPONSE_DEADLINE_DAYS = 7; - private static final int MAX_PAYOUT_DAILY_COUNT = 5; - private static final int MIN_CANCELLATION_REASON_LENGTH = 10; - private static final int MAX_INQUIRY_PAGE_SIZE = 50; - - @Nested - @DisplayName("SC: Settlement Cancellation Invariants") - class SettlementCancellationTests { - - @Test - @DisplayName("SC-001: CancelableSettlementState - COMPLETED/PENDING 상태는 취소 가능") - void cancellableStatesCanBeCancelled() { - assertTrue(isCancellable(SettlementStatus.COMPLETED)); - assertTrue(isCancellable(SettlementStatus.PENDING)); - } - - @ParameterizedTest - @EnumSource(value = SettlementStatus.class, names = {"CANCELLED", "REFUNDED"}) - @DisplayName("SC-001: CancelableSettlementState - terminal 상태는 취소 불가") - void terminalStatesCannotBeCancelled(SettlementStatus status) { - assertFalse(isCancellable(status)); - } - - @Test - @DisplayName("SC-003: CancellationAmountLimit - 취소 금액 초과 시 예외") - void cancellationAmountExceedsOriginalThrowsException() { - assertThrows(InvalidCancellationAmountException.class, () -> - validateCancellationAmount(new BigDecimal("150000"), new BigDecimal("100000"))); - assertDoesNotThrow(() -> - validateCancellationAmount(new BigDecimal("100000"), new BigDecimal("100000"))); - } - - @Test - @DisplayName("SC-005: CancellationTimestamp - 취소 시각 검증") - void cancellationMustBeAfterSettlementTime() { - LocalDateTime settlementTime = LocalDateTime.now().minusDays(1); - assertThrows(InvalidTimestampException.class, () -> - validateCancellationTimestamp(settlementTime.minusHours(1), settlementTime)); - assertDoesNotThrow(() -> - validateCancellationTimestamp(settlementTime.plusHours(1), settlementTime)); - } - - @Test - @DisplayName("SC-008: CancellationReasonRequired - 사유 10자 이상") - void cancellationReasonMustBeAtLeast10Characters() { - assertThrows(MissingCancellationReasonException.class, () -> - validateCancellationReason("단축")); - assertDoesNotThrow(() -> - validateCancellationReason("고객 요청으로 인한 취소")); - } - - @Test - @DisplayName("SC-007: PartialCancellationLimit - 잔여 금액 최소 금액 이상") - void remainingAmountMustBeAboveMinimum() { - assertThrows(InsufficientRemainingAmountException.class, () -> - validatePartialCancellation(new BigDecimal("500"), MIN_SETTLEMENT_AMOUNT)); - assertDoesNotThrow(() -> - validatePartialCancellation(new BigDecimal("1000"), MIN_SETTLEMENT_AMOUNT)); - } - } - - @Nested - @DisplayName("DC: Dispute/Chargeback Invariants") - class DisputeChargebackTests { - - @Test - @DisplayName("DC-001: DisputeEligibilityWindow - 90일 이내 dispute 가능") - void disputeWithin90DaysIsEligible() { - assertTrue(isWithinDisputeWindow(LocalDateTime.now().minusDays(30), DISPUTE_WINDOW_DAYS)); - assertFalse(isWithinDisputeWindow(LocalDateTime.now().minusDays(91), DISPUTE_WINDOW_DAYS)); - } - - @Test - @DisplayName("DC-003: DisputeAmountConstraint - dispute 금액 ≤ settlement 금액") - void disputeAmountMustNotExceedSettlementAmount() { - assertThrows(DisputeAmountExceedsSettlementException.class, () -> - validateDisputeAmount(new BigDecimal("150000"), new BigDecimal("100000"))); - assertDoesNotThrow(() -> - validateDisputeAmount(new BigDecimal("100000"), new BigDecimal("100000"))); - } - - @Test - @DisplayName("DC-007: DisputeChargebackThreshold - 100000원 초과만 chargeback 가능") - void chargebackRequiresAmountAboveThreshold() { - assertThrows(ChargebackThresholdNotMetException.class, () -> - validateChargebackThreshold(new BigDecimal("50000"), CHARGEBACK_THRESHOLD)); - assertDoesNotThrow(() -> - validateChargebackThreshold(new BigDecimal("500000"), CHARGEBACK_THRESHOLD)); - } - - @Test - @DisplayName("DC-008: DisputeResponseDeadline - 7일 이내 응답 필요") - void merchantMustRespondWithin7Days() { - LocalDateTime createdAt = LocalDateTime.now().minusDays(6); - LocalDateTime deadline = createdAt.plusDays(DISPUTE_RESPONSE_DEADLINE_DAYS); - assertDoesNotThrow(() -> - validateDisputeResponseDeadline(LocalDateTime.now(), deadline)); - } - - @Test - @DisplayName("DC-010: DisputeInquiryPagination - 페이지당 최대 50건") - void inquiryPageSizeMustNotExceed50() { - assertThrows(PaginationLimitExceededException.class, () -> - validateInquiryPageSize(51)); - assertDoesNotThrow(() -> - validateInquiryPageSize(50)); - } - } - - @Nested - @DisplayName("PV: Payout Validation Invariants") - class PayoutValidationTests { - - @Test - @DisplayName("PV-001: PayoutBalanceSufficiency - payout ≤ 가용 잔액") - void payoutMustNotExceedAvailableBalance() { - assertThrows(InsufficientBalanceException.class, () -> - validatePayoutBalance(new BigDecimal("600000"), new BigDecimal("500000"))); - assertDoesNotThrow(() -> - validatePayoutBalance(new BigDecimal("300000"), new BigDecimal("500000"))); - } - - @Test - @DisplayName("PV-002: PayoutMinimumAmount - 최소 1000원") - void payoutMustBeAtLeast1000() { - assertThrows(PayoutAmountBelowMinimumException.class, () -> - validatePayoutMinimum(new BigDecimal("500"), MIN_PAYOUT_AMOUNT)); - assertDoesNotThrow(() -> - validatePayoutMinimum(new BigDecimal("1000"), MIN_PAYOUT_AMOUNT)); - } - - @Test - @DisplayName("PV-003: PayoutMaximumAmount - 일별 최대 10000000원") - void payoutMustNotExceedDailyMaximum() { - assertThrows(PayoutDailyLimitExceededException.class, () -> - validatePayoutDailyLimit(new BigDecimal("9500000"), new BigDecimal("600000"), MAX_PAYOUT_DAILY)); - assertDoesNotThrow(() -> - validatePayoutDailyLimit(new BigDecimal("9500000"), new BigDecimal("400000"), MAX_PAYOUT_DAILY)); - } - - @Test - @DisplayName("PV-004: PayoutFrequencyLimit - 1일 최대 5회") - void payoutFrequencyMustNotExceed5PerDay() { - assertThrows(PayoutFrequencyExceededException.class, () -> - validatePayoutFrequency(5, MAX_PAYOUT_DAILY_COUNT)); - assertDoesNotThrow(() -> - validatePayoutFrequency(4, MAX_PAYOUT_DAILY_COUNT)); - } - - @Test - @DisplayName("PV-009: PayoutProcessingTime - 平日 09:00-17:00") - void payoutMustBeWithinProcessingHours() { - assertThrows(OutsideProcessingHoursException.class, () -> - validateProcessingHours(LocalDateTime.now().withHour(20))); - assertDoesNotThrow(() -> - validateProcessingHours(LocalDateTime.now().withHour(14))); - } - - @Test - @DisplayName("PV-010: PayoutAntiFraudCheck - fraud_score < 0.8") - void payoutMustPassFraudCheck() { - assertThrows(FraudDetectionFailedException.class, () -> - validateFraudScore(0.85)); - assertDoesNotThrow(() -> - validateFraudScore(0.5)); - } - } - - @Nested - @DisplayName("State Transition Tests") - class StateTransitionTests { - - @Test - @DisplayName("Settlement 상태 전이 검증") - void settlementStateTransitions() { - assertTrue(isValidSettlementTransition(SettlementStatus.PENDING, SettlementStatus.COMPLETED)); - assertTrue(isValidSettlementTransition(SettlementStatus.PENDING, SettlementStatus.CANCELLED)); - assertTrue(isValidSettlementTransition(SettlementStatus.COMPLETED, SettlementStatus.CANCELLED)); - assertFalse(isValidSettlementTransition(SettlementStatus.CANCELLED, SettlementStatus.COMPLETED)); - } - - @Test - @DisplayName("Dispute 상태 전이 검증") - void disputeStateTransitions() { - assertTrue(isValidDisputeTransition(DisputeStatus.INITIATED, DisputeStatus.UNDER_REVIEW)); - assertTrue(isValidDisputeTransition(DisputeStatus.UNDER_REVIEW, DisputeStatus.ESCALATED)); - assertFalse(isValidDisputeTransition(DisputeStatus.CLOSED, DisputeStatus.INITIATED)); - } - - @Test - @DisplayName("Payout 상태 전이 검증") - void payoutStateTransitions() { - assertTrue(isValidPayoutTransition(PayoutStatus.PENDING, PayoutStatus.PROCESSING)); - assertTrue(isValidPayoutTransition(PayoutStatus.PROCESSING, PayoutStatus.COMPLETED)); - assertTrue(isValidPayoutTransition(PayoutStatus.FAILED, PayoutStatus.PENDING)); - assertFalse(isValidPayoutTransition(PayoutStatus.COMPLETED, PayoutStatus.PENDING)); - } - } - - // Helper methods - private boolean isCancellable(SettlementStatus status) { - return status == SettlementStatus.COMPLETED || status == SettlementStatus.PENDING; - } - - private void validateCancellationAmount(BigDecimal cancellationAmount, BigDecimal originalAmount) { - if (cancellationAmount.compareTo(originalAmount) > 0) { - throw new InvalidCancellationAmountException("Cancellation amount exceeds original"); - } - } - - private void validateCancellationTimestamp(LocalDateTime cancellationTime, LocalDateTime settlementTime) { - if (!cancellationTime.isAfter(settlementTime)) { - throw new InvalidTimestampException("Cancellation must be after settlement"); - } - } - - private void validateCancellationReason(String reason) { - if (reason == null || reason.length() < MIN_CANCELLATION_REASON_LENGTH) { - throw new MissingCancellationReasonException("Cancellation reason must be at least 10 characters"); - } - } - - private void validatePartialCancellation(BigDecimal remaining, BigDecimal minimum) { - if (remaining.compareTo(minimum) < 0) { - throw new InsufficientRemainingAmountException("Remaining amount below minimum"); - } - } - - private boolean isWithinDisputeWindow(LocalDateTime settlementTime, int windowDays) { - return settlementTime.plusDays(windowDays).isAfter(LocalDateTime.now()); - } - - private void validateDisputeAmount(BigDecimal disputeAmount, BigDecimal settlementAmount) { - if (disputeAmount.compareTo(settlementAmount) > 0) { - throw new DisputeAmountExceedsSettlementException("Dispute amount exceeds settlement"); - } - } - - private void validateChargebackThreshold(BigDecimal amount, BigDecimal threshold) { - if (amount.compareTo(threshold) <= 0) { - throw new ChargebackThresholdNotMetException("Chargeback threshold not met"); - } - } - - private void validateDisputeResponseDeadline(LocalDateTime responseTime, LocalDateTime deadline) { - if (responseTime.isAfter(deadline)) { - throw new DisputeResponseDeadlineExceededException("Dispute response deadline exceeded"); - } - } - - private void validateInquiryPageSize(int pageSize) { - if (pageSize > MAX_INQUIRY_PAGE_SIZE) { - throw new PaginationLimitExceededException("Page size exceeds maximum"); - } - } - - private void validatePayoutBalance(BigDecimal payoutAmount, BigDecimal availableBalance) { - if (payoutAmount.compareTo(availableBalance) > 0) { - throw new InsufficientBalanceException("Insufficient balance"); - } - } - - private void validatePayoutMinimum(BigDecimal amount, BigDecimal minimum) { - if (amount.compareTo(minimum) < 0) { - throw new PayoutAmountBelowMinimumException("Payout amount below minimum"); - } - } - - private void validatePayoutDailyLimit(BigDecimal dailyTotal, BigDecimal payoutAmount, BigDecimal maximum) { - if (dailyTotal.add(payoutAmount).compareTo(maximum) > 0) { - throw new PayoutDailyLimitExceededException("Daily payout limit exceeded"); - } - } - - private void validatePayoutFrequency(int dailyCount, int maxCount) { - if (dailyCount >= maxCount) { - throw new PayoutFrequencyExceededException("Daily payout frequency exceeded"); - } - } - - private void validateProcessingHours(LocalDateTime time) { - int hour = time.getHour(); - boolean isWeekday = time.getDayOfWeek().getValue() < 6; - if (!isWeekday || hour < 9 || hour >= 17) { - throw new OutsideProcessingHoursException("Outside processing hours"); - } - } - - private void validateFraudScore(double fraudScore) { - if (fraudScore >= 0.8) { - throw new FraudDetectionFailedException("Fraud detection failed"); - } - } - - private boolean isValidSettlementTransition(SettlementStatus from, SettlementStatus to) { - return switch (from) { - case PENDING -> to == SettlementStatus.COMPLETED || to == SettlementStatus.CANCELLED; - case COMPLETED -> to == SettlementStatus.CANCELLED || to == SettlementStatus.REFUNDED; - case CANCELLED, REFUNDED -> false; - }; - } - - private boolean isValidDisputeTransition(DisputeStatus from, DisputeStatus to) { - return switch (from) { - case INITIATED -> to == DisputeStatus.UNDER_REVIEW || to == DisputeStatus.CLOSED; - case UNDER_REVIEW -> to == DisputeStatus.ESCALATED || to == DisputeStatus.RESOLVED; - case ESCALATED -> to == DisputeStatus.RESOLVED || to == DisputeStatus.CLOSED; - case RESOLVED, CLOSED -> false; - }; - } - - private boolean isValidPayoutTransition(PayoutStatus from, PayoutStatus to) { - return switch (from) { - case PENDING -> to == PayoutStatus.PROCESSING || to == PayoutStatus.FAILED; - case PROCESSING -> to == PayoutStatus.COMPLETED || to == PayoutStatus.FAILED; - case FAILED -> to == PayoutStatus.PENDING; - case COMPLETED -> false; - }; - } -} diff --git a/account-migration/src/test/resources/application-test.yml b/account-migration/src/test/resources/application-test.yml deleted file mode 100644 index 11e478a..0000000 --- a/account-migration/src/test/resources/application-test.yml +++ /dev/null @@ -1,36 +0,0 @@ -spring: - datasource: - url: jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE - driver-class-name: org.h2.Driver - username: sa - password: - jpa: - hibernate: - ddl-auto: create-drop - show-sql: true - properties: - hibernate: - format_sql: true - data: - redis: - host: localhost - port: 6379 - -settlement: - cancellation: - min-reason-length: 10 - min-remaining-amount: 1000 - dispute: - window-days: 90 - response-deadline-days: 7 - chargeback-threshold: 100000 - max-inquiry-page-size: 50 - -payout: - min-amount: 1000 - max-daily-amount: 10000000 - max-daily-count: 5 - processing-hours: - start: 9 - end: 17 - fraud-threshold: 0.8