From d80ff9ac627c4dfd1362f7056458b3bea5545761 Mon Sep 17 00:00:00 2001 From: Ho Yeon CHAE Date: Thu, 12 Feb 2026 18:51:22 +0900 Subject: [PATCH 1/7] Payment : Add domain models and enums --- .../payment/model/CompensationTxLog.java | 96 ++++++++++++++++++ .../payment/model/CompensationTxStatus.java | 5 + .../domain/payment/model/Payment.java | 99 +++++++++++++++++++ .../domain/payment/model/PaymentStatus.java | 5 + 4 files changed, 205 insertions(+) create mode 100644 src/main/java/com/slam/concertreservation/domain/payment/model/CompensationTxLog.java create mode 100644 src/main/java/com/slam/concertreservation/domain/payment/model/CompensationTxStatus.java create mode 100644 src/main/java/com/slam/concertreservation/domain/payment/model/Payment.java create mode 100644 src/main/java/com/slam/concertreservation/domain/payment/model/PaymentStatus.java diff --git a/src/main/java/com/slam/concertreservation/domain/payment/model/CompensationTxLog.java b/src/main/java/com/slam/concertreservation/domain/payment/model/CompensationTxLog.java new file mode 100644 index 0000000..482cb8b --- /dev/null +++ b/src/main/java/com/slam/concertreservation/domain/payment/model/CompensationTxLog.java @@ -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;} +} diff --git a/src/main/java/com/slam/concertreservation/domain/payment/model/CompensationTxStatus.java b/src/main/java/com/slam/concertreservation/domain/payment/model/CompensationTxStatus.java new file mode 100644 index 0000000..48abb02 --- /dev/null +++ b/src/main/java/com/slam/concertreservation/domain/payment/model/CompensationTxStatus.java @@ -0,0 +1,5 @@ +package com.slam.concertreservation.domain.payment.model; + +public enum CompensationTxStatus { + COMPLETED, FAILED, PENDING +} diff --git a/src/main/java/com/slam/concertreservation/domain/payment/model/Payment.java b/src/main/java/com/slam/concertreservation/domain/payment/model/Payment.java new file mode 100644 index 0000000..271017f --- /dev/null +++ b/src/main/java/com/slam/concertreservation/domain/payment/model/Payment.java @@ -0,0 +1,99 @@ +package com.slam.concertreservation.domain.payment.model; + +import io.hypersistence.tsid.TSID; +import java.time.LocalDateTime; +import lombok.Getter; + +/** + * 결제 도메인 모델 + *

+ * 결제 도메인은 사용자의 결제 요청을 처리하고, 결제 상태를 관리하는 역할을 담당합니다. + *

+ * - Payment 데이터의 DB 저장은 도메인 성격을 고려하여 Append-Only 방식을 채택합니다. + *

+ * - 따라서 결제 상태 변경 시 기존 데이터를 수정하지 않고, 새로운 Payment 레코드를 생성하여 상태를 기록합니다. + *

+ * - 정적 팩토리 메서드를 활용해, 각 생성 시나리오 별로 명확한 인스턴스 생성을 지원합니다. + *

+ * - 결제 정보 특성 상, 의도하지 않은 상태 변경을 방지 및 Append-only 특성에 맞게 상태 변경은 Setter 배제하고 새로운 인스턴스 생성을 통해서만 이루어지도록 설계되었습니다. + */ +@Getter +public class Payment { + private Long paymentId; + private Long userId; + private Long reservationId; + private int price; + private PaymentStatus status; + private LocalDateTime createdAt; + + private Payment() {} + private Payment( + Long paymentId, + Long userId, + Long reservationId, + int price, + PaymentStatus paymentStatus, + LocalDateTime createdAt) { + this.userId = userId; + this.reservationId = reservationId; + this.price = price; + this.paymentId = paymentId; + this.status = paymentStatus; + this.createdAt = createdAt; + } + + /** + * 결제 요청에 대한 새로운 Payment 인스턴스를 생성 + * @param userId + * @param price + * @param reservationId + * @return + */ + public static Payment create(Long userId, int price, Long reservationId) { + Payment payment = new Payment(); + payment.paymentId = TSID.fast().toLong(); + payment.userId = userId; + payment.price = price; + payment.reservationId = reservationId; + payment.status = PaymentStatus.PENDING; + payment.createdAt = LocalDateTime.now(); + return payment; + } + + /** + * DB로부터 조회한 Payment JPA Entity -> Payment 도메인 모델로 복원 + * @param paymentId + * @param userId + * @param price + * @param reservationId + * @param status + * @param createdAt + * @return + */ + public static Payment restore( + Long paymentId, + Long userId, + Long reservationId, + int price, + PaymentStatus status, + LocalDateTime createdAt) + { + Payment payment = new Payment(); + payment.paymentId = paymentId; + payment.userId = userId; + payment.price = price; + payment.reservationId = reservationId; + payment.status = status; + payment.createdAt = createdAt; + return payment; + } + + /** + * 결제 상태 변경 시, 새로운 Payment 인스턴스를 반환 + * @param paymentStatus + * @return + */ + public Payment withStatus(PaymentStatus paymentStatus) { + return new Payment(this.paymentId, this.userId, this.reservationId, this.price, paymentStatus, this.createdAt); + } +} diff --git a/src/main/java/com/slam/concertreservation/domain/payment/model/PaymentStatus.java b/src/main/java/com/slam/concertreservation/domain/payment/model/PaymentStatus.java new file mode 100644 index 0000000..370ab8a --- /dev/null +++ b/src/main/java/com/slam/concertreservation/domain/payment/model/PaymentStatus.java @@ -0,0 +1,5 @@ +package com.slam.concertreservation.domain.payment.model; + +public enum PaymentStatus { + COMPLETED, FAILED, REFUNDED, PENDING +} From 87cd310d2cc3fd6faac307d3c8df8c3741ddbbfc Mon Sep 17 00:00:00 2001 From: Ho Yeon CHAE Date: Thu, 12 Feb 2026 18:51:22 +0900 Subject: [PATCH 2/7] Payment : Add infrastructure layer with JPA entities and repositories --- .../CompensationTxLogRepository.java | 31 ++++++ .../payment/repository/PaymentRepository.java | 24 +++++ .../jpa/CompensationTxLogJpaRepository.java | 25 +++++ .../persistence/jpa/PaymentJpaRepository.java | 19 ++++ .../entities/CompensationTxLogJpaEntity.java | 48 +++++++++ .../jpa/entities/PaymentJpaEntity.java | 41 ++++++++ .../impl/CompensationTxLogRepositoryImpl.java | 98 +++++++++++++++++++ .../jpa/impl/PaymentRepositoryImpl.java | 62 ++++++++++++ 8 files changed, 348 insertions(+) create mode 100644 src/main/java/com/slam/concertreservation/domain/payment/repository/CompensationTxLogRepository.java create mode 100644 src/main/java/com/slam/concertreservation/domain/payment/repository/PaymentRepository.java create mode 100644 src/main/java/com/slam/concertreservation/infrastructure/persistence/jpa/CompensationTxLogJpaRepository.java create mode 100644 src/main/java/com/slam/concertreservation/infrastructure/persistence/jpa/PaymentJpaRepository.java create mode 100644 src/main/java/com/slam/concertreservation/infrastructure/persistence/jpa/entities/CompensationTxLogJpaEntity.java create mode 100644 src/main/java/com/slam/concertreservation/infrastructure/persistence/jpa/entities/PaymentJpaEntity.java create mode 100644 src/main/java/com/slam/concertreservation/infrastructure/persistence/jpa/impl/CompensationTxLogRepositoryImpl.java create mode 100644 src/main/java/com/slam/concertreservation/infrastructure/persistence/jpa/impl/PaymentRepositoryImpl.java diff --git a/src/main/java/com/slam/concertreservation/domain/payment/repository/CompensationTxLogRepository.java b/src/main/java/com/slam/concertreservation/domain/payment/repository/CompensationTxLogRepository.java new file mode 100644 index 0000000..613c970 --- /dev/null +++ b/src/main/java/com/slam/concertreservation/domain/payment/repository/CompensationTxLogRepository.java @@ -0,0 +1,31 @@ +package com.slam.concertreservation.domain.payment.repository; + +import com.slam.concertreservation.domain.payment.model.CompensationTxLog; +import com.slam.concertreservation.domain.payment.model.CompensationTxStatus; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +public interface CompensationTxLogRepository { + + CompensationTxLog save(CompensationTxLog compensationTxLog); + + List saveAll(List compensationTxLogs); + + Optional findById(Long txId); + + List findAllByStatus(CompensationTxStatus compensationTxStatus); + + List findAllByRetryCountGreaterThanEqual(int targetRetryCount); + + List findAllByRetryCountLessThan(int targetRetryCount); + + List findAllByUserId(Long userId); + + List findAllByReservationId(Long reservationId); + + List findAllByUserIdAndReservationId(Long userId, Long reservationId); + + List findAllCreatedAtBetween(LocalDateTime startDateTime, LocalDateTime endDateTime); + +} diff --git a/src/main/java/com/slam/concertreservation/domain/payment/repository/PaymentRepository.java b/src/main/java/com/slam/concertreservation/domain/payment/repository/PaymentRepository.java new file mode 100644 index 0000000..ca09870 --- /dev/null +++ b/src/main/java/com/slam/concertreservation/domain/payment/repository/PaymentRepository.java @@ -0,0 +1,24 @@ +package com.slam.concertreservation.domain.payment.repository; + +import com.slam.concertreservation.domain.payment.model.Payment; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +public interface PaymentRepository { + + Payment save(Payment payment); + + Optional findById(Long paymentId); + + List findAllByReservationId(Long reservationId); + + Page findAllByUserId(Long userId, Pageable pageable); + + List findAllByUserIdAndCreatedAtBetween(Long userId, LocalDateTime startDateTime, LocalDateTime endDateTime); + + List findAllByCreatedAtBetween(LocalDateTime startDateTime, LocalDateTime endDateTime); + +} diff --git a/src/main/java/com/slam/concertreservation/infrastructure/persistence/jpa/CompensationTxLogJpaRepository.java b/src/main/java/com/slam/concertreservation/infrastructure/persistence/jpa/CompensationTxLogJpaRepository.java new file mode 100644 index 0000000..45f6fd2 --- /dev/null +++ b/src/main/java/com/slam/concertreservation/infrastructure/persistence/jpa/CompensationTxLogJpaRepository.java @@ -0,0 +1,25 @@ +package com.slam.concertreservation.infrastructure.persistence.jpa; + +import com.slam.concertreservation.infrastructure.persistence.jpa.entities.CompensationTxLogJpaEntity; +import java.time.LocalDateTime; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface CompensationTxLogJpaRepository extends JpaRepository { + + List findAllByStatus(String status); + + List findAllByRetryCountGreaterThanEqual(int maxRetryCount); + + List findAllByRetryCountLessThan(int maxRetryCount); + + List findAllByUserId(Long userId); + + List findAllByReservationId(Long reservationId); + + List findAllByUserIdAndReservationId(Long userId, Long reservationId); + + List findAllByCreatedAtBetween( + LocalDateTime startDateTime, + LocalDateTime endDateTime); +} diff --git a/src/main/java/com/slam/concertreservation/infrastructure/persistence/jpa/PaymentJpaRepository.java b/src/main/java/com/slam/concertreservation/infrastructure/persistence/jpa/PaymentJpaRepository.java new file mode 100644 index 0000000..65ecae7 --- /dev/null +++ b/src/main/java/com/slam/concertreservation/infrastructure/persistence/jpa/PaymentJpaRepository.java @@ -0,0 +1,19 @@ +package com.slam.concertreservation.infrastructure.persistence.jpa; + +import com.slam.concertreservation.infrastructure.persistence.jpa.entities.PaymentJpaEntity; +import java.time.LocalDateTime; +import java.util.List; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface PaymentJpaRepository extends JpaRepository { + + List findAllByReservationId(Long reservationId); + + Page findAllByUserId(Long userId, Pageable pageable); + + List findAllByUserIdAndCreatedAtBetween(Long userId, LocalDateTime createdAtAfter, LocalDateTime createdAtBefore); + + List findAllByCreatedAtBetween(LocalDateTime createdAtAfter, LocalDateTime createdAtBefore); +} diff --git a/src/main/java/com/slam/concertreservation/infrastructure/persistence/jpa/entities/CompensationTxLogJpaEntity.java b/src/main/java/com/slam/concertreservation/infrastructure/persistence/jpa/entities/CompensationTxLogJpaEntity.java new file mode 100644 index 0000000..f2a163d --- /dev/null +++ b/src/main/java/com/slam/concertreservation/infrastructure/persistence/jpa/entities/CompensationTxLogJpaEntity.java @@ -0,0 +1,48 @@ +package com.slam.concertreservation.infrastructure.persistence.jpa.entities; + +import com.slam.concertreservation.domain.payment.model.CompensationTxLog; +import com.slam.concertreservation.domain.payment.model.CompensationTxStatus; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import java.time.LocalDateTime; + +@Entity +public class CompensationTxLogJpaEntity { + + @Id + private Long txLogId; + private Long paymentId; + private Long userId; + private Long reservationId; + private int price; + private String status; + private int retryCount; + private LocalDateTime createdAt; + + public CompensationTxLog toDomain() { + return CompensationTxLog.restore( + this.txLogId, + this.paymentId, + this.userId, + this.reservationId, + this.price, + CompensationTxStatus.valueOf(this.status), + this.retryCount, + this.createdAt + ); + } + + public static CompensationTxLogJpaEntity fromDomain(CompensationTxLog compensationTxLog){ + CompensationTxLogJpaEntity entity = new CompensationTxLogJpaEntity(); + entity.txLogId = compensationTxLog.getTxLogId(); + entity.paymentId = compensationTxLog.getPaymentId(); + entity.userId = compensationTxLog.getUserId(); + entity.reservationId = compensationTxLog.getReservationId(); + entity.price = compensationTxLog.getPrice(); + entity.status = compensationTxLog.getStatus().name(); + entity.retryCount = compensationTxLog.getRetryCount(); + entity.createdAt = compensationTxLog.getCreatedAt(); + return entity; + } + +} diff --git a/src/main/java/com/slam/concertreservation/infrastructure/persistence/jpa/entities/PaymentJpaEntity.java b/src/main/java/com/slam/concertreservation/infrastructure/persistence/jpa/entities/PaymentJpaEntity.java new file mode 100644 index 0000000..9b60006 --- /dev/null +++ b/src/main/java/com/slam/concertreservation/infrastructure/persistence/jpa/entities/PaymentJpaEntity.java @@ -0,0 +1,41 @@ +package com.slam.concertreservation.infrastructure.persistence.jpa.entities; + +import com.slam.concertreservation.domain.payment.model.Payment; +import com.slam.concertreservation.domain.payment.model.PaymentStatus; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import java.time.LocalDateTime; + +@Entity +public class PaymentJpaEntity { + + @Id + private Long paymentId; + private Long userId; + private Long reservationId; + private int price; + private String paymentStatus; + private LocalDateTime createdAt; + + public Payment toDomain() { + return Payment.restore( + this.paymentId, + this.userId, + this.reservationId, + this.price, + PaymentStatus.valueOf(this.paymentStatus), + this.createdAt + ); + } + + public static PaymentJpaEntity fromDomain(Payment payment){ + PaymentJpaEntity entity = new PaymentJpaEntity(); + entity.paymentId = payment.getPaymentId(); + entity.userId = payment.getUserId(); + entity.reservationId = payment.getReservationId(); + entity.price = payment.getPrice(); + entity.paymentStatus = payment.getStatus().name(); + entity.createdAt = payment.getCreatedAt(); + return entity; + } +} diff --git a/src/main/java/com/slam/concertreservation/infrastructure/persistence/jpa/impl/CompensationTxLogRepositoryImpl.java b/src/main/java/com/slam/concertreservation/infrastructure/persistence/jpa/impl/CompensationTxLogRepositoryImpl.java new file mode 100644 index 0000000..4d4124a --- /dev/null +++ b/src/main/java/com/slam/concertreservation/infrastructure/persistence/jpa/impl/CompensationTxLogRepositoryImpl.java @@ -0,0 +1,98 @@ +package com.slam.concertreservation.infrastructure.persistence.jpa.impl; + +import com.slam.concertreservation.domain.payment.model.CompensationTxLog; +import com.slam.concertreservation.domain.payment.model.CompensationTxStatus; +import com.slam.concertreservation.domain.payment.repository.CompensationTxLogRepository; +import com.slam.concertreservation.infrastructure.persistence.jpa.CompensationTxLogJpaRepository; +import com.slam.concertreservation.infrastructure.persistence.jpa.entities.CompensationTxLogJpaEntity; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class CompensationTxLogRepositoryImpl implements CompensationTxLogRepository { + + private final CompensationTxLogJpaRepository compensationTxLogJpaRepository; + + @Override + public CompensationTxLog save(CompensationTxLog compensationTxLog) { + return compensationTxLogJpaRepository.save(CompensationTxLogJpaEntity.fromDomain(compensationTxLog)) + .toDomain(); + } + + @Override + public List saveAll(List compensationTxLogs) { + return compensationTxLogJpaRepository.saveAll( + compensationTxLogs.stream() + .map(CompensationTxLogJpaEntity::fromDomain) + .toList()) + .stream() + .map(CompensationTxLogJpaEntity::toDomain) + .toList(); + } + + @Override + public Optional findById(Long txId) { + return compensationTxLogJpaRepository.findById(txId) + .map(CompensationTxLogJpaEntity::toDomain); + } + + @Override + public List findAllByStatus(CompensationTxStatus compensationTxStatus) { + return compensationTxLogJpaRepository.findAllByStatus(compensationTxStatus.toString()) + .stream() + .map(CompensationTxLogJpaEntity::toDomain) + .toList(); + } + + @Override + public List findAllByRetryCountGreaterThanEqual(int targetRetryCount) { + return compensationTxLogJpaRepository.findAllByRetryCountGreaterThanEqual(targetRetryCount) + .stream() + .map(CompensationTxLogJpaEntity::toDomain) + .toList(); + } + + @Override + public List findAllByRetryCountLessThan(int targetRetryCount) { + return compensationTxLogJpaRepository.findAllByRetryCountLessThan(targetRetryCount) + .stream() + .map(CompensationTxLogJpaEntity::toDomain) + .toList(); + } + + @Override + public List findAllByUserId(Long userId) { + return compensationTxLogJpaRepository.findAllByUserId(userId) + .stream() + .map(CompensationTxLogJpaEntity::toDomain) + .toList(); + } + + @Override + public List findAllByReservationId(Long reservationId) { + return compensationTxLogJpaRepository.findAllByReservationId(reservationId) + .stream() + .map(CompensationTxLogJpaEntity::toDomain) + .toList(); + } + + @Override + public List findAllByUserIdAndReservationId(Long userId, Long reservationId) { + return compensationTxLogJpaRepository.findAllByUserIdAndReservationId(userId, reservationId) + .stream() + .map(CompensationTxLogJpaEntity::toDomain) + .toList(); + } + + @Override + public List findAllCreatedAtBetween(LocalDateTime startDateTime, LocalDateTime endDateTime) { + return compensationTxLogJpaRepository.findAllByCreatedAtBetween(startDateTime, endDateTime) + .stream() + .map(CompensationTxLogJpaEntity::toDomain) + .toList(); + } +} diff --git a/src/main/java/com/slam/concertreservation/infrastructure/persistence/jpa/impl/PaymentRepositoryImpl.java b/src/main/java/com/slam/concertreservation/infrastructure/persistence/jpa/impl/PaymentRepositoryImpl.java new file mode 100644 index 0000000..430c368 --- /dev/null +++ b/src/main/java/com/slam/concertreservation/infrastructure/persistence/jpa/impl/PaymentRepositoryImpl.java @@ -0,0 +1,62 @@ +package com.slam.concertreservation.infrastructure.persistence.jpa.impl; + +import com.slam.concertreservation.common.error.ErrorCode; +import com.slam.concertreservation.common.exceptions.UnavailableRequestException; +import com.slam.concertreservation.domain.payment.model.Payment; +import com.slam.concertreservation.domain.payment.repository.PaymentRepository; +import com.slam.concertreservation.infrastructure.persistence.jpa.PaymentJpaRepository; +import com.slam.concertreservation.infrastructure.persistence.jpa.entities.PaymentJpaEntity; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class PaymentRepositoryImpl implements PaymentRepository { + + private final PaymentJpaRepository paymentJpaRepository; + + @Override + public Payment save(Payment payment) { + return paymentJpaRepository.save(PaymentJpaEntity.fromDomain(payment)) + .toDomain(); + } + + @Override + public Optional findById(Long paymentId) { + return paymentJpaRepository.findById(paymentId) + .map(PaymentJpaEntity::toDomain); + } + + @Override + public List findAllByReservationId(Long reservationId) { + return paymentJpaRepository.findAllByReservationId(reservationId).stream() + .map(PaymentJpaEntity::toDomain) + .toList(); + } + + @Override + public Page findAllByUserId(Long userId, Pageable pageable) { + return paymentJpaRepository.findAllByUserId(userId, pageable) + .map(PaymentJpaEntity::toDomain); + } + + @Override + public List findAllByUserIdAndCreatedAtBetween(Long userId, LocalDateTime startDateTime, + LocalDateTime endDateTime) { + return paymentJpaRepository.findAllByUserIdAndCreatedAtBetween(userId, startDateTime, endDateTime).stream() + .map(PaymentJpaEntity::toDomain) + .toList(); + } + + @Override + public List findAllByCreatedAtBetween(LocalDateTime startDateTime, LocalDateTime endDateTime) { + return paymentJpaRepository.findAllByCreatedAtBetween(startDateTime, endDateTime).stream() + .map(PaymentJpaEntity::toDomain) + .toList(); + } +} From 0e87cf3f1c1aaa3dc396498fb998f95e651be109 Mon Sep 17 00:00:00 2001 From: Ho Yeon CHAE Date: Thu, 12 Feb 2026 18:51:23 +0900 Subject: [PATCH 3/7] Payment : Add domain services --- .../service/CompensationTxLogService.java | 77 +++++++ .../payment/service/PaymentService.java | 68 +++++++ .../CompensationTxLogServiceUnitTest.java | 174 ++++++++++++++++ .../service/PaymentServiceUnitTest.java | 192 ++++++++++++++++++ 4 files changed, 511 insertions(+) create mode 100644 src/main/java/com/slam/concertreservation/domain/payment/service/CompensationTxLogService.java create mode 100644 src/main/java/com/slam/concertreservation/domain/payment/service/PaymentService.java create mode 100644 src/test/java/com/slam/concertreservation/domain/payment/service/CompensationTxLogServiceUnitTest.java create mode 100644 src/test/java/com/slam/concertreservation/domain/payment/service/PaymentServiceUnitTest.java diff --git a/src/main/java/com/slam/concertreservation/domain/payment/service/CompensationTxLogService.java b/src/main/java/com/slam/concertreservation/domain/payment/service/CompensationTxLogService.java new file mode 100644 index 0000000..2011fce --- /dev/null +++ b/src/main/java/com/slam/concertreservation/domain/payment/service/CompensationTxLogService.java @@ -0,0 +1,77 @@ +package com.slam.concertreservation.domain.payment.service; + +import com.slam.concertreservation.common.error.ErrorCode; +import com.slam.concertreservation.common.exceptions.UnavailableRequestException; +import com.slam.concertreservation.domain.payment.model.CompensationTxLog; +import com.slam.concertreservation.domain.payment.model.CompensationTxStatus; +import com.slam.concertreservation.domain.payment.repository.CompensationTxLogRepository; +import java.time.LocalDateTime; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class CompensationTxLogService { + + private final CompensationTxLogRepository compensationTxLogRepository; + + public CompensationTxLog log(Long userId, Long reservationId, Long paymentId, int price) { + CompensationTxLog txLog = CompensationTxLog.create(userId, reservationId, paymentId, price); + return compensationTxLogRepository.save(txLog); + } + + public CompensationTxLog markAsFailed(CompensationTxLog compensationTxLog) { + compensationTxLog.markAsFailed(); + return compensationTxLogRepository.save(compensationTxLog); + } + + public CompensationTxLog markAsCompleted(CompensationTxLog compensationTxLog) { + compensationTxLog.markAsCompleted(); + return compensationTxLogRepository.save(compensationTxLog); + } + + public List getAllRetriables() { + return compensationTxLogRepository.findAllByStatus(CompensationTxStatus.PENDING); + } + + public CompensationTxLog getById(Long txLogId) { + return compensationTxLogRepository.findById(txLogId) + .orElseThrow(() -> new UnavailableRequestException(ErrorCode.RESOURCE_NOT_FOUND, "CompensationTxLog not found for id: " + txLogId)); + } + + public List getByStatus(CompensationTxStatus status) { + return compensationTxLogRepository.findAllByStatus(status); + } + + public List getByRetryCountGreaterThanEqualToMax() { + return compensationTxLogRepository.findAllByRetryCountGreaterThanEqual(CompensationTxLog.MAX_RETRY_COUNT); + } + + public List getByUserId(Long userId) { + return compensationTxLogRepository.findAllByUserId(userId); + } + + public List getByReservationId(Long reservationId) { + return compensationTxLogRepository.findAllByReservationId(reservationId); + } + + public List getByUserIdAndReservationId(Long userId, Long reservationId) { + return compensationTxLogRepository.findAllByUserIdAndReservationId(userId, reservationId); + } + + public List getCompensationTxLogsCreatedAtBetween( + LocalDateTime startDateTime, + LocalDateTime endDateTime) { + return compensationTxLogRepository.findAllCreatedAtBetween(startDateTime, endDateTime); + } + + public List getByRetryCountLessThan(int targetRetryCount) { + return compensationTxLogRepository.findAllByRetryCountLessThan(targetRetryCount); + } + + public List getByRetryCountGreaterThanEqual(int targetRetryCount) { + return compensationTxLogRepository.findAllByRetryCountGreaterThanEqual(targetRetryCount); + } + +} diff --git a/src/main/java/com/slam/concertreservation/domain/payment/service/PaymentService.java b/src/main/java/com/slam/concertreservation/domain/payment/service/PaymentService.java new file mode 100644 index 0000000..2813987 --- /dev/null +++ b/src/main/java/com/slam/concertreservation/domain/payment/service/PaymentService.java @@ -0,0 +1,68 @@ +package com.slam.concertreservation.domain.payment.service; + +import com.slam.concertreservation.common.error.ErrorCode; +import com.slam.concertreservation.common.exceptions.UnavailableRequestException; +import com.slam.concertreservation.domain.payment.model.Payment; +import com.slam.concertreservation.domain.payment.model.PaymentStatus; +import com.slam.concertreservation.domain.payment.repository.PaymentRepository; +import java.time.LocalDateTime; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class PaymentService { + + private final PaymentRepository paymentRepository; + + @Transactional + public Payment initiate(Long userId, int amount, Long reservationId) { + Payment initiatedPayment = Payment.create(userId, amount, reservationId); + return paymentRepository.save(initiatedPayment); + } + + @Transactional + public Payment complete(Payment payment) { + return paymentRepository.save(payment.withStatus(PaymentStatus.COMPLETED)); + } + + public Payment fail(Payment payment) { + return paymentRepository.save(payment.withStatus(PaymentStatus.FAILED)); + } + + public Payment refund(Payment payment) { + return paymentRepository.save(payment.withStatus(PaymentStatus.REFUNDED)); + } + + public Payment getPaymentById(Long paymentId) { + return paymentRepository.findById(paymentId) + .orElseThrow(() -> new UnavailableRequestException(ErrorCode.RESOURCE_NOT_FOUND, "Payment not found for id: " + paymentId)); + } + + public Page getPaymentHistoryOfUser(Long userId, Pageable pageable) { + return paymentRepository.findAllByUserId(userId, pageable); + } + + public List getPaymentsByReservationId(Long reservationId) { + return paymentRepository.findAllByReservationId(reservationId); + } + + public List getPaymentsByUserIdAndDateRange( + Long userId, + LocalDateTime startDateTime, + LocalDateTime endDateTime) + { + return paymentRepository.findAllByUserIdAndCreatedAtBetween(userId, startDateTime, endDateTime); + } + + public List getPaymentsByDateRange( + LocalDateTime startDateTime, + LocalDateTime endDateTime) + { + return paymentRepository.findAllByCreatedAtBetween(startDateTime, endDateTime); + } +} diff --git a/src/test/java/com/slam/concertreservation/domain/payment/service/CompensationTxLogServiceUnitTest.java b/src/test/java/com/slam/concertreservation/domain/payment/service/CompensationTxLogServiceUnitTest.java new file mode 100644 index 0000000..6aa5e55 --- /dev/null +++ b/src/test/java/com/slam/concertreservation/domain/payment/service/CompensationTxLogServiceUnitTest.java @@ -0,0 +1,174 @@ +package com.slam.concertreservation.domain.payment.service; + +import com.slam.concertreservation.common.exceptions.UnavailableRequestException; +import com.slam.concertreservation.domain.payment.model.CompensationTxLog; +import com.slam.concertreservation.domain.payment.model.CompensationTxStatus; +import com.slam.concertreservation.domain.payment.repository.CompensationTxLogRepository; +import com.slam.concertreservation.common.exceptions.BusinessRuleViolationException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +class CompensationTxLogServiceUnitTest { + + @Mock + private CompensationTxLogRepository compensationTxLogRepository; + + @InjectMocks + private CompensationTxLogService compensationTxLogService; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + } + + @Nested + @DisplayName("log 메서드 테스트") + class LogTest { + + @Test + @DisplayName("성공 : 보상 트랜잭션 로그를 생성하면 PENDING 상태의 로그가 저장되고 반환된다.") + void shouldCreateAndSaveCompensationTxLog() { + // given + Long userId = 1L; + Long reservationId = 1L; + Long paymentId = 1L; + int price = 1000; + CompensationTxLog expected = CompensationTxLog.create(userId, reservationId, paymentId, price); + + when(compensationTxLogRepository.save(any(CompensationTxLog.class))).thenReturn(expected); + + // when + CompensationTxLog actual = compensationTxLogService.log(userId, reservationId, paymentId, price); + + // then + verify(compensationTxLogRepository, times(1)).save(any(CompensationTxLog.class)); + assertEquals(CompensationTxStatus.PENDING, actual.getStatus()); + assertEquals(userId, actual.getUserId()); + assertEquals(price, actual.getPrice()); + } + } + + @Nested + @DisplayName("markAsCompleted 메서드 테스트") + class MarkAsCompletedTest { + + @Test + @DisplayName("성공 : 보상 트랜잭션 로그를 완료 처리하면 COMPLETED 상태로 변경된다.") + void shouldMarkLogAsCompleted() { + // given + CompensationTxLog txLog = CompensationTxLog.create(1L, 1L, 1L, 1000); + + when(compensationTxLogRepository.save(any(CompensationTxLog.class))).thenReturn(txLog); + + // when + CompensationTxLog actual = compensationTxLogService.markAsCompleted(txLog); + + // then + verify(compensationTxLogRepository, times(1)).save(txLog); + assertEquals(CompensationTxStatus.COMPLETED, actual.getStatus()); + } + } + + @Nested + @DisplayName("markAsFailed 메서드 테스트") + class MarkAsFailedTest { + + @Test + @DisplayName("성공 : 보상 트랜잭션 로그를 실패 처리하면 FAILED 상태로 변경되고 retryCount가 증가한다.") + void shouldMarkLogAsFailedAndIncrementRetryCount() { + // given + CompensationTxLog txLog = CompensationTxLog.create(1L, 1L, 1L, 1000); + + when(compensationTxLogRepository.save(any(CompensationTxLog.class))).thenReturn(txLog); + + // when + CompensationTxLog actual = compensationTxLogService.markAsFailed(txLog); + + // then + verify(compensationTxLogRepository, times(1)).save(txLog); + assertEquals(CompensationTxStatus.FAILED, actual.getStatus()); + assertEquals(1, actual.getRetryCount()); + } + + @Test + @DisplayName("실패 : 이미 완료 처리된 보상 트랜잭션을 실패 처리하면 BusinessRuleViolationException이 발생한다.") + void shouldThrowException_WhenAlreadyCompleted() { + // given + CompensationTxLog txLog = CompensationTxLog.create(1L, 1L, 1L, 1000); + txLog.markAsCompleted(); + + // when & then + assertThatThrownBy(() -> compensationTxLogService.markAsFailed(txLog)) + .isInstanceOf(BusinessRuleViolationException.class); + } + } + + @Nested + @DisplayName("getAllRetriables 메서드 테스트") + class GetAllRetriablesTest { + + @Test + @DisplayName("성공 : PENDING 상태의 보상 트랜잭션 로그 목록을 반환한다.") + void shouldReturnPendingLogs() { + // given + CompensationTxLog txLog1 = CompensationTxLog.create(1L, 1L, 1L, 1000); + CompensationTxLog txLog2 = CompensationTxLog.create(2L, 2L, 2L, 2000); + + when(compensationTxLogRepository.findAllByStatus(CompensationTxStatus.PENDING)) + .thenReturn(List.of(txLog1, txLog2)); + + // when + List result = compensationTxLogService.getAllRetriables(); + + // then + verify(compensationTxLogRepository, times(1)).findAllByStatus(CompensationTxStatus.PENDING); + assertEquals(2, result.size()); + } + + @Test + @DisplayName("성공 : PENDING 상태의 보상 트랜잭션 로그가 없으면 빈 목록을 반환한다.") + void shouldReturnEmptyList_WhenNoPendingLogs() { + // given + when(compensationTxLogRepository.findAllByStatus(CompensationTxStatus.PENDING)) + .thenReturn(Collections.emptyList()); + + // when + List result = compensationTxLogService.getAllRetriables(); + + // then + assertTrue(result.isEmpty()); + } + } + + @Nested + @DisplayName("getById 메서드 테스트") + class GetByIdTest { + + @Test + @DisplayName("실패 : 존재하지 않는 ID로 조회하면 UnavailableRequestException이 발생하며 실패한다.") + void shouldThrowUnavailableRequestException_WhenNotFound() { + // given + Long txLogId = 999L; + + when(compensationTxLogRepository.findById(txLogId)).thenReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> compensationTxLogService.getById(txLogId)) + .isInstanceOf(UnavailableRequestException.class); + } + } +} diff --git a/src/test/java/com/slam/concertreservation/domain/payment/service/PaymentServiceUnitTest.java b/src/test/java/com/slam/concertreservation/domain/payment/service/PaymentServiceUnitTest.java new file mode 100644 index 0000000..31fd197 --- /dev/null +++ b/src/test/java/com/slam/concertreservation/domain/payment/service/PaymentServiceUnitTest.java @@ -0,0 +1,192 @@ +package com.slam.concertreservation.domain.payment.service; + +import com.slam.concertreservation.common.exceptions.UnavailableRequestException; +import com.slam.concertreservation.domain.payment.model.Payment; +import com.slam.concertreservation.domain.payment.model.PaymentStatus; +import com.slam.concertreservation.domain.payment.repository.PaymentRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +class PaymentServiceUnitTest { + + @Mock + private PaymentRepository paymentRepository; + + @InjectMocks + private PaymentService paymentService; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + } + + @Nested + @DisplayName("initiate 메서드 테스트") + class InitiatePaymentTest { + + @Test + @DisplayName("성공 : 결제를 생성하면 PENDING 상태의 Payment가 저장되고 반환된다.") + void shouldReturnPendingPayment_WhenInitiated() { + // given + Long userId = 1L; + int price = 1000; + Long reservationId = 1L; + Payment expected = Payment.create(userId, price, reservationId); + + when(paymentRepository.save(any(Payment.class))).thenReturn(expected); + + // when + Payment actual = paymentService.initiate(userId, price, reservationId); + + // then + verify(paymentRepository, times(1)).save(any(Payment.class)); + assertEquals(PaymentStatus.PENDING, actual.getStatus()); + assertEquals(userId, actual.getUserId()); + assertEquals(price, actual.getPrice()); + assertEquals(reservationId, actual.getReservationId()); + } + } + + @Nested + @DisplayName("complete 메서드 테스트") + class CompletePaymentTest { + + @Test + @DisplayName("성공 : 결제를 완료하면 COMPLETED 상태의 Payment가 저장되고 반환된다.") + void shouldReturnCompletedPayment_WhenCompleted() { + // given + Payment initiatedPayment = Payment.create(1L, 1000, 1L); + Payment completedPayment = initiatedPayment.withStatus(PaymentStatus.COMPLETED); + + when(paymentRepository.save(any(Payment.class))).thenReturn(completedPayment); + + // when + Payment actual = paymentService.complete(initiatedPayment); + + // then + verify(paymentRepository, times(1)).save(any(Payment.class)); + assertEquals(PaymentStatus.COMPLETED, actual.getStatus()); + } + } + + @Nested + @DisplayName("fail 메서드 테스트") + class FailPaymentTest { + + @Test + @DisplayName("성공 : 결제를 실패 처리하면 FAILED 상태의 Payment가 저장되고 반환된다.") + void shouldReturnFailedPayment_WhenFailed() { + // given + Payment initiatedPayment = Payment.create(1L, 1000, 1L); + Payment failedPayment = initiatedPayment.withStatus(PaymentStatus.FAILED); + + when(paymentRepository.save(any(Payment.class))).thenReturn(failedPayment); + + // when + Payment actual = paymentService.fail(initiatedPayment); + + // then + verify(paymentRepository, times(1)).save(any(Payment.class)); + assertEquals(PaymentStatus.FAILED, actual.getStatus()); + } + } + + @Nested + @DisplayName("refund 메서드 테스트") + class RefundPaymentTest { + + @Test + @DisplayName("성공 : 결제를 환불 처리하면 REFUNDED 상태의 Payment가 저장되고 반환된다.") + void shouldReturnRefundedPayment_WhenRefunded() { + // given + Payment initiatedPayment = Payment.create(1L, 1000, 1L); + Payment refundedPayment = initiatedPayment.withStatus(PaymentStatus.REFUNDED); + + when(paymentRepository.save(any(Payment.class))).thenReturn(refundedPayment); + + // when + Payment actual = paymentService.refund(initiatedPayment); + + // then + verify(paymentRepository, times(1)).save(any(Payment.class)); + assertEquals(PaymentStatus.REFUNDED, actual.getStatus()); + } + } + + @Nested + @DisplayName("getPaymentById 메서드 테스트") + class GetPaymentByIdTest { + + @Test + @DisplayName("성공 : 존재하는 Payment ID로 조회하면 해당 Payment를 반환한다.") + void shouldReturnPayment_WhenPaymentExists() { + // given + Long paymentId = 1L; + Payment expected = Payment.restore(paymentId, 1L, 1L, 1000, PaymentStatus.COMPLETED, null); + + when(paymentRepository.findById(paymentId)).thenReturn(Optional.of(expected)); + + // when + Payment actual = paymentService.getPaymentById(paymentId); + + // then + verify(paymentRepository, times(1)).findById(paymentId); + assertEquals(paymentId, actual.getPaymentId()); + } + + @Test + @DisplayName("실패 : 존재하지 않는 Payment ID로 조회하면 UnavailableRequestException이 발생하며 실패한다.") + void shouldThrowUnavailableRequestException_WhenPaymentNotFound() { + // given + Long paymentId = 999L; + + when(paymentRepository.findById(paymentId)).thenReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> paymentService.getPaymentById(paymentId)) + .isInstanceOf(UnavailableRequestException.class); + } + } + + @Nested + @DisplayName("getPaymentHistoryOfUser 메서드 테스트") + class GetPaymentHistoryOfUserTest { + + @Test + @DisplayName("성공 : 사용자의 결제 이력을 페이지 형태로 반환한다.") + void shouldReturnPageOfPayments_WhenUserHasPayments() { + // given + Long userId = 1L; + Pageable pageable = PageRequest.of(0, 10); + Payment payment1 = Payment.restore(1L, userId, 1L, 1000, PaymentStatus.COMPLETED, null); + Payment payment2 = Payment.restore(2L, userId, 2L, 2000, PaymentStatus.REFUNDED, null); + Page expectedPage = new PageImpl<>(List.of(payment1, payment2), pageable, 2); + + when(paymentRepository.findAllByUserId(userId, pageable)).thenReturn(expectedPage); + + // when + Page actual = paymentService.getPaymentHistoryOfUser(userId, pageable); + + // then + verify(paymentRepository, times(1)).findAllByUserId(userId, pageable); + assertEquals(2, actual.getContent().size()); + } + } +} From 1b64b6ba7a727b5655ab984d042bdea1f5cb4b9d Mon Sep 17 00:00:00 2001 From: Ho Yeon CHAE Date: Thu, 12 Feb 2026 18:51:27 +0900 Subject: [PATCH 4/7] Point & Reservation : Add module API layer for inter-domain communication --- .../Hhp7ConcertReservationApplication.java | 2 + .../domain/point/api/PointModuleApi.java | 12 ++ .../point/api/PointOperationResult.java | 16 ++ .../point/application/PointModuleFacade.java | 56 +++++++ .../domain/point/service/PointService.java | 5 +- .../reservation/api/ReservationModuleApi.java | 14 ++ .../api/ReservationOperationResult.java | 15 ++ .../application/ReservationModuleFacade.java | 40 +++++ ...hp7ConcertReservationApplicationTests.java | 1 + ...TestHhp7ConcertReservationApplication.java | 2 + ...PointModuleFacadeRetryIntegrationTest.java | 138 ++++++++++++++++++ .../ReservationModuleFacadeUnitTest.java | 120 +++++++++++++++ 12 files changed, 420 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/slam/concertreservation/domain/point/api/PointModuleApi.java create mode 100644 src/main/java/com/slam/concertreservation/domain/point/api/PointOperationResult.java create mode 100644 src/main/java/com/slam/concertreservation/domain/point/application/PointModuleFacade.java create mode 100644 src/main/java/com/slam/concertreservation/domain/reservation/api/ReservationModuleApi.java create mode 100644 src/main/java/com/slam/concertreservation/domain/reservation/api/ReservationOperationResult.java create mode 100644 src/main/java/com/slam/concertreservation/domain/reservation/application/ReservationModuleFacade.java create mode 100644 src/test/java/com/slam/concertreservation/domain/point/application/PointModuleFacadeRetryIntegrationTest.java create mode 100644 src/test/java/com/slam/concertreservation/domain/reservation/application/ReservationModuleFacadeUnitTest.java diff --git a/src/main/java/com/slam/concertreservation/Hhp7ConcertReservationApplication.java b/src/main/java/com/slam/concertreservation/Hhp7ConcertReservationApplication.java index d1c9ba2..d56c60c 100644 --- a/src/main/java/com/slam/concertreservation/Hhp7ConcertReservationApplication.java +++ b/src/main/java/com/slam/concertreservation/Hhp7ConcertReservationApplication.java @@ -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) { diff --git a/src/main/java/com/slam/concertreservation/domain/point/api/PointModuleApi.java b/src/main/java/com/slam/concertreservation/domain/point/api/PointModuleApi.java new file mode 100644 index 0000000..200202c --- /dev/null +++ b/src/main/java/com/slam/concertreservation/domain/point/api/PointModuleApi.java @@ -0,0 +1,12 @@ +package com.slam.concertreservation.domain.point.api; + +import com.slam.concertreservation.interfaces.dto.UserPointBalanceResponse; + +/** + * 포인트 API 인터페이스 + */ +public interface PointModuleApi { + + PointOperationResult decreaseUserPointBalance(Long userId, int amount); + PointOperationResult increaseUserPointBalance(Long userId, int amount); +} diff --git a/src/main/java/com/slam/concertreservation/domain/point/api/PointOperationResult.java b/src/main/java/com/slam/concertreservation/domain/point/api/PointOperationResult.java new file mode 100644 index 0000000..31896a4 --- /dev/null +++ b/src/main/java/com/slam/concertreservation/domain/point/api/PointOperationResult.java @@ -0,0 +1,16 @@ +package com.slam.concertreservation.domain.point.api; + +public record PointOperationResult( + boolean success, + Long userId, + int amount, + String errorCode +) { + public static PointOperationResult success(Long userId, int amount) { + return new PointOperationResult(true, userId, amount, null); + } + + public static PointOperationResult fail(Long userId, int amount, String errorCode) { + return new PointOperationResult(false, userId, amount, errorCode); + } +} diff --git a/src/main/java/com/slam/concertreservation/domain/point/application/PointModuleFacade.java b/src/main/java/com/slam/concertreservation/domain/point/application/PointModuleFacade.java new file mode 100644 index 0000000..a076870 --- /dev/null +++ b/src/main/java/com/slam/concertreservation/domain/point/application/PointModuleFacade.java @@ -0,0 +1,56 @@ +package com.slam.concertreservation.domain.point.application; + +import com.slam.concertreservation.common.exceptions.BusinessRuleViolationException; +import com.slam.concertreservation.domain.point.api.PointModuleApi; +import com.slam.concertreservation.domain.point.api.PointOperationResult; +import com.slam.concertreservation.domain.point.service.PointService; +import lombok.RequiredArgsConstructor; +import org.springframework.dao.OptimisticLockingFailureException; +import org.springframework.retry.annotation.Backoff; +import org.springframework.retry.annotation.Recover; +import org.springframework.retry.annotation.Retryable; +import org.springframework.stereotype.Component; + +/** + * 논리적으로 분리된 다른 도메인에서의 포인트 관련 기능을 제공하는 퍼사드 클래스. + *

+ * 비즈니스 규칙 위반 예외에 대해 Exponential Backoff 전략으로 재시도를 수행하도록 설정. (100ms -> 200ms -> 400ms) + *

+ * 재시도 주기는 현행 설계 상 JVM 내부에서만 요청이 발생한다는 점, Connection Pool 고갈 방지를 위해 짧게 설정. + */ + +@Component +@RequiredArgsConstructor +public class PointModuleFacade implements PointModuleApi { + + private final PointService pointService; + + @Override + @Retryable( + retryFor = Exception.class, + noRetryFor = {BusinessRuleViolationException.class, OptimisticLockingFailureException.class}, + maxAttempts = 3, + backoff = @Backoff(delay = 100, multiplier = 2.0) + ) + public PointOperationResult decreaseUserPointBalance(Long userId, int amount) { + pointService.decreaseUserPointBalance(userId, amount); + return PointOperationResult.success(userId, amount); + } + + @Override + @Retryable( + retryFor = Exception.class, + noRetryFor = {BusinessRuleViolationException.class, OptimisticLockingFailureException.class}, + maxAttempts = 3, + backoff = @Backoff(delay = 100, multiplier = 2.0) + ) + public PointOperationResult increaseUserPointBalance(Long userId, int amount) { + pointService.increaseUserPointBalance(userId, amount); + return PointOperationResult.success(userId, amount); + } + + @Recover + public PointOperationResult recoverPointOperation(Exception e, Long userId, int amount) { + return PointOperationResult.fail(userId, amount, e.getMessage()); + } +} diff --git a/src/main/java/com/slam/concertreservation/domain/point/service/PointService.java b/src/main/java/com/slam/concertreservation/domain/point/service/PointService.java index bde2563..466a199 100644 --- a/src/main/java/com/slam/concertreservation/domain/point/service/PointService.java +++ b/src/main/java/com/slam/concertreservation/domain/point/service/PointService.java @@ -1,6 +1,7 @@ package com.slam.concertreservation.domain.point.service; import com.slam.concertreservation.common.error.ErrorCode; +import com.slam.concertreservation.common.exceptions.BusinessRuleViolationException; import com.slam.concertreservation.domain.point.event.PaymentEvent; import com.slam.concertreservation.domain.point.model.Point; import com.slam.concertreservation.domain.point.model.PointHistory; @@ -13,6 +14,9 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.context.ApplicationEventPublisher; +import org.springframework.dao.OptimisticLockingFailureException; +import org.springframework.retry.annotation.Backoff; +import org.springframework.retry.annotation.Retryable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -96,7 +100,6 @@ public UserPointBalance increaseUserPointBalance(Long userId, int increaseAmount userId, increaseAmount, updated.balance().getAmount()); return updated; - } /** diff --git a/src/main/java/com/slam/concertreservation/domain/reservation/api/ReservationModuleApi.java b/src/main/java/com/slam/concertreservation/domain/reservation/api/ReservationModuleApi.java new file mode 100644 index 0000000..eaede3b --- /dev/null +++ b/src/main/java/com/slam/concertreservation/domain/reservation/api/ReservationModuleApi.java @@ -0,0 +1,14 @@ +package com.slam.concertreservation.domain.reservation.api; + +import com.slam.concertreservation.interfaces.dto.ReservationCancelResponse; +import com.slam.concertreservation.interfaces.dto.ReservationConfirmResponse; + +/** + * 예약 API 인터페이스 + */ +public interface ReservationModuleApi { + + ReservationOperationResult confirmReservation(Long reservationId); + + ReservationOperationResult cancelReservation(Long reservationId); +} diff --git a/src/main/java/com/slam/concertreservation/domain/reservation/api/ReservationOperationResult.java b/src/main/java/com/slam/concertreservation/domain/reservation/api/ReservationOperationResult.java new file mode 100644 index 0000000..ecda65c --- /dev/null +++ b/src/main/java/com/slam/concertreservation/domain/reservation/api/ReservationOperationResult.java @@ -0,0 +1,15 @@ +package com.slam.concertreservation.domain.reservation.api; + +public record ReservationOperationResult( + boolean success, + Long reservationId, + String errorCode +) { + public static ReservationOperationResult success(Long reservationId) { + return new ReservationOperationResult(true, reservationId, null); + } + + public static ReservationOperationResult fail(Long reservationId, String errorCode) { + return new ReservationOperationResult(false, reservationId, errorCode); + } +} diff --git a/src/main/java/com/slam/concertreservation/domain/reservation/application/ReservationModuleFacade.java b/src/main/java/com/slam/concertreservation/domain/reservation/application/ReservationModuleFacade.java new file mode 100644 index 0000000..fe71429 --- /dev/null +++ b/src/main/java/com/slam/concertreservation/domain/reservation/application/ReservationModuleFacade.java @@ -0,0 +1,40 @@ +package com.slam.concertreservation.domain.reservation.application; + +import com.slam.concertreservation.domain.reservation.api.ReservationModuleApi; +import com.slam.concertreservation.domain.reservation.api.ReservationOperationResult; +import com.slam.concertreservation.domain.reservation.service.ReservationService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +/** + * 논리적으로 분리된 다른 도메인에서의 예약 확정/취소 관련 기능을 제공하는 퍼사드 클래스. + */ + +@Component +@RequiredArgsConstructor +public class ReservationModuleFacade implements ReservationModuleApi { + + private final ReservationService reservationService; + + @Override + public ReservationOperationResult confirmReservation(Long reservationId) { + try { + reservationService.confirmReservation(reservationId); + return ReservationOperationResult.success(reservationId); + } + catch (Exception e) { + return ReservationOperationResult.fail(reservationId, e.getMessage()); + } + } + + @Override + public ReservationOperationResult cancelReservation(Long reservationId) { + try { + reservationService.cancelReservation(reservationId); + return ReservationOperationResult.success(reservationId); + } + catch (Exception e) { + return ReservationOperationResult.fail(reservationId, e.getMessage()); + } + } +} diff --git a/src/test/java/com/slam/concertreservation/Hhp7ConcertReservationApplicationTests.java b/src/test/java/com/slam/concertreservation/Hhp7ConcertReservationApplicationTests.java index 504bba6..e872a90 100644 --- a/src/test/java/com/slam/concertreservation/Hhp7ConcertReservationApplicationTests.java +++ b/src/test/java/com/slam/concertreservation/Hhp7ConcertReservationApplicationTests.java @@ -5,6 +5,7 @@ import org.springframework.boot.actuate.autoconfigure.metrics.export.prometheus.PrometheusMetricsExportAutoConfiguration; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.context.annotation.Import; +import org.springframework.retry.annotation.EnableRetry; @Import(TestcontainersConfiguration.class) @SpringBootTest(classes = { diff --git a/src/test/java/com/slam/concertreservation/TestHhp7ConcertReservationApplication.java b/src/test/java/com/slam/concertreservation/TestHhp7ConcertReservationApplication.java index 7a335e6..93a3ffa 100644 --- a/src/test/java/com/slam/concertreservation/TestHhp7ConcertReservationApplication.java +++ b/src/test/java/com/slam/concertreservation/TestHhp7ConcertReservationApplication.java @@ -1,7 +1,9 @@ package com.slam.concertreservation; import org.springframework.boot.SpringApplication; +import org.springframework.retry.annotation.EnableRetry; +@EnableRetry public class TestHhp7ConcertReservationApplication { public static void main(String[] args) { diff --git a/src/test/java/com/slam/concertreservation/domain/point/application/PointModuleFacadeRetryIntegrationTest.java b/src/test/java/com/slam/concertreservation/domain/point/application/PointModuleFacadeRetryIntegrationTest.java new file mode 100644 index 0000000..e4cd8f7 --- /dev/null +++ b/src/test/java/com/slam/concertreservation/domain/point/application/PointModuleFacadeRetryIntegrationTest.java @@ -0,0 +1,138 @@ +package com.slam.concertreservation.domain.point.application; + +import com.slam.concertreservation.common.exceptions.BusinessRuleViolationException; +import com.slam.concertreservation.common.error.ErrorCode; +import com.slam.concertreservation.domain.point.api.PointModuleApi; +import com.slam.concertreservation.domain.point.api.PointOperationResult; +import com.slam.concertreservation.domain.point.model.Point; +import com.slam.concertreservation.domain.point.model.UserPointBalance; +import com.slam.concertreservation.domain.point.service.PointService; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.retry.annotation.EnableRetry; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** + * PointModuleFacade 의 @Retryable 동작을 검증하는 통합 테스트. + *
+ * Spring AOP 프록시를 통해 실제 재시도가 발생하는지 확인합니다. + *
+ * RuntimeException 계열은 최대 3회 재시도, BusinessRuleViolationException 등은 재시도하지 않습니다. + */ +@SpringBootTest(classes = {PointModuleFacade.class}) +@EnableRetry +class PointModuleFacadeRetryIntegrationTest { + + @Autowired + private PointModuleApi pointModuleApi; + + @MockitoBean + private PointService pointService; + + @Nested + @DisplayName("decreaseUserPointBalance Retry 테스트") + class DecreaseRetryTest { + + @Test + @DisplayName("잔액 부족(BusinessRuleViolationException)은 재시도하지 않는다") + void shouldNotRetry_WhenInsufficientBalance() { + // given + Long userId = 1L; + int amount = 1000; + + when(pointService.decreaseUserPointBalance(userId, amount)) + .thenThrow(new BusinessRuleViolationException(ErrorCode.INSUFFICIENT_BALANCE, "잔액 부족")); + + // when + PointOperationResult result = pointModuleApi.decreaseUserPointBalance(userId, amount); + + // then — 비즈니스 실패이므로 1회만 호출 + assertFalse(result.success()); + verify(pointService, times(1)).decreaseUserPointBalance(userId, amount); + } + + @Test + @DisplayName("인프라 오류(RuntimeException)는 재시도하고, 두 번째 시도에서 성공한다") + void shouldRetryAndSucceed_WhenTransientFailure() { + // given + Long userId = 1L; + int amount = 1000; + UserPointBalance validBalance = UserPointBalance.create(userId, Point.create(5000)); + + when(pointService.decreaseUserPointBalance(userId, amount)) + .thenThrow(new RuntimeException("일시적 DB 장애")) + .thenReturn(validBalance); + + // when + PointOperationResult result = pointModuleApi.decreaseUserPointBalance(userId, amount); + + // then — 첫 번째 실패, 두 번째 성공 = 2회 호출 + assertTrue(result.success()); + verify(pointService, times(2)).decreaseUserPointBalance(userId, amount); + } + + @Test + @DisplayName("인프라 오류가 3회 연속 발생하면 최종 실패한다") + void shouldFail_WhenAllRetriesExhausted() { + // given + Long userId = 1L; + int amount = 1000; + + when(pointService.decreaseUserPointBalance(userId, amount)) + .thenThrow(new RuntimeException("DB 장애")); + + // when + PointOperationResult result = pointModuleApi.decreaseUserPointBalance(userId, amount); + + // then — 3회 재시도 후 최종 실패 + assertFalse(result.success()); + verify(pointService, times(3)).decreaseUserPointBalance(userId, amount); + } + } + + @Nested + @DisplayName("increaseUserPointBalance Retry 테스트") + class IncreaseRetryTest { + + @Test + @DisplayName("인프라 오류는 재시도하고, 세 번째 시도에서 성공한다") + void shouldRetryAndSucceed_OnThirdAttempt() { + // given + Long userId = 1L; + int amount = 500; + UserPointBalance validBalance = UserPointBalance.create(userId, Point.create(1000)); + + when(pointService.increaseUserPointBalance(userId, amount)) + .thenThrow(new RuntimeException("DB 장애")) + .thenThrow(new RuntimeException("DB 장애")) + .thenReturn(validBalance); + + // when + PointOperationResult result = pointModuleApi.increaseUserPointBalance(userId, amount); + + // then — 세 번째에 성공 = 3회 호출 + assertTrue(result.success()); + verify(pointService, times(3)).increaseUserPointBalance(userId, amount); + } + + @Test + @DisplayName("비즈니스 예외는 재시도하지 않는다") + void shouldNotRetry_WhenBusinessException() { + // given + Long userId = 1L; + int amount = 500; + + when(pointService.increaseUserPointBalance(userId, amount)) + .thenThrow(new BusinessRuleViolationException(ErrorCode.INVALID_REQUEST, "비즈니스 규칙 위반")); + + // when + PointOperationResult result = pointModuleApi.increaseUserPointBalance(userId, amount); + } + } +} \ No newline at end of file diff --git a/src/test/java/com/slam/concertreservation/domain/reservation/application/ReservationModuleFacadeUnitTest.java b/src/test/java/com/slam/concertreservation/domain/reservation/application/ReservationModuleFacadeUnitTest.java new file mode 100644 index 0000000..9709e52 --- /dev/null +++ b/src/test/java/com/slam/concertreservation/domain/reservation/application/ReservationModuleFacadeUnitTest.java @@ -0,0 +1,120 @@ +package com.slam.concertreservation.domain.reservation.application; + +import com.slam.concertreservation.domain.reservation.api.ReservationOperationResult; +import com.slam.concertreservation.domain.reservation.model.Reservation; +import com.slam.concertreservation.domain.reservation.service.ReservationService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class ReservationModuleFacadeUnitTest { + + @Mock + private ReservationService reservationService; + + @InjectMocks + private ReservationModuleFacade reservationModuleFacade; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + } + + @Nested + @DisplayName("confirmReservation 메서드 테스트") + class ConfirmReservationTest { + + @Test + @DisplayName("성공 : 예약 확정이 정상적으로 처리되면 success 결과를 반환한다.") + void shouldReturnSuccess_WhenConfirmationSucceeds() { + // given + Long reservationId = 1L; + Reservation reservation = Reservation.create(reservationId, 1L, 1L, 1L, 1000); + reservation.confirm(); + + when(reservationService.confirmReservation(reservationId)).thenReturn(reservation); + + // when + ReservationOperationResult result = reservationModuleFacade.confirmReservation(reservationId); + + // then + assertTrue(result.success()); + assertEquals(reservationId, result.reservationId()); + assertNull(result.errorCode()); + verify(reservationService, times(1)).confirmReservation(reservationId); + } + + @Test + @DisplayName("실패 : 예약 확정 중 예외가 발생하면 fail 결과를 반환한다.") + void shouldReturnFail_WhenConfirmationThrowsException() { + // given + Long reservationId = 1L; + String errorMessage = "예약 상태가 유효하지 않습니다."; + + when(reservationService.confirmReservation(reservationId)) + .thenThrow(new RuntimeException(errorMessage)); + + // when + ReservationOperationResult result = reservationModuleFacade.confirmReservation(reservationId); + + // then + assertFalse(result.success()); + assertEquals(reservationId, result.reservationId()); + assertEquals(errorMessage, result.errorCode()); + verify(reservationService, times(1)).confirmReservation(reservationId); + } + } + + @Nested + @DisplayName("cancelReservation 메서드 테스트") + class CancelReservationTest { + + @Test + @DisplayName("성공 : 예약 취소가 정상적으로 처리되면 success 결과를 반환한다.") + void shouldReturnSuccess_WhenCancellationSucceeds() { + // given + Long reservationId = 1L; + Reservation reservation = Reservation.create(reservationId, 1L, 1L, 1L, 1000); + reservation.confirm(); + reservation.cancel(); + + when(reservationService.cancelReservation(reservationId)).thenReturn(reservation); + + // when + ReservationOperationResult result = reservationModuleFacade.cancelReservation(reservationId); + + // then + assertTrue(result.success()); + assertEquals(reservationId, result.reservationId()); + assertNull(result.errorCode()); + verify(reservationService, times(1)).cancelReservation(reservationId); + } + + @Test + @DisplayName("실패 : 예약 취소 중 예외가 발생하면 fail 결과를 반환한다.") + void shouldReturnFail_WhenCancellationThrowsException() { + // given + Long reservationId = 1L; + String errorMessage = "예약을 취소할 수 없습니다."; + + when(reservationService.cancelReservation(reservationId)) + .thenThrow(new RuntimeException(errorMessage)); + + // when + ReservationOperationResult result = reservationModuleFacade.cancelReservation(reservationId); + + // then + assertFalse(result.success()); + assertEquals(reservationId, result.reservationId()); + assertEquals(errorMessage, result.errorCode()); + verify(reservationService, times(1)).cancelReservation(reservationId); + } + } +} From 0f908cca2f5556c6bf9b528058456667318ae125 Mon Sep 17 00:00:00 2001 From: Ho Yeon CHAE Date: Thu, 12 Feb 2026 18:51:29 +0900 Subject: [PATCH 5/7] Payment : Add application layer with orchestrator and compensation scheduler --- .../CompensationTxRetryScheduler.java | 40 +++ .../application/PaymentOrchestrator.java | 91 ++++++ .../CompensationTxRetrySchedulerTest.java | 121 ++++++++ .../PaymentOrchestratorUnitTest.java | 277 ++++++++++++++++++ 4 files changed, 529 insertions(+) create mode 100644 src/main/java/com/slam/concertreservation/domain/payment/application/CompensationTxRetryScheduler.java create mode 100644 src/main/java/com/slam/concertreservation/domain/payment/application/PaymentOrchestrator.java create mode 100644 src/test/java/com/slam/concertreservation/domain/payment/application/CompensationTxRetrySchedulerTest.java create mode 100644 src/test/java/com/slam/concertreservation/domain/payment/application/PaymentOrchestratorUnitTest.java diff --git a/src/main/java/com/slam/concertreservation/domain/payment/application/CompensationTxRetryScheduler.java b/src/main/java/com/slam/concertreservation/domain/payment/application/CompensationTxRetryScheduler.java new file mode 100644 index 0000000..ecbb1e7 --- /dev/null +++ b/src/main/java/com/slam/concertreservation/domain/payment/application/CompensationTxRetryScheduler.java @@ -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 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())); + } + + if (result.success()) { + compensationTxLogService.markAsCompleted(log); + } else { + compensationTxLogService.markAsFailed(log); + } + } + } +} diff --git a/src/main/java/com/slam/concertreservation/domain/payment/application/PaymentOrchestrator.java b/src/main/java/com/slam/concertreservation/domain/payment/application/PaymentOrchestrator.java new file mode 100644 index 0000000..1d66e39 --- /dev/null +++ b/src/main/java/com/slam/concertreservation/domain/payment/application/PaymentOrchestrator.java @@ -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; +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)); + } + + 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)); + } + + 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); + } + } +} diff --git a/src/test/java/com/slam/concertreservation/domain/payment/application/CompensationTxRetrySchedulerTest.java b/src/test/java/com/slam/concertreservation/domain/payment/application/CompensationTxRetrySchedulerTest.java new file mode 100644 index 0000000..ed79d99 --- /dev/null +++ b/src/test/java/com/slam/concertreservation/domain/payment/application/CompensationTxRetrySchedulerTest.java @@ -0,0 +1,121 @@ +package com.slam.concertreservation.domain.payment.application; + +import static org.mockito.Mockito.*; + +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 org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class CompensationTxRetrySchedulerTest { + + @InjectMocks + private CompensationTxRetryScheduler compensationTxRetryScheduler; + + @Mock + private CompensationTxLogService compensationTxLogService; + + @Mock + private PointModuleApi pointModuleApi; + + @Nested + @DisplayName("retryFailedCompensations 테스트") + class RetryFailedCompensationsTest { + + @Test + @DisplayName("양수 금액 보상 성공 시 COMPLETED 처리한다") + void shouldMarkResolved_WhenPositiveAmountCompensationSucceeds() { + // given + CompensationTxLog log = CompensationTxLog.create(1L, 100L, 200L, 5000); + + when(compensationTxLogService.getAllRetriables()).thenReturn(List.of(log)); + when(pointModuleApi.increaseUserPointBalance(1L, 5000)) + .thenReturn(PointOperationResult.success(1L, 5000)); + + // when + compensationTxRetryScheduler.retryFailedCompensations(); + + // then + verify(pointModuleApi).increaseUserPointBalance(1L, 5000); + verify(compensationTxLogService).markAsCompleted(log); + } + + @Test + @DisplayName("음수 금액 보상 성공 시 COMPLETED 처리한다") + void shouldMarkResolved_WhenNegativeAmountCompensationSucceeds() { + // given + CompensationTxLog log = CompensationTxLog.create(1L, 100L, 200L, -3000); + + when(compensationTxLogService.getAllRetriables()).thenReturn(List.of(log)); + when(pointModuleApi.decreaseUserPointBalance(1L, 3000)) + .thenReturn(PointOperationResult.success(1L, 3000)); + + // when + compensationTxRetryScheduler.retryFailedCompensations(); + + // then + verify(pointModuleApi).decreaseUserPointBalance(1L, 3000); + verify(compensationTxLogService).markAsCompleted(log); + } + + @Test + @DisplayName("보상 실패 시 retryCount를 증가시킨다") + void shouldIncrementRetryCount_WhenCompensationFails() { + // given + CompensationTxLog log = CompensationTxLog.create(1L, 100L, 200L, 5000); + + when(compensationTxLogService.getAllRetriables()).thenReturn(List.of(log)); + when(pointModuleApi.increaseUserPointBalance(1L, 5000)) + .thenReturn(PointOperationResult.fail(1L, 5000, "포인트 서비스 장애")); + + // when + compensationTxRetryScheduler.retryFailedCompensations(); + + // then + verify(compensationTxLogService).markAsFailed(log); + } + + @Test + @DisplayName("재시도 대상이 없으면 아무 작업도 하지 않는다") + void shouldDoNothing_WhenNoRetryTargets() { + // given + when(compensationTxLogService.getAllRetriables()).thenReturn(List.of()); + + // when + compensationTxRetryScheduler.retryFailedCompensations(); + + // then + verifyNoInteractions(pointModuleApi); + } + + @Test + @DisplayName("여러 건 중 일부만 성공해도 각각 독립적으로 처리한다") + void shouldProcessEachLogIndependently() { + // given + CompensationTxLog successLog = CompensationTxLog.create(1L, 100L, 200L, 5000); + CompensationTxLog failLog = CompensationTxLog.create(2L, 101L, 201L, 3000); + + when(compensationTxLogService.getAllRetriables()).thenReturn(List.of(successLog, failLog)); + when(pointModuleApi.increaseUserPointBalance(1L, 5000)) + .thenReturn(PointOperationResult.success(1L, 5000)); + when(pointModuleApi.increaseUserPointBalance(2L, 3000)) + .thenReturn(PointOperationResult.fail(2L, 3000, "장애")); + + // when + compensationTxRetryScheduler.retryFailedCompensations(); + + // then + verify(compensationTxLogService).markAsCompleted(successLog); + verify(compensationTxLogService).markAsFailed(failLog); + } + } +} \ No newline at end of file diff --git a/src/test/java/com/slam/concertreservation/domain/payment/application/PaymentOrchestratorUnitTest.java b/src/test/java/com/slam/concertreservation/domain/payment/application/PaymentOrchestratorUnitTest.java new file mode 100644 index 0000000..e14e53e --- /dev/null +++ b/src/test/java/com/slam/concertreservation/domain/payment/application/PaymentOrchestratorUnitTest.java @@ -0,0 +1,277 @@ +package com.slam.concertreservation.domain.payment.application; + +import com.slam.concertreservation.domain.payment.model.Payment; +import com.slam.concertreservation.domain.payment.model.PaymentStatus; +import com.slam.concertreservation.domain.payment.service.CompensationTxLogService; +import com.slam.concertreservation.domain.payment.service.PaymentService; +import com.slam.concertreservation.domain.point.api.PointModuleApi; +import com.slam.concertreservation.domain.point.api.PointOperationResult; +import com.slam.concertreservation.domain.reservation.api.ReservationModuleApi; +import com.slam.concertreservation.domain.reservation.api.ReservationOperationResult; +import com.slam.concertreservation.interfaces.dto.PaymentProcessResponse; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import java.time.LocalDateTime; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.never; + +class PaymentOrchestratorUnitTest { + + @Mock + private PaymentService paymentService; + + @Mock + private ReservationModuleApi reservationModuleApi; + + @Mock + private PointModuleApi pointModuleApi; + + @Mock + private CompensationTxLogService compensationTxLogService; + + @InjectMocks + private PaymentOrchestrator paymentOrchestrator; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + } + + @Nested + @DisplayName("processPayment 메서드 테스트") + class ProcessPaymentTest { + + @Test + @DisplayName("성공 : 포인트 차감과 예약 확정이 모두 성공하면 결제가 완료된다.") + void shouldCompletePayment_WhenAllOperationsSucceed() { + // given + Long userId = 1L; + int price = 1000; + Long reservationId = 1L; + Payment initiatedPayment = Payment.create(userId, price, reservationId); + Payment completedPayment = initiatedPayment.withStatus(PaymentStatus.COMPLETED); + + given(paymentService.initiate(userId, price, reservationId)).willReturn(initiatedPayment); + given(pointModuleApi.decreaseUserPointBalance(userId, price)) + .willReturn(PointOperationResult.success(userId, price)); + given(reservationModuleApi.confirmReservation(reservationId)) + .willReturn(ReservationOperationResult.success(reservationId)); + given(paymentService.complete(initiatedPayment)).willReturn(completedPayment); + + // when + PaymentProcessResponse response = paymentOrchestrator.processPayment(userId, price, reservationId); + + // then + assertEquals(PaymentStatus.COMPLETED, response.getPaymentStatus()); + verify(paymentService).complete(initiatedPayment); + verify(compensationTxLogService, never()).log(anyLong(), anyLong(), anyLong(), anyInt()); + } + + @Test + @DisplayName("실패 : 포인트 차감에 실패하면 결제가 실패하고 예약 확정을 시도하지 않는다.") + void shouldFailPayment_WhenPointDeductionFails() { + // given + Long userId = 1L; + int price = 1000; + Long reservationId = 1L; + Payment initiatedPayment = Payment.create(userId, price, reservationId); + Payment failedPayment = initiatedPayment.withStatus(PaymentStatus.FAILED); + + given(paymentService.initiate(userId, price, reservationId)).willReturn(initiatedPayment); + given(pointModuleApi.decreaseUserPointBalance(userId, price)) + .willReturn(PointOperationResult.fail(userId, price, "POINT_NOT_ENOUGH")); + given(paymentService.fail(initiatedPayment)).willReturn(failedPayment); + + // when + PaymentProcessResponse response = paymentOrchestrator.processPayment(userId, price, reservationId); + + // then + assertEquals(PaymentStatus.FAILED, response.getPaymentStatus()); + verify(reservationModuleApi, never()).confirmReservation(anyLong()); + verify(paymentService).fail(initiatedPayment); + } + + @Test + @DisplayName("실패 : 예약 확정에 실패하면 포인트 차감을 보상하고 결제가 실패한다.") + void shouldCompensatePointAndFailPayment_WhenReservationConfirmationFails() { + // given + Long userId = 1L; + int price = 1000; + Long reservationId = 1L; + Payment initiatedPayment = Payment.create(userId, price, reservationId); + Payment failedPayment = initiatedPayment.withStatus(PaymentStatus.FAILED); + + given(paymentService.initiate(userId, price, reservationId)).willReturn(initiatedPayment); + given(pointModuleApi.decreaseUserPointBalance(userId, price)) + .willReturn(PointOperationResult.success(userId, price)); + given(reservationModuleApi.confirmReservation(reservationId)) + .willReturn(ReservationOperationResult.fail(reservationId, "RESERVATION_EXPIRED")); + // 보상 트랜잭션 : 포인트 복구 + given(pointModuleApi.increaseUserPointBalance(userId, price)) + .willReturn(PointOperationResult.success(userId, price)); + given(paymentService.fail(initiatedPayment)).willReturn(failedPayment); + + // when + PaymentProcessResponse response = paymentOrchestrator.processPayment(userId, price, reservationId); + + // then + assertEquals(PaymentStatus.FAILED, response.getPaymentStatus()); + verify(pointModuleApi).increaseUserPointBalance(userId, price); // 보상 로직 호출 검증 + verify(paymentService).fail(initiatedPayment); + } + + @Test + @DisplayName("실패 : 예약 확정 실패 후 포인트 보상까지 실패하면 보상 트랜잭션 로그를 남긴다.") + void shouldLogCompensationTx_WhenCompensationFails() { + // given + Long userId = 1L; + int price = 1000; + Long reservationId = 1L; + Payment initiatedPayment = Payment.create(userId, price, reservationId); + Payment failedPayment = initiatedPayment.withStatus(PaymentStatus.FAILED); + + given(paymentService.initiate(userId, price, reservationId)).willReturn(initiatedPayment); + given(pointModuleApi.decreaseUserPointBalance(userId, price)) + .willReturn(PointOperationResult.success(userId, price)); + given(reservationModuleApi.confirmReservation(reservationId)) + .willReturn(ReservationOperationResult.fail(reservationId, "RESERVATION_EXPIRED")); + // 보상 트랜잭션 실패 시뮬레이션 + given(pointModuleApi.increaseUserPointBalance(userId, price)) + .willReturn(PointOperationResult.fail(userId, price, "SYSTEM_ERROR")); + given(paymentService.fail(initiatedPayment)).willReturn(failedPayment); + + // when + PaymentProcessResponse response = paymentOrchestrator.processPayment(userId, price, reservationId); + + // then + assertEquals(PaymentStatus.FAILED, response.getPaymentStatus()); + verify(compensationTxLogService).log(userId, reservationId, initiatedPayment.getPaymentId(), price); + } + } + + @Nested + @DisplayName("processRefund 메서드 테스트") + class ProcessRefundTest { + + @Test + @DisplayName("성공 : 포인트 환불과 예약 취소가 모두 성공하면 환불이 완료된다.") + void shouldCompleteRefund_WhenAllOperationsSucceed() { + // given + Long userId = 1L; + int price = 1000; + Long reservationId = 1L; + Payment initiatedRefund = Payment.create(userId, price, reservationId); + Payment refundedPayment = initiatedRefund.withStatus(PaymentStatus.REFUNDED); + + given(paymentService.initiate(userId, price, reservationId)).willReturn(initiatedRefund); + given(pointModuleApi.increaseUserPointBalance(userId, price)) + .willReturn(PointOperationResult.success(userId, price)); + given(reservationModuleApi.cancelReservation(reservationId)) + .willReturn(ReservationOperationResult.success(reservationId)); + given(paymentService.refund(initiatedRefund)).willReturn(refundedPayment); + + // when + PaymentProcessResponse response = paymentOrchestrator.processRefund(userId, price, reservationId); + + // then + assertEquals(PaymentStatus.REFUNDED, response.getPaymentStatus()); + verify(paymentService).refund(initiatedRefund); + verify(compensationTxLogService, never()).log(anyLong(), anyLong(), anyLong(), anyInt()); + } + + @Test + @DisplayName("실패 : 포인트 환불(증가)에 실패하면 환불 처리가 실패하고 예약 취소를 시도하지 않는다.") + void shouldFailRefund_WhenPointRestorationFails() { + // given + Long userId = 1L; + int price = 1000; + Long reservationId = 1L; + Payment initiatedRefund = Payment.create(userId, price, reservationId); + Payment failedRefund = initiatedRefund.withStatus(PaymentStatus.FAILED); + + given(paymentService.initiate(userId, price, reservationId)).willReturn(initiatedRefund); + given(pointModuleApi.increaseUserPointBalance(userId, price)) + .willReturn(PointOperationResult.fail(userId, price, "SYSTEM_ERROR")); + given(paymentService.fail(initiatedRefund)).willReturn(failedRefund); + + // when + PaymentProcessResponse response = paymentOrchestrator.processRefund(userId, price, reservationId); + + // then + assertEquals(PaymentStatus.FAILED, response.getPaymentStatus()); + verify(reservationModuleApi, never()).cancelReservation(anyLong()); + verify(paymentService).fail(initiatedRefund); + } + + @Test + @DisplayName("실패 : 예약 취소에 실패하면 포인트 증가를 보상(차감)하고 환불 처리가 실패한다.") + void shouldCompensatePointAndFailRefund_WhenReservationCancellationFails() { + // given + Long userId = 1L; + int price = 1000; + Long reservationId = 1L; + Payment initiatedRefund = Payment.create(userId, price, reservationId); + Payment failedRefund = initiatedRefund.withStatus(PaymentStatus.FAILED); + + given(paymentService.initiate(userId, price, reservationId)).willReturn(initiatedRefund); + given(pointModuleApi.increaseUserPointBalance(userId, price)) + .willReturn(PointOperationResult.success(userId, price)); + given(reservationModuleApi.cancelReservation(reservationId)) + .willReturn(ReservationOperationResult.fail(reservationId, "SYSTEM_ERROR")); + // 보상 트랜잭션 : 포인트 차감 + given(pointModuleApi.decreaseUserPointBalance(userId, price)) + .willReturn(PointOperationResult.success(userId, price)); + given(paymentService.fail(initiatedRefund)).willReturn(failedRefund); + + // when + PaymentProcessResponse response = paymentOrchestrator.processRefund(userId, price, reservationId); + + // then + assertEquals(PaymentStatus.FAILED, response.getPaymentStatus()); + verify(pointModuleApi).decreaseUserPointBalance(userId, price); // 보상 로직 호출 검증 + verify(paymentService).fail(initiatedRefund); + } + + @Test + @DisplayName("실패 : 예약 취소 실패 후 포인트 보상(차감)까지 실패하면 보상 트랜잭션 로그를 남긴다.") + void shouldLogCompensationTx_WhenRefundCompensationFails() { + // given + Long userId = 1L; + int price = 1000; + Long reservationId = 1L; + Payment initiatedRefund = Payment.create(userId, price, reservationId); + Payment failedRefund = initiatedRefund.withStatus(PaymentStatus.FAILED); + + given(paymentService.initiate(userId, price, reservationId)).willReturn(initiatedRefund); + given(pointModuleApi.increaseUserPointBalance(userId, price)) + .willReturn(PointOperationResult.success(userId, price)); + given(reservationModuleApi.cancelReservation(reservationId)) + .willReturn(ReservationOperationResult.fail(reservationId, "SYSTEM_ERROR")); + // 보상 트랜잭션 실패 시뮬레이션 + given(pointModuleApi.decreaseUserPointBalance(userId, price)) + .willReturn(PointOperationResult.fail(userId, price, "POINT_NOT_ENOUGH")); + given(paymentService.fail(initiatedRefund)).willReturn(failedRefund); + + // when + PaymentProcessResponse response = paymentOrchestrator.processRefund(userId, price, reservationId); + + // then + assertEquals(PaymentStatus.FAILED, response.getPaymentStatus()); + // compensatePointIncrease 에서는 -price 로 로그를 남김 + verify(compensationTxLogService).log(userId, reservationId, initiatedRefund.getPaymentId(), -price); + } + } +} From 525d7b9bf427bce42c17b9ee8c88fc9632ae6f07 Mon Sep 17 00:00:00 2001 From: Ho Yeon CHAE Date: Thu, 12 Feb 2026 18:51:30 +0900 Subject: [PATCH 6/7] Payment : Add payment endpoints and response DTOs --- .../ConcertReservationController.java | 38 +++++++++++++++++++ .../dto/PaymentProcessResponse.java | 24 ++++++++++++ .../dto/ReservationCancelResponse.java | 33 ++++++++++++++++ .../dto/ReservationConfirmResponse.java | 33 ++++++++++++++++ 4 files changed, 128 insertions(+) create mode 100644 src/main/java/com/slam/concertreservation/interfaces/dto/PaymentProcessResponse.java create mode 100644 src/main/java/com/slam/concertreservation/interfaces/dto/ReservationCancelResponse.java create mode 100644 src/main/java/com/slam/concertreservation/interfaces/dto/ReservationConfirmResponse.java diff --git a/src/main/java/com/slam/concertreservation/interfaces/ConcertReservationController.java b/src/main/java/com/slam/concertreservation/interfaces/ConcertReservationController.java index 62a17ae..173a3b7 100644 --- a/src/main/java/com/slam/concertreservation/interfaces/ConcertReservationController.java +++ b/src/main/java/com/slam/concertreservation/interfaces/ConcertReservationController.java @@ -7,12 +7,16 @@ import com.slam.concertreservation.domain.concert.model.ConcertSchedule; import com.slam.concertreservation.domain.concert.model.ConcertScheduleWithConcert; import com.slam.concertreservation.domain.concert.model.Seat; +import com.slam.concertreservation.domain.payment.application.PaymentOrchestrator; +import com.slam.concertreservation.domain.payment.service.PaymentService; import com.slam.concertreservation.domain.point.model.UserPointBalance; import com.slam.concertreservation.domain.queue.model.Token; import com.slam.concertreservation.domain.reservation.model.Reservation; import com.slam.concertreservation.domain.user.model.User; import com.slam.concertreservation.interfaces.dto.ConcertResponse; import com.slam.concertreservation.interfaces.dto.ConcertScheduleResponse; +import com.slam.concertreservation.interfaces.dto.PaymentProcessResponse; +import com.slam.concertreservation.interfaces.dto.ReservationConfirmResponse; import com.slam.concertreservation.interfaces.dto.ReservationResponse; import com.slam.concertreservation.interfaces.dto.SeatResponse; import com.slam.concertreservation.interfaces.dto.TokenResponse; @@ -21,6 +25,8 @@ import jakarta.servlet.http.HttpServletResponse; import java.time.LocalDateTime; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.format.annotation.DateTimeFormat; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @@ -36,6 +42,8 @@ public class ConcertReservationController { private final ConcertReservationApplication reservationApp; private final UserApplication userApp; + private final PaymentOrchestrator paymentOrchestrator; + private final PaymentService paymentService; /* ========== User ========== */ @@ -216,6 +224,36 @@ public ResponseEntity> getUserReservations(@PathVariab return ResponseEntity.ok(responses); } + @PostMapping("/payments") + public ResponseEntity processPayment( + @RequestParam Long userId, + @RequestParam Integer price, + @RequestParam Long reservationId) { + PaymentProcessResponse response = paymentOrchestrator.processPayment(userId, price, reservationId); + return ResponseEntity.ok(response); + } + + @PostMapping("/payments/{paymentId}/refund") + public ResponseEntity processRefund( + @PathVariable Long paymentId, + @RequestParam Long userId, + @RequestParam Integer price, + @RequestParam Long reservationId) { + PaymentProcessResponse response = paymentOrchestrator.processRefund(userId, price, reservationId); + return ResponseEntity.ok(response); + } + + @GetMapping("/payments/{paymentId}") + public ResponseEntity getPayment(@PathVariable Long paymentId) { + return ResponseEntity.ok(PaymentProcessResponse.from(paymentService.getPaymentById(paymentId))); + } + + @GetMapping("/payments") + public Page getPayments(@RequestParam Long userId, Pageable pageable) { + return paymentService.getPaymentHistoryOfUser(userId, pageable) + .map(PaymentProcessResponse::from); + } + /* ========== Queue ========== */ /** diff --git a/src/main/java/com/slam/concertreservation/interfaces/dto/PaymentProcessResponse.java b/src/main/java/com/slam/concertreservation/interfaces/dto/PaymentProcessResponse.java new file mode 100644 index 0000000..844380b --- /dev/null +++ b/src/main/java/com/slam/concertreservation/interfaces/dto/PaymentProcessResponse.java @@ -0,0 +1,24 @@ +package com.slam.concertreservation.interfaces.dto; + +import com.slam.concertreservation.domain.payment.model.Payment; +import com.slam.concertreservation.domain.payment.model.PaymentStatus; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class PaymentProcessResponse { + private Long paymentId; + private Long userId; + private Long reservationId; + private PaymentStatus paymentStatus; + + public static PaymentProcessResponse from(Payment payment) { + return PaymentProcessResponse.builder() + .paymentId(payment.getPaymentId()) + .userId(payment.getUserId()) + .reservationId(payment.getReservationId()) + .paymentStatus(payment.getStatus()) + .build(); + } +} diff --git a/src/main/java/com/slam/concertreservation/interfaces/dto/ReservationCancelResponse.java b/src/main/java/com/slam/concertreservation/interfaces/dto/ReservationCancelResponse.java new file mode 100644 index 0000000..2cd6dcf --- /dev/null +++ b/src/main/java/com/slam/concertreservation/interfaces/dto/ReservationCancelResponse.java @@ -0,0 +1,33 @@ +package com.slam.concertreservation.interfaces.dto; + +import com.slam.concertreservation.domain.reservation.model.Reservation; +import java.time.LocalDateTime; +import lombok.Builder; +import lombok.Getter; + +@Builder +@Getter +public class ReservationCancelResponse { + private String id; + private String userId; + private String seatId; + private int seatNumber; + private String concertScheduleId; + private int price; + private String status; + private LocalDateTime expiredAt; + private LocalDateTime createdAt; + + public static ReservationCancelResponse from(Reservation reservation) { + return ReservationCancelResponse.builder() + .id(String.valueOf(reservation.getId())) + .userId(String.valueOf(reservation.getUserId())) + .seatId(String.valueOf(reservation.getSeatId())) + .concertScheduleId(String.valueOf(reservation.getConcertScheduleId())) + .price(reservation.getPrice()) + .status(reservation.getStatus().name()) + .expiredAt(reservation.getExpiredAt()) + .createdAt(reservation.getCreatedAt()) + .build(); + } +} diff --git a/src/main/java/com/slam/concertreservation/interfaces/dto/ReservationConfirmResponse.java b/src/main/java/com/slam/concertreservation/interfaces/dto/ReservationConfirmResponse.java new file mode 100644 index 0000000..1e0ebac --- /dev/null +++ b/src/main/java/com/slam/concertreservation/interfaces/dto/ReservationConfirmResponse.java @@ -0,0 +1,33 @@ +package com.slam.concertreservation.interfaces.dto; + +import com.slam.concertreservation.domain.reservation.model.Reservation; +import java.time.LocalDateTime; +import lombok.Builder; +import lombok.Getter; + +@Builder +@Getter +public class ReservationConfirmResponse { + private String id; + private String userId; + private String seatId; + private int seatNumber; + private String concertScheduleId; + private int price; + private String status; + private LocalDateTime expiredAt; + private LocalDateTime createdAt; + + public static ReservationConfirmResponse from(Reservation reservation) { + return ReservationConfirmResponse.builder() + .id(String.valueOf(reservation.getId())) + .userId(String.valueOf(reservation.getUserId())) + .seatId(String.valueOf(reservation.getSeatId())) + .concertScheduleId(String.valueOf(reservation.getConcertScheduleId())) + .price(reservation.getPrice()) + .status(reservation.getStatus().name()) + .expiredAt(reservation.getExpiredAt()) + .createdAt(reservation.getCreatedAt()) + .build(); + } +} From 78a0d2d56c65944bedabe9cc63f942791f231fa8 Mon Sep 17 00:00:00 2001 From: Ho Yeon CHAE Date: Tue, 24 Mar 2026 10:04:23 +0900 Subject: [PATCH 7/7] ADR : ADR for orchestration saga added --- .../en/adr-payment-saga-module-api.md | 116 ++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 docs/detailed_docs/en/adr-payment-saga-module-api.md diff --git a/docs/detailed_docs/en/adr-payment-saga-module-api.md b/docs/detailed_docs/en/adr-payment-saga-module-api.md new file mode 100644 index 0000000..8e689a7 --- /dev/null +++ b/docs/detailed_docs/en/adr-payment-saga-module-api.md @@ -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. + +### 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. +- **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.