계정/정산 업무 규칙과 전환 불변식 추출 #3

Open
forge-bot wants to merge 9 commits from forge/codex-bais-final3-20260713-192718-BAIS-SPRING-ANA-ACCOUNT-001-attempt-1-run-a2485546e101 into main
Showing only changes of commit cb0bce269f - Show all commits

View file

@ -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;
};
}
}