Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 116 additions & 0 deletions docs/detailed_docs/en/adr-payment-saga-module-api.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
# ADR: Payment Domain with Saga/Compensation Pattern and Inter-Domain Module APIs

## Status

Accepted

---

## Context

The Concert Reservation System requires payment processing that coordinates multiple domains — **Payment**, **Point**, and **Reservation** — within a single business transaction. Specifically:

- **Processing a payment** involves: deducting user points, confirming a reservation, and recording the payment.
- **Processing a refund** involves: restoring user points, cancelling a reservation, and recording the refund.

These cross-domain operations introduce the following challenges:

1. **Partial failure**: Any step in the multi-step flow can fail independently. Without proper handling, the system may end up in an inconsistent state (e.g., points deducted but reservation not confirmed).
2. **Synchronous user expectation**: Payment is a user-facing operation where the user expects a deterministic result immediately upon request. The coordination mechanism must be able to return a definitive success or failure within the same request-response cycle.
3. **Tight cross-domain coupling**: Prior to this change, domains called each other's services directly, creating hidden dependencies and making it difficult to reason about failure boundaries.
4. **Transient failures under concurrency**: Point balance operations use optimistic locking, which is prone to transient conflicts during high-concurrency scenarios.

---

## Decision

### 1. Saga/Compensation Pattern for Payment Orchestration

The `PaymentOrchestrator` coordinates multi-step payment and refund flows using the **orchestration-based saga pattern**.

**Payment Flow:**
1. Create a `PENDING` payment record.
2. Decrease user point balance via `PointModuleApi`.
3. On failure at step 2: mark payment as `FAILED` and return (no compensation needed — no side effect has occurred).
4. Confirm reservation via `ReservationModuleApi`.
5. On success: mark payment as `COMPLETED`.
6. On failure at step 4: execute compensating transaction (restore points), mark payment as `FAILED`.
7. If compensation itself fails: persist a `CompensationTxLog` entry for deferred retry.

**Refund Flow:**
1. Create a `PENDING` payment record.
2. Increase user point balance via `PointModuleApi`.
3. On failure at step 2: mark payment as `FAILED` and return (no compensation needed).
4. Cancel reservation via `ReservationModuleApi`.
5. On success: mark payment as `REFUNDED`.
6. On failure at step 4: execute compensating transaction (deduct restored points), mark payment as `FAILED`.
7. If compensation itself fails: persist a `CompensationTxLog` entry with negative price for deferred retry.

A `CompensationTxRetryScheduler` runs on a fixed 60-second interval, picking up all `PENDING` compensation logs and retrying them up to a maximum of 3 attempts.

### 2. Module API Layer for Inter-Domain Communication

Each domain exposes a public contract through a **Module API** interface:

- `PointModuleApi` — declares `decreaseUserPointBalance()` and `increaseUserPointBalance()`
- `ReservationModuleApi` — declares `confirmReservation()` and `cancelReservation()`

Each interface is accompanied by:
- An **operation result record** (`PointOperationResult`, `ReservationOperationResult`) that wraps success/failure status and error codes.
- A **facade implementation** (`PointModuleFacade`, `ReservationModuleFacade`) that delegates to internal domain services and translates exceptions into result objects.

Callers (e.g., `PaymentOrchestrator`) depend **only on the interface**, not on concrete service classes. This enforces a clear boundary between domains and makes it straightforward to evolve each domain independently.

### 3. Retry with Exponential Backoff (Point Domain)

`PointModuleFacade` applies Spring `@Retryable` to handle transient failures:

| Configuration | Value |
|---------------------|-----------------------------------------|
| Max attempts | 3 |
| Initial delay | 100 ms |
| Multiplier | 2.0 (100ms → 200ms → 400ms) |
| Excluded exceptions | `BusinessRuleViolationException`, `OptimisticLockingFailureException` |

Non-retryable exceptions (business rule violations, optimistic lock failures) are returned immediately as a failed result via the `@Recover` method, avoiding unnecessary retry on deterministic failures.

Comment on lines +73 to +76

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Retry policy is internally contradictory for optimistic locking failures.

