계정/정산 업무 규칙과 전환 불변식 추출 #3
9 changed files with 842 additions and 0 deletions
|
|
@ -0,0 +1,3 @@
|
||||||
|
# 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`.
|
||||||
44
account-migration/README.md
Normal file
44
account-migration/README.md
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
# 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
|
||||||
77
account-migration/pom.xml
Normal file
77
account-migration/pom.xml
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
|
||||||
|
<parent>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-parent</artifactId>
|
||||||
|
<version>3.2.5</version>
|
||||||
|
<relativePath/>
|
||||||
|
</parent>
|
||||||
|
|
||||||
|
<groupId>com.payman</groupId>
|
||||||
|
<artifactId>account-migration</artifactId>
|
||||||
|
<version>1.0.0-SNAPSHOT</version>
|
||||||
|
<packaging>jar</packaging>
|
||||||
|
|
||||||
|
<name>account-migration</name>
|
||||||
|
<description>Account Migration Module - Transaction Semantics</description>
|
||||||
|
|
||||||
|
<properties>
|
||||||
|
<java.version>17</java.version>
|
||||||
|
<maven.compiler.source>17</maven.compiler.source>
|
||||||
|
<maven.compiler.target>17</maven.compiler.target>
|
||||||
|
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||||
|
</properties>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-data-jpa</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-validation</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-data-redis</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>jakarta.persistence</groupId>
|
||||||
|
<artifactId>jakarta.persistence-api</artifactId>
|
||||||
|
<version>3.1.0</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>jakarta.validation</groupId>
|
||||||
|
<artifactId>jakarta.validation-api</artifactId>
|
||||||
|
<version>3.0.2</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.h2database</groupId>
|
||||||
|
<artifactId>h2</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-test</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
|
||||||
|
<build>
|
||||||
|
<plugins>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||||
|
</plugin>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
|
<artifactId>maven-surefire-plugin</artifactId>
|
||||||
|
<version>3.2.5</version>
|
||||||
|
</plugin>
|
||||||
|
</plugins>
|
||||||
|
</build>
|
||||||
|
</project>
|
||||||
|
|
@ -0,0 +1,121 @@
|
||||||
|
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; }
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
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); }
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,150 @@
|
||||||
|
# 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*
|
||||||
|
|
@ -0,0 +1,344 @@
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
36
account-migration/src/test/resources/application-test.yml
Normal file
36
account-migration/src/test/resources/application-test.yml
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
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
|
||||||
Loading…
Add table
Add a link
Reference in a new issue