diff --git a/apps/commerce-api/src/main/java/com/loopers/application/purchasing/PaymentRequestCommand.java b/apps/commerce-api/src/main/java/com/loopers/application/purchasing/PaymentRequestCommand.java new file mode 100644 index 000000000..3834136a6 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/purchasing/PaymentRequestCommand.java @@ -0,0 +1,43 @@ +package com.loopers.application.purchasing; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; + +/** + * 결제 요청 명령. + *

+ * PG 결제 요청에 필요한 정보를 담는 명령 모델입니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +public record PaymentRequestCommand( + String userId, + Long orderId, + String cardType, + String cardNo, + Long amount, + String callbackUrl +) { + public PaymentRequestCommand { + if (userId == null || userId.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "userId는 필수입니다."); + } + if (orderId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "orderId는 필수입니다."); + } + if (cardType == null || cardType.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "cardType은 필수입니다."); + } + if (cardNo == null || cardNo.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "cardNo는 필수입니다."); + } + if (amount == null || amount <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "amount는 0보다 커야 합니다."); + } + if (callbackUrl == null || callbackUrl.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "callbackUrl은 필수입니다."); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/purchasing/PurchasingFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/purchasing/PurchasingFacade.java index 0a2b1b208..3da1072dd 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/purchasing/PurchasingFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/purchasing/PurchasingFacade.java @@ -1,33 +1,30 @@ package com.loopers.application.purchasing; -import com.loopers.domain.coupon.Coupon; -import com.loopers.domain.coupon.CouponRepository; -import com.loopers.domain.coupon.UserCoupon; -import com.loopers.domain.coupon.UserCouponRepository; -import com.loopers.domain.coupon.discount.CouponDiscountStrategyFactory; import com.loopers.domain.order.Order; import com.loopers.domain.order.OrderItem; -import com.loopers.domain.order.OrderRepository; +import com.loopers.domain.order.OrderService; import com.loopers.domain.order.OrderStatus; -import org.springframework.orm.ObjectOptimisticLockingFailureException; import com.loopers.domain.product.Product; -import com.loopers.domain.product.ProductRepository; +import com.loopers.domain.product.ProductService; import com.loopers.domain.user.Point; import com.loopers.domain.user.User; -import com.loopers.domain.user.UserRepository; -import com.loopers.infrastructure.paymentgateway.PaymentGatewaySchedulerClient; -import com.loopers.infrastructure.paymentgateway.PaymentGatewayDto; -import com.loopers.infrastructure.paymentgateway.PaymentGatewayAdapter; -import com.loopers.domain.order.PaymentFailureClassifier; -import com.loopers.domain.order.PaymentFailureType; -import com.loopers.domain.order.OrderStatusUpdater; -import com.loopers.domain.order.OrderCancellationService; +import com.loopers.domain.user.UserService; +import com.loopers.domain.coupon.CouponService; +import com.loopers.infrastructure.payment.PaymentGatewayDto; +import com.loopers.domain.payment.PaymentRequestResult; +import com.loopers.domain.payment.PaymentService; +import com.loopers.domain.payment.Payment; +import com.loopers.domain.payment.PaymentStatus; +import com.loopers.domain.payment.CardType; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import feign.FeignException; +import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.support.TransactionSynchronization; import org.springframework.transaction.support.TransactionSynchronizationManager; +import org.springframework.transaction.support.TransactionTemplate; +import org.springframework.transaction.PlatformTransactionManager; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; @@ -49,29 +46,31 @@ @Component public class PurchasingFacade { - private final UserRepository userRepository; - private final ProductRepository productRepository; - private final OrderRepository orderRepository; - private final CouponRepository couponRepository; - private final UserCouponRepository userCouponRepository; - private final CouponDiscountStrategyFactory couponDiscountStrategyFactory; - private final PaymentGatewaySchedulerClient paymentGatewaySchedulerClient; // 스케줄러용 (Retry 적용) - private final PaymentRequestBuilder paymentRequestBuilder; - private final PaymentGatewayAdapter paymentGatewayAdapter; - private final PaymentFailureClassifier paymentFailureClassifier; - private final PaymentRecoveryService paymentRecoveryService; - private final OrderCancellationService orderCancellationService; - private final OrderStatusUpdater orderStatusUpdater; - private final PaymentFailureHandler paymentFailureHandler; + private final UserService userService; + private final ProductService productService; + private final CouponService couponService; + private final OrderService orderService; + private final PaymentService paymentService; // Payment 관련: PaymentService만 의존 (DIP 준수) + private final PlatformTransactionManager transactionManager; /** * 주문을 생성한다. *

* 1. 사용자 조회 및 존재 여부 검증
* 2. 상품 재고 검증 및 차감
- * 3. 사용자 포인트 검증 및 차감
- * 4. 주문 저장
- * 5. PG 결제 요청 (비동기) + * 3. 쿠폰 할인 적용
+ * 4. 사용자 포인트 차감 (지정된 금액만)
+ * 5. 주문 저장
+ * 6. Payment 생성 (포인트+쿠폰 혼합 지원)
+ * 7. PG 결제 금액이 0이면 바로 완료, 아니면 PG 결제 요청 (비동기) + *

+ *

+ * 결제 방식: + *

*

*

* 동시성 제어 전략: @@ -103,12 +102,13 @@ public class PurchasingFacade { * * @param userId 사용자 식별자 (로그인 ID) * @param commands 주문 상품 정보 - * @param cardType 카드 타입 (SAMSUNG, KB, HYUNDAI) - * @param cardNo 카드 번호 (xxxx-xxxx-xxxx-xxxx 형식) + * @param usedPoint 포인트 사용량 (선택, 기본값: 0) + * @param cardType 카드 타입 (paidAmount > 0일 때만 필수) + * @param cardNo 카드 번호 (paidAmount > 0일 때만 필수) * @return 생성된 주문 정보 */ @Transactional - public OrderInfo createOrder(String userId, List commands, String cardType, String cardNo) { + public OrderInfo createOrder(String userId, List commands, Long usedPoint, String cardType, String cardNo) { if (userId == null || userId.isBlank()) { throw new CoreException(ErrorType.BAD_REQUEST, "사용자 ID는 필수입니다."); } @@ -120,7 +120,7 @@ public OrderInfo createOrder(String userId, List commands, Str // - userId는 UNIQUE 인덱스가 있어 Lock 범위 최소화 (Record Lock만 적용) // - Lost Update 방지: 동시 주문 시 포인트 중복 차감 방지 (금전적 손실 방지) // - 트랜잭션 내부에 외부 I/O 없음, lock holding time 매우 짧음 - User user = loadUserForUpdate(userId); + User user = userService.findByUserIdForUpdate(userId); // ✅ Deadlock 방지: 상품 ID를 정렬하여 일관된 락 획득 순서 보장 // 여러 상품을 주문할 때, 항상 동일한 순서로 락을 획득하여 deadlock 방지 @@ -144,9 +144,7 @@ public OrderInfo createOrder(String userId, List commands, Str // - Lost Update 방지: 동시 주문 시 재고 음수 방지 및 정확한 차감 보장 (재고 oversell 방지) // - 트랜잭션 내부에 외부 I/O 없음, lock holding time 매우 짧음 // - ✅ 정렬된 순서로 락 획득하여 deadlock 방지 - Product product = productRepository.findByIdForUpdate(productId) - .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, - String.format("상품을 찾을 수 없습니다. (상품 ID: %d)", productId))); + Product product = productService.findByIdForUpdate(productId); productMap.put(productId, product); } @@ -169,43 +167,92 @@ public OrderInfo createOrder(String userId, List commands, Str String couponCode = extractCouponCode(commands); Integer discountAmount = 0; if (couponCode != null && !couponCode.isBlank()) { - discountAmount = applyCoupon(user.getId(), couponCode, calculateSubtotal(orderItems)); + discountAmount = couponService.applyCoupon(user.getId(), couponCode, calculateSubtotal(orderItems)); } - Order order = Order.of(user.getId(), orderItems, couponCode, discountAmount); + // 포인트 차감 (지정된 금액만) + Long usedPointAmount = Objects.requireNonNullElse(usedPoint, 0L); + + // 포인트 잔액 검증: 포인트를 사용하는 경우에만 검증 + // 재고 차감 전에 검증하여 원자성 보장 (검증 실패 시 아무것도 변경되지 않음) + if (usedPointAmount > 0) { + Long userPointBalance = user.getPoint().getValue(); + if (userPointBalance < usedPointAmount) { + throw new CoreException(ErrorType.BAD_REQUEST, + String.format("포인트가 부족합니다. (현재 잔액: %d, 사용 요청 금액: %d)", userPointBalance, usedPointAmount)); + } + } + + // OrderService를 사용하여 주문 생성 + Order savedOrder = orderService.create(user.getId(), orderItems, couponCode, discountAmount); // 주문은 PENDING 상태로 생성됨 (Order 생성자에서 기본값으로 설정) // 결제 성공 후에만 COMPLETED로 변경됨 - decreaseStocksForOrderItems(order.getItems(), products); - deductUserPoint(user, order.getTotalAmount()); - // 주문은 PENDING 상태로 유지 (결제 요청 중 상태) - // 결제 성공 시 콜백이나 상태 확인 API를 통해 COMPLETED로 변경됨 + // 재고 차감 + decreaseStocksForOrderItems(savedOrder.getItems(), products); + + // 포인트 차감 (지정된 금액만) + if (usedPointAmount > 0) { + deductUserPoint(user, usedPointAmount.intValue()); + } + + // PG 결제 금액 계산 + // Order.getTotalAmount()는 이미 쿠폰 할인이 적용된 금액이므로 discountAmount를 다시 빼면 안 됨 + Long totalAmount = savedOrder.getTotalAmount().longValue(); + Long paidAmount = totalAmount - usedPointAmount; + + // Payment 생성 (포인트+쿠폰 혼합 지원) + CardType cardTypeEnum = (cardType != null && !cardType.isBlank()) ? convertCardType(cardType) : null; + Payment payment = paymentService.create( + savedOrder.getId(), + user.getId(), + totalAmount, + usedPointAmount, + cardTypeEnum, + cardNo, + java.time.LocalDateTime.now() + ); + + // 포인트+쿠폰으로 전액 결제 완료된 경우 + if (paidAmount == 0) { + // PG 요청 없이 바로 완료 + orderService.completeOrder(savedOrder.getId()); + paymentService.toSuccess(payment.getId(), java.time.LocalDateTime.now()); + productService.saveAll(products); + userService.save(user); + log.debug("포인트+쿠폰으로 전액 결제 완료. (orderId: {})", savedOrder.getId()); + return OrderInfo.from(savedOrder); + } - products.forEach(productRepository::save); - userRepository.save(user); + // PG 결제가 필요한 경우 + if (cardType == null || cardType.isBlank() || cardNo == null || cardNo.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, + "포인트와 쿠폰만으로 결제할 수 없습니다. 카드 정보를 입력해주세요."); + } - Order savedOrder = orderRepository.save(order); - // 주문은 PENDING 상태로 저장됨 + productService.saveAll(products); + userService.save(user); // PG 결제 요청을 트랜잭션 커밋 후에 실행하여 DB 커넥션 풀 고갈 방지 // 트랜잭션 내에서 외부 HTTP 호출을 하면 PG 지연/타임아웃 시 DB 커넥션이 오래 유지되어 커넥션 풀 고갈 위험 Long orderId = savedOrder.getId(); - Integer totalAmount = savedOrder.getTotalAmount(); + TransactionSynchronizationManager.registerSynchronization( new TransactionSynchronization() { @Override public void afterCommit() { // 트랜잭션 커밋 후 PG 호출 (DB 커넥션 해제 후 실행) try { - String transactionKey = requestPaymentToGateway(userId, orderId, cardType, cardNo, totalAmount); + String transactionKey = requestPaymentToGateway( + userId, user.getId(), orderId, cardType, cardNo, paidAmount.intValue() + ); if (transactionKey != null) { // 결제 성공: 별도 트랜잭션에서 주문 상태를 COMPLETED로 변경 updateOrderStatusToCompleted(orderId, transactionKey); - log.info("PG 결제 요청 완료. (orderId: {}, transactionKey: {})", orderId, transactionKey); } else { // PG 요청 실패: 외부 시스템 장애로 간주 // 주문은 PENDING 상태로 유지되어 나중에 상태 확인 API나 콜백으로 복구 가능 - log.info("PG 결제 요청 실패. 주문은 PENDING 상태로 유지됩니다. (orderId: {})", orderId); + log.debug("PG 결제 요청 실패. 주문은 PENDING 상태로 유지됩니다. (orderId: {})", orderId); } } catch (Exception e) { // PG 요청 중 예외 발생 시에도 주문은 이미 저장되어 있으므로 유지 @@ -244,7 +291,46 @@ public void afterCommit() { */ @Transactional public void cancelOrder(Order order, User user) { - orderCancellationService.cancel(order, user); + if (order == null || user == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "취소할 주문과 사용자 정보는 필수입니다."); + } + + // ✅ Deadlock 방지: User 락을 먼저 획득하여 createOrder와 동일한 락 획득 순서 보장 + User lockedUser = userService.findByUserIdForUpdate(user.getUserId()); + if (lockedUser == null) { + throw new CoreException(ErrorType.NOT_FOUND, "사용자를 찾을 수 없습니다."); + } + + // ✅ Deadlock 방지: 상품 ID를 정렬하여 일관된 락 획득 순서 보장 + List sortedProductIds = order.getItems().stream() + .map(OrderItem::getProductId) + .distinct() + .sorted() + .toList(); + + // 정렬된 순서대로 상품 락 획득 (Deadlock 방지) + Map productMap = new java.util.HashMap<>(); + for (Long productId : sortedProductIds) { + Product product = productService.findByIdForUpdate(productId); + productMap.put(productId, product); + } + + // OrderItem 순서대로 Product 리스트 생성 + List products = order.getItems().stream() + .map(item -> productMap.get(item.getProductId())) + .toList(); + + // 실제로 사용된 포인트만 환불 (Payment에서 확인) + Long refundPointAmount = paymentService.findByOrderId(order.getId()) + .map(Payment::getUsedPoint) + .orElse(0L); + + // 도메인 서비스를 통한 주문 취소 처리 + orderService.cancelOrder(order, products, lockedUser, refundPointAmount); + + // 저장 + productService.saveAll(products); + userService.save(lockedUser); } /** @@ -255,8 +341,8 @@ public void cancelOrder(Order order, User user) { */ @Transactional(readOnly = true) public List getOrders(String userId) { - User user = loadUser(userId); - List orders = orderRepository.findAllByUserId(user.getId()); + User user = userService.findByUserId(userId); + List orders = orderService.findAllByUserId(user.getId()); return orders.stream() .map(OrderInfo::from) .toList(); @@ -271,9 +357,8 @@ public List getOrders(String userId) { */ @Transactional(readOnly = true) public OrderInfo getOrder(String userId, Long orderId) { - User user = loadUser(userId); - Order order = orderRepository.findById(orderId) - .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "주문을 찾을 수 없습니다.")); + User user = userService.findByUserId(userId); + Order order = orderService.getById(orderId); if (!order.getUserId().equals(user.getId())) { throw new CoreException(ErrorType.NOT_FOUND, "주문을 찾을 수 없습니다."); @@ -304,34 +389,6 @@ private void deductUserPoint(User user, Integer totalAmount) { user.deductPoint(Point.of(totalAmount.longValue())); } - private User loadUser(String userId) { - User user = userRepository.findByUserId(userId); - if (user == null) { - throw new CoreException(ErrorType.NOT_FOUND, "사용자를 찾을 수 없습니다."); - } - return user; - } - - /** - * 비관적 락을 사용하여 사용자를 조회합니다. - *

- * 포인트 차감 등 동시성 제어가 필요한 경우 사용합니다. - *

- *

- * 전제 조건: userId는 상위 계층에서 이미 null/blank 검증이 완료되어야 합니다. - *

- * - * @param userId 사용자 ID (null이 아니고 비어있지 않아야 함) - * @return 조회된 사용자 - * @throws CoreException 사용자를 찾을 수 없는 경우 - */ - private User loadUserForUpdate(String userId) { - User user = userRepository.findByUserIdForUpdate(userId); - if (user == null) { - throw new CoreException(ErrorType.NOT_FOUND, "사용자를 찾을 수 없습니다."); - } - return user; - } /** * 주문 명령에서 쿠폰 코드를 추출합니다. @@ -347,71 +404,131 @@ private String extractCouponCode(List commands) { .orElse(null); } + /** - * 쿠폰을 적용하여 할인 금액을 계산하고 쿠폰을 사용 처리합니다. - *

- * 동시성 제어 전략: - *

- *

+ * 주문 아이템 목록으로부터 소계 금액을 계산합니다. * - * @param userId 사용자 ID - * @param couponCode 쿠폰 코드 - * @param subtotal 주문 소계 금액 - * @return 할인 금액 - * @throws CoreException 쿠폰을 찾을 수 없거나 사용 불가능한 경우, 동시 사용으로 인한 충돌 시 + * @param orderItems 주문 아이템 목록 + * @return 계산된 소계 금액 */ - private Integer applyCoupon(Long userId, String couponCode, Integer subtotal) { - // 쿠폰 존재 여부 확인 - Coupon coupon = couponRepository.findByCode(couponCode) - .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, - String.format("쿠폰을 찾을 수 없습니다. (쿠폰 코드: %s)", couponCode))); - - // 낙관적 락을 사용하여 사용자 쿠폰 조회 (동시성 제어) - // @Version 필드가 있어 자동으로 낙관적 락이 적용됨 - UserCoupon userCoupon = userCouponRepository.findByUserIdAndCouponCodeForUpdate(userId, couponCode) - .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, - String.format("사용자가 소유한 쿠폰을 찾을 수 없습니다. (쿠폰 코드: %s)", couponCode))); - - // 쿠폰 사용 가능 여부 확인 - if (!userCoupon.isAvailable()) { - throw new CoreException(ErrorType.BAD_REQUEST, - String.format("이미 사용된 쿠폰입니다. (쿠폰 코드: %s)", couponCode)); - } - - // 쿠폰 사용 처리 - userCoupon.use(); - - // 할인 금액 계산 (전략 패턴 사용) - Integer discountAmount = coupon.calculateDiscountAmount(subtotal, couponDiscountStrategyFactory); + private Integer calculateSubtotal(List orderItems) { + return orderItems.stream() + .mapToInt(item -> item.getPrice() * item.getQuantity()) + .sum(); + } + /** + * 카드 타입 문자열을 CardType enum으로 변환합니다. + * + * @param cardType 카드 타입 문자열 + * @return CardType enum + * @throws CoreException 잘못된 카드 타입인 경우 + */ + private CardType convertCardType(String cardType) { try { - // 사용자 쿠폰 저장 (version 체크 자동 수행) - // 다른 트랜잭션이 먼저 수정했다면 OptimisticLockException 발생 - userCouponRepository.save(userCoupon); - } catch (ObjectOptimisticLockingFailureException e) { - // 낙관적 락 충돌: 다른 트랜잭션이 먼저 쿠폰을 사용함 - throw new CoreException(ErrorType.CONFLICT, - String.format("쿠폰이 이미 사용되었습니다. (쿠폰 코드: %s)", couponCode)); + return CardType.valueOf(cardType.toUpperCase()); + } catch (IllegalArgumentException e) { + throw new CoreException(ErrorType.BAD_REQUEST, + String.format("잘못된 카드 타입입니다. (cardType: %s)", cardType)); } + } - return discountAmount; + /** + * PaymentGatewayDto.TransactionStatus를 PaymentStatus 도메인 모델로 변환합니다. + * + * @param transactionStatus 인프라 계층의 TransactionStatus + * @return 도메인 모델의 PaymentStatus + */ + private PaymentStatus convertToPaymentStatus( + PaymentGatewayDto.TransactionStatus transactionStatus + ) { + return switch (transactionStatus) { + case SUCCESS -> PaymentStatus.SUCCESS; + case FAILED -> PaymentStatus.FAILED; + case PENDING -> PaymentStatus.PENDING; + }; + } + + /** + * PaymentStatus 도메인 모델을 PaymentGatewayDto.TransactionStatus로 변환합니다. + * + * @param paymentStatus 도메인 모델의 PaymentStatus + * @return 인프라 계층의 TransactionStatus + */ + private PaymentGatewayDto.TransactionStatus convertToInfraStatus(PaymentStatus paymentStatus) { + return switch (paymentStatus) { + case SUCCESS -> PaymentGatewayDto.TransactionStatus.SUCCESS; + case FAILED -> PaymentGatewayDto.TransactionStatus.FAILED; + case PENDING -> PaymentGatewayDto.TransactionStatus.PENDING; + }; } /** - * 주문 아이템 목록으로부터 소계 금액을 계산합니다. + * 결제 상태에 따라 주문 상태를 업데이트합니다. + *

+ * 별도 트랜잭션으로 실행하여 외부 시스템 호출과 독립적으로 처리합니다. + *

* - * @param orderItems 주문 아이템 목록 - * @return 계산된 소계 금액 + * @param orderId 주문 ID + * @param paymentStatus 결제 상태 (도메인 모델) + * @param transactionKey 트랜잭션 키 + * @param reason 실패 사유 (실패 시) + * @return 업데이트 성공 여부 (true: 성공, false: 실패) */ - private Integer calculateSubtotal(List orderItems) { - return orderItems.stream() - .mapToInt(item -> item.getPrice() * item.getQuantity()) - .sum(); + @Transactional(propagation = Propagation.REQUIRES_NEW) + public boolean updateOrderStatusByPaymentResult( + Long orderId, + PaymentStatus paymentStatus, + String transactionKey, + String reason + ) { + try { + Order order = orderService.findById(orderId).orElse(null); + + if (order == null) { + log.warn("주문 상태 업데이트 시 주문을 찾을 수 없습니다. (orderId: {})", orderId); + return false; + } + + // 이미 완료되거나 취소된 주문인 경우 처리하지 않음 (정상적인 경우이므로 true 반환) + if (order.getStatus() == OrderStatus.COMPLETED) { + log.debug("이미 완료된 주문입니다. 상태 업데이트를 건너뜁니다. (orderId: {})", orderId); + return true; + } + + if (order.getStatus() == OrderStatus.CANCELED) { + log.debug("이미 취소된 주문입니다. 상태 업데이트를 건너뜁니다. (orderId: {})", orderId); + return true; + } + + if (paymentStatus == PaymentStatus.SUCCESS) { + // 결제 성공: 주문 완료 + orderService.updateStatusByPaymentResult(order, paymentStatus); + log.info("결제 상태 확인 결과, 주문 상태를 COMPLETED로 업데이트했습니다. (orderId: {}, transactionKey: {})", + orderId, transactionKey); + return true; + } else if (paymentStatus == PaymentStatus.FAILED) { + // 결제 실패: 주문 취소 및 리소스 원복 + User user = userService.findById(order.getUserId()); + if (user == null) { + log.warn("주문 상태 업데이트 시 사용자를 찾을 수 없습니다. (orderId: {}, userId: {})", + orderId, order.getUserId()); + return false; + } + cancelOrder(order, user); + log.info("결제 상태 확인 결과, 주문 상태를 CANCELED로 업데이트했습니다. (orderId: {}, transactionKey: {}, reason: {})", + orderId, transactionKey, reason); + return true; + } else { + // PENDING 상태: 아직 처리 중 (정상적인 경우이므로 true 반환) + log.debug("결제 상태 확인 결과, 아직 처리 중입니다. 주문은 PENDING 상태로 유지됩니다. (orderId: {}, transactionKey: {})", + orderId, transactionKey); + return true; + } + } catch (Exception e) { + log.error("주문 상태 업데이트 중 오류 발생. (orderId: {})", orderId, e); + return false; + } } /** @@ -425,20 +542,26 @@ private Integer calculateSubtotal(List orderItems) { */ @Transactional(propagation = org.springframework.transaction.annotation.Propagation.REQUIRES_NEW) public void updateOrderStatusToCompleted(Long orderId, String transactionKey) { - Order order = orderRepository.findById(orderId).orElse(null); - if (order == null) { + try { + Order order = orderService.getById(orderId); + + if (order.getStatus() == OrderStatus.COMPLETED) { + log.debug("이미 완료된 주문입니다. 상태 업데이트를 건너뜁니다. (orderId: {})", orderId); + return; + } + + // Payment 상태 업데이트 (PaymentService 사용) + paymentService.findByOrderId(orderId).ifPresent(payment -> { + if (payment.getStatus() == PaymentStatus.PENDING) { + paymentService.toSuccess(payment.getId(), java.time.LocalDateTime.now()); + } + }); + + orderService.completeOrder(orderId); + log.info("주문 상태를 COMPLETED로 업데이트했습니다. (orderId: {}, transactionKey: {})", orderId, transactionKey); + } catch (CoreException e) { log.warn("주문 상태 업데이트 시 주문을 찾을 수 없습니다. (orderId: {})", orderId); - return; } - - if (order.getStatus() == OrderStatus.COMPLETED) { - log.info("이미 완료된 주문입니다. 상태 업데이트를 건너뜁니다. (orderId: {})", orderId); - return; - } - - order.complete(); - orderRepository.save(order); - log.info("주문 상태를 COMPLETED로 업데이트했습니다. (orderId: {}, transactionKey: {})", orderId, transactionKey); } /** @@ -448,54 +571,55 @@ public void updateOrderStatusToCompleted(Long orderId, String transactionKey) { * 실패 시에도 주문은 이미 저장되어 있으므로, 로그만 기록합니다. *

* - * @param userId 사용자 ID + * @param userId 사용자 ID (String - User.userId, PG 요청용) + * @param userEntityId 사용자 엔티티 ID (Long - User.id, Payment 엔티티용) * @param orderId 주문 ID * @param cardType 카드 타입 * @param cardNo 카드 번호 * @param amount 결제 금액 * @return transactionKey (성공 시), null (실패 시) */ - private String requestPaymentToGateway(String userId, Long orderId, String cardType, String cardNo, Integer amount) { + private String requestPaymentToGateway(String userId, Long userEntityId, Long orderId, String cardType, String cardNo, Integer amount) { try { - // 결제 요청 생성 - PaymentRequest request = paymentRequestBuilder.build(userId, orderId, cardType, cardNo, amount); - - // PG 결제 요청 전송 - var result = paymentGatewayAdapter.requestPayment(request); + // PaymentService를 통한 PG 결제 요청 + PaymentRequestResult result = paymentService.requestPayment( + orderId, userId, userEntityId, cardType, cardNo, amount.longValue() + ); // 결과 처리 - return result.handle( - success -> { - log.info("PG 결제 요청 성공. (orderId: {}, transactionKey: {})", - orderId, success.transactionKey()); - return success.transactionKey(); - }, - failure -> { - PaymentFailureType failureType = paymentFailureClassifier.classify(failure.errorCode()); - - if (failureType == PaymentFailureType.BUSINESS_FAILURE) { - // 비즈니스 실패: 주문 취소 (별도 트랜잭션으로 처리) - paymentFailureHandler.handle(userId, orderId, failure.errorCode(), failure.message()); - } else if (failure.isTimeout()) { - // 타임아웃: 상태 확인 후 복구 - log.info("타임아웃 발생. PG 결제 상태 확인 API를 호출하여 실제 결제 상태를 확인합니다. (orderId: {})", orderId); - paymentRecoveryService.recoverAfterTimeout(userId, orderId); - } else { - // 외부 시스템 장애: 주문은 PENDING 상태로 유지 - log.info("외부 시스템 장애로 인한 실패. 주문은 PENDING 상태로 유지됩니다. (orderId: {}, errorCode: {})", - orderId, failure.errorCode()); - } + if (result instanceof PaymentRequestResult.Success success) { + return success.transactionKey(); + } else if (result instanceof PaymentRequestResult.Failure failure) { + // PaymentService 내부에서 이미 실패 분류가 완료되었으므로, 여기서는 처리만 수행 + // 비즈니스 실패는 PaymentService에서 이미 처리되었으므로, 여기서는 타임아웃/외부 시스템 장애만 처리 + + // Circuit Breaker OPEN은 외부 시스템 장애이므로 주문을 취소하지 않음 + if ("CIRCUIT_BREAKER_OPEN".equals(failure.errorCode())) { + // 외부 시스템 장애: 주문은 PENDING 상태로 유지 + log.warn("Circuit Breaker OPEN 상태. 주문은 PENDING 상태로 유지됩니다. (orderId: {})", orderId); return null; } - ); + + if (failure.isTimeout()) { + // 타임아웃: 상태 확인 후 복구 + log.debug("타임아웃 발생. PG 결제 상태 확인 API를 호출하여 실제 결제 상태를 확인합니다. (orderId: {})", orderId); + paymentService.recoverAfterTimeout(userId, orderId); + } else if (!failure.isRetryable()) { + // 비즈니스 실패: 주문 취소 (별도 트랜잭션으로 처리) + handlePaymentFailure(userId, orderId, failure.errorCode(), failure.message()); + } + // 외부 시스템 장애는 PaymentService에서 이미 PENDING 상태로 유지하므로 추가 처리 불필요 + return null; + } + + return null; } catch (CoreException e) { // 잘못된 카드 타입 등 검증 오류 - log.warn("결제 요청 생성 실패. (orderId: {}, error: {})", orderId, e.getMessage()); + log.warn("결제 요청 실패. (orderId: {}, error: {})", orderId, e.getMessage()); return null; } catch (Exception e) { // 기타 예외 처리 log.error("PG 결제 요청 중 예상치 못한 오류 발생. (orderId: {})", orderId, e); - log.info("예상치 못한 오류 발생. 주문은 PENDING 상태로 유지됩니다. (orderId: {})", orderId); return null; } } @@ -531,10 +655,10 @@ private String requestPaymentToGateway(String userId, Long orderId, String cardT public void handlePaymentCallback(Long orderId, PaymentGatewayDto.CallbackRequest callbackRequest) { try { // 주문 조회 - Order order = orderRepository.findById(orderId) - .orElse(null); - - if (order == null) { + Order order; + try { + order = orderService.getById(orderId); + } catch (CoreException e) { log.warn("콜백 처리 시 주문을 찾을 수 없습니다. (orderId: {}, transactionKey: {})", orderId, callbackRequest.transactionKey()); return; @@ -542,13 +666,13 @@ public void handlePaymentCallback(Long orderId, PaymentGatewayDto.CallbackReques // 이미 완료되거나 취소된 주문인 경우 처리하지 않음 if (order.getStatus() == OrderStatus.COMPLETED) { - log.info("이미 완료된 주문입니다. 콜백 처리를 건너뜁니다. (orderId: {}, transactionKey: {})", + log.debug("이미 완료된 주문입니다. 콜백 처리를 건너뜁니다. (orderId: {}, transactionKey: {})", orderId, callbackRequest.transactionKey()); return; } if (order.getStatus() == OrderStatus.CANCELED) { - log.info("이미 취소된 주문입니다. 콜백 처리를 건너뜁니다. (orderId: {}, transactionKey: {})", + log.debug("이미 취소된 주문입니다. 콜백 처리를 건너뜁니다. (orderId: {}, transactionKey: {})", orderId, callbackRequest.transactionKey()); return; } @@ -558,10 +682,19 @@ public void handlePaymentCallback(Long orderId, PaymentGatewayDto.CallbackReques PaymentGatewayDto.TransactionStatus verifiedStatus = verifyCallbackWithPgInquiry( order.getUserId(), orderId, callbackRequest); - // OrderStatusUpdater를 사용하여 상태 업데이트 - boolean updated = orderStatusUpdater.updateByPaymentStatus( + // PaymentService를 통한 콜백 처리 (도메인 모델로 변환) + PaymentStatus paymentStatus = convertToPaymentStatus(verifiedStatus); + paymentService.handleCallback( orderId, - verifiedStatus, + callbackRequest.transactionKey(), + paymentStatus, + callbackRequest.reason() + ); + + // 주문 상태 업데이트 처리 + boolean updated = updateOrderStatusByPaymentResult( + orderId, + paymentStatus, callbackRequest.transactionKey(), callbackRequest.reason() ); @@ -606,8 +739,10 @@ private PaymentGatewayDto.TransactionStatus verifyCallbackWithPgInquiry( try { // User의 userId (String)를 가져오기 위해 User 조회 - User user = userRepository.findById(userId); - if (user == null) { + User user; + try { + user = userService.findById(userId); + } catch (CoreException e) { log.warn("콜백 검증 시 사용자를 찾을 수 없습니다. 콜백 정보를 사용합니다. (orderId: {}, userId: {})", orderId, userId); return callbackRequest.status(); // 사용자를 찾을 수 없으면 콜백 정보 사용 @@ -615,27 +750,11 @@ private PaymentGatewayDto.TransactionStatus verifyCallbackWithPgInquiry( String userIdString = user.getUserId(); - // PG에서 주문별 결제 정보 조회 (스케줄러 전용 클라이언트 사용 - Retry 적용) - // 주문 ID를 6자리 이상 문자열로 변환 (pg-simulator 검증 요구사항) - String orderIdString = paymentRequestBuilder.formatOrderId(orderId); - PaymentGatewayDto.ApiResponse response = - paymentGatewaySchedulerClient.getTransactionsByOrder(userIdString, orderIdString); + // PaymentService를 통한 결제 상태 조회 (PG 원장 기준) + PaymentStatus paymentStatus = paymentService.getPaymentStatus(userIdString, orderId); - if (response == null || response.meta() == null - || response.meta().result() != PaymentGatewayDto.ApiResponse.Metadata.Result.SUCCESS - || response.data() == null || response.data().transactions() == null - || response.data().transactions().isEmpty()) { - // PG 조회 실패: 콜백 정보를 사용하되 경고 로그 기록 - log.warn("콜백 검증 시 PG 조회 API 호출 실패. 콜백 정보를 사용합니다. (orderId: {}, transactionKey: {})", - orderId, callbackRequest.transactionKey()); - return callbackRequest.status(); - } - - // 가장 최근 트랜잭션의 상태 확인 (PG 원장 기준) - PaymentGatewayDto.TransactionResponse latestTransaction = - response.data().transactions().get(response.data().transactions().size() - 1); - - PaymentGatewayDto.TransactionStatus pgStatus = latestTransaction.status(); + // 도메인 모델을 인프라 DTO로 변환 (검증 로직에서 사용) + PaymentGatewayDto.TransactionStatus pgStatus = convertToInfraStatus(paymentStatus); PaymentGatewayDto.TransactionStatus callbackStatus = callbackRequest.status(); // 콜백 정보와 PG 조회 결과 비교 @@ -680,14 +799,14 @@ private PaymentGatewayDto.TransactionStatus verifyCallbackWithPgInquiry( @Transactional public void recoverOrderStatusByPaymentCheck(String userId, Long orderId) { try { - // PG에서 결제 상태 조회 - // 주문 ID를 6자리 이상 문자열로 변환 (pg-simulator 검증 요구사항) - String orderIdString = paymentRequestBuilder.formatOrderId(orderId); - PaymentGatewayDto.TransactionStatus status = - paymentGatewayAdapter.getPaymentStatus(userId, orderIdString); + // PaymentService를 통한 타임아웃 복구 + paymentService.recoverAfterTimeout(userId, orderId); + + // 결제 상태 조회 + PaymentStatus paymentStatus = paymentService.getPaymentStatus(userId, orderId); - // OrderStatusUpdater를 사용하여 상태 업데이트 - boolean updated = orderStatusUpdater.updateByPaymentStatus(orderId, status, null, null); + // 주문 상태 업데이트 처리 + boolean updated = updateOrderStatusByPaymentResult(orderId, paymentStatus, null, null); if (!updated) { log.warn("상태 복구 실패. 주문 상태 업데이트에 실패했습니다. (orderId: {})", orderId); @@ -699,5 +818,87 @@ public void recoverOrderStatusByPaymentCheck(String userId, Long orderId) { } } + /** + * 결제 실패 시 주문 취소 및 리소스 원복을 처리합니다. + *

+ * 결제 요청이 실패한 경우, 이미 생성된 주문을 취소하고 + * 차감된 포인트를 환불하며 재고를 원복합니다. + *

+ *

+ * 처리 내용: + *

    + *
  • 주문 상태를 CANCELED로 변경
  • + *
  • 차감된 포인트 환불
  • + *
  • 차감된 재고 원복
  • + *
+ *

+ *

+ * 트랜잭션 전략: + *

    + *
  • TransactionTemplate 사용: afterCommit 콜백에서 호출되므로 명시적으로 새 트랜잭션 생성
  • + *
  • 결제 실패 처리 중 오류가 발생해도 기존 주문 생성 트랜잭션에 영향을 주지 않음
  • + *
  • Self-invocation 문제 해결: TransactionTemplate을 사용하여 명시적으로 트랜잭션 관리
  • + *
+ *

+ *

+ * 주의사항: + *

    + *
  • 주문이 이미 취소되었거나 존재하지 않는 경우 로그만 기록합니다.
  • + *
  • 결제 실패 처리 중 오류 발생 시에도 로그만 기록합니다.
  • + *
+ *

+ * + * @param userId 사용자 ID (로그인 ID) + * @param orderId 주문 ID + * @param errorCode 오류 코드 + * @param errorMessage 오류 메시지 + */ + private void handlePaymentFailure(String userId, Long orderId, String errorCode, String errorMessage) { + // TransactionTemplate을 사용하여 명시적으로 새 트랜잭션 생성 + // afterCommit 콜백에서 호출되므로 @Transactional 어노테이션이 작동하지 않음 + TransactionTemplate transactionTemplate = new TransactionTemplate(transactionManager); + transactionTemplate.setPropagationBehavior(TransactionTemplate.PROPAGATION_REQUIRES_NEW); + + transactionTemplate.executeWithoutResult(status -> { + try { + // 사용자 조회 (Service를 통한 접근) + User user; + try { + user = userService.findByUserId(userId); + } catch (CoreException e) { + log.warn("결제 실패 처리 시 사용자를 찾을 수 없습니다. (userId: {}, orderId: {})", userId, orderId); + return; + } + + // 주문 조회 (Service를 통한 접근) + Order order; + try { + order = orderService.getById(orderId); + } catch (CoreException e) { + log.warn("결제 실패 처리 시 주문을 찾을 수 없습니다. (orderId: {})", orderId); + return; + } + + // 이미 취소된 주문인 경우 처리하지 않음 + if (order.getStatus() == OrderStatus.CANCELED) { + log.debug("이미 취소된 주문입니다. 결제 실패 처리를 건너뜁니다. (orderId: {})", orderId); + return; + } + + // 주문 취소 및 리소스 원복 + cancelOrder(order, user); + + log.info("결제 실패로 인한 주문 취소 완료. (orderId: {}, errorCode: {}, errorMessage: {})", + orderId, errorCode, errorMessage); + } catch (Exception e) { + // 결제 실패 처리 중 오류 발생 시에도 로그만 기록 + // 이미 주문은 생성되어 있으므로, 나중에 수동으로 처리할 수 있도록 로그 기록 + log.error("결제 실패 처리 중 오류 발생. (orderId: {}, errorCode: {})", + orderId, errorCode, e); + // 예외를 다시 던져서 트랜잭션이 롤백되도록 함 + throw new RuntimeException("결제 실패 처리 중 오류 발생", e); + } + }); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/config/Resilience4jRetryConfig.java b/apps/commerce-api/src/main/java/com/loopers/config/Resilience4jRetryConfig.java index 90923e2f3..ee554579f 100644 --- a/apps/commerce-api/src/main/java/com/loopers/config/Resilience4jRetryConfig.java +++ b/apps/commerce-api/src/main/java/com/loopers/config/Resilience4jRetryConfig.java @@ -119,9 +119,9 @@ public RetryRegistry retryRegistry() { // Exponential Backoff 적용하여 일시적 오류 자동 복구 retryRegistry.addConfiguration("paymentGatewaySchedulerClient", retryConfig); - log.info("Resilience4j Retry 설정 완료:"); - log.info(" - 결제 요청 API (paymentGatewayClient): Retry 없음 (유저 요청 경로 - 빠른 실패)"); - log.info(" - 조회 API (paymentGatewaySchedulerClient): Exponential Backoff 적용 (스케줄러 - 비동기/배치 기반 Retry)"); + log.debug("Resilience4j Retry 설정 완료:"); + log.debug(" - 결제 요청 API (paymentGatewayClient): Retry 없음 (유저 요청 경로 - 빠른 실패)"); + log.debug(" - 조회 API (paymentGatewaySchedulerClient): Exponential Backoff 적용 (스케줄러 - 비동기/배치 기반 Retry)"); return retryRegistry; } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponService.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponService.java new file mode 100644 index 000000000..ec0b09d2b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponService.java @@ -0,0 +1,84 @@ +package com.loopers.domain.coupon; + +import com.loopers.domain.coupon.discount.CouponDiscountStrategyFactory; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.orm.ObjectOptimisticLockingFailureException; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +/** + * 쿠폰 도메인 서비스. + *

+ * 쿠폰 조회, 사용 등의 도메인 로직을 처리합니다. + * Repository에 의존하며 비즈니스 규칙을 캡슐화합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +@RequiredArgsConstructor +@Component +public class CouponService { + private final CouponRepository couponRepository; + private final UserCouponRepository userCouponRepository; + private final CouponDiscountStrategyFactory couponDiscountStrategyFactory; + + /** + * 쿠폰을 적용하여 할인 금액을 계산하고 쿠폰을 사용 처리합니다. + *

+ * 동시성 제어 전략: + *

    + *
  • OPTIMISTIC_LOCK 사용 근거: 쿠폰 중복 사용 방지, Hot Spot 대응
  • + *
  • @Version 필드: UserCoupon 엔티티의 version 필드를 통해 자동으로 낙관적 락 적용
  • + *
  • 동시 사용 시: 한 명만 성공하고 나머지는 OptimisticLockException 발생
  • + *
  • 사용 목적: 동일 쿠폰으로 여러 기기에서 동시 주문해도 한 번만 사용되도록 보장
  • + *
+ *

+ * + * @param userId 사용자 ID + * @param couponCode 쿠폰 코드 + * @param subtotal 주문 소계 금액 + * @return 할인 금액 + * @throws CoreException 쿠폰을 찾을 수 없거나 사용 불가능한 경우, 동시 사용으로 인한 충돌 시 + */ + @Transactional + public Integer applyCoupon(Long userId, String couponCode, Integer subtotal) { + // 쿠폰 존재 여부 확인 + Coupon coupon = couponRepository.findByCode(couponCode) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, + String.format("쿠폰을 찾을 수 없습니다. (쿠폰 코드: %s)", couponCode))); + + // 낙관적 락을 사용하여 사용자 쿠폰 조회 (동시성 제어) + // @Version 필드가 있어 자동으로 낙관적 락이 적용됨 + UserCoupon userCoupon = userCouponRepository.findByUserIdAndCouponCodeForUpdate(userId, couponCode) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, + String.format("사용자가 소유한 쿠폰을 찾을 수 없습니다. (쿠폰 코드: %s)", couponCode))); + + // 쿠폰 사용 가능 여부 확인 + if (!userCoupon.isAvailable()) { + throw new CoreException(ErrorType.BAD_REQUEST, + String.format("이미 사용된 쿠폰입니다. (쿠폰 코드: %s)", couponCode)); + } + + // 쿠폰 사용 처리 + userCoupon.use(); + + // 할인 금액 계산 (전략 패턴 사용) + Integer discountAmount = coupon.calculateDiscountAmount(subtotal, couponDiscountStrategyFactory); + + try { + // 사용자 쿠폰 저장 (version 체크 자동 수행) + // 다른 트랜잭션이 먼저 수정했다면 OptimisticLockException 발생 + userCouponRepository.save(userCoupon); + } catch (ObjectOptimisticLockingFailureException e) { + // 낙관적 락 충돌: 다른 트랜잭션이 먼저 쿠폰을 사용함 + throw new CoreException(ErrorType.CONFLICT, + String.format("쿠폰이 이미 사용되었습니다. (쿠폰 코드: %s)", couponCode)); + } + + return discountAmount; + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java new file mode 100644 index 000000000..e74c024d7 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java @@ -0,0 +1,216 @@ +package com.loopers.domain.order; + +import com.loopers.domain.payment.PaymentStatus; +import com.loopers.domain.product.Product; +import com.loopers.domain.user.Point; +import com.loopers.domain.user.User; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * 주문 도메인 서비스. + *

+ * 주문의 기본 CRUD 및 상태 변경을 담당합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +@Component +@RequiredArgsConstructor +public class OrderService { + + private final OrderRepository orderRepository; + + /** + * 주문을 저장합니다. + * + * @param order 저장할 주문 + * @return 저장된 주문 + */ + @Transactional + public Order save(Order order) { + return orderRepository.save(order); + } + + /** + * 주문 ID로 주문을 조회합니다. + * + * @param orderId 주문 ID + * @return 조회된 주문 + * @throws CoreException 주문을 찾을 수 없는 경우 + */ + @Transactional(readOnly = true) + public Order getById(Long orderId) { + return orderRepository.findById(orderId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "주문을 찾을 수 없습니다.")); + } + + /** + * 주문 ID로 주문을 조회합니다 (Optional 반환). + * + * @param orderId 주문 ID + * @return 조회된 주문 (없으면 Optional.empty()) + */ + @Transactional(readOnly = true) + public Optional findById(Long orderId) { + return orderRepository.findById(orderId); + } + + /** + * 사용자 ID로 주문 목록을 조회합니다. + * + * @param userId 사용자 ID + * @return 해당 사용자의 주문 목록 + */ + @Transactional(readOnly = true) + public List findAllByUserId(Long userId) { + return orderRepository.findAllByUserId(userId); + } + + /** + * 주문 상태로 주문 목록을 조회합니다. + * + * @param status 주문 상태 + * @return 해당 상태의 주문 목록 + */ + @Transactional(readOnly = true) + public List findAllByStatus(OrderStatus status) { + return orderRepository.findAllByStatus(status); + } + + /** + * 주문을 생성합니다. + * + * @param userId 사용자 ID + * @param items 주문 아이템 목록 + * @param couponCode 쿠폰 코드 (선택) + * @param discountAmount 할인 금액 (선택) + * @return 생성된 주문 + */ + @Transactional + public Order create(Long userId, List items, String couponCode, Integer discountAmount) { + Order order = Order.of(userId, items, couponCode, discountAmount); + return orderRepository.save(order); + } + + /** + * 주문을 생성합니다 (쿠폰 없음). + * + * @param userId 사용자 ID + * @param items 주문 아이템 목록 + * @return 생성된 주문 + */ + @Transactional + public Order create(Long userId, List items) { + Order order = Order.of(userId, items); + return orderRepository.save(order); + } + + /** + * 주문을 완료 상태로 변경합니다. + * + * @param orderId 주문 ID + * @return 완료된 주문 + * @throws CoreException 주문을 찾을 수 없는 경우 + */ + @Transactional + public Order completeOrder(Long orderId) { + Order order = getById(orderId); + order.complete(); + return orderRepository.save(order); + } + + /** + * 주문을 취소 상태로 변경하고 재고를 원복하며 포인트를 환불합니다. + *

+ * 도메인 로직만 처리합니다. 사용자 조회, 상품 조회, Payment 조회는 애플리케이션 레이어에서 처리합니다. + *

+ * + * @param order 주문 엔티티 + * @param products 주문 아이템에 해당하는 상품 목록 (락이 이미 획득된 상태) + * @param user 사용자 엔티티 (락이 이미 획득된 상태) + * @param refundPointAmount 환불할 포인트 금액 + * @throws CoreException 주문 또는 사용자 정보가 null인 경우 + */ + @Transactional + public void cancelOrder(Order order, List products, User user, Long refundPointAmount) { + if (order == null || user == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "취소할 주문과 사용자 정보는 필수입니다."); + } + + // 주문 취소 + order.cancel(); + + // 재고 원복 + increaseStocksForOrderItems(order.getItems(), products); + + // 포인트 환불 + if (refundPointAmount > 0) { + user.receivePoint(Point.of(refundPointAmount)); + } + + orderRepository.save(order); + } + + /** + * 결제 상태에 따라 주문 상태를 업데이트합니다. + *

+ * 도메인 로직만 처리합니다. 사용자 조회, 트랜잭션 관리, 로깅은 애플리케이션 레이어에서 처리합니다. + *

+ * + * @param order 주문 엔티티 + * @param paymentStatus 결제 상태 + * @throws CoreException 주문이 null이거나 이미 완료/취소된 경우 + */ + @Transactional + public void updateStatusByPaymentResult(Order order, PaymentStatus paymentStatus) { + if (order == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "주문 정보는 필수입니다."); + } + + // 이미 완료되거나 취소된 주문인 경우 처리하지 않음 + if (order.getStatus() == OrderStatus.COMPLETED || order.getStatus() == OrderStatus.CANCELED) { + return; + } + + if (paymentStatus == PaymentStatus.SUCCESS) { + // 결제 성공: 주문 완료 + order.complete(); + orderRepository.save(order); + } else if (paymentStatus == PaymentStatus.FAILED) { + // 결제 실패: 주문 취소 (재고 원복 및 포인트 환불은 애플리케이션 레이어에서 처리) + order.cancel(); + orderRepository.save(order); + } + // PENDING 상태: 상태 유지 (아무 작업도 하지 않음) + } + + /** + * 주문 아이템에 대해 재고를 증가시킵니다. + * + * @param items 주문 아이템 목록 + * @param products 상품 목록 + */ + private void increaseStocksForOrderItems(List items, List products) { + Map productMap = products.stream() + .collect(java.util.stream.Collectors.toMap(Product::getId, product -> product)); + + for (OrderItem item : items) { + Product product = productMap.get(item.getProductId()); + if (product == null) { + throw new CoreException(ErrorType.NOT_FOUND, + String.format("상품을 찾을 수 없습니다. (상품 ID: %d)", item.getProductId())); + } + product.increaseStock(item.getQuantity()); + } + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/payment/CardType.java b/apps/commerce-api/src/main/java/com/loopers/domain/payment/CardType.java new file mode 100644 index 000000000..ea3ab7d43 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/payment/CardType.java @@ -0,0 +1,11 @@ +package com.loopers.domain.payment; + +/** + * 카드 타입. + */ +public enum CardType { + SAMSUNG, + KB, + HYUNDAI +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/payment/Payment.java b/apps/commerce-api/src/main/java/com/loopers/domain/payment/Payment.java new file mode 100644 index 000000000..2a9178162 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/payment/Payment.java @@ -0,0 +1,289 @@ +package com.loopers.domain.payment; + +import com.loopers.domain.BaseEntity; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * 결제 도메인 엔티티. + *

+ * 결제의 상태, 금액, 포인트 사용 정보를 관리합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +@Entity +@Table( + name = "payment", + indexes = { + @Index(name = "idx_payment_order_id", columnList = "ref_order_id"), + @Index(name = "idx_payment_user_id", columnList = "ref_user_id"), + @Index(name = "idx_payment_status", columnList = "status") + } +) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class Payment extends BaseEntity { + + @Column(name = "ref_order_id", nullable = false) + private Long orderId; + + @Column(name = "ref_user_id", nullable = false) + private Long userId; + + @Column(name = "total_amount", nullable = false) + private Long totalAmount; + + @Column(name = "used_point", nullable = false) + private Long usedPoint; + + @Column(name = "paid_amount", nullable = false) + private Long paidAmount; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false) + private PaymentStatus status; + + @Enumerated(EnumType.STRING) + @Column(name = "card_type") + private CardType cardType; + + @Column(name = "card_no") + private String cardNo; + + @Column(name = "failure_reason", length = 500) + private String failureReason; + + @Column(name = "pg_requested_at", nullable = false) + private LocalDateTime pgRequestedAt; + + @Column(name = "pg_completed_at") + private LocalDateTime pgCompletedAt; + + /** + * 카드 결제용 Payment를 생성합니다. + * + * @param orderId 주문 ID + * @param userId 사용자 ID + * @param cardType 카드 타입 + * @param cardNo 카드 번호 + * @param amount 결제 금액 + * @param requestedAt PG 요청 시각 + * @return 생성된 Payment 인스턴스 + * @throws CoreException 유효성 검증 실패 시 + */ + public static Payment of( + Long orderId, + Long userId, + CardType cardType, + String cardNo, + Long amount, + LocalDateTime requestedAt + ) { + validateOrderId(orderId); + validateUserId(userId); + validateCardType(cardType); + validateCardNo(cardNo); + validateAmount(amount); + validateRequestedAt(requestedAt); + + Payment payment = new Payment(); + payment.orderId = orderId; + payment.userId = userId; + payment.totalAmount = amount; + payment.usedPoint = 0L; + payment.paidAmount = amount; + payment.status = PaymentStatus.PENDING; + payment.cardType = cardType; + payment.cardNo = cardNo; + payment.pgRequestedAt = requestedAt; + + return payment; + } + + /** + * 포인트 결제용 Payment를 생성합니다. + * + * @param orderId 주문 ID + * @param userId 사용자 ID + * @param totalAmount 총 결제 금액 + * @param usedPoint 사용 포인트 + * @param requestedAt PG 요청 시각 + * @return 생성된 Payment 인스턴스 + * @throws CoreException 유효성 검증 실패 시 + */ + public static Payment of( + Long orderId, + Long userId, + Long totalAmount, + Long usedPoint, + LocalDateTime requestedAt + ) { + return of(orderId, userId, totalAmount, usedPoint, null, null, requestedAt); + } + + /** + * 포인트와 카드 혼합 결제용 Payment를 생성합니다. + *

+ * 포인트와 쿠폰 할인을 적용한 후 남은 금액을 카드로 결제하는 경우 사용합니다. + *

+ * + * @param orderId 주문 ID + * @param userId 사용자 ID + * @param totalAmount 총 결제 금액 + * @param usedPoint 사용 포인트 + * @param cardType 카드 타입 (paidAmount > 0일 때만 필수) + * @param cardNo 카드 번호 (paidAmount > 0일 때만 필수) + * @param requestedAt PG 요청 시각 + * @return 생성된 Payment 인스턴스 + * @throws CoreException 유효성 검증 실패 시 + */ + public static Payment of( + Long orderId, + Long userId, + Long totalAmount, + Long usedPoint, + CardType cardType, + String cardNo, + LocalDateTime requestedAt + ) { + validateOrderId(orderId); + validateUserId(userId); + validateAmount(totalAmount); + validateUsedPoint(usedPoint); + validateRequestedAt(requestedAt); + + Long paidAmount = totalAmount - usedPoint; + validatePaidAmount(paidAmount); + + // paidAmount > 0이면 카드 정보 필수 + if (paidAmount > 0) { + if (cardType == null || cardNo == null || cardNo.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, + "포인트와 쿠폰만으로 결제할 수 없습니다. 카드 정보를 입력해주세요."); + } + } + + Payment payment = new Payment(); + payment.orderId = orderId; + payment.userId = userId; + payment.totalAmount = totalAmount; + payment.usedPoint = usedPoint; + payment.paidAmount = paidAmount; + payment.status = (paidAmount == 0L) ? PaymentStatus.SUCCESS : PaymentStatus.PENDING; + payment.cardType = cardType; // paidAmount > 0일 때만 설정 + payment.cardNo = cardNo; // paidAmount > 0일 때만 설정 + payment.pgRequestedAt = requestedAt; + + return payment; + } + + /** + * 결제를 SUCCESS 상태로 전이합니다. + *

+ * 멱등성 보장: 이미 SUCCESS 상태인 경우 아무 작업도 하지 않습니다. + *

+ * + * @param completedAt PG 완료 시각 + * @throws CoreException PENDING 상태가 아닌 경우 (SUCCESS는 제외) + */ + public void toSuccess(LocalDateTime completedAt) { + if (status == PaymentStatus.SUCCESS) { + // 멱등성: 이미 성공 상태면 아무 작업도 하지 않음 + return; + } + if (status != PaymentStatus.PENDING) { + throw new CoreException(ErrorType.BAD_REQUEST, "결제 대기 상태에서만 성공으로 전이할 수 있습니다."); + } + this.status = PaymentStatus.SUCCESS; + this.pgCompletedAt = completedAt; + } + + /** + * 결제를 FAILED 상태로 전이합니다. + *

+ * 멱등성 보장: 이미 FAILED 상태인 경우 아무 작업도 하지 않습니다. + *

+ * + * @param failureReason 실패 사유 + * @param completedAt PG 완료 시각 + * @throws CoreException PENDING 상태가 아닌 경우 (FAILED는 제외) + */ + public void toFailed(String failureReason, LocalDateTime completedAt) { + if (status == PaymentStatus.FAILED) { + // 멱등성: 이미 실패 상태면 아무 작업도 하지 않음 + return; + } + if (status != PaymentStatus.PENDING) { + throw new CoreException(ErrorType.BAD_REQUEST, "결제 대기 상태에서만 실패로 전이할 수 있습니다."); + } + this.status = PaymentStatus.FAILED; + this.failureReason = failureReason; + this.pgCompletedAt = completedAt; + } + + /** + * 결제가 완료되었는지 확인합니다. + * + * @return 완료 여부 + */ + public boolean isCompleted() { + return status.isCompleted(); + } + + private static void validateOrderId(Long orderId) { + if (orderId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "주문 ID는 필수입니다."); + } + } + + private static void validateUserId(Long userId) { + if (userId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "사용자 ID는 필수입니다."); + } + } + + private static void validateCardType(CardType cardType) { + if (cardType == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "카드 타입은 필수입니다."); + } + } + + private static void validateCardNo(String cardNo) { + if (cardNo == null || cardNo.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "카드 번호는 필수입니다."); + } + } + + private static void validateAmount(Long amount) { + if (amount == null || amount <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "결제 금액은 0보다 커야 합니다."); + } + } + + private static void validateUsedPoint(Long usedPoint) { + if (usedPoint == null || usedPoint < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "사용 포인트는 0 이상이어야 합니다."); + } + } + + private static void validatePaidAmount(Long paidAmount) { + if (paidAmount < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "포인트와 쿠폰 할인의 합이 주문 금액을 초과합니다."); + } + } + + private static void validateRequestedAt(LocalDateTime requestedAt) { + if (requestedAt == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "PG 요청 시각은 필수입니다."); + } + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentFailureClassifier.java b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentFailureClassifier.java new file mode 100644 index 000000000..e590916bc --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentFailureClassifier.java @@ -0,0 +1,74 @@ +package com.loopers.domain.payment; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.Set; + +/** + * 결제 실패 분류 도메인 서비스. + *

+ * 결제 실패를 비즈니스 실패와 외부 시스템 장애로 분류합니다. + *

+ *

+ * 비즈니스 실패 예시: + *

    + *
  • 카드 한도 초과 (LIMIT_EXCEEDED)
  • + *
  • 잘못된 카드 번호 (INVALID_CARD)
  • + *
  • 카드 오류 (CARD_ERROR)
  • + *
  • 잔액 부족 (INSUFFICIENT_FUNDS)
  • + *
+ *

+ *

+ * 외부 시스템 장애 예시: + *

    + *
  • CircuitBreaker Open (CIRCUIT_BREAKER_OPEN)
  • + *
  • 서버 오류 (5xx)
  • + *
  • 타임아웃
  • + *
  • 네트워크 오류
  • + *
+ *

+ * + * @author Loopers + * @version 1.0 + */ +@Component +@RequiredArgsConstructor +public class PaymentFailureClassifier { + + private static final Set BUSINESS_FAILURE_CODES = Set.of( + "LIMIT_EXCEEDED", + "INVALID_CARD", + "CARD_ERROR", + "INSUFFICIENT_FUNDS", + "PAYMENT_FAILED" + ); + + private static final String CIRCUIT_BREAKER_OPEN = "CIRCUIT_BREAKER_OPEN"; + + /** + * 오류 코드를 기반으로 결제 실패 유형을 분류합니다. + * + * @param errorCode 오류 코드 + * @return 결제 실패 유형 + */ + public PaymentFailureType classify(String errorCode) { + if (errorCode == null) { + return PaymentFailureType.EXTERNAL_SYSTEM_FAILURE; + } + + // CircuitBreaker Open 상태는 명시적으로 외부 시스템 장애로 간주 + if (CIRCUIT_BREAKER_OPEN.equals(errorCode)) { + return PaymentFailureType.EXTERNAL_SYSTEM_FAILURE; + } + + // 명확한 비즈니스 실패 오류 코드만 취소 처리 + boolean isBusinessFailure = BUSINESS_FAILURE_CODES.stream() + .anyMatch(errorCode::contains); + + return isBusinessFailure + ? PaymentFailureType.BUSINESS_FAILURE + : PaymentFailureType.EXTERNAL_SYSTEM_FAILURE; + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentFailureType.java b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentFailureType.java new file mode 100644 index 000000000..353b8e9d9 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentFailureType.java @@ -0,0 +1,25 @@ +package com.loopers.domain.payment; + +/** + * 결제 실패 유형. + *

+ * 결제 실패를 비즈니스 실패와 외부 시스템 장애로 구분합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +public enum PaymentFailureType { + /** + * 비즈니스 실패: 주문 취소 필요 + * 예: 카드 한도 초과, 잘못된 카드 번호 등 + */ + BUSINESS_FAILURE, + + /** + * 외부 시스템 장애: 주문 PENDING 상태 유지 + * 예: CircuitBreaker Open, 서버 오류, 타임아웃 등 + */ + EXTERNAL_SYSTEM_FAILURE +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentGateway.java b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentGateway.java new file mode 100644 index 000000000..a8f2864d8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentGateway.java @@ -0,0 +1,33 @@ +package com.loopers.domain.payment; + +import com.loopers.application.purchasing.PaymentRequestCommand; + +/** + * 결제 게이트웨이 인터페이스. + *

+ * 도메인 계층에 정의하여 DIP를 준수합니다. + * 인프라 계층이 이 인터페이스를 구현합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +public interface PaymentGateway { + /** + * PG 결제 요청을 전송합니다. + * + * @param command 결제 요청 명령 + * @return 결제 요청 결과 + */ + PaymentRequestResult requestPayment(PaymentRequestCommand command); + + /** + * 결제 상태를 조회합니다. + * + * @param userId 사용자 ID + * @param orderId 주문 ID + * @return 결제 상태 + */ + PaymentStatus getPaymentStatus(String userId, Long orderId); +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentRepository.java new file mode 100644 index 000000000..7d3a73868 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentRepository.java @@ -0,0 +1,54 @@ +package com.loopers.domain.payment; + +import java.util.List; +import java.util.Optional; + +/** + * 결제 저장소 인터페이스. + *

+ * Payment 엔티티의 영속성 계층 접근을 추상화합니다. + *

+ */ +public interface PaymentRepository { + + /** + * 결제를 저장합니다. + * + * @param payment 저장할 결제 + * @return 저장된 결제 + */ + Payment save(Payment payment); + + /** + * 결제 ID로 결제를 조회합니다. + * + * @param paymentId 조회할 결제 ID + * @return 조회된 결제 + */ + Optional findById(Long paymentId); + + /** + * 주문 ID로 결제를 조회합니다. + * + * @param orderId 주문 ID + * @return 조회된 결제 + */ + Optional findByOrderId(Long orderId); + + /** + * 사용자 ID로 결제 목록을 조회합니다. + * + * @param userId 사용자 ID + * @return 해당 사용자의 결제 목록 + */ + List findAllByUserId(Long userId); + + /** + * 결제 상태로 결제 목록을 조회합니다. + * + * @param status 결제 상태 + * @return 해당 상태의 결제 목록 + */ + List findAllByStatus(PaymentStatus status); +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentRequestResult.java b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentRequestResult.java new file mode 100644 index 000000000..9c62dee8c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentRequestResult.java @@ -0,0 +1,30 @@ +package com.loopers.domain.payment; + +/** + * 결제 요청 결과. + *

+ * PG 결제 요청의 결과를 나타내는 도메인 모델입니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +public sealed interface PaymentRequestResult { + /** + * 결제 요청 성공. + * + * @param transactionKey 트랜잭션 키 + */ + record Success(String transactionKey) implements PaymentRequestResult {} + + /** + * 결제 요청 실패. + * + * @param errorCode 오류 코드 + * @param message 오류 메시지 + * @param isTimeout 타임아웃 여부 + * @param isRetryable 재시도 가능 여부 + */ + record Failure(String errorCode, String message, boolean isTimeout, boolean isRetryable) implements PaymentRequestResult {} +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentResult.java b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentResult.java new file mode 100644 index 000000000..792362f02 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentResult.java @@ -0,0 +1,55 @@ +package com.loopers.domain.payment; + +import java.util.function.Function; + +/** + * 결제 결과 도메인 모델. + *

+ * 결제 요청의 성공/실패 결과를 표현합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +public sealed interface PaymentResult { + + /** + * 성공 결과. + */ + record Success(String transactionKey) implements PaymentResult { + } + + /** + * 실패 결과. + */ + record Failure( + String errorCode, + String message, + boolean isTimeout, + boolean isServerError, + boolean isClientError + ) implements PaymentResult { + } + + /** + * 결과에 따라 처리합니다. + * + * @param successHandler 성공 시 처리 함수 + * @param failureHandler 실패 시 처리 함수 + * @param 반환 타입 + * @return 처리 결과 + */ + default T handle( + Function successHandler, + Function failureHandler + ) { + if (this instanceof Success success) { + return successHandler.apply(success); + } else if (this instanceof Failure failure) { + return failureHandler.apply(failure); + } else { + throw new IllegalStateException("Unknown PaymentResult type: " + this.getClass()); + } + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentService.java b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentService.java new file mode 100644 index 000000000..b7308675c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentService.java @@ -0,0 +1,440 @@ +package com.loopers.domain.payment; + +import com.loopers.application.purchasing.PaymentRequestCommand; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +/** + * 결제 도메인 서비스. + *

+ * 결제의 생성, 조회, 상태 변경 및 PG 연동을 담당합니다. + * 도메인 로직은 Payment 엔티티에 위임하며, Service는 조회/저장 및 PG 연동을 담당합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class PaymentService { + + private final PaymentRepository paymentRepository; + private final PaymentGateway paymentGateway; // 인터페이스에 의존 (DIP 준수) + private final PaymentFailureClassifier paymentFailureClassifier; + + @Value("${payment.callback.base-url}") + private String callbackBaseUrl; + + /** + * 카드 결제를 생성합니다. + * + * @param orderId 주문 ID + * @param userId 사용자 ID + * @param cardType 카드 타입 + * @param cardNo 카드 번호 + * @param amount 결제 금액 + * @param requestedAt PG 요청 시각 + * @return 생성된 Payment + */ + @Transactional + public Payment create( + Long orderId, + Long userId, + CardType cardType, + String cardNo, + Long amount, + LocalDateTime requestedAt + ) { + Payment payment = Payment.of(orderId, userId, cardType, cardNo, amount, requestedAt); + return paymentRepository.save(payment); + } + + /** + * 포인트 결제를 생성합니다. + * + * @param orderId 주문 ID + * @param userId 사용자 ID + * @param totalAmount 총 결제 금액 + * @param usedPoint 사용 포인트 + * @param requestedAt PG 요청 시각 + * @return 생성된 Payment + */ + @Transactional + public Payment create( + Long orderId, + Long userId, + Long totalAmount, + Long usedPoint, + LocalDateTime requestedAt + ) { + Payment payment = Payment.of(orderId, userId, totalAmount, usedPoint, requestedAt); + return paymentRepository.save(payment); + } + + /** + * 포인트와 카드 혼합 결제를 생성합니다. + *

+ * 포인트와 쿠폰 할인을 적용한 후 남은 금액을 카드로 결제하는 경우 사용합니다. + *

+ * + * @param orderId 주문 ID + * @param userId 사용자 ID + * @param totalAmount 총 결제 금액 + * @param usedPoint 사용 포인트 + * @param cardType 카드 타입 (paidAmount > 0일 때만 필수) + * @param cardNo 카드 번호 (paidAmount > 0일 때만 필수) + * @param requestedAt PG 요청 시각 + * @return 생성된 Payment + */ + @Transactional + public Payment create( + Long orderId, + Long userId, + Long totalAmount, + Long usedPoint, + CardType cardType, + String cardNo, + LocalDateTime requestedAt + ) { + Payment payment = Payment.of(orderId, userId, totalAmount, usedPoint, cardType, cardNo, requestedAt); + return paymentRepository.save(payment); + } + + /** + * 결제를 SUCCESS 상태로 전이합니다. + *

+ * 멱등성 보장: 이미 SUCCESS 상태인 경우 아무 작업도 하지 않습니다. + *

+ * + * @param paymentId 결제 ID + * @param completedAt PG 완료 시각 + * @throws CoreException 결제를 찾을 수 없는 경우 + */ + @Transactional + public void toSuccess(Long paymentId, LocalDateTime completedAt) { + Payment payment = paymentRepository.findById(paymentId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "결제를 찾을 수 없습니다.")); + payment.toSuccess(completedAt); // Entity에 위임 + paymentRepository.save(payment); + } + + /** + * 결제를 FAILED 상태로 전이합니다. + *

+ * 멱등성 보장: 이미 FAILED 상태인 경우 아무 작업도 하지 않습니다. + *

+ * + * @param paymentId 결제 ID + * @param failureReason 실패 사유 + * @param completedAt PG 완료 시각 + * @throws CoreException 결제를 찾을 수 없는 경우 + */ + @Transactional + public void toFailed(Long paymentId, String failureReason, LocalDateTime completedAt) { + Payment payment = paymentRepository.findById(paymentId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "결제를 찾을 수 없습니다.")); + payment.toFailed(failureReason, completedAt); // Entity에 위임 + paymentRepository.save(payment); + } + + /** + * 결제 ID로 결제를 조회합니다. + * + * @param paymentId 결제 ID + * @return 조회된 Payment + * @throws CoreException 결제를 찾을 수 없는 경우 + */ + @Transactional(readOnly = true) + public Payment findById(Long paymentId) { + return paymentRepository.findById(paymentId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "결제를 찾을 수 없습니다.")); + } + + /** + * 주문 ID로 결제를 조회합니다. + * + * @param orderId 주문 ID + * @return 조회된 Payment (없으면 Optional.empty()) + */ + @Transactional(readOnly = true) + public Optional findByOrderId(Long orderId) { + return paymentRepository.findByOrderId(orderId); + } + + /** + * 사용자 ID로 결제 목록을 조회합니다. + * + * @param userId 사용자 ID + * @return 해당 사용자의 결제 목록 + */ + @Transactional(readOnly = true) + public List findAllByUserId(Long userId) { + return paymentRepository.findAllByUserId(userId); + } + + /** + * 결제 상태로 결제 목록을 조회합니다. + * + * @param status 결제 상태 + * @return 해당 상태의 결제 목록 + */ + @Transactional(readOnly = true) + public List findAllByStatus(PaymentStatus status) { + return paymentRepository.findAllByStatus(status); + } + + /** + * PG 결제 요청을 생성하고 전송합니다. + *

+ * 결제를 생성하고 PG에 결제 요청을 전송합니다. + *

+ * + * @param orderId 주문 ID + * @param userId 사용자 ID (String - User.userId) + * @param userEntityId 사용자 엔티티 ID (Long - User.id) + * @param cardType 카드 타입 + * @param cardNo 카드 번호 + * @param amount 결제 금액 + * @return 결제 요청 결과 + */ + @Transactional + public PaymentRequestResult requestPayment( + Long orderId, + String userId, + Long userEntityId, + String cardType, + String cardNo, + Long amount + ) { + // 1. 카드 번호 유효성 검증 + validateCardNo(cardNo); + + // 2. 결제 생성 (User 엔티티의 id 사용) + Payment payment = create( + orderId, + userEntityId, + convertCardType(cardType), + cardNo, + amount, + LocalDateTime.now() + ); + + // 3. 결제 요청 명령 생성 (PG 요청에는 String userId 사용) + String callbackUrl = generateCallbackUrl(orderId); + PaymentRequestCommand command = new PaymentRequestCommand( + userId, + orderId, + cardType, + cardNo, + amount, + callbackUrl + ); + + // 4. PG 결제 요청 전송 + PaymentRequestResult result = paymentGateway.requestPayment(command); + + // 5. 결과 처리 + if (result instanceof PaymentRequestResult.Success success) { + log.info("PG 결제 요청 성공. (orderId: {}, transactionKey: {})", orderId, success.transactionKey()); + return result; + } else if (result instanceof PaymentRequestResult.Failure failure) { + // 실패 분류 + PaymentFailureType failureType = paymentFailureClassifier.classify(failure.errorCode()); + if (failureType == PaymentFailureType.BUSINESS_FAILURE) { + // 비즈니스 실패: 결제 상태를 FAILED로 변경 + toFailed(payment.getId(), failure.message(), LocalDateTime.now()); + } + // 외부 시스템 장애는 PENDING 상태 유지 + log.warn("PG 결제 요청 실패. (orderId: {}, errorCode: {}, message: {})", + orderId, failure.errorCode(), failure.message()); + return result; + } + + return result; + } + + /** + * 결제 상태를 조회합니다. + * + * @param userId 사용자 ID + * @param orderId 주문 ID + * @return 결제 상태 + */ + @Transactional(readOnly = true) + public PaymentStatus getPaymentStatus(String userId, Long orderId) { + return paymentGateway.getPaymentStatus(userId, orderId); + } + + /** + * PG 콜백을 처리합니다. + * + * @param orderId 주문 ID + * @param transactionKey 트랜잭션 키 + * @param status 결제 상태 + * @param reason 실패 사유 (실패 시) + */ + @Transactional + public void handleCallback(Long orderId, String transactionKey, PaymentStatus status, String reason) { + Optional paymentOpt = findByOrderId(orderId); + if (paymentOpt.isEmpty()) { + log.warn("콜백 처리 시 결제를 찾을 수 없습니다. (orderId: {})", orderId); + return; + } + + Payment payment = paymentOpt.get(); + + if (status == PaymentStatus.SUCCESS) { + toSuccess(payment.getId(), LocalDateTime.now()); + log.info("결제 콜백 처리 완료: SUCCESS. (orderId: {}, transactionKey: {})", orderId, transactionKey); + } else if (status == PaymentStatus.FAILED) { + toFailed(payment.getId(), reason != null ? reason : "결제 실패", LocalDateTime.now()); + log.warn("결제 콜백 처리 완료: FAILED. (orderId: {}, transactionKey: {}, reason: {})", + orderId, transactionKey, reason); + } else { + // PENDING 상태: 상태 유지 + log.debug("결제 콜백 처리: PENDING 상태 유지. (orderId: {}, transactionKey: {})", orderId, transactionKey); + } + } + + /** + * 타임아웃 후 결제 상태를 복구합니다. + *

+ * 타임아웃 발생 후 실제 결제 상태를 확인하여 결제 상태를 업데이트합니다. + *

+ * + * @param userId 사용자 ID + * @param orderId 주문 ID + * @param delayDuration 대기 시간 (PG 처리 시간 고려) + */ + public void recoverAfterTimeout(String userId, Long orderId, Duration delayDuration) { + try { + // 잠시 대기 후 상태 확인 (PG 처리 시간 고려) + if (delayDuration != null && !delayDuration.isZero()) { + Thread.sleep(delayDuration.toMillis()); + } + + // 결제 상태 조회 + PaymentStatus status = getPaymentStatus(userId, orderId); + Optional paymentOpt = findByOrderId(orderId); + + if (paymentOpt.isEmpty()) { + log.warn("복구 시 결제를 찾을 수 없습니다. (orderId: {})", orderId); + return; + } + + Payment payment = paymentOpt.get(); + + if (status == PaymentStatus.SUCCESS) { + toSuccess(payment.getId(), LocalDateTime.now()); + log.info("타임아웃 후 상태 확인 완료: SUCCESS. (orderId: {})", orderId); + } else if (status == PaymentStatus.FAILED) { + toFailed(payment.getId(), "타임아웃 후 상태 확인 실패", LocalDateTime.now()); + log.warn("타임아웃 후 상태 확인 완료: FAILED. (orderId: {})", orderId); + } else { + // PENDING 상태: 상태 유지 + log.debug("타임아웃 후 상태 확인: PENDING 상태 유지. (orderId: {})", orderId); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + log.warn("타임아웃 후 상태 확인 중 인터럽트 발생. (orderId: {})", orderId); + } catch (Exception e) { + log.error("타임아웃 후 상태 확인 중 오류 발생. (orderId: {})", orderId, e); + } + } + + /** + * 타임아웃 후 결제 상태를 복구합니다 (기본 대기 시간: 1초). + * + * @param userId 사용자 ID + * @param orderId 주문 ID + */ + public void recoverAfterTimeout(String userId, Long orderId) { + recoverAfterTimeout(userId, orderId, Duration.ofSeconds(1)); + } + + // 내부 헬퍼 메서드들 + + private CardType convertCardType(String cardType) { + try { + return CardType.valueOf(cardType.toUpperCase()); + } catch (IllegalArgumentException e) { + throw new CoreException(ErrorType.BAD_REQUEST, + String.format("잘못된 카드 타입입니다. (cardType: %s)", cardType)); + } + } + + private String generateCallbackUrl(Long orderId) { + return String.format("%s/api/v1/orders/%d/callback", callbackBaseUrl, orderId); + } + + /** + * 카드 번호 유효성 검증을 수행합니다. + * + * @param cardNo 카드 번호 + * @throws CoreException 유효하지 않은 카드 번호인 경우 + */ + private void validateCardNo(String cardNo) { + if (cardNo == null || cardNo.isEmpty()) { + throw new CoreException(ErrorType.BAD_REQUEST, "카드 번호는 필수입니다."); + } + + // 공백/하이픈 제거 및 정규화 + String normalized = cardNo.replaceAll("[\\s-]", ""); + + // 길이 검증 (13-19자리) + if (normalized.length() < 13 || normalized.length() > 19) { + throw new CoreException(ErrorType.BAD_REQUEST, + String.format("유효하지 않은 카드 번호 길이입니다. (길이: %d, 요구사항: 13-19자리)", normalized.length())); + } + + // 숫자만 포함하는지 검증 + if (!normalized.matches("\\d+")) { + throw new CoreException(ErrorType.BAD_REQUEST, "카드 번호는 숫자만 포함해야 합니다."); + } + + // Luhn 알고리즘 체크섬 검증 + if (!isValidLuhn(normalized)) { + throw new CoreException(ErrorType.BAD_REQUEST, "유효하지 않은 카드 번호입니다. (Luhn 알고리즘 검증 실패)"); + } + } + + /** + * Luhn 알고리즘을 사용하여 카드 번호의 체크섬을 검증합니다. + * + * @param cardNo 정규화된 카드 번호 (숫자만 포함) + * @return 유효한 경우 true, 그렇지 않으면 false + */ + private boolean isValidLuhn(String cardNo) { + int sum = 0; + boolean alternate = false; + + // 오른쪽에서 왼쪽으로 순회 + for (int i = cardNo.length() - 1; i >= 0; i--) { + int digit = Character.getNumericValue(cardNo.charAt(i)); + + if (alternate) { + digit *= 2; + if (digit > 9) { + digit = (digit % 10) + 1; + } + } + + sum += digit; + alternate = !alternate; + } + + return (sum % 10) == 0; + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentStatus.java b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentStatus.java new file mode 100644 index 000000000..7335929c3 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentStatus.java @@ -0,0 +1,29 @@ +package com.loopers.domain.payment; + +/** + * 결제 상태. + */ +public enum PaymentStatus { + PENDING, + SUCCESS, + FAILED; + + /** + * 결제가 완료되었는지 확인합니다. + * + * @return 완료 여부 (SUCCESS 또는 FAILED) + */ + public boolean isCompleted() { + return this == SUCCESS || this == FAILED; + } + + /** + * 결제가 성공했는지 확인합니다. + * + * @return 성공 여부 + */ + public boolean isSuccess() { + return this == SUCCESS; + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java new file mode 100644 index 000000000..dde8b402b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java @@ -0,0 +1,53 @@ +package com.loopers.domain.product; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +/** + * 상품 도메인 서비스. + *

+ * 상품 조회, 저장 등의 도메인 로직을 처리합니다. + * Repository에 의존하며 비즈니스 규칙을 캡슐화합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +@RequiredArgsConstructor +@Component +public class ProductService { + private final ProductRepository productRepository; + + /** + * 상품 ID로 상품을 조회합니다. (비관적 락) + *

+ * 동시성 제어가 필요한 경우 사용합니다. (예: 재고 차감) + *

+ * + * @param productId 조회할 상품 ID + * @return 조회된 상품 + * @throws CoreException 상품을 찾을 수 없는 경우 + */ + @Transactional + public Product findByIdForUpdate(Long productId) { + return productRepository.findByIdForUpdate(productId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, + String.format("상품을 찾을 수 없습니다. (상품 ID: %d)", productId))); + } + + /** + * 상품 목록을 저장합니다. + * + * @param products 저장할 상품 목록 + */ + @Transactional + public void saveAll(List products) { + products.forEach(productRepository::save); + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java index 6621a8b62..8c6d062e2 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java @@ -5,11 +5,12 @@ import lombok.RequiredArgsConstructor; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; /** * 사용자 도메인 서비스. *

- * 사용자 생성 등의 도메인 로직을 처리합니다. + * 사용자 생성, 조회 등의 도메인 로직을 처리합니다. * Repository에 의존하며 데이터 무결성 제약 조건을 처리합니다. *

* @@ -43,4 +44,65 @@ public User create(String userId, String email, String birthDateStr, Gender gend } } + /** + * 사용자 ID로 사용자를 조회합니다. + * + * @param userId 조회할 사용자 ID + * @return 조회된 사용자 + * @throws CoreException 사용자를 찾을 수 없는 경우 + */ + @Transactional(readOnly = true) + public User findByUserId(String userId) { + User user = userRepository.findByUserId(userId); + if (user == null) { + throw new CoreException(ErrorType.NOT_FOUND, "사용자를 찾을 수 없습니다."); + } + return user; + } + + /** + * 사용자 ID로 사용자를 조회합니다. (비관적 락) + *

+ * 포인트 차감 등 동시성 제어가 필요한 경우 사용합니다. + *

+ * + * @param userId 조회할 사용자 ID + * @return 조회된 사용자 + * @throws CoreException 사용자를 찾을 수 없는 경우 + */ + @Transactional + public User findByUserIdForUpdate(String userId) { + User user = userRepository.findByUserIdForUpdate(userId); + if (user == null) { + throw new CoreException(ErrorType.NOT_FOUND, "사용자를 찾을 수 없습니다."); + } + return user; + } + + /** + * 사용자 ID (PK)로 사용자를 조회합니다. + * + * @param id 사용자 ID (PK) + * @return 조회된 사용자 + * @throws CoreException 사용자를 찾을 수 없는 경우 + */ + @Transactional(readOnly = true) + public User findById(Long id) { + User user = userRepository.findById(id); + if (user == null) { + throw new CoreException(ErrorType.NOT_FOUND, "사용자를 찾을 수 없습니다."); + } + return user; + } + + /** + * 사용자를 저장합니다. + * + * @param user 저장할 사용자 + * @return 저장된 사용자 + */ + @Transactional + public User save(User user) { + return userRepository.save(user); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/DelayProvider.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/DelayProvider.java new file mode 100644 index 000000000..22fabd259 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/DelayProvider.java @@ -0,0 +1,23 @@ +package com.loopers.infrastructure.payment; + +import java.time.Duration; + +/** + * 지연 제공자 인터페이스. + *

+ * 테스트 가능성을 위해 Thread.sleep을 추상화합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +public interface DelayProvider { + + /** + * 지정된 시간만큼 대기합니다. + * + * @param duration 대기 시간 + * @throws InterruptedException 인터럽트 발생 시 + */ + void delay(Duration duration) throws InterruptedException; +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentGatewayClient.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentGatewayClient.java new file mode 100644 index 000000000..cf6e1b2d6 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentGatewayClient.java @@ -0,0 +1,84 @@ +package com.loopers.infrastructure.payment; + +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestParam; + +/** + * PG 결제 게이트웨이 FeignClient. + *

+ * CircuitBreaker, Bulkhead가 적용되어 있습니다. + *

+ *

+ * Bulkhead 패턴: + *

    + *
  • 동시 호출 최대 20개로 제한 (Building Resilient Distributed Systems: 격벽 패턴)
  • + *
  • PG 호출 실패가 다른 API에 영향을 주지 않도록 격리
  • + *
+ *

+ *

+ * Retry 정책: + *

    + *
  • 결제 요청 API (requestPayment): 5xx 서버 오류만 재시도, 4xx 클라이언트 오류는 재시도하지 않음
  • + *
  • 조회 API (getTransactionsByOrder, getTransaction): Exponential Backoff 적용 (스케줄러 - 비동기/배치 기반 Retry)
  • + *
+ *

+ *

+ * 설계 근거: + *

    + *
  • 5xx 서버 오류: 일시적 오류이므로 재시도하여 복구 가능
  • + *
  • 4xx 클라이언트 오류: 비즈니스 로직 오류이므로 재시도해도 성공하지 않음
  • + *
  • Eventually Consistent: 실패 시 주문은 PENDING 상태로 유지되어 스케줄러에서 복구
  • + *
+ *

+ */ +@FeignClient( + name = "paymentGatewayClient", + url = "${payment-gateway.url}", + path = "/api/v1/payments" +) +public interface PaymentGatewayClient { + + /** + * 결제 요청. + * + * @param userId 사용자 ID (X-USER-ID 헤더) + * @param request 결제 요청 정보 + * @return 결제 응답 + */ + @PostMapping + PaymentGatewayDto.ApiResponse requestPayment( + @RequestHeader("X-USER-ID") String userId, + @RequestBody PaymentGatewayDto.PaymentRequest request + ); + + /** + * 결제 정보 확인 (트랜잭션 키로 조회). + * + * @param userId 사용자 ID (X-USER-ID 헤더) + * @param transactionKey 트랜잭션 키 + * @return 결제 상세 정보 + */ + @GetMapping("/{transactionKey}") + PaymentGatewayDto.ApiResponse getTransaction( + @RequestHeader("X-USER-ID") String userId, + @PathVariable("transactionKey") String transactionKey + ); + + /** + * 주문에 엮인 결제 정보 조회. + * + * @param userId 사용자 ID (X-USER-ID 헤더) + * @param orderId 주문 ID + * @return 주문별 결제 목록 + */ + @GetMapping + PaymentGatewayDto.ApiResponse getTransactionsByOrder( + @RequestHeader("X-USER-ID") String userId, + @RequestParam("orderId") String orderId + ); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentGatewayDto.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentGatewayDto.java new file mode 100644 index 000000000..4ca22424f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentGatewayDto.java @@ -0,0 +1,105 @@ +package com.loopers.infrastructure.payment; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * PG 결제 게이트웨이 DTO. + */ +public class PaymentGatewayDto { + + /** + * PG 결제 요청 DTO. + */ + public record PaymentRequest( + @JsonProperty("orderId") String orderId, + @JsonProperty("cardType") CardType cardType, + @JsonProperty("cardNo") String cardNo, + @JsonProperty("amount") Long amount, + @JsonProperty("callbackUrl") String callbackUrl + ) { + } + + /** + * PG 결제 응답 DTO. + */ + public record TransactionResponse( + @JsonProperty("transactionKey") String transactionKey, + @JsonProperty("status") TransactionStatus status, + @JsonProperty("reason") String reason + ) { + } + + /** + * PG 결제 상세 응답 DTO. + */ + public record TransactionDetailResponse( + @JsonProperty("transactionKey") String transactionKey, + @JsonProperty("orderId") String orderId, + @JsonProperty("cardType") CardType cardType, + @JsonProperty("cardNo") String cardNo, + @JsonProperty("amount") Long amount, + @JsonProperty("status") TransactionStatus status, + @JsonProperty("reason") String reason + ) { + } + + /** + * PG 주문별 결제 목록 응답 DTO. + */ + public record OrderResponse( + @JsonProperty("orderId") String orderId, + @JsonProperty("transactions") java.util.List transactions + ) { + } + + /** + * 카드 타입. + */ + public enum CardType { + SAMSUNG, + KB, + HYUNDAI + } + + /** + * 거래 상태. + */ + public enum TransactionStatus { + PENDING, + SUCCESS, + FAILED + } + + /** + * PG 콜백 요청 DTO (PG에서 보내는 TransactionInfo). + */ + public record CallbackRequest( + @JsonProperty("transactionKey") String transactionKey, + @JsonProperty("orderId") String orderId, + @JsonProperty("cardType") CardType cardType, + @JsonProperty("cardNo") String cardNo, + @JsonProperty("amount") Long amount, + @JsonProperty("status") TransactionStatus status, + @JsonProperty("reason") String reason + ) { + } + + /** + * PG API 응답 래퍼. + */ + public record ApiResponse( + @JsonProperty("meta") Metadata meta, + @JsonProperty("data") T data + ) { + public record Metadata( + @JsonProperty("result") Result result, + @JsonProperty("errorCode") String errorCode, + @JsonProperty("message") String message + ) { + public enum Result { + SUCCESS, + FAIL + } + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentGatewayImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentGatewayImpl.java new file mode 100644 index 000000000..5d4b994fa --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentGatewayImpl.java @@ -0,0 +1,148 @@ +package com.loopers.infrastructure.payment; + +import com.loopers.domain.payment.PaymentGateway; +import com.loopers.application.purchasing.PaymentRequestCommand; +import com.loopers.domain.payment.PaymentRequestResult; +import com.loopers.domain.payment.PaymentStatus; +import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +/** + * PaymentGateway 인터페이스의 구현체. + *

+ * 도메인 계층의 PaymentGateway 인터페이스를 구현합니다. + * 인프라 관심사(FeignClient 호출, 예외 처리)를 도메인 모델로 변환합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class PaymentGatewayImpl implements PaymentGateway { + + private final PaymentGatewayClient paymentGatewayClient; + private final PaymentGatewaySchedulerClient paymentGatewaySchedulerClient; + private final PaymentGatewayMetrics metrics; + + /** + * PG 결제 요청을 전송합니다. + * + * @param command 결제 요청 명령 + * @return 결제 요청 결과 + */ + @Override + @CircuitBreaker(name = "pgCircuit", fallbackMethod = "fallback") + public PaymentRequestResult requestPayment(PaymentRequestCommand command) { + PaymentGatewayDto.PaymentRequest dtoRequest = toDto(command); + PaymentGatewayDto.ApiResponse response = + paymentGatewayClient.requestPayment(command.userId(), dtoRequest); + + return toDomainResult(response, command.orderId()); + } + + /** + * Circuit Breaker fallback 메서드. + * + * @param command 결제 요청 명령 + * @param t 발생한 예외 + * @return 결제 대기 상태의 실패 결과 + */ + public PaymentRequestResult fallback(PaymentRequestCommand command, Throwable t) { + log.warn("Circuit Breaker fallback 호출됨. (orderId: {}, exception: {})", + command.orderId(), t.getClass().getSimpleName(), t); + metrics.recordFallback("paymentGatewayClient"); + return new PaymentRequestResult.Failure( + "CIRCUIT_BREAKER_OPEN", + "결제 대기 상태", + false, + false + ); + } + + /** + * Circuit Breaker fallback 메서드 (결제 상태 조회). + * + * @param userId 사용자 ID + * @param orderId 주문 ID + * @param t 발생한 예외 + * @return PENDING 상태 반환 + */ + public PaymentStatus getPaymentStatusFallback(String userId, Long orderId, Throwable t) { + log.warn("Circuit Breaker fallback 호출됨 (결제 상태 조회). (orderId: {}, exception: {})", + orderId, t.getClass().getSimpleName(), t); + metrics.recordFallback("paymentGatewaySchedulerClient"); + return PaymentStatus.PENDING; + } + + /** + * 결제 상태를 조회합니다. + * + * @param userId 사용자 ID + * @param orderId 주문 ID + * @return 결제 상태 (SUCCESS, FAILED, PENDING) + */ + @Override + @CircuitBreaker(name = "pgCircuit", fallbackMethod = "getPaymentStatusFallback") + public PaymentStatus getPaymentStatus(String userId, Long orderId) { + // 주문 ID를 6자리 이상 문자열로 변환 (pg-simulator 검증 요구사항) + String orderIdString = String.format("%06d", orderId); + PaymentGatewayDto.ApiResponse response = + paymentGatewaySchedulerClient.getTransactionsByOrder(userId, orderIdString); + + if (response == null || response.meta() == null + || response.meta().result() != PaymentGatewayDto.ApiResponse.Metadata.Result.SUCCESS + || response.data() == null || response.data().transactions() == null + || response.data().transactions().isEmpty()) { + return PaymentStatus.PENDING; + } + + // 가장 최근 트랜잭션의 상태 반환 + PaymentGatewayDto.TransactionResponse latestTransaction = + response.data().transactions().get(response.data().transactions().size() - 1); + return convertToPaymentStatus(latestTransaction.status()); + } + + private PaymentGatewayDto.PaymentRequest toDto(PaymentRequestCommand command) { + return new PaymentGatewayDto.PaymentRequest( + String.format("%06d", command.orderId()), // 주문 ID를 6자리 이상 문자열로 변환 + PaymentGatewayDto.CardType.valueOf(command.cardType().toUpperCase()), + command.cardNo(), + command.amount(), + command.callbackUrl() + ); + } + + private PaymentRequestResult toDomainResult( + PaymentGatewayDto.ApiResponse response, + Long orderId + ) { + if (response != null && response.meta() != null + && response.meta().result() == PaymentGatewayDto.ApiResponse.Metadata.Result.SUCCESS + && response.data() != null) { + String transactionKey = response.data().transactionKey(); + metrics.recordSuccess("paymentGatewayClient"); + return new PaymentRequestResult.Success(transactionKey); + } else { + String errorCode = response != null && response.meta() != null + ? response.meta().errorCode() : "UNKNOWN"; + String message = response != null && response.meta() != null + ? response.meta().message() : "응답이 null입니다."; + log.warn("PG 결제 요청 실패. (orderId: {}, errorCode: {}, message: {})", + orderId, errorCode, message); + return new PaymentRequestResult.Failure(errorCode, message, false, false); + } + } + + private PaymentStatus convertToPaymentStatus(PaymentGatewayDto.TransactionStatus status) { + return switch (status) { + case SUCCESS -> PaymentStatus.SUCCESS; + case FAILED -> PaymentStatus.FAILED; + case PENDING -> PaymentStatus.PENDING; + }; + } + +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentGatewayMetrics.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentGatewayMetrics.java new file mode 100644 index 000000000..72bc0b96b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentGatewayMetrics.java @@ -0,0 +1,85 @@ +package com.loopers.infrastructure.payment; + +import io.micrometer.core.instrument.MeterRegistry; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +/** + * 결제 게이트웨이 메트릭. + *

+ * PG 서버 오류, 타임아웃, Fallback 등의 이벤트를 Prometheus 메트릭으로 기록합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +@Component +@RequiredArgsConstructor +public class PaymentGatewayMetrics { + + private final MeterRegistry meterRegistry; + + /** + * PG 서버 오류(5xx) 발생 횟수를 기록합니다. + * + * @param clientName 클라이언트 이름 (paymentGatewayClient, paymentGatewaySchedulerClient) + * @param status HTTP 상태 코드 + */ + public void recordServerError(String clientName, int status) { + meterRegistry.counter( + "payment.gateway.server.error", + "client", clientName, + "status", String.valueOf(status) + ).increment(); + } + + /** + * PG 타임아웃 발생 횟수를 기록합니다. + * + * @param clientName 클라이언트 이름 + */ + public void recordTimeout(String clientName) { + meterRegistry.counter( + "payment.gateway.timeout", + "client", clientName + ).increment(); + } + + /** + * PG 클라이언트 오류(4xx) 발생 횟수를 기록합니다. + * + * @param clientName 클라이언트 이름 + * @param status HTTP 상태 코드 + */ + public void recordClientError(String clientName, int status) { + meterRegistry.counter( + "payment.gateway.client.error", + "client", clientName, + "status", String.valueOf(status) + ).increment(); + } + + /** + * Fallback 호출 횟수를 기록합니다. + * + * @param clientName 클라이언트 이름 + */ + public void recordFallback(String clientName) { + meterRegistry.counter( + "payment.gateway.fallback", + "client", clientName + ).increment(); + } + + /** + * PG 결제 요청 성공 횟수를 기록합니다. + * + * @param clientName 클라이언트 이름 + */ + public void recordSuccess(String clientName) { + meterRegistry.counter( + "payment.gateway.request.success", + "client", clientName + ).increment(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentGatewaySchedulerClient.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentGatewaySchedulerClient.java new file mode 100644 index 000000000..01451ab62 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentGatewaySchedulerClient.java @@ -0,0 +1,69 @@ +package com.loopers.infrastructure.payment; + +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestParam; + +/** + * PG 결제 게이트웨이 FeignClient (스케줄러 전용). + *

+ * 스케줄러에서 사용하는 조회 API에 Retry를 적용합니다. + *

+ *

+ * Retry 정책: + *

    + *
  • Exponential Backoff 적용: 초기 500ms → 1000ms (최대 5초)
  • + *
  • 최대 재시도 횟수: 3회 (초기 시도 포함)
  • + *
  • 재시도 대상: 5xx 서버 오류, 타임아웃, 네트워크 오류
  • + *
+ *

+ *

+ * 설계 근거: + *

    + *
  • 비동기/배치 기반: 스케줄러는 배치 작업이므로 Retry가 안전하게 적용 가능
  • + *
  • 일시적 오류 복구: 네트워크 일시적 오류나 PG 서버 일시적 장애 시 자동 복구
  • + *
  • 유저 요청 스레드 점유 없음: 스케줄러 스레드에서 실행되므로 유저 경험에 영향 없음
  • + *
+ *

+ */ +@FeignClient( + name = "paymentGatewaySchedulerClient", + url = "${payment-gateway.url}", + path = "/api/v1/payments" +) +public interface PaymentGatewaySchedulerClient { + + /** + * 결제 정보 확인 (트랜잭션 키로 조회). + *

+ * 스케줄러에서 사용하며, Retry가 적용됩니다. + *

+ * + * @param userId 사용자 ID (X-USER-ID 헤더) + * @param transactionKey 트랜잭션 키 + * @return 결제 상세 정보 + */ + @GetMapping("/{transactionKey}") + PaymentGatewayDto.ApiResponse getTransaction( + @RequestHeader("X-USER-ID") String userId, + @PathVariable("transactionKey") String transactionKey + ); + + /** + * 주문에 엮인 결제 정보 조회. + *

+ * 스케줄러에서 사용하며, Retry가 적용됩니다. + *

+ * + * @param userId 사용자 ID (X-USER-ID 헤더) + * @param orderId 주문 ID + * @return 주문별 결제 목록 + */ + @GetMapping + PaymentGatewayDto.ApiResponse getTransactionsByOrder( + @RequestHeader("X-USER-ID") String userId, + @RequestParam("orderId") String orderId + ); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentJpaRepository.java new file mode 100644 index 000000000..a34757237 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentJpaRepository.java @@ -0,0 +1,20 @@ +package com.loopers.infrastructure.payment; + +import com.loopers.domain.payment.Payment; +import com.loopers.domain.payment.PaymentStatus; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +/** + * Payment JPA Repository. + */ +public interface PaymentJpaRepository extends JpaRepository { + Optional findByOrderId(Long orderId); + + List findAllByUserId(Long userId); + + List findAllByStatus(PaymentStatus status); +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentRepositoryImpl.java new file mode 100644 index 000000000..9b38ff60a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentRepositoryImpl.java @@ -0,0 +1,46 @@ +package com.loopers.infrastructure.payment; + +import com.loopers.domain.payment.Payment; +import com.loopers.domain.payment.PaymentRepository; +import com.loopers.domain.payment.PaymentStatus; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +/** + * Payment Repository 구현체. + */ +@Repository +@RequiredArgsConstructor +public class PaymentRepositoryImpl implements PaymentRepository { + + private final PaymentJpaRepository paymentJpaRepository; + + @Override + public Payment save(Payment payment) { + return paymentJpaRepository.save(payment); + } + + @Override + public Optional findById(Long paymentId) { + return paymentJpaRepository.findById(paymentId); + } + + @Override + public Optional findByOrderId(Long orderId) { + return paymentJpaRepository.findByOrderId(orderId); + } + + @Override + public List findAllByUserId(Long userId) { + return paymentJpaRepository.findAllByUserId(userId); + } + + @Override + public List findAllByStatus(PaymentStatus status) { + return paymentJpaRepository.findAllByStatus(status); + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/ThreadDelayProvider.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/ThreadDelayProvider.java new file mode 100644 index 000000000..d31ef49d9 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/ThreadDelayProvider.java @@ -0,0 +1,20 @@ +package com.loopers.infrastructure.payment; + +import org.springframework.stereotype.Component; + +import java.time.Duration; + +/** + * Thread.sleep을 사용하는 DelayProvider 구현체. + * + * @author Loopers + * @version 1.0 + */ +@Component +public class ThreadDelayProvider implements DelayProvider { + + @Override + public void delay(Duration duration) throws InterruptedException { + Thread.sleep(duration.toMillis()); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/scheduler/PaymentRecoveryScheduler.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/scheduler/PaymentRecoveryScheduler.java new file mode 100644 index 000000000..048e91755 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/scheduler/PaymentRecoveryScheduler.java @@ -0,0 +1,123 @@ +package com.loopers.infrastructure.scheduler; + +import com.loopers.application.purchasing.PurchasingFacade; +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderRepository; +import com.loopers.domain.order.OrderStatus; +import com.loopers.infrastructure.user.UserJpaRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.util.List; + +/** + * 결제 상태 복구 스케줄러. + *

+ * 콜백이 오지 않은 PENDING 상태의 주문들을 주기적으로 조회하여 + * PG 시스템의 결제 상태 확인 API를 통해 상태를 복구합니다. + *

+ *

+ * 동작 원리: + *

    + *
  1. 주기적으로 실행 (기본: 1분마다)
  2. + *
  3. PENDING 상태인 주문들을 조회
  4. + *
  5. 각 주문에 대해 PG 결제 상태 확인 API 호출
  6. + *
  7. 결제 상태에 따라 주문 상태 업데이트
  8. + *
+ *

+ *

+ * 설계 근거: + *

    + *
  • 주기적 복구: 콜백이 오지 않아도 자동으로 상태 복구
  • + *
  • Eventually Consistent: 약간의 지연 허용 가능
  • + *
  • 안전한 처리: 각 주문별로 독립적으로 처리하여 실패 시에도 다른 주문에 영향 없음
  • + *
  • 성능 고려: 배치로 처리하여 PG 시스템 부하 최소화
  • + *
+ *

+ *

+ * 레이어 위치 근거: + *

    + *
  • 스케줄링은 기술적 관심사이므로 Infrastructure Layer에 위치
  • + *
  • 비즈니스 로직은 Application Layer의 PurchasingFacade에 위임
  • + *
+ *

+ * + * @author Loopers + * @version 1.0 + */ +@Slf4j +@RequiredArgsConstructor +@Component +public class PaymentRecoveryScheduler { + + private final OrderRepository orderRepository; + private final UserJpaRepository userJpaRepository; + private final PurchasingFacade purchasingFacade; + + /** + * PENDING 상태인 주문들의 결제 상태를 복구합니다. + *

+ * 1분마다 실행되어 PENDING 상태인 주문들을 조회하고, + * 각 주문에 대해 PG 결제 상태 확인 API를 호출하여 상태를 복구합니다. + *

+ *

+ * 처리 전략: + *

    + *
  • 배치 처리: 한 번에 여러 주문 처리
  • + *
  • 독립적 처리: 각 주문별로 독립적으로 처리하여 실패 시에도 다른 주문에 영향 없음
  • + *
  • 안전한 예외 처리: 개별 주문 처리 실패 시에도 계속 진행
  • + *
+ *

+ */ + @Scheduled(fixedDelay = 60000) // 1분마다 실행 + public void recoverPendingOrders() { + try { + log.debug("결제 상태 복구 스케줄러 시작"); + + // PENDING 상태인 주문들 조회 + List pendingOrders = orderRepository.findAllByStatus(OrderStatus.PENDING); + + if (pendingOrders.isEmpty()) { + log.debug("복구할 PENDING 상태 주문이 없습니다."); + return; + } + + log.info("PENDING 상태 주문 {}건에 대한 결제 상태 복구 시작", pendingOrders.size()); + + int successCount = 0; + int failureCount = 0; + + // 각 주문에 대해 결제 상태 확인 및 복구 + for (Order order : pendingOrders) { + try { + // Order의 userId는 User의 id (Long)이므로 User를 조회하여 userId (String)를 가져옴 + var userOptional = userJpaRepository.findById(order.getUserId()); + if (userOptional.isEmpty()) { + log.warn("주문의 사용자를 찾을 수 없습니다. 복구를 건너뜁니다. (orderId: {}, userId: {})", + order.getId(), order.getUserId()); + failureCount++; + continue; + } + + String userId = userOptional.get().getUserId(); + + // 결제 상태 확인 및 복구 + purchasingFacade.recoverOrderStatusByPaymentCheck(userId, order.getId()); + successCount++; + } catch (Exception e) { + // 개별 주문 처리 실패 시에도 계속 진행 + log.error("주문 상태 복구 중 오류 발생. (orderId: {})", order.getId(), e); + failureCount++; + } + } + + log.info("결제 상태 복구 완료. 성공: {}건, 실패: {}건", successCount, failureCount); + + } catch (Exception e) { + log.error("결제 상태 복구 스케줄러 실행 중 오류 발생", e); + } + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/purchasing/PurchasingV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/purchasing/PurchasingV1Controller.java index fcea74ac2..937a9cf20 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/purchasing/PurchasingV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/purchasing/PurchasingV1Controller.java @@ -2,7 +2,7 @@ import com.loopers.application.purchasing.OrderInfo; import com.loopers.application.purchasing.PurchasingFacade; -import com.loopers.infrastructure.paymentgateway.PaymentGatewayDto; +import com.loopers.infrastructure.payment.PaymentGatewayDto; import com.loopers.interfaces.api.ApiResponse; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @@ -41,6 +41,7 @@ public ApiResponse createOrder( OrderInfo orderInfo = purchasingFacade.createOrder( userId, request.toCommands(), + request.payment().usedPoint(), request.payment().cardType(), request.payment().cardNo() ); diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/purchasing/PurchasingV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/purchasing/PurchasingV1Dto.java index e1307ca42..f2e552d09 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/purchasing/PurchasingV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/purchasing/PurchasingV1Dto.java @@ -35,10 +35,9 @@ public List toCommands() { * 결제 정보 요청 DTO. */ public record PaymentRequest( - @NotNull(message = "카드 타입은 필수입니다.") - String cardType, - @NotNull(message = "카드 번호는 필수입니다.") - String cardNo + Long usedPoint, // 포인트 사용량 (선택, 기본값: 0) + String cardType, // 카드 타입 (paidAmount > 0일 때만 필수) + String cardNo // 카드 번호 (paidAmount > 0일 때만 필수) ) { } diff --git a/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadeCircuitBreakerTest.java b/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadeCircuitBreakerTest.java index 72b6052a8..c8de02c57 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadeCircuitBreakerTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadeCircuitBreakerTest.java @@ -12,8 +12,8 @@ import com.loopers.domain.user.Point; import com.loopers.domain.user.User; import com.loopers.domain.user.UserRepository; -import com.loopers.infrastructure.paymentgateway.PaymentGatewayClient; -import com.loopers.infrastructure.paymentgateway.PaymentGatewayDto; +import com.loopers.infrastructure.payment.PaymentGatewayClient; +import com.loopers.infrastructure.payment.PaymentGatewayDto; import com.loopers.utils.DatabaseCleanUp; import io.github.resilience4j.circuitbreaker.CircuitBreaker; import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry; @@ -142,6 +142,7 @@ void createOrder_consecutiveFailures_circuitBreakerOpens() { purchasingFacade.createOrder( user.getUserId(), commands, + null, "SAMSUNG", "4111-1111-1111-1111" ); @@ -189,6 +190,7 @@ void createOrder_circuitBreakerOpen_fallbackExecuted() { purchasingFacade.createOrder( user.getUserId(), commands, + null, "SAMSUNG", "4111-1111-1111-1111" ); @@ -244,6 +246,7 @@ void createOrder_circuitBreakerHalfOpen_success_transitionsToClosed() { purchasingFacade.createOrder( user.getUserId(), commands, + null, "SAMSUNG", "4111-1111-1111-1111" ); @@ -299,6 +302,7 @@ void createOrder_circuitBreakerHalfOpen_failure_transitionsToOpen() { OrderInfo orderInfo = purchasingFacade.createOrder( user.getUserId(), commands, + null, "SAMSUNG", "4111-1111-1111-1111" ); @@ -337,9 +341,11 @@ void createOrder_circuitBreakerOpen_internalSystemRespondsNormally() { } // act + // 포인트를 사용하지 않고 카드로만 결제 (Circuit Breaker OPEN 상태에서도 주문은 PENDING 상태로 유지) OrderInfo orderInfo = purchasingFacade.createOrder( user.getUserId(), commands, + null, "SAMSUNG", "4111-1111-1111-1111" ); @@ -350,12 +356,13 @@ void createOrder_circuitBreakerOpen_internalSystemRespondsNormally() { assertThat(orderInfo.orderId()).isNotNull(); assertThat(orderInfo.status()).isEqualTo(OrderStatus.PENDING); - // 재고와 포인트는 정상적으로 차감되어야 함 + // 재고는 정상적으로 차감되어야 함 Product savedProduct = productRepository.findById(product.getId()).orElseThrow(); assertThat(savedProduct.getStock()).isEqualTo(9); + // 포인트는 사용하지 않았으므로 차감되지 않음 User savedUser = userRepository.findByUserId(user.getUserId()); - assertThat(savedUser.getPoint().getValue()).isEqualTo(40_000L); + assertThat(savedUser.getPoint().getValue()).isEqualTo(50_000L); } @Test @@ -396,6 +403,7 @@ void createOrder_fallbackResponseWithCircuitBreakerOpen_orderRemainsPending() { OrderInfo orderInfo = purchasingFacade.createOrder( user.getUserId(), commands, + null, "SAMSUNG", "4111-1111-1111-1111" ); @@ -452,6 +460,7 @@ void createOrder_retryFailure_circuitBreakerOpens_fallbackExecuted() { purchasingFacade.createOrder( user.getUserId(), commands, + null, "SAMSUNG", "4111-1111-1111-1111" ); @@ -519,9 +528,11 @@ void createOrder_retryFailure_fallbackCalled_circuitBreakerOpenHandled() { .thenReturn(fallbackResponse); // act + // 포인트를 사용하지 않고 카드로만 결제 (Circuit Breaker OPEN 상태에서도 주문은 PENDING 상태로 유지) OrderInfo orderInfo = purchasingFacade.createOrder( user.getUserId(), commands, + null, "SAMSUNG", "4111-1111-1111-1111" ); @@ -537,12 +548,13 @@ void createOrder_retryFailure_fallbackCalled_circuitBreakerOpenHandled() { // 3. CIRCUIT_BREAKER_OPEN은 외부 시스템 장애로 간주되므로 주문 취소가 발생하지 않아야 함 assertThat(savedOrder.getStatus()).isNotEqualTo(OrderStatus.CANCELED); - // 4. 재고와 포인트는 정상적으로 차감되어야 함 (주문은 생성되었지만 결제는 PENDING) + // 4. 재고는 정상적으로 차감되어야 함 (주문은 생성되었지만 결제는 PENDING) Product savedProduct = productRepository.findById(product.getId()).orElseThrow(); assertThat(savedProduct.getStock()).isEqualTo(9); + // 포인트는 사용하지 않았으므로 차감되지 않음 User savedUser = userRepository.findByUserId(user.getUserId()); - assertThat(savedUser.getPoint().getValue()).isEqualTo(40_000L); + assertThat(savedUser.getPoint().getValue()).isEqualTo(50_000L); } @Test @@ -582,6 +594,7 @@ void createOrder_fallbackResponse_circuitBreakerOpenErrorCode_orderRemainsPendin OrderInfo orderInfo = purchasingFacade.createOrder( user.getUserId(), commands, + null, "SAMSUNG", "4111-1111-1111-1111" ); @@ -645,6 +658,7 @@ void createOrder_retryExhausted_circuitBreakerOpens_fallbackCalled_orderPending( purchasingFacade.createOrder( user.getUserId(), commands, + null, "SAMSUNG", "4111-1111-1111-1111" ); @@ -692,6 +706,7 @@ void createOrder_retryExhausted_circuitBreakerOpens_fallbackCalled_orderPending( OrderInfo fallbackOrderInfo = purchasingFacade.createOrder( user.getUserId(), commands, + null, "SAMSUNG", "4111-1111-1111-1111" ); diff --git a/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadeConcurrencyTest.java b/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadeConcurrencyTest.java index bdfda839d..f4fc9e213 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadeConcurrencyTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadeConcurrencyTest.java @@ -128,7 +128,8 @@ void concurrencyTest_pointShouldProperlyDecreaseWhenOrderCreated() throws Interr List commands = List.of( OrderItemCommand.of(products.get(index).getId(), 1) ); - purchasingFacade.createOrder(userId, commands, "SAMSUNG", "4111-1111-1111-1111"); + // 포인트를 사용하여 주문 (각 주문마다 10,000 포인트 사용) + purchasingFacade.createOrder(userId, commands, 10_000L, "SAMSUNG", "4111-1111-1111-1111"); successCount.incrementAndGet(); } catch (Exception e) { synchronized (exceptions) { @@ -176,11 +177,13 @@ void concurrencyTest_stockShouldBeProperlyDecreasedWhenOrdersCreated() throws In List commands = List.of( OrderItemCommand.of(productId, quantityPerOrder) ); - purchasingFacade.createOrder(userId, commands, "SAMSUNG", "4111-1111-1111-1111"); + purchasingFacade.createOrder(userId, commands, null, "SAMSUNG", "4111-1111-1111-1111"); successCount.incrementAndGet(); } catch (Exception e) { synchronized (exceptions) { exceptions.add(e); + System.out.println("Exception in stock test: " + e.getClass().getSimpleName() + " - " + e.getMessage()); + e.printStackTrace(); } } finally { latch.countDown(); @@ -194,6 +197,7 @@ void concurrencyTest_stockShouldBeProperlyDecreasedWhenOrdersCreated() throws In Product savedProduct = productRepository.findById(productId).orElseThrow(); int expectedStock = 100 - (successCount.get() * quantityPerOrder); + System.out.println("Success count: " + successCount.get() + ", Exceptions: " + exceptions.size()); assertThat(savedProduct.getStock()).isEqualTo(expectedStock); assertThat(successCount.get() + exceptions.size()).isEqualTo(orderCount); } @@ -228,7 +232,7 @@ void concurrencyTest_couponShouldBeUsedOnlyOnceWhenOrdersCreated() throws Interr List commands = List.of( new OrderItemCommand(product.getId(), 1, couponCode) ); - purchasingFacade.createOrder(userId, commands, "SAMSUNG", "4111-1111-1111-1111"); + purchasingFacade.createOrder(userId, commands, null, "SAMSUNG", "4111-1111-1111-1111"); successCount.incrementAndGet(); } catch (Exception e) { synchronized (exceptions) { @@ -277,7 +281,7 @@ void concurrencyTest_cancelOrderShouldRestoreStockAccuratelyDuringConcurrentStoc List commands = List.of( OrderItemCommand.of(productId, orderQuantity) ); - OrderInfo orderInfo = purchasingFacade.createOrder(userId, commands, "SAMSUNG", "4111-1111-1111-1111"); + OrderInfo orderInfo = purchasingFacade.createOrder(userId, commands, null, "SAMSUNG", "4111-1111-1111-1111"); Long orderId = orderInfo.orderId(); // 주문 취소 전 재고 확인 (100 - 5 = 95) @@ -317,7 +321,7 @@ void concurrencyTest_cancelOrderShouldRestoreStockAccuratelyDuringConcurrentStoc List otherCommands = List.of( OrderItemCommand.of(productId, 3) ); - purchasingFacade.createOrder(userId, otherCommands, "SAMSUNG", "4111-1111-1111-1111"); + purchasingFacade.createOrder(userId, otherCommands, null, "SAMSUNG", "4111-1111-1111-1111"); orderSuccess.incrementAndGet(); } catch (Exception e) { synchronized (exceptions) { diff --git a/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadePaymentCallbackTest.java b/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadePaymentCallbackTest.java index 24195fab4..b2c9cc516 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadePaymentCallbackTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadePaymentCallbackTest.java @@ -11,9 +11,9 @@ import com.loopers.domain.user.Point; import com.loopers.domain.user.User; import com.loopers.domain.user.UserRepository; -import com.loopers.infrastructure.paymentgateway.PaymentGatewayClient; -import com.loopers.infrastructure.paymentgateway.PaymentGatewayDto; -import com.loopers.infrastructure.paymentgateway.PaymentGatewaySchedulerClient; +import com.loopers.infrastructure.payment.PaymentGatewayClient; +import com.loopers.infrastructure.payment.PaymentGatewayDto; +import com.loopers.infrastructure.payment.PaymentGatewaySchedulerClient; import com.loopers.utils.DatabaseCleanUp; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.DisplayName; @@ -121,6 +121,7 @@ void handlePaymentCallback_successCallback_orderStatusUpdatedToCompleted() { OrderInfo orderInfo = purchasingFacade.createOrder( user.getUserId(), commands, + null, "SAMSUNG", "4111-1111-1111-1111" // 유효한 Luhn 알고리즘 통과 카드 번호 ); @@ -197,6 +198,7 @@ void handlePaymentCallback_failureCallback_orderStatusUpdatedToCanceled() { OrderInfo orderInfo = purchasingFacade.createOrder( user.getUserId(), commands, + null, "SAMSUNG", "4111-1111-1111-1111" // 유효한 Luhn 알고리즘 통과 카드 번호 ); @@ -268,6 +270,7 @@ void recoverOrderStatus_afterTimeout_statusRecoveredByStatusCheck() { OrderInfo orderInfo = purchasingFacade.createOrder( user.getUserId(), commands, + null, "SAMSUNG", "4111-1111-1111-1111" // 유효한 Luhn 알고리즘 통과 카드 번호 ); diff --git a/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadePaymentGatewayTest.java b/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadePaymentGatewayTest.java index 6a3f9f837..366a0b91a 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadePaymentGatewayTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadePaymentGatewayTest.java @@ -11,8 +11,8 @@ import com.loopers.domain.user.Point; import com.loopers.domain.user.User; import com.loopers.domain.user.UserRepository; -import com.loopers.infrastructure.paymentgateway.PaymentGatewayClient; -import com.loopers.infrastructure.paymentgateway.PaymentGatewayDto; +import com.loopers.infrastructure.payment.PaymentGatewayClient; +import com.loopers.infrastructure.payment.PaymentGatewayDto; import com.loopers.utils.DatabaseCleanUp; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.DisplayName; @@ -111,6 +111,7 @@ void createOrder_paymentGatewayTimeout_orderCreatedWithPendingStatus() { OrderInfo orderInfo = purchasingFacade.createOrder( user.getUserId(), commands, + null, "SAMSUNG", "4111-1111-1111-1111" // 유효한 Luhn 알고리즘 통과 카드 번호 ); @@ -122,9 +123,9 @@ void createOrder_paymentGatewayTimeout_orderCreatedWithPendingStatus() { Product savedProduct = productRepository.findById(product.getId()).orElseThrow(); assertThat(savedProduct.getStock()).isEqualTo(9); - // 포인트는 차감되었는지 확인 + // 포인트 차감 확인 (usedPoint가 null이므로 포인트 차감 없음) User savedUser = userRepository.findByUserId(user.getUserId()); - assertThat(savedUser.getPoint().getValue()).isEqualTo(40_000L); + assertThat(savedUser.getPoint().getValue()).isEqualTo(50_000L); // 포인트 차감 없음 // 주문이 저장되었는지 확인 Order savedOrder = orderRepository.findById(orderInfo.orderId()).orElseThrow(); @@ -160,6 +161,7 @@ void createOrder_paymentGatewayFailure_orderCreatedWithPendingStatus() { OrderInfo orderInfo = purchasingFacade.createOrder( user.getUserId(), commands, + null, "SAMSUNG", "4111-1111-1111-1111" // 유효한 Luhn 알고리즘 통과 카드 번호 ); @@ -197,6 +199,7 @@ void createOrder_paymentGatewayServerError_orderCreatedWithPendingStatus() { OrderInfo orderInfo = purchasingFacade.createOrder( user.getUserId(), commands, + null, "SAMSUNG", "4111-1111-1111-1111" // 유효한 Luhn 알고리즘 통과 카드 번호 ); @@ -234,6 +237,7 @@ void createOrder_paymentGatewayConnectionFailure_orderCreatedWithPendingStatus() OrderInfo orderInfo = purchasingFacade.createOrder( user.getUserId(), commands, + null, "SAMSUNG", "4111-1111-1111-1111" // 유효한 Luhn 알고리즘 통과 카드 번호 ); @@ -266,6 +270,7 @@ void createOrder_paymentGatewayTimeout_internalSystemRespondsNormally() { OrderInfo orderInfo = purchasingFacade.createOrder( user.getUserId(), commands, + null, "SAMSUNG", "4111-1111-1111-1111" // 유효한 Luhn 알고리즘 통과 카드 번호 ); diff --git a/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadeTest.java index e9b01e55e..4fa80edfa 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadeTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadeTest.java @@ -15,8 +15,8 @@ import com.loopers.domain.user.Point; import com.loopers.domain.user.User; import com.loopers.domain.user.UserRepository; -import com.loopers.infrastructure.paymentgateway.PaymentGatewayClient; -import com.loopers.infrastructure.paymentgateway.PaymentGatewayDto; +import com.loopers.infrastructure.payment.PaymentGatewayClient; +import com.loopers.infrastructure.payment.PaymentGatewayDto; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import com.loopers.utils.DatabaseCleanUp; @@ -153,7 +153,7 @@ void createOrder_successFlow() { ); // act - OrderInfo orderInfo = purchasingFacade.createOrder(user.getUserId(), commands, "SAMSUNG", "4111-1111-1111-1111"); + OrderInfo orderInfo = purchasingFacade.createOrder(user.getUserId(), commands, null, "SAMSUNG", "4111-1111-1111-1111"); // assert // createOrder는 주문을 PENDING 상태로 생성하고, PG 결제 요청은 afterCommit 콜백에서 비동기로 실행됨 @@ -165,9 +165,9 @@ void createOrder_successFlow() { assertThat(savedProduct1.getStock()).isEqualTo(8); // 10 - 2 assertThat(savedProduct2.getStock()).isEqualTo(4); // 5 - 1 - // 포인트 차감 확인 + // 포인트 차감 확인 (usedPoint가 null이므로 포인트 차감 없음) User savedUser = userRepository.findByUserId(user.getUserId()); - assertThat(savedUser.getPoint().getValue()).isEqualTo(25_000L); // 50_000 - (10_000 * 2 + 5_000 * 1) + assertThat(savedUser.getPoint().getValue()).isEqualTo(50_000L); // 포인트 차감 없음 } @Test @@ -178,7 +178,7 @@ void createOrder_emptyItems_throwsException() { List emptyCommands = List.of(); // act & assert - assertThatThrownBy(() -> purchasingFacade.createOrder(userId, emptyCommands, "SAMSUNG", "4111-1111-1111-1111")) + assertThatThrownBy(() -> purchasingFacade.createOrder(userId, emptyCommands, null, "SAMSUNG", "4111-1111-1111-1111")) .isInstanceOf(CoreException.class) .hasFieldOrPropertyWithValue("errorType", ErrorType.BAD_REQUEST); } @@ -193,7 +193,7 @@ void createOrder_userNotFound() { ); // act & assert - assertThatThrownBy(() -> purchasingFacade.createOrder(unknownUserId, commands, "SAMSUNG", "4111-1111-1111-1111")) + assertThatThrownBy(() -> purchasingFacade.createOrder(unknownUserId, commands, null, "SAMSUNG", "4111-1111-1111-1111")) .isInstanceOf(CoreException.class) .hasFieldOrPropertyWithValue("errorType", ErrorType.NOT_FOUND); } @@ -215,7 +215,7 @@ void createOrder_stockNotEnough() { ); // act & assert - assertThatThrownBy(() -> purchasingFacade.createOrder(userId, commands, "SAMSUNG", "4111-1111-1111-1111")) + assertThatThrownBy(() -> purchasingFacade.createOrder(userId, commands, null, "SAMSUNG", "4111-1111-1111-1111")) .isInstanceOf(CoreException.class) .hasFieldOrPropertyWithValue("errorType", ErrorType.BAD_REQUEST); @@ -245,7 +245,7 @@ void createOrder_stockZero() { ); // act & assert - assertThatThrownBy(() -> purchasingFacade.createOrder(userId, commands, "SAMSUNG", "4111-1111-1111-1111")) + assertThatThrownBy(() -> purchasingFacade.createOrder(userId, commands, null, "SAMSUNG", "4111-1111-1111-1111")) .isInstanceOf(CoreException.class) .hasFieldOrPropertyWithValue("errorType", ErrorType.BAD_REQUEST); @@ -275,7 +275,8 @@ void createOrder_pointNotEnough() { ); // act & assert - assertThatThrownBy(() -> purchasingFacade.createOrder(userId, commands, "SAMSUNG", "4111-1111-1111-1111")) + // 포인트를 사용하려고 하지만 잔액이 부족한 경우 + assertThatThrownBy(() -> purchasingFacade.createOrder(userId, commands, 10_000L, "SAMSUNG", "4111-1111-1111-1111")) .isInstanceOf(CoreException.class) .hasFieldOrPropertyWithValue("errorType", ErrorType.BAD_REQUEST); @@ -305,7 +306,7 @@ void createOrder_duplicateProducts_throwsException() { ); // act & assert - assertThatThrownBy(() -> purchasingFacade.createOrder(userId, commands, "SAMSUNG", "4111-1111-1111-1111")) + assertThatThrownBy(() -> purchasingFacade.createOrder(userId, commands, null, "SAMSUNG", "4111-1111-1111-1111")) .isInstanceOf(CoreException.class) .hasFieldOrPropertyWithValue("errorType", ErrorType.BAD_REQUEST); @@ -325,7 +326,7 @@ void getOrders_returnsUserOrders() { List commands = List.of( OrderItemCommand.of(product.getId(), 1) ); - purchasingFacade.createOrder(user.getUserId(), commands, "SAMSUNG", "4111-1111-1111-1111"); + purchasingFacade.createOrder(user.getUserId(), commands, null, "SAMSUNG", "4111-1111-1111-1111"); // act List orders = purchasingFacade.getOrders(user.getUserId()); @@ -347,7 +348,7 @@ void getOrder_returnsSingleOrder() { List commands = List.of( OrderItemCommand.of(product.getId(), 1) ); - OrderInfo createdOrder = purchasingFacade.createOrder(user.getUserId(), commands, "SAMSUNG", "4111-1111-1111-1111"); + OrderInfo createdOrder = purchasingFacade.createOrder(user.getUserId(), commands, null, "SAMSUNG", "4111-1111-1111-1111"); // act OrderInfo found = purchasingFacade.getOrder(user.getUserId(), createdOrder.orderId()); @@ -373,7 +374,7 @@ void getOrder_withDifferentUser_throwsException() { List commands = List.of( OrderItemCommand.of(product.getId(), 1) ); - OrderInfo user1Order = purchasingFacade.createOrder(user1Id, commands, "SAMSUNG", "4111-1111-1111-1111"); + OrderInfo user1Order = purchasingFacade.createOrder(user1Id, commands, null, "SAMSUNG", "4111-1111-1111-1111"); final Long orderId = user1Order.orderId(); // act & assert @@ -405,7 +406,7 @@ void createOrder_atomicityGuaranteed() { ); // act & assert - assertThatThrownBy(() -> purchasingFacade.createOrder(userId, commands, "SAMSUNG", "4111-1111-1111-1111")) + assertThatThrownBy(() -> purchasingFacade.createOrder(userId, commands, null, "SAMSUNG", "4111-1111-1111-1111")) .isInstanceOf(CoreException.class) .hasFieldOrPropertyWithValue("errorType", ErrorType.BAD_REQUEST); @@ -447,7 +448,7 @@ void createOrder_success_allOperationsReflected() { final int totalAmount = (10_000 * 3) + (15_000 * 2); // act - OrderInfo orderInfo = purchasingFacade.createOrder(userId, commands, "SAMSUNG", "4111-1111-1111-1111"); + OrderInfo orderInfo = purchasingFacade.createOrder(userId, commands, null, "SAMSUNG", "4111-1111-1111-1111"); // assert // 주문이 정상적으로 생성되었는지 확인 @@ -461,9 +462,9 @@ void createOrder_success_allOperationsReflected() { assertThat(savedProduct1.getStock()).isEqualTo(initialStock1 - 3); assertThat(savedProduct2.getStock()).isEqualTo(initialStock2 - 2); - // 포인트가 정상적으로 차감되었는지 확인 + // 포인트 차감 확인 (usedPoint가 null이므로 포인트 차감 없음) User savedUser = userRepository.findByUserId(userId); - assertThat(savedUser.getPoint().getValue()).isEqualTo(initialPoint - totalAmount); + assertThat(savedUser.getPoint().getValue()).isEqualTo(initialPoint); // 포인트 차감 없음 // 주문이 저장되었는지 확인 List orders = purchasingFacade.getOrders(userId); @@ -488,10 +489,10 @@ void createOrder_withFixedAmountCoupon_success() { ); // act - OrderInfo orderInfo = purchasingFacade.createOrder(userId, commands, "SAMSUNG", "4111-1111-1111-1111"); + OrderInfo orderInfo = purchasingFacade.createOrder(userId, commands, null, "SAMSUNG", "4111-1111-1111-1111"); // assert - // createOrder는 주문을 PENDING 상태로 생성하고, PG 결제 요청은 afterCommit 콜백에서 비동기로 실행됨 + // 쿠폰 할인 후 남은 금액(5,000원)을 카드로 결제해야 하므로 주문은 PENDING 상태로 유지됨 assertThat(orderInfo.status()).isEqualTo(OrderStatus.PENDING); assertThat(orderInfo.totalAmount()).isEqualTo(5_000); // 10,000 - 5,000 = 5,000 @@ -518,7 +519,7 @@ void createOrder_withPercentageCoupon_success() { ); // act - OrderInfo orderInfo = purchasingFacade.createOrder(userId, commands, "SAMSUNG", "4111-1111-1111-1111"); + OrderInfo orderInfo = purchasingFacade.createOrder(userId, commands, null, "SAMSUNG", "4111-1111-1111-1111"); // assert // createOrder는 주문을 PENDING 상태로 생성하고, PG 결제 요청은 afterCommit 콜백에서 비동기로 실행됨 @@ -545,7 +546,7 @@ void createOrder_withNonExistentCoupon_shouldFail() { ); // act & assert - assertThatThrownBy(() -> purchasingFacade.createOrder(userId, commands, "SAMSUNG", "4111-1111-1111-1111")) + assertThatThrownBy(() -> purchasingFacade.createOrder(userId, commands, null, "SAMSUNG", "4111-1111-1111-1111")) .isInstanceOf(CoreException.class) .hasFieldOrPropertyWithValue("errorType", ErrorType.NOT_FOUND); } @@ -568,7 +569,7 @@ void createOrder_withCouponNotOwnedByUser_shouldFail() { ); // act & assert - assertThatThrownBy(() -> purchasingFacade.createOrder(userId, commands, "SAMSUNG", "4111-1111-1111-1111")) + assertThatThrownBy(() -> purchasingFacade.createOrder(userId, commands, null, "SAMSUNG", "4111-1111-1111-1111")) .isInstanceOf(CoreException.class) .hasFieldOrPropertyWithValue("errorType", ErrorType.NOT_FOUND); } @@ -592,7 +593,7 @@ void createOrder_withUsedCoupon_shouldFail() { ); // act & assert - assertThatThrownBy(() -> purchasingFacade.createOrder(userId, commands, "SAMSUNG", "4111-1111-1111-1111")) + assertThatThrownBy(() -> purchasingFacade.createOrder(userId, commands, null, "SAMSUNG", "4111-1111-1111-1111")) .isInstanceOf(CoreException.class) .hasFieldOrPropertyWithValue("errorType", ErrorType.BAD_REQUEST); } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponServiceTest.java new file mode 100644 index 000000000..c15742303 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponServiceTest.java @@ -0,0 +1,196 @@ +package com.loopers.domain.coupon; + +import com.loopers.domain.coupon.discount.CouponDiscountStrategy; +import com.loopers.domain.coupon.discount.CouponDiscountStrategyFactory; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +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; +import org.springframework.orm.ObjectOptimisticLockingFailureException; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.*; + +/** + * CouponService 테스트. + */ +@ExtendWith(MockitoExtension.class) +@DisplayName("CouponService") +public class CouponServiceTest { + + @Mock + private CouponRepository couponRepository; + + @Mock + private UserCouponRepository userCouponRepository; + + @Mock + private CouponDiscountStrategyFactory couponDiscountStrategyFactory; + + @Mock + private CouponDiscountStrategy couponDiscountStrategy; + + @InjectMocks + private CouponService couponService; + + @DisplayName("쿠폰 적용") + @Nested + class ApplyCoupon { + @DisplayName("쿠폰을 적용하여 할인 금액을 계산하고 쿠폰을 사용 처리할 수 있다.") + @Test + void appliesCouponAndCalculatesDiscount() { + // arrange + Long userId = 1L; + String couponCode = "FIXED5000"; + Integer subtotal = 10_000; + Integer expectedDiscount = 5_000; + + Coupon coupon = Coupon.of(couponCode, CouponType.FIXED_AMOUNT, 5_000); + UserCoupon userCoupon = UserCoupon.of(userId, coupon); + + when(couponRepository.findByCode(couponCode)).thenReturn(Optional.of(coupon)); + when(userCouponRepository.findByUserIdAndCouponCodeForUpdate(userId, couponCode)) + .thenReturn(Optional.of(userCoupon)); + when(couponDiscountStrategyFactory.getStrategy(CouponType.FIXED_AMOUNT)) + .thenReturn(couponDiscountStrategy); + when(couponDiscountStrategy.calculateDiscountAmount(subtotal, 5_000)) + .thenReturn(expectedDiscount); + when(userCouponRepository.save(any(UserCoupon.class))).thenReturn(userCoupon); + + // act + Integer result = couponService.applyCoupon(userId, couponCode, subtotal); + + // assert + assertThat(result).isEqualTo(expectedDiscount); + assertThat(userCoupon.getIsUsed()).isTrue(); // 쿠폰이 사용되었는지 확인 + verify(couponRepository, times(1)).findByCode(couponCode); + verify(userCouponRepository, times(1)).findByUserIdAndCouponCodeForUpdate(userId, couponCode); + verify(userCouponRepository, times(1)).save(userCoupon); + } + + @DisplayName("쿠폰을 찾을 수 없으면 예외가 발생한다.") + @Test + void throwsException_whenCouponNotFound() { + // arrange + Long userId = 1L; + String couponCode = "NON_EXISTENT"; + Integer subtotal = 10_000; + + when(couponRepository.findByCode(couponCode)).thenReturn(Optional.empty()); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + couponService.applyCoupon(userId, couponCode, subtotal); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + assertThat(result.getMessage()).contains("쿠폰을 찾을 수 없습니다"); + verify(couponRepository, times(1)).findByCode(couponCode); + verify(userCouponRepository, never()).findByUserIdAndCouponCodeForUpdate(any(), any()); + } + + @DisplayName("사용자가 소유한 쿠폰을 찾을 수 없으면 예외가 발생한다.") + @Test + void throwsException_whenUserCouponNotFound() { + // arrange + Long userId = 1L; + String couponCode = "FIXED5000"; + Integer subtotal = 10_000; + + Coupon coupon = Coupon.of(couponCode, CouponType.FIXED_AMOUNT, 5_000); + + when(couponRepository.findByCode(couponCode)).thenReturn(Optional.of(coupon)); + when(userCouponRepository.findByUserIdAndCouponCodeForUpdate(userId, couponCode)) + .thenReturn(Optional.empty()); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + couponService.applyCoupon(userId, couponCode, subtotal); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + assertThat(result.getMessage()).contains("사용자가 소유한 쿠폰을 찾을 수 없습니다"); + verify(couponRepository, times(1)).findByCode(couponCode); + verify(userCouponRepository, times(1)).findByUserIdAndCouponCodeForUpdate(userId, couponCode); + } + + @DisplayName("이미 사용된 쿠폰이면 예외가 발생한다.") + @Test + void throwsException_whenCouponAlreadyUsed() { + // arrange + Long userId = 1L; + String couponCode = "USED_COUPON"; + Integer subtotal = 10_000; + + Coupon coupon = Coupon.of(couponCode, CouponType.FIXED_AMOUNT, 5_000); + UserCoupon userCoupon = UserCoupon.of(userId, coupon); + userCoupon.use(); // 이미 사용 처리 + + when(couponRepository.findByCode(couponCode)).thenReturn(Optional.of(coupon)); + when(userCouponRepository.findByUserIdAndCouponCodeForUpdate(userId, couponCode)) + .thenReturn(Optional.of(userCoupon)); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + couponService.applyCoupon(userId, couponCode, subtotal); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + assertThat(result.getMessage()).contains("이미 사용된 쿠폰입니다"); + verify(couponRepository, times(1)).findByCode(couponCode); + verify(userCouponRepository, times(1)).findByUserIdAndCouponCodeForUpdate(userId, couponCode); + verify(userCouponRepository, never()).save(any(UserCoupon.class)); + } + + @DisplayName("낙관적 락 충돌 시 예외가 발생한다.") + @Test + void throwsException_whenOptimisticLockConflict() { + // arrange + Long userId = 1L; + String couponCode = "FIXED5000"; + Integer subtotal = 10_000; + + Coupon coupon = Coupon.of(couponCode, CouponType.FIXED_AMOUNT, 5_000); + UserCoupon userCoupon = UserCoupon.of(userId, coupon); + + when(couponRepository.findByCode(couponCode)).thenReturn(Optional.of(coupon)); + when(userCouponRepository.findByUserIdAndCouponCodeForUpdate(userId, couponCode)) + .thenReturn(Optional.of(userCoupon)); + // Coupon.calculateDiscountAmount()가 호출될 때 getStrategy()가 호출되므로 stubbing 필요 + when(couponDiscountStrategyFactory.getStrategy(any(CouponType.class))) + .thenReturn(couponDiscountStrategy); + when(couponDiscountStrategy.calculateDiscountAmount(anyInt(), anyInt())) + .thenReturn(5_000); + when(userCouponRepository.save(any(UserCoupon.class))) + .thenThrow(new ObjectOptimisticLockingFailureException(UserCoupon.class, userCoupon)); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + couponService.applyCoupon(userId, couponCode, subtotal); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.CONFLICT); + assertThat(result.getMessage()).contains("쿠폰이 이미 사용되었습니다"); + verify(couponRepository, times(1)).findByCode(couponCode); + verify(userCouponRepository, times(1)).findByUserIdAndCouponCodeForUpdate(userId, couponCode); + verify(couponDiscountStrategyFactory, times(1)).getStrategy(CouponType.FIXED_AMOUNT); + verify(couponDiscountStrategy, times(1)).calculateDiscountAmount(subtotal, 5_000); + verify(userCouponRepository, times(1)).save(userCoupon); + } + } +} + diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceTest.java new file mode 100644 index 000000000..1dab0a951 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceTest.java @@ -0,0 +1,458 @@ +package com.loopers.domain.order; + +import com.loopers.domain.payment.PaymentStatus; +import com.loopers.domain.product.Product; +import com.loopers.domain.user.Gender; +import com.loopers.domain.user.Point; +import com.loopers.domain.user.User; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +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; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +/** + * OrderService 테스트. + */ +@ExtendWith(MockitoExtension.class) +@DisplayName("OrderService") +public class OrderServiceTest { + + @Mock + private OrderRepository orderRepository; + + @InjectMocks + private OrderService orderService; + + @DisplayName("주문 저장") + @Nested + class SaveOrder { + @DisplayName("주문을 저장할 수 있다.") + @Test + void savesOrder() { + // arrange + Order order = Order.of( + OrderTestFixture.ValidOrder.USER_ID, + OrderTestFixture.ValidOrderItem.createMultipleItems() + ); + when(orderRepository.save(any(Order.class))).thenReturn(order); + + // act + Order result = orderService.save(order); + + // assert + assertThat(result).isNotNull(); + verify(orderRepository, times(1)).save(order); + } + } + + @DisplayName("주문 조회") + @Nested + class FindOrder { + @DisplayName("주문 ID로 주문을 조회할 수 있다.") + @Test + void findsById() { + // arrange + Long orderId = 1L; + Order expectedOrder = Order.of( + OrderTestFixture.ValidOrder.USER_ID, + OrderTestFixture.ValidOrderItem.createMultipleItems() + ); + when(orderRepository.findById(orderId)).thenReturn(Optional.of(expectedOrder)); + + // act + Order result = orderService.getById(orderId); + + // assert + assertThat(result).isEqualTo(expectedOrder); + verify(orderRepository, times(1)).findById(orderId); + } + + @DisplayName("주문 ID로 주문을 조회할 수 있다 (Optional 반환).") + @Test + void findsByIdOptional() { + // arrange + Long orderId = 1L; + Order expectedOrder = Order.of( + OrderTestFixture.ValidOrder.USER_ID, + OrderTestFixture.ValidOrderItem.createMultipleItems() + ); + when(orderRepository.findById(orderId)).thenReturn(Optional.of(expectedOrder)); + + // act + Optional result = orderService.findById(orderId); + + // assert + assertThat(result).isPresent(); + assertThat(result.get()).isEqualTo(expectedOrder); + verify(orderRepository, times(1)).findById(orderId); + } + + @DisplayName("주문을 찾을 수 없으면 예외가 발생한다.") + @Test + void throwsException_whenOrderNotFound() { + // arrange + Long orderId = 999L; + when(orderRepository.findById(orderId)).thenReturn(Optional.empty()); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + orderService.getById(orderId); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + verify(orderRepository, times(1)).findById(orderId); + } + + @DisplayName("사용자 ID로 주문 목록을 조회할 수 있다.") + @Test + void findsAllByUserId() { + // arrange + Long userId = OrderTestFixture.ValidOrder.USER_ID; + List expectedOrders = List.of( + Order.of(userId, OrderTestFixture.ValidOrderItem.createMultipleItems()) + ); + when(orderRepository.findAllByUserId(userId)).thenReturn(expectedOrders); + + // act + List result = orderService.findAllByUserId(userId); + + // assert + assertThat(result).hasSize(1); + assertThat(result).isEqualTo(expectedOrders); + verify(orderRepository, times(1)).findAllByUserId(userId); + } + + @DisplayName("주문 상태로 주문 목록을 조회할 수 있다.") + @Test + void findsAllByStatus() { + // arrange + OrderStatus status = OrderStatus.PENDING; + List expectedOrders = List.of( + Order.of(OrderTestFixture.ValidOrder.USER_ID, OrderTestFixture.ValidOrderItem.createMultipleItems()) + ); + when(orderRepository.findAllByStatus(status)).thenReturn(expectedOrders); + + // act + List result = orderService.findAllByStatus(status); + + // assert + assertThat(result).hasSize(1); + assertThat(result).isEqualTo(expectedOrders); + verify(orderRepository, times(1)).findAllByStatus(status); + } + } + + @DisplayName("주문 생성") + @Nested + class CreateOrder { + @DisplayName("주문을 생성할 수 있다 (쿠폰 없음).") + @Test + void createsOrder() { + // arrange + Long userId = OrderTestFixture.ValidOrder.USER_ID; + List items = OrderTestFixture.ValidOrderItem.createMultipleItems(); + Order expectedOrder = Order.of(userId, items); + when(orderRepository.save(any(Order.class))).thenReturn(expectedOrder); + + // act + Order result = orderService.create(userId, items); + + // assert + assertThat(result).isNotNull(); + verify(orderRepository, times(1)).save(any(Order.class)); + } + + @DisplayName("주문을 생성할 수 있다 (쿠폰 포함).") + @Test + void createsOrderWithCoupon() { + // arrange + Long userId = OrderTestFixture.ValidOrder.USER_ID; + List items = OrderTestFixture.ValidOrderItem.createMultipleItems(); + String couponCode = "COUPON123"; + Integer discountAmount = 1000; + Order expectedOrder = Order.of(userId, items, couponCode, discountAmount); + when(orderRepository.save(any(Order.class))).thenReturn(expectedOrder); + + // act + Order result = orderService.create(userId, items, couponCode, discountAmount); + + // assert + assertThat(result).isNotNull(); + verify(orderRepository, times(1)).save(any(Order.class)); + } + } + + @DisplayName("주문 완료") + @Nested + class CompleteOrder { + @DisplayName("주문을 완료 상태로 변경할 수 있다.") + @Test + void completesOrder() { + // arrange + Long orderId = 1L; + Order order = Order.of( + OrderTestFixture.ValidOrder.USER_ID, + OrderTestFixture.ValidOrderItem.createMultipleItems() + ); + when(orderRepository.findById(orderId)).thenReturn(Optional.of(order)); + when(orderRepository.save(any(Order.class))).thenReturn(order); + + // act + Order result = orderService.completeOrder(orderId); + + // assert + assertThat(result).isNotNull(); + verify(orderRepository, times(1)).findById(orderId); + verify(orderRepository, times(1)).save(order); + // 상태 변경 검증은 OrderTest에서 이미 검증했으므로 제거 + } + + @DisplayName("주문을 찾을 수 없으면 예외가 발생한다.") + @Test + void throwsException_whenOrderNotFound() { + // arrange + Long orderId = 999L; + when(orderRepository.findById(orderId)).thenReturn(Optional.empty()); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + orderService.completeOrder(orderId); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + verify(orderRepository, times(1)).findById(orderId); + verify(orderRepository, never()).save(any(Order.class)); + } + } + + @DisplayName("주문 취소") + @Nested + class CancelOrder { + @DisplayName("주문을 취소하고 재고를 원복하며 포인트를 환불할 수 있다.") + @Test + void cancelsOrderAndRecoversResources() { + // arrange + Long userId = OrderTestFixture.ValidOrder.USER_ID; + User user = createUser(userId); + Order order = Order.of(userId, OrderTestFixture.ValidOrderItem.createMultipleItems()); + + List items = order.getItems(); + Product product1 = createProduct(items.get(0).getProductId()); + Product product2 = createProduct(items.get(1).getProductId()); + List products = List.of(product1, product2); + Long refundPointAmount = 5000L; + + when(orderRepository.save(any(Order.class))).thenReturn(order); + + // act + orderService.cancelOrder(order, products, user, refundPointAmount); + + // assert + verify(orderRepository, times(1)).save(order); + verify(product1, times(1)).increaseStock(items.get(0).getQuantity()); + verify(product2, times(1)).increaseStock(items.get(1).getQuantity()); + // 상태 변경 검증은 OrderTest에서 이미 검증했으므로 제거 + } + + @DisplayName("주문이 null이면 예외가 발생한다.") + @Test + void throwsException_whenOrderIsNull() { + // arrange + User user = createUser(OrderTestFixture.ValidOrder.USER_ID); + List products = List.of(); + Long refundPointAmount = 0L; + + // act + CoreException result = assertThrows(CoreException.class, () -> { + orderService.cancelOrder(null, products, user, refundPointAmount); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + verify(orderRepository, never()).save(any(Order.class)); + } + + @DisplayName("사용자가 null이면 예외가 발생한다.") + @Test + void throwsException_whenUserIsNull() { + // arrange + Order order = Order.of( + OrderTestFixture.ValidOrder.USER_ID, + OrderTestFixture.ValidOrderItem.createMultipleItems() + ); + List products = List.of(); + Long refundPointAmount = 0L; + + // act + CoreException result = assertThrows(CoreException.class, () -> { + orderService.cancelOrder(order, products, null, refundPointAmount); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + verify(orderRepository, never()).save(any(Order.class)); + } + + @DisplayName("포인트를 사용하지 않은 주문은 포인트 환불 없이 취소할 수 있다.") + @Test + void cancelsOrderWithoutPointRefund() { + // arrange + Long userId = OrderTestFixture.ValidOrder.USER_ID; + User user = createUser(userId); + Order order = Order.of(userId, OrderTestFixture.ValidOrderItem.createMultipleItems()); + + List items = order.getItems(); + Product product1 = createProduct(items.get(0).getProductId()); + Product product2 = createProduct(items.get(1).getProductId()); + List products = List.of(product1, product2); + Long refundPointAmount = 0L; + + when(orderRepository.save(any(Order.class))).thenReturn(order); + + // act + orderService.cancelOrder(order, products, user, refundPointAmount); + + // assert + verify(orderRepository, times(1)).save(order); + verify(product1, times(1)).increaseStock(items.get(0).getQuantity()); + verify(product2, times(1)).increaseStock(items.get(1).getQuantity()); + } + } + + @DisplayName("결제 결과에 따른 주문 상태 업데이트") + @Nested + class UpdateStatusByPaymentResult { + @DisplayName("결제 성공 시 주문을 완료 상태로 변경할 수 있다.") + @Test + void completesOrder_whenPaymentSuccess() { + // arrange + Order order = Order.of( + OrderTestFixture.ValidOrder.USER_ID, + OrderTestFixture.ValidOrderItem.createMultipleItems() + ); + when(orderRepository.save(any(Order.class))).thenReturn(order); + + // act + orderService.updateStatusByPaymentResult(order, PaymentStatus.SUCCESS); + + // assert + verify(orderRepository, times(1)).save(order); + // 상태 변경 검증은 OrderTest에서 이미 검증했으므로 제거 + } + + @DisplayName("결제 실패 시 주문을 취소 상태로 변경할 수 있다.") + @Test + void cancelsOrder_whenPaymentFailed() { + // arrange + Order order = Order.of( + OrderTestFixture.ValidOrder.USER_ID, + OrderTestFixture.ValidOrderItem.createMultipleItems() + ); + when(orderRepository.save(any(Order.class))).thenReturn(order); + + // act + orderService.updateStatusByPaymentResult(order, PaymentStatus.FAILED); + + // assert + verify(orderRepository, times(1)).save(order); + // 상태 변경 검증은 OrderTest에서 이미 검증했으므로 제거 + } + + @DisplayName("결제 대기 상태면 주문 상태를 유지한다.") + @Test + void maintainsOrderStatus_whenPaymentPending() { + // arrange + Order order = Order.of( + OrderTestFixture.ValidOrder.USER_ID, + OrderTestFixture.ValidOrderItem.createMultipleItems() + ); + + // act + orderService.updateStatusByPaymentResult(order, PaymentStatus.PENDING); + + // assert + verify(orderRepository, never()).save(any(Order.class)); + } + + @DisplayName("이미 완료된 주문은 처리하지 않는다.") + @Test + void skipsProcessing_whenOrderAlreadyCompleted() { + // arrange + Order order = Order.of( + OrderTestFixture.ValidOrder.USER_ID, + OrderTestFixture.ValidOrderItem.createMultipleItems() + ); + order.complete(); // 이미 완료 상태 + + // act + orderService.updateStatusByPaymentResult(order, PaymentStatus.SUCCESS); + + // assert + verify(orderRepository, never()).save(any(Order.class)); + } + + @DisplayName("이미 취소된 주문은 처리하지 않는다.") + @Test + void skipsProcessing_whenOrderAlreadyCanceled() { + // arrange + Order order = Order.of( + OrderTestFixture.ValidOrder.USER_ID, + OrderTestFixture.ValidOrderItem.createMultipleItems() + ); + order.cancel(); // 이미 취소 상태 + + // act + orderService.updateStatusByPaymentResult(order, PaymentStatus.FAILED); + + // assert + verify(orderRepository, never()).save(any(Order.class)); + } + + @DisplayName("주문이 null이면 예외가 발생한다.") + @Test + void throwsException_whenOrderIsNull() { + // act + CoreException result = assertThrows(CoreException.class, () -> { + orderService.updateStatusByPaymentResult(null, PaymentStatus.SUCCESS); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + verify(orderRepository, never()).save(any(Order.class)); + } + } + + private User createUser(Long userId) { + return User.of( + String.valueOf(userId), + "test@example.com", + "1990-01-01", + Gender.MALE, + Point.of(0L) + ); + } + + private Product createProduct(Long productId) { + // Mock을 사용하여 ID 설정 + Product mockedProduct = mock(Product.class); + when(mockedProduct.getId()).thenReturn(productId); + doNothing().when(mockedProduct).increaseStock(any(Integer.class)); + return mockedProduct; + } +} + diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/payment/PaymentServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/payment/PaymentServiceTest.java new file mode 100644 index 000000000..963eab173 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/payment/PaymentServiceTest.java @@ -0,0 +1,665 @@ +package com.loopers.domain.payment; + +import com.loopers.application.purchasing.PaymentRequestCommand; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +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.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.LocalDateTime; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +/** + * PaymentService 테스트. + */ +@ExtendWith(MockitoExtension.class) +@DisplayName("PaymentService") +public class PaymentServiceTest { + + @Mock + private PaymentRepository paymentRepository; + + @Mock + private PaymentGateway paymentGateway; + + @Mock + private PaymentFailureClassifier paymentFailureClassifier; + + @InjectMocks + private PaymentService paymentService; + + @BeforeEach + void setUp() { + // @Value 어노테이션 필드 설정 + ReflectionTestUtils.setField(paymentService, "callbackBaseUrl", "http://localhost:8080"); + } + + @DisplayName("결제 생성") + @Nested + class CreatePayment { + @DisplayName("카드 결제를 생성할 수 있다.") + @Test + void createsCardPayment() { + // arrange + Long orderId = PaymentTestFixture.ValidPayment.ORDER_ID; + Long userId = PaymentTestFixture.ValidPayment.USER_ID; + CardType cardType = PaymentTestFixture.ValidPayment.CARD_TYPE; + String cardNo = PaymentTestFixture.ValidPayment.CARD_NO; + Long amount = PaymentTestFixture.ValidPayment.AMOUNT; + LocalDateTime requestedAt = PaymentTestFixture.ValidPayment.REQUESTED_AT; + + Payment expectedPayment = Payment.of(orderId, userId, cardType, cardNo, amount, requestedAt); + when(paymentRepository.save(any(Payment.class))).thenReturn(expectedPayment); + + // act + Payment result = paymentService.create(orderId, userId, cardType, cardNo, amount, requestedAt); + + // assert + assertThat(result).isNotNull(); + verify(paymentRepository, times(1)).save(any(Payment.class)); + } + + @DisplayName("포인트 결제를 생성할 수 있다.") + @Test + void createsPointPayment() { + // arrange + Long orderId = PaymentTestFixture.ValidPayment.ORDER_ID; + Long userId = PaymentTestFixture.ValidPayment.USER_ID; + Long totalAmount = PaymentTestFixture.ValidPayment.AMOUNT; + Long usedPoint = PaymentTestFixture.ValidPayment.FULL_POINT; // 포인트로 전액 결제 + LocalDateTime requestedAt = PaymentTestFixture.ValidPayment.REQUESTED_AT; + + Payment expectedPayment = Payment.of(orderId, userId, totalAmount, usedPoint, requestedAt); + when(paymentRepository.save(any(Payment.class))).thenReturn(expectedPayment); + + // act + Payment result = paymentService.create(orderId, userId, totalAmount, usedPoint, requestedAt); + + // assert + assertThat(result).isNotNull(); + verify(paymentRepository, times(1)).save(any(Payment.class)); + } + } + + @DisplayName("결제 상태 변경") + @Nested + class UpdatePaymentStatus { + @DisplayName("결제를 SUCCESS 상태로 전이할 수 있다.") + @Test + void transitionsToSuccess() { + // arrange + Long paymentId = 1L; + Payment payment = Payment.of( + PaymentTestFixture.ValidPayment.ORDER_ID, + PaymentTestFixture.ValidPayment.USER_ID, + PaymentTestFixture.ValidPayment.CARD_TYPE, + PaymentTestFixture.ValidPayment.CARD_NO, + PaymentTestFixture.ValidPayment.AMOUNT, + PaymentTestFixture.ValidPayment.REQUESTED_AT + ); + LocalDateTime completedAt = LocalDateTime.of(2025, 12, 1, 10, 5, 0); + + when(paymentRepository.findById(paymentId)).thenReturn(Optional.of(payment)); + when(paymentRepository.save(any(Payment.class))).thenReturn(payment); + + // act + paymentService.toSuccess(paymentId, completedAt); + + // assert + verify(paymentRepository, times(1)).findById(paymentId); + verify(paymentRepository, times(1)).save(payment); + // 상태 변경 검증은 PaymentTest에서 이미 검증했으므로 제거 + } + + @DisplayName("결제를 FAILED 상태로 전이할 수 있다.") + @Test + void transitionsToFailed() { + // arrange + Long paymentId = 1L; + Payment payment = Payment.of( + PaymentTestFixture.ValidPayment.ORDER_ID, + PaymentTestFixture.ValidPayment.USER_ID, + PaymentTestFixture.ValidPayment.CARD_TYPE, + PaymentTestFixture.ValidPayment.CARD_NO, + PaymentTestFixture.ValidPayment.AMOUNT, + PaymentTestFixture.ValidPayment.REQUESTED_AT + ); + LocalDateTime completedAt = LocalDateTime.of(2025, 12, 1, 10, 5, 0); + String failureReason = "카드 한도 초과"; + + when(paymentRepository.findById(paymentId)).thenReturn(Optional.of(payment)); + when(paymentRepository.save(any(Payment.class))).thenReturn(payment); + + // act + paymentService.toFailed(paymentId, failureReason, completedAt); + + // assert + verify(paymentRepository, times(1)).findById(paymentId); + verify(paymentRepository, times(1)).save(payment); + // 상태 변경 검증은 PaymentTest에서 이미 검증했으므로 제거 + } + + @DisplayName("결제를 찾을 수 없으면 예외가 발생한다.") + @Test + void throwsException_whenPaymentNotFound() { + // arrange + Long paymentId = 999L; + LocalDateTime completedAt = LocalDateTime.now(); + + when(paymentRepository.findById(paymentId)).thenReturn(Optional.empty()); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + paymentService.toSuccess(paymentId, completedAt); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + verify(paymentRepository, times(1)).findById(paymentId); + verify(paymentRepository, never()).save(any(Payment.class)); + } + } + + @DisplayName("결제 조회") + @Nested + class FindPayment { + @DisplayName("결제 ID로 결제를 조회할 수 있다.") + @Test + void findsById() { + // arrange + Long paymentId = 1L; + Payment expectedPayment = Payment.of( + PaymentTestFixture.ValidPayment.ORDER_ID, + PaymentTestFixture.ValidPayment.USER_ID, + PaymentTestFixture.ValidPayment.CARD_TYPE, + PaymentTestFixture.ValidPayment.CARD_NO, + PaymentTestFixture.ValidPayment.AMOUNT, + PaymentTestFixture.ValidPayment.REQUESTED_AT + ); + + when(paymentRepository.findById(paymentId)).thenReturn(Optional.of(expectedPayment)); + + // act + Payment result = paymentService.findById(paymentId); + + // assert + assertThat(result).isEqualTo(expectedPayment); + verify(paymentRepository, times(1)).findById(paymentId); + } + + @DisplayName("주문 ID로 결제를 조회할 수 있다.") + @Test + void findsByOrderId() { + // arrange + Long orderId = PaymentTestFixture.ValidPayment.ORDER_ID; + Payment expectedPayment = Payment.of( + orderId, + PaymentTestFixture.ValidPayment.USER_ID, + PaymentTestFixture.ValidPayment.CARD_TYPE, + PaymentTestFixture.ValidPayment.CARD_NO, + PaymentTestFixture.ValidPayment.AMOUNT, + PaymentTestFixture.ValidPayment.REQUESTED_AT + ); + + when(paymentRepository.findByOrderId(orderId)).thenReturn(Optional.of(expectedPayment)); + + // act + Optional result = paymentService.findByOrderId(orderId); + + // assert + assertThat(result).isPresent(); + assertThat(result.get()).isEqualTo(expectedPayment); + verify(paymentRepository, times(1)).findByOrderId(orderId); + } + + @DisplayName("결제를 찾을 수 없으면 예외가 발생한다.") + @Test + void throwsException_whenPaymentNotFound() { + // arrange + Long paymentId = 999L; + + when(paymentRepository.findById(paymentId)).thenReturn(Optional.empty()); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + paymentService.findById(paymentId); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + verify(paymentRepository, times(1)).findById(paymentId); + } + } + + @DisplayName("PG 결제 요청") + @Nested + class RequestPayment { + @DisplayName("PG 결제 요청을 성공적으로 처리할 수 있다.") + @Test + void requestsPaymentSuccessfully() { + // arrange + Long orderId = PaymentTestFixture.ValidPayment.ORDER_ID; + String userId = "user123"; // User.userId (String) + Long userEntityId = PaymentTestFixture.ValidPayment.USER_ID; // User.id (Long) + String cardType = "SAMSUNG"; + String cardNo = PaymentTestFixture.ValidPayment.CARD_NO; + Long amount = PaymentTestFixture.ValidPayment.AMOUNT; + + Payment payment = Payment.of( + orderId, + userEntityId, + CardType.SAMSUNG, + cardNo, + amount, + LocalDateTime.now() + ); + + PaymentRequestResult.Success successResult = new PaymentRequestResult.Success("TXN123456"); + + when(paymentRepository.save(any(Payment.class))).thenReturn(payment); + when(paymentGateway.requestPayment(any(PaymentRequestCommand.class))).thenReturn(successResult); + + // act + PaymentRequestResult result = paymentService.requestPayment(orderId, userId, userEntityId, cardType, cardNo, amount); + + // assert + assertThat(result).isInstanceOf(PaymentRequestResult.Success.class); + assertThat(((PaymentRequestResult.Success) result).transactionKey()).isEqualTo("TXN123456"); + verify(paymentRepository, times(1)).save(any(Payment.class)); + verify(paymentGateway, times(1)).requestPayment(any(PaymentRequestCommand.class)); + } + + @DisplayName("비즈니스 실패 시 결제 상태를 FAILED로 변경한다.") + @Test + void updatesPaymentToFailed_whenBusinessFailure() { + // arrange + Long orderId = PaymentTestFixture.ValidPayment.ORDER_ID; + String userId = "user123"; // User.userId (String) + Long userEntityId = PaymentTestFixture.ValidPayment.USER_ID; // User.id (Long) + String cardType = "SAMSUNG"; + String cardNo = PaymentTestFixture.ValidPayment.CARD_NO; + Long amount = PaymentTestFixture.ValidPayment.AMOUNT; + + Payment payment = Payment.of( + orderId, + userEntityId, + CardType.SAMSUNG, + cardNo, + amount, + LocalDateTime.now() + ); + + PaymentRequestResult.Failure failureResult = new PaymentRequestResult.Failure( + "LIMIT_EXCEEDED", + "카드 한도 초과", + false, + false + ); + + when(paymentRepository.save(any(Payment.class))).thenReturn(payment); + when(paymentGateway.requestPayment(any(PaymentRequestCommand.class))).thenReturn(failureResult); + when(paymentFailureClassifier.classify("LIMIT_EXCEEDED")).thenReturn(PaymentFailureType.BUSINESS_FAILURE); + when(paymentRepository.findById(anyLong())).thenReturn(Optional.of(payment)); + + // act + PaymentRequestResult result = paymentService.requestPayment(orderId, userId, userEntityId, cardType, cardNo, amount); + + // assert + assertThat(result).isInstanceOf(PaymentRequestResult.Failure.class); + verify(paymentRepository, times(2)).save(any(Payment.class)); // 생성 + 실패 상태 변경 + verify(paymentFailureClassifier, times(1)).classify("LIMIT_EXCEEDED"); + } + + @DisplayName("외부 시스템 장애 시 결제 상태를 PENDING으로 유지한다.") + @Test + void maintainsPendingStatus_whenExternalSystemFailure() { + // arrange + Long orderId = PaymentTestFixture.ValidPayment.ORDER_ID; + String userId = "user123"; // User.userId (String) + Long userEntityId = PaymentTestFixture.ValidPayment.USER_ID; // User.id (Long) + String cardType = "SAMSUNG"; + String cardNo = PaymentTestFixture.ValidPayment.CARD_NO; + Long amount = PaymentTestFixture.ValidPayment.AMOUNT; + + Payment payment = Payment.of( + orderId, + userEntityId, + CardType.SAMSUNG, + cardNo, + amount, + LocalDateTime.now() + ); + + PaymentRequestResult.Failure failureResult = new PaymentRequestResult.Failure( + "CIRCUIT_BREAKER_OPEN", + "결제 대기 상태", + false, + false + ); + + when(paymentRepository.save(any(Payment.class))).thenReturn(payment); + when(paymentGateway.requestPayment(any(PaymentRequestCommand.class))).thenReturn(failureResult); + when(paymentFailureClassifier.classify("CIRCUIT_BREAKER_OPEN")).thenReturn(PaymentFailureType.EXTERNAL_SYSTEM_FAILURE); + + // act + PaymentRequestResult result = paymentService.requestPayment(orderId, userId, userEntityId, cardType, cardNo, amount); + + // assert + assertThat(result).isInstanceOf(PaymentRequestResult.Failure.class); + verify(paymentRepository, times(1)).save(any(Payment.class)); // 생성만 + verify(paymentFailureClassifier, times(1)).classify("CIRCUIT_BREAKER_OPEN"); + verify(paymentRepository, never()).findById(anyLong()); // 상태 변경 없음 + } + + @DisplayName("잘못된 카드 번호로 인해 예외가 발생한다.") + @Test + void throwsException_whenInvalidCardNo() { + // arrange + Long orderId = PaymentTestFixture.ValidPayment.ORDER_ID; + String userId = "user123"; // User.userId (String) + Long userEntityId = PaymentTestFixture.ValidPayment.USER_ID; // User.id (Long) + String cardType = "SAMSUNG"; + String invalidCardNo = "1234"; // 잘못된 카드 번호 + Long amount = PaymentTestFixture.ValidPayment.AMOUNT; + + // act & assert + CoreException result = assertThrows(CoreException.class, () -> { + paymentService.requestPayment(orderId, userId, userEntityId, cardType, invalidCardNo, amount); + }); + + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + verify(paymentRepository, never()).save(any(Payment.class)); + verify(paymentGateway, never()).requestPayment(any(PaymentRequestCommand.class)); + } + + @DisplayName("잘못된 카드 타입으로 인해 예외가 발생한다.") + @Test + void throwsException_whenInvalidCardType() { + // arrange + Long orderId = PaymentTestFixture.ValidPayment.ORDER_ID; + String userId = "user123"; // User.userId (String) + Long userEntityId = PaymentTestFixture.ValidPayment.USER_ID; // User.id (Long) + String invalidCardType = "INVALID"; + String cardNo = PaymentTestFixture.ValidPayment.CARD_NO; + Long amount = PaymentTestFixture.ValidPayment.AMOUNT; + + // act & assert + CoreException result = assertThrows(CoreException.class, () -> { + paymentService.requestPayment(orderId, userId, userEntityId, invalidCardType, cardNo, amount); + }); + + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + verify(paymentRepository, never()).save(any(Payment.class)); + verify(paymentGateway, never()).requestPayment(any(PaymentRequestCommand.class)); + } + } + + @DisplayName("결제 상태 조회") + @Nested + class GetPaymentStatus { + @DisplayName("결제 상태를 조회할 수 있다.") + @Test + void getsPaymentStatus() { + // arrange + String userId = "user123"; + Long orderId = PaymentTestFixture.ValidPayment.ORDER_ID; + PaymentStatus expectedStatus = PaymentStatus.SUCCESS; + + when(paymentGateway.getPaymentStatus(userId, orderId)).thenReturn(expectedStatus); + + // act + PaymentStatus result = paymentService.getPaymentStatus(userId, orderId); + + // assert + assertThat(result).isEqualTo(expectedStatus); + verify(paymentGateway, times(1)).getPaymentStatus(userId, orderId); + } + } + + @DisplayName("콜백 처리") + @Nested + class HandleCallback { + @DisplayName("SUCCESS 콜백을 처리할 수 있다.") + @Test + void handlesSuccessCallback() { + // arrange + Long orderId = PaymentTestFixture.ValidPayment.ORDER_ID; + String transactionKey = "TXN123456"; + PaymentStatus status = PaymentStatus.SUCCESS; + String reason = null; + + Payment payment = Payment.of( + orderId, + PaymentTestFixture.ValidPayment.USER_ID, + PaymentTestFixture.ValidPayment.CARD_TYPE, + PaymentTestFixture.ValidPayment.CARD_NO, + PaymentTestFixture.ValidPayment.AMOUNT, + LocalDateTime.now() + ); + + when(paymentRepository.findByOrderId(orderId)).thenReturn(Optional.of(payment)); + when(paymentRepository.findById(anyLong())).thenReturn(Optional.of(payment)); + when(paymentRepository.save(any(Payment.class))).thenReturn(payment); + + // act + paymentService.handleCallback(orderId, transactionKey, status, reason); + + // assert + verify(paymentRepository, times(1)).findByOrderId(orderId); + verify(paymentRepository, times(1)).findById(anyLong()); + verify(paymentRepository, times(1)).save(any(Payment.class)); + } + + @DisplayName("FAILED 콜백을 처리할 수 있다.") + @Test + void handlesFailedCallback() { + // arrange + Long orderId = PaymentTestFixture.ValidPayment.ORDER_ID; + String transactionKey = "TXN123456"; + PaymentStatus status = PaymentStatus.FAILED; + String reason = "카드 한도 초과"; + + Payment payment = Payment.of( + orderId, + PaymentTestFixture.ValidPayment.USER_ID, + PaymentTestFixture.ValidPayment.CARD_TYPE, + PaymentTestFixture.ValidPayment.CARD_NO, + PaymentTestFixture.ValidPayment.AMOUNT, + LocalDateTime.now() + ); + + when(paymentRepository.findByOrderId(orderId)).thenReturn(Optional.of(payment)); + when(paymentRepository.findById(anyLong())).thenReturn(Optional.of(payment)); + when(paymentRepository.save(any(Payment.class))).thenReturn(payment); + + // act + paymentService.handleCallback(orderId, transactionKey, status, reason); + + // assert + verify(paymentRepository, times(1)).findByOrderId(orderId); + verify(paymentRepository, times(1)).findById(anyLong()); + verify(paymentRepository, times(1)).save(any(Payment.class)); + } + + @DisplayName("PENDING 콜백은 상태를 유지한다.") + @Test + void maintainsStatus_whenPendingCallback() { + // arrange + Long orderId = PaymentTestFixture.ValidPayment.ORDER_ID; + String transactionKey = "TXN123456"; + PaymentStatus status = PaymentStatus.PENDING; + String reason = null; + + Payment payment = Payment.of( + orderId, + PaymentTestFixture.ValidPayment.USER_ID, + PaymentTestFixture.ValidPayment.CARD_TYPE, + PaymentTestFixture.ValidPayment.CARD_NO, + PaymentTestFixture.ValidPayment.AMOUNT, + LocalDateTime.now() + ); + + when(paymentRepository.findByOrderId(orderId)).thenReturn(Optional.of(payment)); + + // act + paymentService.handleCallback(orderId, transactionKey, status, reason); + + // assert + verify(paymentRepository, times(1)).findByOrderId(orderId); + verify(paymentRepository, never()).findById(anyLong()); + verify(paymentRepository, never()).save(any(Payment.class)); + } + + @DisplayName("결제를 찾을 수 없으면 로그만 기록한다.") + @Test + void logsWarning_whenPaymentNotFound() { + // arrange + Long orderId = PaymentTestFixture.ValidPayment.ORDER_ID; + String transactionKey = "TXN123456"; + PaymentStatus status = PaymentStatus.SUCCESS; + String reason = null; + + when(paymentRepository.findByOrderId(orderId)).thenReturn(Optional.empty()); + + // act + paymentService.handleCallback(orderId, transactionKey, status, reason); + + // assert + verify(paymentRepository, times(1)).findByOrderId(orderId); + verify(paymentRepository, never()).findById(anyLong()); + verify(paymentRepository, never()).save(any(Payment.class)); + } + } + + @DisplayName("타임아웃 복구") + @Nested + class RecoverAfterTimeout { + @DisplayName("SUCCESS 상태로 복구할 수 있다.") + @Test + void recoversToSuccess() { + // arrange + String userId = "user123"; + Long orderId = PaymentTestFixture.ValidPayment.ORDER_ID; + PaymentStatus status = PaymentStatus.SUCCESS; + + Payment payment = Payment.of( + orderId, + PaymentTestFixture.ValidPayment.USER_ID, + PaymentTestFixture.ValidPayment.CARD_TYPE, + PaymentTestFixture.ValidPayment.CARD_NO, + PaymentTestFixture.ValidPayment.AMOUNT, + LocalDateTime.now() + ); + + when(paymentGateway.getPaymentStatus(userId, orderId)).thenReturn(status); + when(paymentRepository.findByOrderId(orderId)).thenReturn(Optional.of(payment)); + when(paymentRepository.findById(anyLong())).thenReturn(Optional.of(payment)); + when(paymentRepository.save(any(Payment.class))).thenReturn(payment); + + // act + paymentService.recoverAfterTimeout(userId, orderId); + + // assert + verify(paymentGateway, times(1)).getPaymentStatus(userId, orderId); + verify(paymentRepository, times(1)).findByOrderId(orderId); + verify(paymentRepository, times(1)).findById(anyLong()); + verify(paymentRepository, times(1)).save(any(Payment.class)); + } + + @DisplayName("FAILED 상태로 복구할 수 있다.") + @Test + void recoversToFailed() { + // arrange + String userId = "user123"; + Long orderId = PaymentTestFixture.ValidPayment.ORDER_ID; + PaymentStatus status = PaymentStatus.FAILED; + + Payment payment = Payment.of( + orderId, + PaymentTestFixture.ValidPayment.USER_ID, + PaymentTestFixture.ValidPayment.CARD_TYPE, + PaymentTestFixture.ValidPayment.CARD_NO, + PaymentTestFixture.ValidPayment.AMOUNT, + LocalDateTime.now() + ); + + when(paymentGateway.getPaymentStatus(userId, orderId)).thenReturn(status); + when(paymentRepository.findByOrderId(orderId)).thenReturn(Optional.of(payment)); + when(paymentRepository.findById(anyLong())).thenReturn(Optional.of(payment)); + when(paymentRepository.save(any(Payment.class))).thenReturn(payment); + + // act + paymentService.recoverAfterTimeout(userId, orderId); + + // assert + verify(paymentGateway, times(1)).getPaymentStatus(userId, orderId); + verify(paymentRepository, times(1)).findByOrderId(orderId); + verify(paymentRepository, times(1)).findById(anyLong()); + verify(paymentRepository, times(1)).save(any(Payment.class)); + } + + @DisplayName("PENDING 상태는 유지한다.") + @Test + void maintainsPendingStatus() { + // arrange + String userId = "user123"; + Long orderId = PaymentTestFixture.ValidPayment.ORDER_ID; + PaymentStatus status = PaymentStatus.PENDING; + + Payment payment = Payment.of( + orderId, + PaymentTestFixture.ValidPayment.USER_ID, + PaymentTestFixture.ValidPayment.CARD_TYPE, + PaymentTestFixture.ValidPayment.CARD_NO, + PaymentTestFixture.ValidPayment.AMOUNT, + LocalDateTime.now() + ); + + when(paymentGateway.getPaymentStatus(userId, orderId)).thenReturn(status); + when(paymentRepository.findByOrderId(orderId)).thenReturn(Optional.of(payment)); + + // act + paymentService.recoverAfterTimeout(userId, orderId); + + // assert + verify(paymentGateway, times(1)).getPaymentStatus(userId, orderId); + verify(paymentRepository, times(1)).findByOrderId(orderId); + verify(paymentRepository, never()).findById(anyLong()); + verify(paymentRepository, never()).save(any(Payment.class)); + } + + @DisplayName("결제를 찾을 수 없으면 로그만 기록한다.") + @Test + void logsWarning_whenPaymentNotFound() { + // arrange + String userId = "user123"; + Long orderId = PaymentTestFixture.ValidPayment.ORDER_ID; + PaymentStatus status = PaymentStatus.SUCCESS; + + when(paymentGateway.getPaymentStatus(userId, orderId)).thenReturn(status); + when(paymentRepository.findByOrderId(orderId)).thenReturn(Optional.empty()); + + // act + paymentService.recoverAfterTimeout(userId, orderId); + + // assert + verify(paymentGateway, times(1)).getPaymentStatus(userId, orderId); + verify(paymentRepository, times(1)).findByOrderId(orderId); + verify(paymentRepository, never()).findById(anyLong()); + verify(paymentRepository, never()).save(any(Payment.class)); + } + } +} + diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/payment/PaymentTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/payment/PaymentTest.java new file mode 100644 index 000000000..cc49a6467 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/payment/PaymentTest.java @@ -0,0 +1,306 @@ +package com.loopers.domain.payment; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +public class PaymentTest { + + @DisplayName("필수 입력값 검증") + @Nested + class InputValidation { + @DisplayName("결제 생성 시 주문 ID가 null이면 예외가 발생한다.") + @Test + void throwsException_whenOrderIdIsNull() { + // arrange + Long userId = PaymentTestFixture.ValidPayment.USER_ID; + Long amount = PaymentTestFixture.ValidPayment.AMOUNT; + + // act + CoreException result = assertThrows(CoreException.class, () -> { + Payment.of(null, userId, PaymentTestFixture.ValidPayment.CARD_TYPE, PaymentTestFixture.ValidPayment.CARD_NO, amount, PaymentTestFixture.ValidPayment.REQUESTED_AT); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("결제 생성 시 결제 금액이 0 이하이면 예외가 발생한다.") + @Test + void throwsException_whenAmountIsNotPositive() { + // arrange + Long orderId = PaymentTestFixture.ValidPayment.ORDER_ID; + Long userId = PaymentTestFixture.ValidPayment.USER_ID; + Long invalidAmount = PaymentTestFixture.InvalidPayment.INVALID_AMOUNT; + + // act + CoreException result = assertThrows(CoreException.class, () -> { + Payment.of(orderId, userId, PaymentTestFixture.ValidPayment.CARD_TYPE, PaymentTestFixture.ValidPayment.CARD_NO, invalidAmount, PaymentTestFixture.ValidPayment.REQUESTED_AT); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("상태 검증") + @Nested + class StatusValidation { + @DisplayName("포인트로 전액 결제하면 SUCCESS 상태로 생성된다.") + @Test + void hasSuccessStatus_whenPointCoversTotalAmount() { + // arrange + Long orderId = PaymentTestFixture.ValidPayment.ORDER_ID; + Long userId = PaymentTestFixture.ValidPayment.USER_ID; + Long totalAmount = PaymentTestFixture.ValidPayment.AMOUNT; + Long usedPoint = totalAmount; // 포인트로 전액 결제 + + // act + Payment payment = Payment.of(orderId, userId, totalAmount, usedPoint, PaymentTestFixture.ValidPayment.REQUESTED_AT); + + // assert + assertThat(payment.getStatus()).isEqualTo(PaymentStatus.SUCCESS); + assertThat(payment.getUsedPoint()).isEqualTo(usedPoint); + assertThat(payment.getPaidAmount()).isEqualTo(0L); + } + + @DisplayName("포인트로 결제하지 않으면 PENDING 상태로 생성된다.") + @Test + void hasPendingStatus_whenPointDoesNotCoverTotalAmount() { + // arrange + Long orderId = PaymentTestFixture.ValidPayment.ORDER_ID; + Long userId = PaymentTestFixture.ValidPayment.USER_ID; + Long amount = PaymentTestFixture.ValidPayment.AMOUNT; + + // act + Payment payment = Payment.of( + orderId, + userId, + PaymentTestFixture.ValidPayment.CARD_TYPE, + PaymentTestFixture.ValidPayment.CARD_NO, + amount, + PaymentTestFixture.ValidPayment.REQUESTED_AT + ); + + // assert + assertThat(payment.getStatus()).isEqualTo(PaymentStatus.PENDING); + assertThat(payment.getUsedPoint()).isEqualTo(0L); + assertThat(payment.getPaidAmount()).isEqualTo(amount); + } + + @DisplayName("포인트로 부분 결제하면 PENDING 상태로 생성된다.") + @Test + void hasPendingStatus_whenPointPartiallyCoversTotalAmount() { + // arrange + Long orderId = PaymentTestFixture.ValidPayment.ORDER_ID; + Long userId = PaymentTestFixture.ValidPayment.USER_ID; + Long totalAmount = PaymentTestFixture.ValidPayment.AMOUNT; + Long usedPoint = PaymentTestFixture.ValidPayment.PARTIAL_POINT; // 포인트로 절반 결제 + + // act + Payment payment = Payment.of( + orderId, + userId, + totalAmount, + usedPoint, + PaymentTestFixture.ValidPayment.CARD_TYPE, + PaymentTestFixture.ValidPayment.CARD_NO, + PaymentTestFixture.ValidPayment.REQUESTED_AT + ); + + // assert + assertThat(payment.getStatus()).isEqualTo(PaymentStatus.PENDING); + assertThat(payment.getUsedPoint()).isEqualTo(usedPoint); + assertThat(payment.getPaidAmount()).isEqualTo(totalAmount - usedPoint); + } + + @DisplayName("결제는 PENDING 상태에서 SUCCESS 상태로 전이할 수 있다.") + @Test + void canTransitionToSuccess_whenPending() { + // arrange + Payment payment = Payment.of( + PaymentTestFixture.ValidPayment.ORDER_ID, + PaymentTestFixture.ValidPayment.USER_ID, + PaymentTestFixture.ValidPayment.CARD_TYPE, + PaymentTestFixture.ValidPayment.CARD_NO, + PaymentTestFixture.ValidPayment.AMOUNT, + PaymentTestFixture.ValidPayment.REQUESTED_AT + ); + LocalDateTime completedAt = LocalDateTime.of(2025, 12, 1, 10, 5, 0); + + // act + payment.toSuccess(completedAt); + + // assert + assertThat(payment.getStatus()).isEqualTo(PaymentStatus.SUCCESS); + assertThat(payment.getPgCompletedAt()).isEqualTo(completedAt); + } + + @DisplayName("결제는 PENDING 상태에서 FAILED 상태로 전이할 수 있다.") + @Test + void canTransitionToFailed_whenPending() { + // arrange + Payment payment = Payment.of( + PaymentTestFixture.ValidPayment.ORDER_ID, + PaymentTestFixture.ValidPayment.USER_ID, + PaymentTestFixture.ValidPayment.CARD_TYPE, + PaymentTestFixture.ValidPayment.CARD_NO, + PaymentTestFixture.ValidPayment.AMOUNT, + PaymentTestFixture.ValidPayment.REQUESTED_AT + ); + LocalDateTime completedAt = LocalDateTime.of(2025, 12, 1, 10, 5, 0); + String failureReason = "카드 한도 초과"; + + // act + payment.toFailed(failureReason, completedAt); + + // assert + assertThat(payment.getStatus()).isEqualTo(PaymentStatus.FAILED); + assertThat(payment.getFailureReason()).isEqualTo(failureReason); + assertThat(payment.getPgCompletedAt()).isEqualTo(completedAt); + } + + @DisplayName("FAILED 상태에서 SUCCESS로 전이할 수 없다.") + @Test + void throwsException_whenTransitioningToSuccessFromFailed() { + // arrange + Payment payment = Payment.of( + PaymentTestFixture.ValidPayment.ORDER_ID, + PaymentTestFixture.ValidPayment.USER_ID, + PaymentTestFixture.ValidPayment.CARD_TYPE, + PaymentTestFixture.ValidPayment.CARD_NO, + PaymentTestFixture.ValidPayment.AMOUNT, + PaymentTestFixture.ValidPayment.REQUESTED_AT + ); + payment.toFailed("실패 사유", LocalDateTime.now()); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + payment.toSuccess(LocalDateTime.now()); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("SUCCESS 상태에서 FAILED로 전이할 수 없다.") + @Test + void throwsException_whenTransitioningToFailedFromSuccess() { + // arrange + Payment payment = Payment.of( + PaymentTestFixture.ValidPayment.ORDER_ID, + PaymentTestFixture.ValidPayment.USER_ID, + PaymentTestFixture.ValidPayment.CARD_TYPE, + PaymentTestFixture.ValidPayment.CARD_NO, + PaymentTestFixture.ValidPayment.AMOUNT, + PaymentTestFixture.ValidPayment.REQUESTED_AT + ); + payment.toSuccess(LocalDateTime.now()); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + payment.toFailed("실패 사유", LocalDateTime.now()); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("완료된 결제는 isCompleted가 true를 반환한다.") + @Test + void returnsTrue_whenPaymentIsCompleted() { + // arrange + Payment successPayment = Payment.of( + PaymentTestFixture.ValidPayment.ORDER_ID, + PaymentTestFixture.ValidPayment.USER_ID, + PaymentTestFixture.ValidPayment.CARD_TYPE, + PaymentTestFixture.ValidPayment.CARD_NO, + PaymentTestFixture.ValidPayment.AMOUNT, + PaymentTestFixture.ValidPayment.REQUESTED_AT + ); + successPayment.toSuccess(LocalDateTime.now()); + + Payment failedPayment = Payment.of( + PaymentTestFixture.ValidPayment.ORDER_ID, + PaymentTestFixture.ValidPayment.USER_ID, + PaymentTestFixture.ValidPayment.CARD_TYPE, + PaymentTestFixture.ValidPayment.CARD_NO, + PaymentTestFixture.ValidPayment.AMOUNT, + PaymentTestFixture.ValidPayment.REQUESTED_AT + ); + failedPayment.toFailed("ERROR", LocalDateTime.now()); + + Payment pendingPayment = Payment.of( + PaymentTestFixture.ValidPayment.ORDER_ID, + PaymentTestFixture.ValidPayment.USER_ID, + PaymentTestFixture.ValidPayment.CARD_TYPE, + PaymentTestFixture.ValidPayment.CARD_NO, + PaymentTestFixture.ValidPayment.AMOUNT, + PaymentTestFixture.ValidPayment.REQUESTED_AT + ); + + // assert + assertThat(successPayment.isCompleted()).isTrue(); + assertThat(failedPayment.isCompleted()).isTrue(); + assertThat(pendingPayment.isCompleted()).isFalse(); + } + + @DisplayName("이미 SUCCESS 상태인 결제를 다시 SUCCESS로 전이해도 예외가 발생하지 않는다.") + @Test + void doesNotThrowException_whenTransitioningToSuccessFromSuccess() { + // arrange + Payment payment = Payment.of( + PaymentTestFixture.ValidPayment.ORDER_ID, + PaymentTestFixture.ValidPayment.USER_ID, + PaymentTestFixture.ValidPayment.CARD_TYPE, + PaymentTestFixture.ValidPayment.CARD_NO, + PaymentTestFixture.ValidPayment.AMOUNT, + PaymentTestFixture.ValidPayment.REQUESTED_AT + ); + LocalDateTime firstCompletedAt = LocalDateTime.of(2025, 12, 1, 10, 5, 0); + LocalDateTime secondCompletedAt = LocalDateTime.of(2025, 12, 1, 10, 6, 0); + payment.toSuccess(firstCompletedAt); + + // act + payment.toSuccess(secondCompletedAt); // 멱등성: 이미 SUCCESS 상태면 아무 작업도 하지 않음 + + // assert + assertThat(payment.getStatus()).isEqualTo(PaymentStatus.SUCCESS); + assertThat(payment.getPgCompletedAt()).isEqualTo(firstCompletedAt); // 첫 번째 시각 유지 + } + + @DisplayName("이미 FAILED 상태인 결제를 다시 FAILED로 전이해도 예외가 발생하지 않는다.") + @Test + void doesNotThrowException_whenTransitioningToFailedFromFailed() { + // arrange + Payment payment = Payment.of( + PaymentTestFixture.ValidPayment.ORDER_ID, + PaymentTestFixture.ValidPayment.USER_ID, + PaymentTestFixture.ValidPayment.CARD_TYPE, + PaymentTestFixture.ValidPayment.CARD_NO, + PaymentTestFixture.ValidPayment.AMOUNT, + PaymentTestFixture.ValidPayment.REQUESTED_AT + ); + LocalDateTime firstCompletedAt = LocalDateTime.of(2025, 12, 1, 10, 5, 0); + LocalDateTime secondCompletedAt = LocalDateTime.of(2025, 12, 1, 10, 6, 0); + String firstReason = "첫 번째 실패 사유"; + String secondReason = "두 번째 실패 사유"; + payment.toFailed(firstReason, firstCompletedAt); + + // act + payment.toFailed(secondReason, secondCompletedAt); // 멱등성: 이미 FAILED 상태면 아무 작업도 하지 않음 + + // assert + assertThat(payment.getStatus()).isEqualTo(PaymentStatus.FAILED); + assertThat(payment.getFailureReason()).isEqualTo(firstReason); // 첫 번째 사유 유지 + assertThat(payment.getPgCompletedAt()).isEqualTo(firstCompletedAt); // 첫 번째 시각 유지 + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/payment/PaymentTestFixture.java b/apps/commerce-api/src/test/java/com/loopers/domain/payment/PaymentTestFixture.java new file mode 100644 index 000000000..5e781bceb --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/payment/PaymentTestFixture.java @@ -0,0 +1,30 @@ +package com.loopers.domain.payment; + +import java.time.LocalDateTime; + +/** + * 테스트용 고정 데이터 (Fixture) 클래스 + * 모든 Payment 관련 테스트에서 사용하는 공통 데이터를 관리 + */ +public class PaymentTestFixture { + + // 기본 유효한 테스트 데이터 + public static final class ValidPayment { + public static final Long ORDER_ID = 1L; + public static final Long USER_ID = 100L; + public static final Long AMOUNT = 50000L; + public static final CardType CARD_TYPE = CardType.SAMSUNG; + public static final String CARD_NO = "4111-1111-1111-1111"; + public static final LocalDateTime REQUESTED_AT = LocalDateTime.of(2025, 12, 1, 10, 0, 0); + public static final String TRANSACTION_KEY = "tx-key-12345"; + public static final Long ZERO_POINT = 0L; + public static final Long FULL_POINT = AMOUNT; // 전액 포인트 + public static final Long PARTIAL_POINT = AMOUNT / 2; // 부분 포인트 + } + + // 유효하지 않은 테스트 데이터 + public static final class InvalidPayment { + public static final Long INVALID_AMOUNT = 0L; + } +} + diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceTest.java new file mode 100644 index 000000000..2ae4afeb2 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceTest.java @@ -0,0 +1,93 @@ +package com.loopers.domain.product; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +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; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +/** + * ProductService 테스트. + */ +@ExtendWith(MockitoExtension.class) +@DisplayName("ProductService") +public class ProductServiceTest { + + @Mock + private ProductRepository productRepository; + + @InjectMocks + private ProductService productService; + + @DisplayName("상품 조회 (비관적 락)") + @Nested + class FindProductForUpdate { + @DisplayName("상품 ID로 상품을 조회할 수 있다. (비관적 락)") + @Test + void findsProductByIdForUpdate() { + // arrange + Long productId = 1L; + Product expectedProduct = Product.of("상품", 10_000, 10, 1L); + when(productRepository.findByIdForUpdate(productId)).thenReturn(Optional.of(expectedProduct)); + + // act + Product result = productService.findByIdForUpdate(productId); + + // assert + assertThat(result).isEqualTo(expectedProduct); + verify(productRepository, times(1)).findByIdForUpdate(productId); + } + + @DisplayName("상품을 찾을 수 없으면 예외가 발생한다.") + @Test + void throwsException_whenProductNotFound() { + // arrange + Long productId = 999L; + when(productRepository.findByIdForUpdate(productId)).thenReturn(Optional.empty()); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + productService.findByIdForUpdate(productId); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + assertThat(result.getMessage()).contains("상품을 찾을 수 없습니다"); + verify(productRepository, times(1)).findByIdForUpdate(productId); + } + } + + @DisplayName("상품 저장") + @Nested + class SaveProducts { + @DisplayName("상품 목록을 저장할 수 있다.") + @Test + void savesAllProducts() { + // arrange + Product product1 = Product.of("상품1", 10_000, 10, 1L); + Product product2 = Product.of("상품2", 20_000, 5, 1L); + List products = List.of(product1, product2); + when(productRepository.save(any(Product.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + // act + productService.saveAll(products); + + // assert + verify(productRepository, times(1)).save(product1); + verify(productRepository, times(1)).save(product2); + } + } +} + diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceTest.java new file mode 100644 index 000000000..087413f54 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceTest.java @@ -0,0 +1,164 @@ +package com.loopers.domain.user; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +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; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +/** + * UserService 테스트. + */ +@ExtendWith(MockitoExtension.class) +@DisplayName("UserService") +public class UserServiceTest { + + @Mock + private UserRepository userRepository; + + @InjectMocks + private UserService userService; + + @DisplayName("사용자 조회") + @Nested + class FindUser { + @DisplayName("사용자 ID로 사용자를 조회할 수 있다.") + @Test + void findsUserByUserId() { + // arrange + String userId = "testuser"; + User expectedUser = User.of(userId, "test@example.com", "1990-01-01", Gender.MALE, Point.of(1000L)); + when(userRepository.findByUserId(userId)).thenReturn(expectedUser); + + // act + User result = userService.findByUserId(userId); + + // assert + assertThat(result).isEqualTo(expectedUser); + verify(userRepository, times(1)).findByUserId(userId); + } + + @DisplayName("사용자를 찾을 수 없으면 예외가 발생한다.") + @Test + void throwsException_whenUserNotFound() { + // arrange + String userId = "unknown"; + when(userRepository.findByUserId(userId)).thenReturn(null); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + userService.findByUserId(userId); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + assertThat(result.getMessage()).contains("사용자를 찾을 수 없습니다"); + verify(userRepository, times(1)).findByUserId(userId); + } + } + + @DisplayName("사용자 조회 (비관적 락)") + @Nested + class FindUserForUpdate { + @DisplayName("사용자 ID로 사용자를 조회할 수 있다. (비관적 락)") + @Test + void findsUserByUserIdForUpdate() { + // arrange + String userId = "testuser"; + User expectedUser = User.of(userId, "test@example.com", "1990-01-01", Gender.MALE, Point.of(1000L)); + when(userRepository.findByUserIdForUpdate(userId)).thenReturn(expectedUser); + + // act + User result = userService.findByUserIdForUpdate(userId); + + // assert + assertThat(result).isEqualTo(expectedUser); + verify(userRepository, times(1)).findByUserIdForUpdate(userId); + } + + @DisplayName("사용자를 찾을 수 없으면 예외가 발생한다.") + @Test + void throwsException_whenUserNotFound() { + // arrange + String userId = "unknown"; + when(userRepository.findByUserIdForUpdate(userId)).thenReturn(null); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + userService.findByUserIdForUpdate(userId); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + assertThat(result.getMessage()).contains("사용자를 찾을 수 없습니다"); + verify(userRepository, times(1)).findByUserIdForUpdate(userId); + } + } + + @DisplayName("사용자 조회 (ID)") + @Nested + class FindUserById { + @DisplayName("사용자 ID (PK)로 사용자를 조회할 수 있다.") + @Test + void findsUserById() { + // arrange + Long id = 1L; + User expectedUser = User.of("testuser", "test@example.com", "1990-01-01", Gender.MALE, Point.of(1000L)); + when(userRepository.findById(id)).thenReturn(expectedUser); + + // act + User result = userService.findById(id); + + // assert + assertThat(result).isEqualTo(expectedUser); + verify(userRepository, times(1)).findById(id); + } + + @DisplayName("사용자를 찾을 수 없으면 예외가 발생한다.") + @Test + void throwsException_whenUserNotFound() { + // arrange + Long id = 999L; + when(userRepository.findById(id)).thenReturn(null); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + userService.findById(id); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + assertThat(result.getMessage()).contains("사용자를 찾을 수 없습니다"); + verify(userRepository, times(1)).findById(id); + } + } + + @DisplayName("사용자 저장") + @Nested + class SaveUser { + @DisplayName("사용자를 저장할 수 있다.") + @Test + void savesUser() { + // arrange + User user = User.of("testuser", "test@example.com", "1990-01-01", Gender.MALE, Point.of(1000L)); + when(userRepository.save(any(User.class))).thenReturn(user); + + // act + User result = userService.save(user); + + // assert + assertThat(result).isEqualTo(user); + verify(userRepository, times(1)).save(user); + } + } +} + diff --git a/apps/commerce-api/src/test/java/com/loopers/infrastructure/payment/PaymentGatewayClientTest.java b/apps/commerce-api/src/test/java/com/loopers/infrastructure/payment/PaymentGatewayClientTest.java new file mode 100644 index 000000000..dbfac8411 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/infrastructure/payment/PaymentGatewayClientTest.java @@ -0,0 +1,276 @@ +package com.loopers.infrastructure.payment; + +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import feign.FeignException; +import feign.Request; +import org.springframework.test.context.ActiveProfiles; + +import java.net.SocketTimeoutException; +import java.util.Collections; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; + +/** + * PaymentGatewayClient 타임아웃 및 실패 처리 테스트. + *

+ * 외부 PG 시스템과의 통신에서 발생할 수 있는 다양한 장애 시나리오를 검증합니다. + * - 타임아웃 처리 + * - 네트워크 오류 처리 + * - 서버 오류 처리 + *

+ */ +@SpringBootTest +@ActiveProfiles("test") +@DisplayName("PaymentGatewayClient 타임아웃 및 실패 처리 테스트") +class PaymentGatewayClientTest { + + @MockitoBean + private PaymentGatewayClient paymentGatewayClient; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + reset(paymentGatewayClient); + } + + @Test + @DisplayName("PG 결제 요청 시 타임아웃이 발생하면 적절한 예외가 발생한다") + void requestPayment_timeout_throwsException() { + // arrange + String userId = "testuser"; + PaymentGatewayDto.PaymentRequest request = new PaymentGatewayDto.PaymentRequest( + "ORDER001", + PaymentGatewayDto.CardType.SAMSUNG, + "4111-1111-1111-1111", + 10_000L, + "http://localhost:8080/api/v1/orders/1/callback" + ); + + // Mock 서버에서 타임아웃 예외 발생 + SocketTimeoutException timeoutException = new SocketTimeoutException("Request timeout"); + doThrow(new RuntimeException(timeoutException)) + .when(paymentGatewayClient).requestPayment(anyString(), any(PaymentGatewayDto.PaymentRequest.class)); + + // act & assert + assertThatThrownBy(() -> paymentGatewayClient.requestPayment(userId, request)) + .isInstanceOf(RuntimeException.class) + .hasCauseInstanceOf(SocketTimeoutException.class) + .hasMessageContaining("Request timeout"); + } + + @Test + @DisplayName("PG 결제 요청 시 연결 타임아웃이 발생하면 적절한 예외가 발생한다") + void requestPayment_connectionTimeout_throwsException() { + // arrange + String userId = "testuser"; + PaymentGatewayDto.PaymentRequest request = new PaymentGatewayDto.PaymentRequest( + "ORDER001", + PaymentGatewayDto.CardType.SAMSUNG, + "4111-1111-1111-1111", + 10_000L, + "http://localhost:8080/api/v1/orders/1/callback" + ); + + // Mock 서버에서 연결 실패 예외 발생 + SocketTimeoutException timeoutException = new SocketTimeoutException("Connection timeout"); + doThrow(new RuntimeException(timeoutException)) + .when(paymentGatewayClient).requestPayment(anyString(), any(PaymentGatewayDto.PaymentRequest.class)); + + // act & assert + assertThatThrownBy(() -> paymentGatewayClient.requestPayment(userId, request)) + .isInstanceOf(RuntimeException.class) + .hasCauseInstanceOf(SocketTimeoutException.class) + .hasMessageContaining("Connection timeout"); + } + + @Test + @DisplayName("PG 결제 요청 시 읽기 타임아웃이 발생하면 적절한 예외가 발생한다") + void requestPayment_readTimeout_throwsException() { + // arrange + String userId = "testuser"; + PaymentGatewayDto.PaymentRequest request = new PaymentGatewayDto.PaymentRequest( + "ORDER001", + PaymentGatewayDto.CardType.SAMSUNG, + "4111-1111-1111-1111", + 10_000L, + "http://localhost:8080/api/v1/orders/1/callback" + ); + + // Mock 서버에서 읽기 타임아웃 예외 발생 + SocketTimeoutException timeoutException = new SocketTimeoutException("Read timed out"); + doThrow(new RuntimeException(timeoutException)) + .when(paymentGatewayClient).requestPayment(anyString(), any(PaymentGatewayDto.PaymentRequest.class)); + + // act & assert + assertThatThrownBy(() -> paymentGatewayClient.requestPayment(userId, request)) + .isInstanceOf(RuntimeException.class) + .hasCauseInstanceOf(SocketTimeoutException.class) + .hasMessageContaining("Read timed out"); + } + + @Test + @DisplayName("PG 결제 상태 확인 API 호출 시 타임아웃이 발생하면 적절한 예외가 발생한다") + void getTransaction_timeout_throwsException() { + // arrange + String userId = "testuser"; + String transactionKey = "TXN123456"; + + // Mock 서버에서 타임아웃 예외 발생 + SocketTimeoutException timeoutException = new SocketTimeoutException("Request timeout"); + doThrow(new RuntimeException(timeoutException)) + .when(paymentGatewayClient).getTransaction(anyString(), anyString()); + + // act & assert + assertThatThrownBy(() -> paymentGatewayClient.getTransaction(userId, transactionKey)) + .isInstanceOf(RuntimeException.class) + .hasCauseInstanceOf(SocketTimeoutException.class) + .hasMessageContaining("Request timeout"); + } + + @Test + @DisplayName("PG 서버가 500 에러를 반환하면 적절한 예외가 발생한다") + void requestPayment_serverError_throwsException() { + // arrange + String userId = "testuser"; + PaymentGatewayDto.PaymentRequest request = new PaymentGatewayDto.PaymentRequest( + "ORDER001", + PaymentGatewayDto.CardType.SAMSUNG, + "4111-1111-1111-1111", + 10_000L, + "http://localhost:8080/api/v1/orders/1/callback" + ); + + // Mock 서버에서 500 에러 반환 + when(paymentGatewayClient.requestPayment(anyString(), any(PaymentGatewayDto.PaymentRequest.class))) + .thenThrow(new FeignException.InternalServerError( + "Internal Server Error", + Request.create(Request.HttpMethod.POST, "/api/v1/payments", Collections.emptyMap(), null, null, null), + null, + Collections.emptyMap() + )); + + // act & assert + assertThatThrownBy(() -> paymentGatewayClient.requestPayment(userId, request)) + .isInstanceOf(FeignException.class) + .matches(e -> ((FeignException) e).status() == 500); + } + + @Test + @DisplayName("PG 서버가 400 에러를 반환하면 적절한 예외가 발생한다") + void requestPayment_badRequest_throwsException() { + // arrange + String userId = "testuser"; + PaymentGatewayDto.PaymentRequest request = new PaymentGatewayDto.PaymentRequest( + "ORDER001", + PaymentGatewayDto.CardType.SAMSUNG, + "INVALID_CARD", // 잘못된 카드 번호 + 10_000L, + "http://localhost:8080/api/v1/orders/1/callback" + ); + + // Mock 서버에서 400 에러 반환 + when(paymentGatewayClient.requestPayment(anyString(), any(PaymentGatewayDto.PaymentRequest.class))) + .thenThrow(new FeignException.BadRequest( + "Bad Request", + Request.create(Request.HttpMethod.POST, "/api/v1/payments", Collections.emptyMap(), null, null, null), + null, + Collections.emptyMap() + )); + + // act & assert + assertThatThrownBy(() -> paymentGatewayClient.requestPayment(userId, request)) + .isInstanceOf(FeignException.class) + .matches(e -> ((FeignException) e).status() == 400); + } + + @Test + @DisplayName("PG 결제 요청이 성공하면 정상적인 응답을 받는다") + void requestPayment_success_returnsResponse() { + // arrange + String userId = "testuser"; + PaymentGatewayDto.PaymentRequest request = new PaymentGatewayDto.PaymentRequest( + "ORDER001", + PaymentGatewayDto.CardType.SAMSUNG, + "4111-1111-1111-1111", + 10_000L, + "http://localhost:8080/api/v1/orders/1/callback" + ); + + // Mock 서버에서 성공 응답 반환 + PaymentGatewayDto.ApiResponse successResponse = + new PaymentGatewayDto.ApiResponse<>( + new PaymentGatewayDto.ApiResponse.Metadata( + PaymentGatewayDto.ApiResponse.Metadata.Result.SUCCESS, + null, + null + ), + new PaymentGatewayDto.TransactionResponse( + "TXN123456", + PaymentGatewayDto.TransactionStatus.PENDING, + null + ) + ); + when(paymentGatewayClient.requestPayment(anyString(), any(PaymentGatewayDto.PaymentRequest.class))) + .thenReturn(successResponse); + + // act + PaymentGatewayDto.ApiResponse response = + paymentGatewayClient.requestPayment(userId, request); + + // assert + assertThat(response.meta().result()).isEqualTo(PaymentGatewayDto.ApiResponse.Metadata.Result.SUCCESS); + assertThat(response.data()).isNotNull(); + assertThat(response.data().transactionKey()).isNotNull(); + } + + @Test + @DisplayName("PG 결제 상태 확인 API가 성공하면 정상적인 응답을 받는다") + void getTransaction_success_returnsResponse() { + // arrange + String userId = "testuser"; + String transactionKey = "TXN123456"; + + // Mock 서버에서 성공 응답 반환 + PaymentGatewayDto.ApiResponse successResponse = + new PaymentGatewayDto.ApiResponse<>( + new PaymentGatewayDto.ApiResponse.Metadata( + PaymentGatewayDto.ApiResponse.Metadata.Result.SUCCESS, + null, + null + ), + new PaymentGatewayDto.TransactionDetailResponse( + transactionKey, + "ORDER001", + PaymentGatewayDto.CardType.SAMSUNG, + "4111-1111-1111-1111", + 10_000L, + PaymentGatewayDto.TransactionStatus.SUCCESS, + null + ) + ); + when(paymentGatewayClient.getTransaction(anyString(), anyString())) + .thenReturn(successResponse); + + // act + PaymentGatewayDto.ApiResponse response = + paymentGatewayClient.getTransaction(userId, transactionKey); + + // assert + assertThat(response.meta().result()).isEqualTo(PaymentGatewayDto.ApiResponse.Metadata.Result.SUCCESS); + assertThat(response.data()).isNotNull(); + assertThat(response.data().transactionKey()).isEqualTo(transactionKey); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/PurchasingV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/PurchasingV1ApiE2ETest.java index 46e6f964a..a09516c93 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/PurchasingV1ApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/PurchasingV1ApiE2ETest.java @@ -9,9 +9,9 @@ import com.loopers.domain.product.ProductRepository; import com.loopers.domain.user.Gender; import com.loopers.domain.user.UserTestFixture; -import com.loopers.infrastructure.paymentgateway.PaymentGatewayClient; -import com.loopers.infrastructure.paymentgateway.PaymentGatewayDto; -import com.loopers.infrastructure.paymentgateway.PaymentGatewaySchedulerClient; +import com.loopers.infrastructure.payment.PaymentGatewayClient; +import com.loopers.infrastructure.payment.PaymentGatewayDto; +import com.loopers.infrastructure.payment.PaymentGatewaySchedulerClient; import com.loopers.interfaces.api.ApiResponse; import com.loopers.interfaces.api.purchasing.PurchasingV1Dto; import com.loopers.utils.DatabaseCleanUp; @@ -119,7 +119,7 @@ private HttpEntity createOrderRequest(Long produc List.of( new PurchasingV1Dto.ItemRequest(savedProduct.getId(), 1) ), - new PurchasingV1Dto.PaymentRequest("SAMSUNG", "4111-1111-1111-1111") + new PurchasingV1Dto.PaymentRequest(null, "SAMSUNG", "4111-1111-1111-1111") ); HttpHeaders headers = new HttpHeaders(); @@ -252,7 +252,7 @@ void returns200_whenPaymentCallbackSuccess() { List.of( new PurchasingV1Dto.ItemRequest(savedProduct.getId(), 1) ), - new PurchasingV1Dto.PaymentRequest("SAMSUNG", "4111-1111-1111-1111") + new PurchasingV1Dto.PaymentRequest(null, "SAMSUNG", "4111-1111-1111-1111") ); HttpHeaders createHeaders = new HttpHeaders(); @@ -357,7 +357,7 @@ void returns200_whenPaymentCallbackFailure() { List.of( new PurchasingV1Dto.ItemRequest(savedProduct.getId(), 1) ), - new PurchasingV1Dto.PaymentRequest("SAMSUNG", "4111-1111-1111-1111") + new PurchasingV1Dto.PaymentRequest(null, "SAMSUNG", "4111-1111-1111-1111") ); HttpHeaders createHeaders = new HttpHeaders(); @@ -468,7 +468,7 @@ void returns200_whenOrderStatusRecovered() { List.of( new PurchasingV1Dto.ItemRequest(savedProduct.getId(), 1) ), - new PurchasingV1Dto.PaymentRequest("SAMSUNG", "4111-1111-1111-1111") + new PurchasingV1Dto.PaymentRequest(null, "SAMSUNG", "4111-1111-1111-1111") ); HttpHeaders createHeaders = new HttpHeaders();