Line 73 excludes OptimisticLockingFailureException from retries, but Line 99 and Line 110 describe optimistic lock conflicts as transient and improved by retry. Please align these sections so the ADR reflects one consistent policy.

Proposed ADR text adjustment
-| Excluded exceptions | `BusinessRuleViolationException`, `OptimisticLockingFailureException` |
+| Excluded exceptions | `BusinessRuleViolationException` |

If optimistic locking is intentionally excluded, then Line 99 and Line 110 should be rewritten accordingly.

Also applies to: 99-100, 110-110

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/detailed_docs/en/adr-payment-saga-module-api.md` around lines 73 - 76,
The ADR currently contradicts itself about OptimisticLockingFailureException:
the table at line referencing "Excluded exceptions" lists
OptimisticLockingFailureException as non-retryable, while later paragraphs
(around the text referencing retries and transient optimistic lock conflicts,
including mentions near the `@Recover` behavior) treat optimistic lock failures as
transient and recommend retry; pick one consistent policy and update all
occurrences accordingly—either remove OptimisticLockingFailureException from the
excluded list and change the descriptions around retry/@Recover to mark
optimistic locks as transient and retriable, or keep it excluded and rewrite the
later paragraphs (and any `@Recover` behavior text) to state optimistic lock
conflicts are deterministic/non-retriable; be sure to update the table entry for
Excluded exceptions, the paragraphs discussing transient optimistic lock
conflicts, and any references to `@Recover` to reflect the chosen policy and use
the exact symbol OptimisticLockingFailureException where applicable.

### 4. Append-Only Payment Model

The `Payment` domain model follows an **append-only** design:

- No setters — state transitions produce a new instance via `withStatus()`.
- Each status change is persisted as a new record, preserving a complete audit trail.
- Statuses: `PENDING` → `COMPLETED` | `FAILED` | `REFUNDED`.

---

## Alternatives Considered

### Choreography-Based Saga (Event-Driven)
- Each domain publishes events and reacts to events from other domains. No central coordinator — domains are fully decoupled.
- Rejected because payment processing requires a **synchronous, deterministic response** to the user. In a choreography saga, the outcome is resolved asynchronously through event propagation, making it impossible to return a definitive success or failure within the same request-response cycle. The orchestration approach keeps the entire flow within a single synchronous call, giving the user immediate feedback on their payment or refund request.

### Direct Service-to-Service Calls (No API Layer)
- Simpler to implement — just inject and call the target domain's service directly.
- Rejected because it creates hidden cross-domain dependencies, makes failure handling inconsistent, and complicates future migration to a distributed architecture (e.g., microservices).

### Fail-Fast Without Retry
- Simpler to reason about — every failure is immediately surfaced to the caller.
- Rejected for the Point domain because optimistic lock conflicts under concurrent reservations are **expected** transient failures. A brief exponential backoff significantly improves success rates without adding meaningful latency.

---

## Consequences

### Positive

- **Clear domain boundaries**: Module API interfaces serve as explicit contracts. Cross-domain dependencies are visible and auditable in the interface definitions.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Use non-redundant wording for API contracts.

Line 107 can be tightened: “Module APIs serve as explicit contracts” (instead of “Module API interfaces…”).

🧰 Tools
🪛 LanguageTool

[style] ~107-~107: This phrase is redundant (‘I’ stands for ‘interfaces’). Use simply “APIs”.
Context: ... - Clear domain boundaries: Module API interfaces serve as explicit contracts. Cross-doma...

(ACRONYM_TAUTOLOGY)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/detailed_docs/en/adr-payment-saga-module-api.md` at line 107, The
wording on line 107 is redundant; replace "Module API interfaces serve as
explicit contracts." with the tighter phrasing "Module APIs serve as explicit
contracts." and ensure the following sentence ("Cross-domain dependencies are
visible and auditable in the interface definitions.") still reads
fluently—adjust "interface definitions" to "API definitions" if needed so both
sentences consistently use "API" instead of "interface".

- **Fault-tolerant payment flows**: The compensation pattern ensures the system recovers from partial failures automatically, with scheduled retry as a safety net.
- **Audit trail**: Append-only payment records provide a complete history of state transitions for debugging and compliance.
- **Retry resilience**: Point operations gracefully handle transient concurrency conflicts without propagating failures to the user.

### Negative / Trade-offs

- **Eventual consistency**: When compensation is deferred to `CompensationTxLog`, the system is temporarily inconsistent (up to 60 seconds per retry cycle, maximum 3 attempts). Monitoring and alerting on failed compensation logs is operationally required.
- **Operational overhead**: The `CompensationTxRetryScheduler` and `CompensationTxLog` table require monitoring to detect cases where all retry attempts are exhausted (manual intervention needed).
- **Asymmetric retry**: Only `PointModuleFacade` implements retry logic. `ReservationModuleFacade` uses simple exception handling — a single transient failure at the reservation step will trigger the compensation path rather than retrying in-place.
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.retry.annotation.EnableRetry;
import org.springframework.scheduling.annotation.EnableScheduling;

@SpringBootApplication
@EnableScheduling
@EnableRetry
public class Hhp7ConcertReservationApplication {

public static void main(String[] args) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package com.slam.concertreservation.domain.payment.application;

import com.slam.concertreservation.domain.payment.model.CompensationTxLog;
import com.slam.concertreservation.domain.payment.service.CompensationTxLogService;
import com.slam.concertreservation.domain.point.api.PointModuleApi;
import com.slam.concertreservation.domain.point.api.PointOperationResult;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

@Component
@RequiredArgsConstructor
public class CompensationTxRetryScheduler {

private final CompensationTxLogService compensationTxLogService;
private final PointModuleApi pointModuleApi;

@Scheduled(fixedDelay = 60000) // 1분 간격
public void retryFailedCompensations() {
List<CompensationTxLog> targets = compensationTxLogService.getAllRetriables();

for (CompensationTxLog log : targets) {
PointOperationResult result;

// 양수: 포인트 복구(증가), 음수: 포인트 회수(차감)
if (log.getPrice() > 0) {
result = pointModuleApi.increaseUserPointBalance(log.getUserId(), log.getPrice());
} else {
result = pointModuleApi.decreaseUserPointBalance(log.getUserId(), Math.abs(log.getPrice()));
}
Comment on lines +27 to +31

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Edge case: price == 0 falls into the else branch.

If log.getPrice() is 0, it will call decreaseUserPointBalance with amount 0. While likely a no-op, consider guarding with an explicit check or logging a warning, since a zero-price compensation log is likely a bug upstream.

🤖 Prompt for AI Agents
In
`@src/main/java/com/slam/concertreservation/domain/payment/application/CompensationTxRetryScheduler.java`
around lines 27 - 31, In CompensationTxRetryScheduler, guard the price==0 case
so it doesn't fall through to decreaseUserPointBalance with 0: update the method
that currently branches on log.getPrice() (using log.getPrice(),
pointModuleApi.increaseUserPointBalance and
pointModuleApi.decreaseUserPointBalance) to explicitly check for price == 0
first, log a warning or error mentioning the unexpected zero-price compensation
(include identifying info like log.getUserId()/log id), and skip calling the
pointModuleApi in that case; keep the existing increase/decrease branches for
positive/negative prices unchanged.


if (result.success()) {
compensationTxLogService.markAsCompleted(log);
} else {
compensationTxLogService.markAsFailed(log);
}
}
Comment on lines +19 to +38

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check the CompensationTxLogService implementation
echo "=== Finding CompensationTxLogService ==="
find . -type f -name "CompensationTxLogService.java" | head -5

echo "=== Searching for getAllRetriables method ==="
rg -n "getAllRetriables" --type=java -B2 -A10

echo "=== Searching for markAsFailed method ==="
rg -n "markAsFailed" --type=java -B2 -A10

echo "=== Searching for findAllByStatus usage in service ==="
rg -n "findAllByStatus" --type=java -B2 -A3

Repository: leonroars/slam

Length of output: 39115


🏁 Script executed:

#!/bin/bash
# Check the full CompensationTxRetryScheduler implementation
echo "=== Full CompensationTxRetryScheduler.java ==="
cat -n src/main/java/com/slam/concertreservation/domain/payment/application/CompensationTxRetryScheduler.java

echo ""
echo "=== Check if there are other scheduled methods or retry mechanisms ==="
rg -n "@Scheduled|retryCount|FAILED" src/main/java/com/slam/concertreservation/domain/payment --type=java -B1 -A1 | head -40

Repository: leonroars/slam

Length of output: 5690


Compensation logs get only one retry — FAILED status is never re-fetched.

getAllRetriables() queries only PENDING logs. When a retry fails, markAsFailed() sets status to FAILED, so the log is never picked up again. This means the scheduler provides a single additional attempt, not continuous retries. For an eventual-consistency mechanism, consider either keeping logs in PENDING with an incremented retry count and a retry cap, or querying both PENDING and FAILED with a configurable max retry limit.

🤖 Prompt for AI Agents
In
`@src/main/java/com/slam/concertreservation/domain/payment/application/CompensationTxRetryScheduler.java`
around lines 19 - 38, retryFailedCompensations() only reprocesses PENDING
because getAllRetriables() excludes FAILED and markAsFailed() flips status to
FAILED, so logs get only one retry; change the flow so retries continue until a
max: either (A) modify compensationTxLogService.getAllRetriables() to return
entries with status PENDING or FAILED whose retryCount < maxRetries
(configurable) and leave markAsFailed() to only increment retryCount and
optionally set status=FAILED only when retryCount >= maxRetries, or (B) keep
getAllRetriables() returning only PENDING but change
markAsFailed(CompensationTxLog) to increment and persist a retryCount and keep
status=PENDING until retryCount exceeds maxRetries then set FAILED; update
CompensationTxLog to track retryCount and ensure retry cap is enforced before
calling markAsCompleted()/final failure.

}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
package com.slam.concertreservation.domain.payment.application;

import com.slam.concertreservation.domain.payment.service.CompensationTxLogService;
import com.slam.concertreservation.domain.point.api.PointOperationResult;
import com.slam.concertreservation.domain.reservation.api.ReservationOperationResult;
import com.slam.concertreservation.interfaces.dto.PaymentProcessResponse;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Presentation-layer DTO (PaymentProcessResponse) leaks into the domain/application layer.

PaymentOrchestrator lives in domain.payment.application but imports and returns interfaces.dto.PaymentProcessResponse. This couples the domain layer to the presentation layer, violating the layered architecture boundaries this PR establishes for other modules (Point, Reservation expose only api interfaces and DTOs).

The orchestrator should return a domain object (e.g., Payment) and let the controller map to the response DTO.

Proposed approach
-import com.slam.concertreservation.interfaces.dto.PaymentProcessResponse;
 ...
-    public PaymentProcessResponse processPayment(Long userId, int price, Long reservationId) {
+    public Payment processPayment(Long userId, int price, Long reservationId) {
         Payment initiatedPayment = paymentService.initiate(userId, price, reservationId);
 
         PointOperationResult deduction = pointModuleApi.decreaseUserPointBalance(userId, price);
         if (!deduction.success()) {
-            return PaymentProcessResponse.from(paymentService.fail(initiatedPayment));
+            return paymentService.fail(initiatedPayment);
         }
         // ... similar changes for other return statements

Then in the controller:

-    PaymentProcessResponse response = paymentOrchestrator.processPayment(userId, price, reservationId);
-    return ResponseEntity.ok(response);
+    Payment payment = paymentOrchestrator.processPayment(userId, price, reservationId);
+    return ResponseEntity.ok(PaymentProcessResponse.from(payment));

Also applies to: 26-50

🤖 Prompt for AI Agents
In
`@src/main/java/com/slam/concertreservation/domain/payment/application/PaymentOrchestrator.java`
at line 6, PaymentOrchestrator currently imports and returns the presentation
DTO PaymentProcessResponse, leaking presentation concerns into the
domain/application layer; change the orchestrator to return a domain-level
Payment (or other domain result object) instead of PaymentProcessResponse,
remove the import of
com.slam.concertreservation.interfaces.dto.PaymentProcessResponse from
PaymentOrchestrator, update the orchestrator method signature(s) (e.g.,
processPayment/orchestratePayment) and any internal returns to produce the
domain Payment, and move the mapping to PaymentProcessResponse into the
controller layer so controllers call PaymentOrchestrator to get the domain
Payment and translate it to the DTO for the API response.

import com.slam.concertreservation.domain.payment.model.Payment;
import com.slam.concertreservation.domain.payment.service.PaymentService;
import com.slam.concertreservation.domain.point.api.PointModuleApi;
import com.slam.concertreservation.domain.reservation.api.ReservationModuleApi;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;

@Component
@RequiredArgsConstructor
public class PaymentOrchestrator {

private final PaymentService paymentService;

/** 외부 모듈 인터페이스 **/
private final ReservationModuleApi reservationModuleApi;
private final PointModuleApi pointModuleApi;

private final CompensationTxLogService compensationTxLogService;

public PaymentProcessResponse processPayment(Long userId, int price, Long reservationId) {
// 1. PENDING 상태의 Payment 생성
Payment initiatedPayment = paymentService.initiate(userId, price, reservationId);

// 2. 포인트 차감
PointOperationResult deduction = pointModuleApi.decreaseUserPointBalance(userId, price);
if (!deduction.success()) {
return PaymentProcessResponse.from(paymentService.fail(initiatedPayment));
}

// 3. 예약 확정
ReservationOperationResult confirmation = reservationModuleApi.confirmReservation(reservationId);
if (!confirmation.success()) {
compensatePointDeduction(
initiatedPayment.getUserId(),
initiatedPayment.getReservationId(),
initiatedPayment.getPaymentId(),
initiatedPayment.getPrice());

return PaymentProcessResponse.from(paymentService.fail(initiatedPayment));
}

// 4. 결제 확정
return PaymentProcessResponse.from(paymentService.complete(initiatedPayment));
Comment on lines +48 to +49

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

No error handling if the final complete/refund step throws.

If paymentService.complete(initiatedPayment) on line 49 (or paymentService.refund on line 75) throws an exception, points have already been deducted and the reservation confirmed, but the payment record stays PENDING. There is no compensation path for this scenario.

Consider wrapping the final persistence step in a try-catch that either retries or logs a compensation entry so the payment record can be reconciled.

🤖 Prompt for AI Agents
In
`@src/main/java/com/slam/concertreservation/domain/payment/application/PaymentOrchestrator.java`
around lines 48 - 49, Wrap the final persistence calls
paymentService.complete(initiatedPayment) and
paymentService.refund(initiatedPayment) in a try-catch inside
PaymentOrchestrator so a thrown exception doesn't leave the system with deducted
points and a PENDING payment; on catch, perform a compensating action such as
retrying the persistence (with a limited retry loop/backoff) or
creating/flagging a reconciliation/compensation record (e.g., via a new
paymentRepository.createCompensationEntry or
paymentService.markForReconciliation) and log the full error and context, then
return an appropriate failure response instead of letting the exception escape;
ensure PaymentProcessResponse.from is only called after the persistence
succeeds.

}

public PaymentProcessResponse processRefund(Long userId, int price, Long reservationId) {
// 1. PENDING 상태의 Payment 생성
Payment initiatedRefund = paymentService.initiate(userId, price, reservationId);

// 2. 포인트 원상 복구
PointOperationResult restoration = pointModuleApi.increaseUserPointBalance(userId, price);
if (!restoration.success()) {
return PaymentProcessResponse.from(paymentService.fail(initiatedRefund));
}

// 3. 예약 취소
ReservationOperationResult cancellation = reservationModuleApi.cancelReservation(reservationId);
if (!cancellation.success()) {
compensatePointIncrease(
initiatedRefund.getUserId(),
initiatedRefund.getReservationId(),
initiatedRefund.getPaymentId(),
initiatedRefund.getPrice());

return PaymentProcessResponse.from(paymentService.fail(initiatedRefund));
}

// 4. 환불 확정
return PaymentProcessResponse.from(paymentService.refund(initiatedRefund));
}
Comment on lines +52 to +76

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Refund flow doesn't validate current reservation/payment state before proceeding.

processRefund accepts userId, price, and reservationId as raw parameters without verifying the reservation is in a refundable state (e.g., CONFIRMED) or that a completed payment exists for it. A caller could trigger a refund for an already-cancelled reservation, causing points to be credited without a valid charge to reverse.

Consider fetching and validating the existing payment/reservation state before initiating the refund.

🤖 Prompt for AI Agents
In
`@src/main/java/com/slam/concertreservation/domain/payment/application/PaymentOrchestrator.java`
around lines 52 - 76, Before creating a PENDING refund in processRefund, fetch
and validate the current reservation and payment state: call
reservationModuleApi.getReservation(reservationId) (or equivalent) and verify
it's in a refundable state (e.g., CONFIRMED) and fetch the existing payment via
paymentService.findByReservationId(reservationId) to ensure a completed charge
exists; if validation fails, return a failure response without calling
paymentService.initiate, pointModuleApi.increaseUserPointBalance,
reservationModuleApi.cancelReservation, or paymentService.refund. Keep existing
compensation logic (compensatePointIncrease and paymentService.fail) for
downstream failures but only after the initial pre-checks pass.


private void compensatePointDeduction(Long userId, Long reservationId, Long paymentId, int price) {
PointOperationResult result = pointModuleApi.increaseUserPointBalance(userId, price);
if (!result.success()) {
compensationTxLogService.log(userId, reservationId, paymentId, price);
}
}

private void compensatePointIncrease(Long userId, Long reservationId, Long paymentId, int price) {
PointOperationResult result = pointModuleApi.decreaseUserPointBalance(userId, price);
if (!result.success()) {
compensationTxLogService.log(userId, reservationId, paymentId, -price);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
package com.slam.concertreservation.domain.payment.model;

import com.slam.concertreservation.common.error.ErrorCode;
import com.slam.concertreservation.common.exceptions.BusinessRuleViolationException;
import io.hypersistence.tsid.TSID;
import java.time.LocalDateTime;
import lombok.Getter;

/**
* 보상 트랜잭션 로그 모델
*/
@Getter
public class CompensationTxLog {

public static final int MAX_RETRY_COUNT = 3;

private Long txLogId;
private Long paymentId;
private Long userId;
private Long reservationId;
private int price;
private CompensationTxStatus status;
private int retryCount;
private LocalDateTime createdAt;

private CompensationTxLog(){}
private CompensationTxLog(
Long txLogId,
Long paymentId,
Long userId,
Long reservationId,
int price,
CompensationTxStatus status,
int retryCount,
LocalDateTime createdAt) {
this.txLogId = txLogId;
this.paymentId = paymentId;
this.userId = userId;
this.reservationId = reservationId;
this.price = price;
this.status = status;
this.retryCount = retryCount;
this.createdAt = createdAt;
}


public static CompensationTxLog create(Long userId, Long reservationId, Long paymentId, int price){
CompensationTxLog txLog = new CompensationTxLog();
txLog.txLogId = TSID.fast().toLong();
txLog.userId = userId;
txLog.reservationId = reservationId;
txLog.paymentId = paymentId;
txLog.price = price;
txLog.status = CompensationTxStatus.PENDING;
txLog.retryCount = 0;
txLog.createdAt = LocalDateTime.now();
return txLog;
}

public static CompensationTxLog restore(
Long txLogId,
Long paymentId,
Long userId,
Long reservationId,
int price,
CompensationTxStatus status,
int retryCount,
LocalDateTime createdAt) {
return new CompensationTxLog(
txLogId,
paymentId,
userId,
reservationId,
price,
status,
retryCount,
createdAt
);
}

public CompensationTxLog markAsCompleted() {
this.status = CompensationTxStatus.COMPLETED;
return this;
}

public CompensationTxLog markAsFailed() {
if(this.status == CompensationTxStatus.COMPLETED) {
throw new BusinessRuleViolationException(ErrorCode.INVALID_REQUEST, "이미 완료 처리된 보상 트랜잭션입니다.");
}
this.status = CompensationTxStatus.FAILED;
this.incrementRetryCount();
return this;
}

private void incrementRetryCount() {++this.retryCount;}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package com.slam.concertreservation.domain.payment.model;

public enum CompensationTxStatus {
COMPLETED, FAILED, PENDING
}
Loading
Loading