diff --git a/.gitignore b/.gitignore index 43944eb..7739048 100644 --- a/.gitignore +++ b/.gitignore @@ -3,7 +3,7 @@ CLAUDE.md ### Docs ### -**/TEST_LIST.md +**/*.md ### Gradle ### .gradle/ diff --git a/buildSrc/src/main/groovy/paybook.java-conventions.gradle b/buildSrc/src/main/groovy/paybook.java-conventions.gradle index 4bac554..9eed60b 100644 --- a/buildSrc/src/main/groovy/paybook.java-conventions.gradle +++ b/buildSrc/src/main/groovy/paybook.java-conventions.gradle @@ -1,6 +1,7 @@ plugins { id 'java' id 'io.spring.dependency-management' + id 'jacoco' } group = 'com.paybook' @@ -31,4 +32,12 @@ dependencyManagement { test { useJUnitPlatform() + finalizedBy jacocoTestReport +} + +jacocoTestReport { + dependsOn test + reports { + csv.required = true + } } diff --git a/core/src/main/java/com/paybook/core/entity/CouponEntity.java b/core/src/main/java/com/paybook/core/entity/CouponEntity.java index 05cecb8..c07ca3a 100644 --- a/core/src/main/java/com/paybook/core/entity/CouponEntity.java +++ b/core/src/main/java/com/paybook/core/entity/CouponEntity.java @@ -36,6 +36,9 @@ public class CouponEntity { private Integer minOrderAmount; + @Version + private Long version; + public CouponEntity(String couponId, CouponStatus status, CouponType couponType, DiscountType discountType, int discountValue, Integer maxDiscountAmount) { this(couponId, status, couponType, discountType, discountValue, maxDiscountAmount, null); diff --git a/core/src/main/java/com/paybook/core/entity/PaymentEntity.java b/core/src/main/java/com/paybook/core/entity/PaymentEntity.java index a1056c3..9b41488 100644 --- a/core/src/main/java/com/paybook/core/entity/PaymentEntity.java +++ b/core/src/main/java/com/paybook/core/entity/PaymentEntity.java @@ -20,7 +20,7 @@ public class PaymentEntity { @Column(nullable = false, unique = true) private String paymentId; - @Column(nullable = false) + @Column(nullable = false, unique = true) private String orderId; private int pgPaymentAmount; @@ -34,6 +34,9 @@ public class PaymentEntity { @Column(nullable = false) private LocalDateTime createdAt; + @Version + private Long version; + public PaymentEntity(String paymentId, String orderId, int pgPaymentAmount) { this.paymentId = paymentId; this.orderId = orderId; diff --git a/core/src/main/java/com/paybook/core/entity/UserPointEntity.java b/core/src/main/java/com/paybook/core/entity/UserPointEntity.java index d4866bd..9995836 100644 --- a/core/src/main/java/com/paybook/core/entity/UserPointEntity.java +++ b/core/src/main/java/com/paybook/core/entity/UserPointEntity.java @@ -20,6 +20,9 @@ public class UserPointEntity { private int balance; + @Version + private Long version; + public UserPointEntity(String userId, int balance) { this.userId = userId; this.balance = balance; diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..ed8052e --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,41 @@ +services: + orderdb: + image: postgres:17 + container_name: paybook-orderdb + environment: + POSTGRES_DB: orderdb + POSTGRES_USER: paybook + POSTGRES_PASSWORD: paybook + ports: + - "5432:5432" + volumes: + - orderdb-data:/var/lib/postgresql/data + + paymentdb: + image: postgres:17 + container_name: paybook-paymentdb + environment: + POSTGRES_DB: paymentdb + POSTGRES_USER: paybook + POSTGRES_PASSWORD: paybook + ports: + - "5433:5432" + volumes: + - paymentdb-data:/var/lib/postgresql/data + + settlementdb: + image: postgres:17 + container_name: paybook-settlementdb + environment: + POSTGRES_DB: settlementdb + POSTGRES_USER: paybook + POSTGRES_PASSWORD: paybook + ports: + - "5434:5432" + volumes: + - settlementdb-data:/var/lib/postgresql/data + +volumes: + orderdb-data: + paymentdb-data: + settlementdb-data: diff --git a/order/build.gradle b/order/build.gradle index b1fa88a..6a60c17 100644 --- a/order/build.gradle +++ b/order/build.gradle @@ -7,7 +7,8 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' - runtimeOnly 'com.h2database:h2' + runtimeOnly 'org.postgresql:postgresql' + testRuntimeOnly 'com.h2database:h2' testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test' testImplementation 'org.springframework.boot:spring-boot-starter-data-jpa-test' diff --git a/order/src/main/java/com/paybook/order/dto/OrderResponse.java b/order/src/main/java/com/paybook/order/dto/OrderResponse.java index 050d789..c0c8cea 100644 --- a/order/src/main/java/com/paybook/order/dto/OrderResponse.java +++ b/order/src/main/java/com/paybook/order/dto/OrderResponse.java @@ -12,6 +12,7 @@ public record OrderResponse( int pgPaymentAmount, int deliveryFee, String status, + String couponId, String createdAt ) { public record OrderItemResponse( diff --git a/order/src/main/java/com/paybook/order/entity/OrderEntity.java b/order/src/main/java/com/paybook/order/entity/OrderEntity.java index fe0058d..7e556d2 100644 --- a/order/src/main/java/com/paybook/order/entity/OrderEntity.java +++ b/order/src/main/java/com/paybook/order/entity/OrderEntity.java @@ -2,9 +2,7 @@ import com.paybook.order.exception.OrderException; import jakarta.persistence.*; -import lombok.AccessLevel; -import lombok.Getter; -import lombok.NoArgsConstructor; +import lombok.*; import java.time.LocalDateTime; import java.util.ArrayList; @@ -16,6 +14,8 @@ @Table(name = "orders") @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Builder public class OrderEntity { private static final Set NON_CANCELLABLE_STATUSES = EnumSet.of( @@ -34,6 +34,7 @@ public class OrderEntity { private String userId; @OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true) + @Builder.Default private List items = new ArrayList<>(); private int totalAmount; @@ -48,7 +49,8 @@ public class OrderEntity { @Enumerated(EnumType.STRING) @Column(nullable = false) - private OrderStatus status; + @Builder.Default + private OrderStatus status = OrderStatus.PENDING_PAYMENT; private String deliveryAddress; @@ -57,25 +59,11 @@ public class OrderEntity { private Integer pointAmountToUse; @Column(nullable = false) - private LocalDateTime createdAt; - - public OrderEntity(String orderId, String userId, int totalAmount, - int couponDiscountAmount, int pointDiscountAmount, int pgPaymentAmount, - int deliveryFee, String deliveryAddress, String couponId, - Integer pointAmountToUse) { - this.orderId = orderId; - this.userId = userId; - this.totalAmount = totalAmount; - this.couponDiscountAmount = couponDiscountAmount; - this.pointDiscountAmount = pointDiscountAmount; - this.pgPaymentAmount = pgPaymentAmount; - this.deliveryFee = deliveryFee; - this.deliveryAddress = deliveryAddress; - this.couponId = couponId; - this.pointAmountToUse = pointAmountToUse; - this.status = OrderStatus.PENDING_PAYMENT; - this.createdAt = LocalDateTime.now(); - } + @Builder.Default + private LocalDateTime createdAt = LocalDateTime.now(); + + @Version + private Long version; public void addItem(OrderItemEntity item) { items.add(item); diff --git a/order/src/main/java/com/paybook/order/repository/OrderRepository.java b/order/src/main/java/com/paybook/order/repository/OrderRepository.java index 2160efc..b8be6af 100644 --- a/order/src/main/java/com/paybook/order/repository/OrderRepository.java +++ b/order/src/main/java/com/paybook/order/repository/OrderRepository.java @@ -7,7 +7,6 @@ import org.springframework.data.jpa.repository.JpaRepository; import java.time.LocalDateTime; -import java.util.List; import java.util.Optional; public interface OrderRepository extends JpaRepository { @@ -18,5 +17,5 @@ public interface OrderRepository extends JpaRepository { Page findByUserIdAndStatusOrderByCreatedAtDesc(String userId, OrderStatus status, Pageable pageable); - List findByStatusAndCreatedAtBefore(OrderStatus status, LocalDateTime cutoff); + Page findByStatusAndCreatedAtBefore(OrderStatus status, LocalDateTime cutoff, Pageable pageable); } diff --git a/order/src/main/java/com/paybook/order/service/OrderService.java b/order/src/main/java/com/paybook/order/service/OrderService.java index 28a9142..438e744 100644 --- a/order/src/main/java/com/paybook/order/service/OrderService.java +++ b/order/src/main/java/com/paybook/order/service/OrderService.java @@ -24,7 +24,7 @@ import org.springframework.transaction.annotation.Transactional; import java.util.List; -import java.util.concurrent.atomic.AtomicLong; +import java.util.UUID; import java.util.stream.IntStream; @Service @@ -32,9 +32,6 @@ public class OrderService { private static final int PERCENT_DIVISOR = 100; - private static final int ORDER_ID_PAD_LENGTH = 6; - - private final AtomicLong sequence = new AtomicLong(1); private final OrderRepository orderRepository; private final ProductRepository productRepository; @@ -261,13 +258,20 @@ private void validateMinPgPayment(int totalAmount, int pgPaymentAmount, int deli private OrderEntity buildAndSaveOrder(CreateOrderRequest request, List products, int totalAmount, int couponDiscount, int pointDiscount, int pgPaymentAmount, int deliveryFee) { - String orderId = "ORD-" + String.format("%0" + ORDER_ID_PAD_LENGTH + "d", sequence.getAndIncrement()); - - OrderEntity order = new OrderEntity( - orderId, request.userId(), totalAmount, - couponDiscount, pointDiscount, pgPaymentAmount, - deliveryFee, request.deliveryAddress(), request.couponId(), request.pointAmountToUse() - ); + String orderId = "ORD-" + UUID.randomUUID().toString().substring(0, 8); + + OrderEntity order = OrderEntity.builder() + .orderId(orderId) + .userId(request.userId()) + .totalAmount(totalAmount) + .couponDiscountAmount(couponDiscount) + .pointDiscountAmount(pointDiscount) + .pgPaymentAmount(pgPaymentAmount) + .deliveryFee(deliveryFee) + .deliveryAddress(request.deliveryAddress()) + .couponId(request.couponId()) + .pointAmountToUse(request.pointAmountToUse()) + .build(); IntStream.range(0, request.items().size()).forEach(i -> { CreateOrderRequest.OrderItemRequest itemReq = request.items().get(i); @@ -335,6 +339,7 @@ private OrderResponse toResponse(OrderEntity order) { order.getTotalAmount(), order.getCouponDiscountAmount(), order.getPointDiscountAmount(), order.getPgPaymentAmount(), order.getDeliveryFee(), order.getStatus().name(), + order.getCouponId(), order.getCreatedAt().toString()); } } diff --git a/order/src/main/java/com/paybook/order/service/PaymentTimeoutScheduler.java b/order/src/main/java/com/paybook/order/service/PaymentTimeoutScheduler.java index 50f4eb3..3075cd2 100644 --- a/order/src/main/java/com/paybook/order/service/PaymentTimeoutScheduler.java +++ b/order/src/main/java/com/paybook/order/service/PaymentTimeoutScheduler.java @@ -6,17 +6,20 @@ import com.paybook.order.repository.OrderRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; import java.time.LocalDateTime; -import java.util.List; @Slf4j @Component @RequiredArgsConstructor public class PaymentTimeoutScheduler { + private static final int BATCH_SIZE = 500; + private final OrderRepository orderRepository; private final OrderService orderService; private final PaymentTimeoutConfig paymentTimeoutConfig; @@ -24,24 +27,34 @@ public class PaymentTimeoutScheduler { @Scheduled(fixedDelayString = "${order.payment.timeout-check-interval:60000}") public void cancelTimedOutOrders() { LocalDateTime cutoff = LocalDateTime.now().minusMinutes(paymentTimeoutConfig.timeoutMinutes()); - List timedOutOrders = orderRepository.findByStatusAndCreatedAtBefore( - OrderStatus.PENDING_PAYMENT, cutoff); - - timedOutOrders.forEach(order -> { - try { - orderService.cancelOrder(order.getOrderId()); - log.info("결제 타임아웃 주문 자동 취소: {}", order.getOrderId()); - } catch (Exception e) { - log.error("결제 타임아웃 주문 취소 실패: {}", order.getOrderId(), e); - } - }); + int cancelled = processBatch(cutoff); + if (cancelled > 0) { + log.info("결제 타임아웃 주문 자동 취소: {}건", cancelled); + } } public int cancelTimedOutOrdersManually(LocalDateTime cutoff) { - List timedOutOrders = orderRepository.findByStatusAndCreatedAtBefore( - OrderStatus.PENDING_PAYMENT, cutoff); + return processBatch(cutoff); + } + + private int processBatch(LocalDateTime cutoff) { + int totalCancelled = 0; + Page page; + + do { + page = orderRepository.findByStatusAndCreatedAtBefore( + OrderStatus.PENDING_PAYMENT, cutoff, PageRequest.of(0, BATCH_SIZE)); + + for (OrderEntity order : page.getContent()) { + try { + orderService.cancelOrder(order.getOrderId()); + totalCancelled++; + } catch (Exception e) { + log.error("결제 타임아웃 주문 취소 실패: {}", order.getOrderId(), e); + } + } + } while (page.hasNext()); - timedOutOrders.forEach(order -> orderService.cancelOrder(order.getOrderId())); - return timedOutOrders.size(); + return totalCancelled; } } diff --git a/order/src/main/resources/application.yml b/order/src/main/resources/application.yml index fd09bb5..0014d5e 100644 --- a/order/src/main/resources/application.yml +++ b/order/src/main/resources/application.yml @@ -2,13 +2,13 @@ spring: application: name: order-service datasource: - url: jdbc:h2:mem:orderdb - driver-class-name: org.h2.Driver - username: sa - password: + url: jdbc:postgresql://localhost:5432/orderdb + driver-class-name: org.postgresql.Driver + username: paybook + password: paybook jpa: hibernate: - ddl-auto: create-drop + ddl-auto: update open-in-view: false server: diff --git a/order/src/test/java/com/paybook/order/service/unit/OrderServiceUnitTest.java b/order/src/test/java/com/paybook/order/service/unit/OrderServiceUnitTest.java new file mode 100644 index 0000000..231215b --- /dev/null +++ b/order/src/test/java/com/paybook/order/service/unit/OrderServiceUnitTest.java @@ -0,0 +1,1091 @@ +package com.paybook.order.service.unit; + +import com.paybook.core.entity.*; +import com.paybook.core.repository.CouponRepository; +import com.paybook.core.repository.ProductRepository; +import com.paybook.core.repository.UserPointRepository; +import com.paybook.order.config.DeliveryFeeConfig; +import com.paybook.order.config.DiscountPolicyConfig; +import com.paybook.order.dto.CreateOrderRequest; +import com.paybook.order.dto.CreateOrderRequest.OrderItemRequest; +import com.paybook.order.dto.OrderResponse; +import com.paybook.order.entity.OrderEntity; +import com.paybook.order.entity.OrderItemEntity; +import com.paybook.order.entity.OrderStatus; +import com.paybook.order.exception.OrderException; +import com.paybook.order.repository.OrderRepository; +import com.paybook.order.service.OrderService; +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.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.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.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +/** + * OrderService 순수 유닛 테스트 (런던 학파). + * + * Spring 컨텍스트 없이, 모든 의존성을 Mock으로 대체하여 + * OrderService의 모든 분기 로직을 검증한다. + * + * 원칙: 실패 원인 1개 = 테스트 1개. + * 실패 시 어떤 분기에서 문제가 생겼는지 즉시 특정할 수 있다. + */ +@ExtendWith(MockitoExtension.class) +@DisplayName("OrderService 유닛 테스트") +class OrderServiceUnitTest { + + @InjectMocks + private OrderService orderService; + + @Mock + private OrderRepository orderRepository; + + @Mock + private ProductRepository productRepository; + + @Mock + private CouponRepository couponRepository; + + @Mock + private UserPointRepository userPointRepository; + + @Mock + private DiscountPolicyConfig discountPolicyConfig; + + @Mock + private DeliveryFeeConfig deliveryFeeConfig; + + // ════════════════════════════════════════ + // createOrder — 재고 검증 분기 + // ════════════════════════════════════════ + + @Nested + @DisplayName("createOrder: 재고 검증") + class CreateOrder_StockValidation { + + @Test + @DisplayName("상품 없음 → PRODUCT_NOT_FOUND, 쿠폰·포인트 검증까지 가지 않는다") + void productNotFound_stopsEarly() { + given(productRepository.findByProductId("PROD-NONE")).willReturn(Optional.empty()); + + assertException(request("PROD-NONE", 1), "PRODUCT_NOT_FOUND"); + + verify(couponRepository, never()).findByCouponId(anyString()); + verify(userPointRepository, never()).findByUserId(anyString()); + verify(orderRepository, never()).save(any()); + } + + @Test + @DisplayName("재고 부족 → OUT_OF_STOCK, 쿠폰·포인트 검증까지 가지 않는다") + void outOfStock_stopsEarly() { + givenProduct("PROD-001", 10000, 5); + + assertException(request("PROD-001", 10), "OUT_OF_STOCK"); + + verify(couponRepository, never()).findByCouponId(anyString()); + verify(orderRepository, never()).save(any()); + } + + @Test + @DisplayName("여러 상품 중 두 번째 재고 부족 → OUT_OF_STOCK, 주문 저장 안 됨") + void secondItemOutOfStock_stopsEarly() { + givenProduct("PROD-001", 10000, 100); + givenProduct("PROD-002", 20000, 1); + + CreateOrderRequest request = new CreateOrderRequest("USER-001", + List.of(new OrderItemRequest("PROD-001", 2), new OrderItemRequest("PROD-002", 5)), + "서울시", null, null); + + assertException(request, "OUT_OF_STOCK"); + verify(orderRepository, never()).save(any()); + } + } + + // ════════════════════════════════════════ + // createOrder — 쿠폰 검증 분기 + // ════════════════════════════════════════ + + @Nested + @DisplayName("createOrder: 쿠폰 검증") + class CreateOrder_CouponValidation { + + @Test + @DisplayName("쿠폰 null → 쿠폰 조회 자체를 하지 않는다") + void nullCoupon_skipsCouponLookup() { + givenProduct("PROD-001", 10000, 100); + givenDefaultPolicy(); + givenOrderSave(); + + orderService.createOrder(request("PROD-001", 1)); + + verify(couponRepository, never()).findByCouponId(anyString()); + } + + @Test + @DisplayName("쿠폰 없음 → COUPON_NOT_FOUND, 포인트 검증까지 가지 않는다") + void couponNotFound_stopsBeforePoints() { + givenProduct("PROD-001", 10000, 100); + given(couponRepository.findByCouponId("COUPON-NONE")).willReturn(Optional.empty()); + + assertException(requestWithCoupon("COUPON-NONE"), "COUPON_NOT_FOUND"); + + verify(userPointRepository, never()).findByUserId(anyString()); + verify(orderRepository, never()).save(any()); + } + + @Test + @DisplayName("사용된 쿠폰 → COUPON_ALREADY_USED, 포인트 검증까지 가지 않는다") + void couponUsed_stopsBeforePoints() { + givenProduct("PROD-001", 10000, 100); + givenCoupon("COUPON-USED", CouponStatus.USED, 1000); + + assertException(requestWithCoupon("COUPON-USED"), "COUPON_ALREADY_USED"); + + verify(userPointRepository, never()).findByUserId(anyString()); + verify(orderRepository, never()).save(any()); + } + + @Test + @DisplayName("만료된 쿠폰 → COUPON_EXPIRED, 포인트 검증까지 가지 않는다") + void couponExpired_stopsBeforePoints() { + givenProduct("PROD-001", 10000, 100); + givenCoupon("COUPON-EXP", CouponStatus.EXPIRED, 1000); + + assertException(requestWithCoupon("COUPON-EXP"), "COUPON_EXPIRED"); + + verify(userPointRepository, never()).findByUserId(anyString()); + verify(orderRepository, never()).save(any()); + } + + @Test + @DisplayName("최소 주문금액 미달 → COUPON_MIN_ORDER_NOT_MET") + void couponMinOrderNotMet() { + givenProduct("PROD-001", 5000, 100); + given(couponRepository.findByCouponId("COUPON-MIN")).willReturn(Optional.of( + new CouponEntity("COUPON-MIN", CouponStatus.ACTIVE, CouponType.PLATFORM, + DiscountType.FIXED_AMOUNT, 1000, null, 10000))); + + // 총액 5,000 < 최소주문 10,000 + assertException(requestWithCoupon("COUPON-MIN"), "COUPON_MIN_ORDER_NOT_MET"); + verify(orderRepository, never()).save(any()); + } + } + + // ════════════════════════════════════════ + // createOrder — 포인트 검증 분기 + // ════════════════════════════════════════ + + @Nested + @DisplayName("createOrder: 포인트 검증") + class CreateOrder_PointsValidation { + + @Test + @DisplayName("포인트 null → 포인트 조회 자체를 하지 않는다") + void nullPoints_skipsPointLookup() { + givenProduct("PROD-001", 10000, 100); + givenDefaultPolicy(); + givenOrderSave(); + + orderService.createOrder(request("PROD-001", 1)); + + verify(userPointRepository, never()).findByUserId(anyString()); + } + + @Test + @DisplayName("포인트 0 → 포인트 조회 자체를 하지 않는다") + void zeroPoints_skipsPointLookup() { + givenProduct("PROD-001", 10000, 100); + givenDefaultPolicy(); + givenOrderSave(); + + CreateOrderRequest request = new CreateOrderRequest( + "USER-001", List.of(new OrderItemRequest("PROD-001", 1)), + "서울시", null, 0); + orderService.createOrder(request); + + verify(userPointRepository, never()).findByUserId(anyString()); + } + + @Test + @DisplayName("포인트 계정 없음 → POINTS_UNAVAILABLE, 할인 계산까지 가지 않는다") + void pointAccountNotFound_stopsBeforeDiscountCalc() { + givenProduct("PROD-001", 10000, 100); + given(userPointRepository.findByUserId("USER-001")).willReturn(Optional.empty()); + + assertException(requestWithPoints(1000), "POINTS_UNAVAILABLE"); + + verify(discountPolicyConfig, never()).maxDiscountPercent(); + verify(orderRepository, never()).save(any()); + } + + @Test + @DisplayName("포인트 잔액 부족 → POINTS_UNAVAILABLE, 할인 계산까지 가지 않는다") + void insufficientPoints_stopsBeforeDiscountCalc() { + givenProduct("PROD-001", 10000, 100); + given(userPointRepository.findByUserId("USER-001")) + .willReturn(Optional.of(new UserPointEntity("USER-001", 500))); + + assertException(requestWithPoints(1000), "POINTS_UNAVAILABLE"); + + verify(discountPolicyConfig, never()).maxDiscountPercent(); + verify(orderRepository, never()).save(any()); + } + } + + // ════════════════════════════════════════ + // createOrder — 할인 한도 검증 분기 + // ════════════════════════════════════════ + + @Nested + @DisplayName("createOrder: 할인 한도 검증") + class CreateOrder_DiscountLimit { + + @Test + @DisplayName("할인 초과 → DISCOUNT_LIMIT_EXCEEDED, 배송비 계산까지 가지 않는다") + void discountExceeded_stopsBeforeDeliveryFee() { + givenProduct("PROD-001", 10000, 100); + givenCoupon("COUPON-BIG", CouponStatus.ACTIVE, 2000); + given(userPointRepository.findByUserId("USER-001")) + .willReturn(Optional.of(new UserPointEntity("USER-001", 5000))); + given(discountPolicyConfig.maxDiscountPercent()).willReturn(30); + + // 총액 10,000 → 한도 3,000 / 쿠폰 2,000 + 포인트 2,000 = 4,000 → 초과 + CreateOrderRequest request = new CreateOrderRequest("USER-001", + List.of(new OrderItemRequest("PROD-001", 1)), + "서울시", "COUPON-BIG", 2000); + + assertException(request, "DISCOUNT_LIMIT_EXCEEDED"); + + verify(deliveryFeeConfig, never()).freeThreshold(); + verify(orderRepository, never()).save(any()); + } + } + + // ════════════════════════════════════════ + // createOrder — 배송비 계산 분기 + // ════════════════════════════════════════ + + @Nested + @DisplayName("createOrder: 배송비 계산") + class CreateOrder_DeliveryFee { + + @Test + @DisplayName("총액 >= 무료배송 기준 → 배송비 0") + void aboveThreshold_freeDelivery() { + givenProduct("PROD-001", 50000, 100); + given(discountPolicyConfig.maxDiscountPercent()).willReturn(30); + given(discountPolicyConfig.minPgPaymentPercent()).willReturn(50); + given(deliveryFeeConfig.freeThreshold()).willReturn(30000); + givenOrderSave(); + + OrderResponse response = orderService.createOrder(request("PROD-001", 1)); + + assertThat(response.deliveryFee()).isZero(); + verify(deliveryFeeConfig, never()).fee(); + } + + @Test + @DisplayName("총액 < 무료배송 기준 → 배송비 부과") + void belowThreshold_chargesDeliveryFee() { + givenProduct("PROD-001", 10000, 100); + given(discountPolicyConfig.maxDiscountPercent()).willReturn(30); + given(discountPolicyConfig.minPgPaymentPercent()).willReturn(50); + given(deliveryFeeConfig.freeThreshold()).willReturn(30000); + given(deliveryFeeConfig.fee()).willReturn(3000); + givenOrderSave(); + + OrderResponse response = orderService.createOrder(request("PROD-001", 1)); + + assertThat(response.deliveryFee()).isEqualTo(3000); + } + } + + // ════════════════════════════════════════ + // createOrder — PG 최소 결제 검증 분기 + // ════════════════════════════════════════ + + @Nested + @DisplayName("createOrder: PG 최소 결제 검증") + class CreateOrder_MinPgPayment { + + @Test + @DisplayName("PG 결제 미달 → PG_PAYMENT_BELOW_MINIMUM, 주문 저장까지 가지 않는다") + void pgBelowMinimum_stopsBeforeSave() { + givenProduct("PROD-001", 10000, 100); + givenCoupon("COUPON-OK", CouponStatus.ACTIVE, 1000); + given(userPointRepository.findByUserId("USER-001")) + .willReturn(Optional.of(new UserPointEntity("USER-001", 5000))); + given(discountPolicyConfig.maxDiscountPercent()).willReturn(30); + given(discountPolicyConfig.minPgPaymentPercent()).willReturn(80); + given(deliveryFeeConfig.freeThreshold()).willReturn(0); + + // 총액 10,000 → 할인 3,000 → pg 7,000 / 최소 8,000 → 위반 + CreateOrderRequest request = new CreateOrderRequest("USER-001", + List.of(new OrderItemRequest("PROD-001", 1)), + "서울시", "COUPON-OK", 2000); + + assertException(request, "PG_PAYMENT_BELOW_MINIMUM"); + verify(orderRepository, never()).save(any()); + } + } + + // ════════════════════════════════════════ + // createOrder — 성공 시 부수효과 (각각 개별 검증) + // ════════════════════════════════════════ + + @Nested + @DisplayName("createOrder: 성공 시 부수효과") + class CreateOrder_Success { + + @Test + @DisplayName("주문 저장이 호출된다") + void savesOrder() { + givenProduct("PROD-001", 10000, 100); + givenDefaultPolicy(); + givenOrderSave(); + + orderService.createOrder(request("PROD-001", 2)); + + verify(orderRepository).save(any(OrderEntity.class)); + } + + @Test + @DisplayName("상품 재고가 주문 수량만큼 차감된다") + void deductsStock() { + ProductEntity product = product("PROD-001", 10000, 100); + given(productRepository.findByProductId("PROD-001")).willReturn(Optional.of(product)); + givenDefaultPolicy(); + givenOrderSave(); + + orderService.createOrder(request("PROD-001", 3)); + + assertThat(product.getStockQuantity()).isEqualTo(97); + } + + @Test + @DisplayName("쿠폰 사용 시 → 쿠폰 상태가 USED로 변경된다") + void marksCouponUsed() { + givenProduct("PROD-001", 10000, 100); + CouponEntity coupon = activeCoupon("COUPON-OK", 1000); + given(couponRepository.findByCouponId("COUPON-OK")).willReturn(Optional.of(coupon)); + givenDefaultPolicy(); + givenOrderSave(); + + orderService.createOrder(requestWithCoupon("COUPON-OK")); + + assertThat(coupon.getStatus()).isEqualTo(CouponStatus.USED); + } + + @Test + @DisplayName("쿠폰 미사용 시 → 쿠폰 조회/변경 없음") + void noCoupon_noMarkUsed() { + givenProduct("PROD-001", 10000, 100); + givenDefaultPolicy(); + givenOrderSave(); + + orderService.createOrder(request("PROD-001", 1)); + + verify(couponRepository, never()).findByCouponId(anyString()); + } + + @Test + @DisplayName("포인트 사용 시 → 포인트가 차감된다") + void deductsPoints() { + givenProduct("PROD-001", 10000, 100); + UserPointEntity userPoint = new UserPointEntity("USER-001", 5000); + given(userPointRepository.findByUserId("USER-001")).willReturn(Optional.of(userPoint)); + givenDefaultPolicy(); + givenOrderSave(); + + orderService.createOrder(requestWithPoints(1000)); + + assertThat(userPoint.getBalance()).isEqualTo(4000); + } + + @Test + @DisplayName("포인트 미사용 시 → 포인트 조회/차감 없음") + void noPoints_noDeduction() { + givenProduct("PROD-001", 10000, 100); + givenDefaultPolicy(); + givenOrderSave(); + + orderService.createOrder(request("PROD-001", 1)); + + verify(userPointRepository, never()).findByUserId(anyString()); + } + + @Test + @DisplayName("응답 상태가 PENDING_PAYMENT이다") + void responseStatusIsPending() { + givenProduct("PROD-001", 10000, 100); + givenDefaultPolicy(); + givenOrderSave(); + + OrderResponse response = orderService.createOrder(request("PROD-001", 1)); + + assertThat(response.status()).isEqualTo("PENDING_PAYMENT"); + } + } + + // ════════════════════════════════════════ + // getOrder + // ════════════════════════════════════════ + + @Nested + @DisplayName("getOrder") + class GetOrder { + + @Test + @DisplayName("주문 없음 → ORDER_NOT_FOUND") + void orderNotFound() { + given(orderRepository.findByOrderId("ORD-NONE")).willReturn(Optional.empty()); + + assertThatThrownBy(() -> orderService.getOrder("ORD-NONE")) + .isInstanceOf(OrderException.class) + .satisfies(ex -> assertThat(((OrderException) ex).getCode()) + .isEqualTo("ORDER_NOT_FOUND")); + } + + @Test + @DisplayName("주문 있음 → 응답 반환") + void orderFound_returnsResponse() { + given(orderRepository.findByOrderId("ORD-001")) + .willReturn(Optional.of(orderWithItem("ORD-001"))); + + OrderResponse response = orderService.getOrder("ORD-001"); + + assertThat(response.orderId()).isEqualTo("ORD-001"); + } + } + + // ════════════════════════════════════════ + // getOrdersByUserId + // ════════════════════════════════════════ + + @Nested + @DisplayName("getOrdersByUserId") + class GetOrdersByUserId { + + private final Pageable pageable = PageRequest.of(0, 10); + + @Test + @DisplayName("status null → 전체 조회 쿼리 호출") + void statusNull_callsFindByUserId() { + given(orderRepository.findByUserIdOrderByCreatedAtDesc("USER-001", pageable)) + .willReturn(new PageImpl<>(List.of(orderWithItem("ORD-001")))); + + Page result = orderService.getOrdersByUserId("USER-001", null, pageable); + + assertThat(result.getContent()).hasSize(1); + verify(orderRepository).findByUserIdOrderByCreatedAtDesc("USER-001", pageable); + verify(orderRepository, never()).findByUserIdAndStatusOrderByCreatedAtDesc( + anyString(), any(OrderStatus.class), any(Pageable.class)); + } + + @Test + @DisplayName("status 지정 → 상태 필터 쿼리 호출") + void statusGiven_callsFindByUserIdAndStatus() { + given(orderRepository.findByUserIdAndStatusOrderByCreatedAtDesc( + "USER-001", OrderStatus.CONFIRMED, pageable)) + .willReturn(new PageImpl<>(List.of())); + + Page result = orderService.getOrdersByUserId( + "USER-001", OrderStatus.CONFIRMED, pageable); + + assertThat(result.getContent()).isEmpty(); + verify(orderRepository).findByUserIdAndStatusOrderByCreatedAtDesc( + "USER-001", OrderStatus.CONFIRMED, pageable); + verify(orderRepository, never()).findByUserIdOrderByCreatedAtDesc( + anyString(), any(Pageable.class)); + } + } + + // ════════════════════════════════════════ + // 상태 전이: confirmOrder + // ════════════════════════════════════════ + + @Nested + @DisplayName("confirmOrder") + class ConfirmOrder { + + @Test + @DisplayName("주문 없음 → ORDER_NOT_FOUND") + void orderNotFound() { + givenOrderNotFound("ORD-NONE"); + assertThrowsOrderNotFound(() -> orderService.confirmOrder("ORD-NONE")); + } + + @Test + @DisplayName("성공 → CONFIRMED 상태") + void success() { + OrderEntity order = orderWithItem("ORD-001"); + given(orderRepository.findByOrderId("ORD-001")).willReturn(Optional.of(order)); + + OrderResponse response = orderService.confirmOrder("ORD-001"); + + assertThat(response.status()).isEqualTo("CONFIRMED"); + } + } + + // ════════════════════════════════════════ + // 상태 전이: markPaymentFailed + // ════════════════════════════════════════ + + @Nested + @DisplayName("markPaymentFailed") + class MarkPaymentFailed { + + @Test + @DisplayName("주문 없음 → ORDER_NOT_FOUND") + void orderNotFound() { + givenOrderNotFound("ORD-NONE"); + assertThrowsOrderNotFound(() -> orderService.markPaymentFailed("ORD-NONE")); + } + + @Test + @DisplayName("성공 → PAYMENT_FAILED 상태 + 리소스 복원 호출") + void success_restoresResources() { + OrderEntity order = orderWithItemAndResources("ORD-001", "COUPON-OK", 1000); + ProductEntity product = product("PROD-001", 10000, 90); + CouponEntity coupon = activeCoupon("COUPON-OK", 1000); + coupon.markUsed(); + UserPointEntity userPoint = new UserPointEntity("USER-001", 4000); + + given(orderRepository.findByOrderId("ORD-001")).willReturn(Optional.of(order)); + given(productRepository.findByProductId("PROD-001")).willReturn(Optional.of(product)); + given(couponRepository.findByCouponId("COUPON-OK")).willReturn(Optional.of(coupon)); + given(userPointRepository.findByUserId("USER-001")).willReturn(Optional.of(userPoint)); + + OrderResponse response = orderService.markPaymentFailed("ORD-001"); + + assertThat(response.status()).isEqualTo("PAYMENT_FAILED"); + assertThat(product.getStockQuantity()).isEqualTo(92); + assertThat(coupon.getStatus()).isEqualTo(CouponStatus.ACTIVE); + assertThat(userPoint.getBalance()).isEqualTo(5000); + } + } + + // ════════════════════════════════════════ + // 상태 전이: startShipping + // ════════════════════════════════════════ + + @Nested + @DisplayName("startShipping") + class StartShipping { + + @Test + @DisplayName("주문 없음 → ORDER_NOT_FOUND") + void orderNotFound() { + givenOrderNotFound("ORD-NONE"); + assertThrowsOrderNotFound(() -> orderService.startShipping("ORD-NONE")); + } + + @Test + @DisplayName("PENDING → SHIPPING 시도 → INVALID_STATUS_TRANSITION") + void invalidFromPending() { + given(orderRepository.findByOrderId("ORD-001")) + .willReturn(Optional.of(orderWithItem("ORD-001"))); + + assertThatThrownBy(() -> orderService.startShipping("ORD-001")) + .isInstanceOf(OrderException.class) + .satisfies(ex -> assertThat(((OrderException) ex).getCode()) + .isEqualTo("INVALID_STATUS_TRANSITION")); + } + + @Test + @DisplayName("CONFIRMED → SHIPPING 성공") + void validFromConfirmed() { + OrderEntity order = orderWithItem("ORD-001"); + order.confirm(); + given(orderRepository.findByOrderId("ORD-001")).willReturn(Optional.of(order)); + + OrderResponse response = orderService.startShipping("ORD-001"); + + assertThat(response.status()).isEqualTo("SHIPPING"); + } + } + + // ════════════════════════════════════════ + // 상태 전이: markDelivered + // ════════════════════════════════════════ + + @Nested + @DisplayName("markDelivered") + class MarkDelivered { + + @Test + @DisplayName("주문 없음 → ORDER_NOT_FOUND") + void orderNotFound() { + givenOrderNotFound("ORD-NONE"); + assertThrowsOrderNotFound(() -> orderService.markDelivered("ORD-NONE")); + } + + @Test + @DisplayName("CONFIRMED → DELIVERED 시도 → INVALID_STATUS_TRANSITION") + void invalidFromConfirmed() { + OrderEntity order = orderWithItem("ORD-001"); + order.confirm(); + given(orderRepository.findByOrderId("ORD-001")).willReturn(Optional.of(order)); + + assertThatThrownBy(() -> orderService.markDelivered("ORD-001")) + .isInstanceOf(OrderException.class) + .satisfies(ex -> assertThat(((OrderException) ex).getCode()) + .isEqualTo("INVALID_STATUS_TRANSITION")); + } + + @Test + @DisplayName("SHIPPING → DELIVERED 성공") + void validFromShipping() { + OrderEntity order = orderWithItem("ORD-001"); + order.confirm(); + order.startShipping(); + given(orderRepository.findByOrderId("ORD-001")).willReturn(Optional.of(order)); + + OrderResponse response = orderService.markDelivered("ORD-001"); + + assertThat(response.status()).isEqualTo("DELIVERED"); + } + } + + // ════════════════════════════════════════ + // 상태 전이: confirmPurchase + // ════════════════════════════════════════ + + @Nested + @DisplayName("confirmPurchase") + class ConfirmPurchase { + + @Test + @DisplayName("주문 없음 → ORDER_NOT_FOUND") + void orderNotFound() { + givenOrderNotFound("ORD-NONE"); + assertThrowsOrderNotFound(() -> orderService.confirmPurchase("ORD-NONE")); + } + + @Test + @DisplayName("SHIPPING → PURCHASE_CONFIRMED 시도 → INVALID_STATUS_TRANSITION") + void invalidFromShipping() { + OrderEntity order = orderWithItem("ORD-001"); + order.confirm(); + order.startShipping(); + given(orderRepository.findByOrderId("ORD-001")).willReturn(Optional.of(order)); + + assertThatThrownBy(() -> orderService.confirmPurchase("ORD-001")) + .isInstanceOf(OrderException.class) + .satisfies(ex -> assertThat(((OrderException) ex).getCode()) + .isEqualTo("INVALID_STATUS_TRANSITION")); + } + + @Test + @DisplayName("DELIVERED → PURCHASE_CONFIRMED 성공") + void validFromDelivered() { + OrderEntity order = deliveredOrder("ORD-001"); + given(orderRepository.findByOrderId("ORD-001")).willReturn(Optional.of(order)); + + OrderResponse response = orderService.confirmPurchase("ORD-001"); + + assertThat(response.status()).isEqualTo("PURCHASE_CONFIRMED"); + } + } + + // ════════════════════════════════════════ + // 상태 전이: requestReturn + // ════════════════════════════════════════ + + @Nested + @DisplayName("requestReturn") + class RequestReturn { + + @Test + @DisplayName("주문 없음 → ORDER_NOT_FOUND") + void orderNotFound() { + givenOrderNotFound("ORD-NONE"); + assertThrowsOrderNotFound(() -> orderService.requestReturn("ORD-NONE")); + } + + @Test + @DisplayName("CONFIRMED → RETURN_REQUESTED 시도 → INVALID_STATUS_TRANSITION") + void invalidFromConfirmed() { + OrderEntity order = orderWithItem("ORD-001"); + order.confirm(); + given(orderRepository.findByOrderId("ORD-001")).willReturn(Optional.of(order)); + + assertThatThrownBy(() -> orderService.requestReturn("ORD-001")) + .isInstanceOf(OrderException.class) + .satisfies(ex -> assertThat(((OrderException) ex).getCode()) + .isEqualTo("INVALID_STATUS_TRANSITION")); + } + + @Test + @DisplayName("DELIVERED → RETURN_REQUESTED 성공") + void validFromDelivered() { + OrderEntity order = deliveredOrder("ORD-001"); + given(orderRepository.findByOrderId("ORD-001")).willReturn(Optional.of(order)); + + OrderResponse response = orderService.requestReturn("ORD-001"); + + assertThat(response.status()).isEqualTo("RETURN_REQUESTED"); + } + } + + // ════════════════════════════════════════ + // 상태 전이: completeReturn + // ════════════════════════════════════════ + + @Nested + @DisplayName("completeReturn") + class CompleteReturn { + + @Test + @DisplayName("주문 없음 → ORDER_NOT_FOUND") + void orderNotFound() { + givenOrderNotFound("ORD-NONE"); + assertThrowsOrderNotFound(() -> orderService.completeReturn("ORD-NONE")); + } + + @Test + @DisplayName("DELIVERED → RETURNED 시도 → INVALID_STATUS_TRANSITION") + void invalidFromDelivered() { + OrderEntity order = deliveredOrder("ORD-001"); + given(orderRepository.findByOrderId("ORD-001")).willReturn(Optional.of(order)); + + assertThatThrownBy(() -> orderService.completeReturn("ORD-001")) + .isInstanceOf(OrderException.class) + .satisfies(ex -> assertThat(((OrderException) ex).getCode()) + .isEqualTo("INVALID_STATUS_TRANSITION")); + } + + @Test + @DisplayName("RETURN_REQUESTED → RETURNED 성공 + 리소스 복원") + void validFromReturnRequested_restoresResources() { + OrderEntity order = deliveredOrder("ORD-001"); + order.requestReturn(); + // 쿠폰/포인트 없는 주문 + given(orderRepository.findByOrderId("ORD-001")).willReturn(Optional.of(order)); + ProductEntity product = product("PROD-001", 10000, 90); + given(productRepository.findByProductId("PROD-001")).willReturn(Optional.of(product)); + + OrderResponse response = orderService.completeReturn("ORD-001"); + + assertThat(response.status()).isEqualTo("RETURNED"); + assertThat(product.getStockQuantity()).isEqualTo(92); + } + } + + // ════════════════════════════════════════ + // cancelOrder + // ════════════════════════════════════════ + + @Nested + @DisplayName("cancelOrder") + class CancelOrder { + + @Test + @DisplayName("주문 없음 → ORDER_NOT_FOUND") + void orderNotFound() { + givenOrderNotFound("ORD-NONE"); + assertThrowsOrderNotFound(() -> orderService.cancelOrder("ORD-NONE")); + } + + @Test + @DisplayName("이미 취소됨 → ORDER_ALREADY_CANCELLED") + void alreadyCancelled() { + OrderEntity order = orderWithItem("ORD-001"); + order.cancel(); + given(orderRepository.findByOrderId("ORD-001")).willReturn(Optional.of(order)); + given(productRepository.findByProductId("PROD-001")) + .willReturn(Optional.of(product("PROD-001", 10000, 100))); + + assertThatThrownBy(() -> orderService.cancelOrder("ORD-001")) + .isInstanceOf(OrderException.class) + .satisfies(ex -> assertThat(((OrderException) ex).getCode()) + .isEqualTo("ORDER_ALREADY_CANCELLED")); + } + + @Test + @DisplayName("취소 불가 상태(PURCHASE_CONFIRMED) → ORDER_NOT_CANCELLABLE") + void notCancellable() { + OrderEntity order = deliveredOrder("ORD-001"); + order.confirmPurchase(); + given(orderRepository.findByOrderId("ORD-001")).willReturn(Optional.of(order)); + given(productRepository.findByProductId("PROD-001")) + .willReturn(Optional.of(product("PROD-001", 10000, 100))); + + assertThatThrownBy(() -> orderService.cancelOrder("ORD-001")) + .isInstanceOf(OrderException.class) + .satisfies(ex -> assertThat(((OrderException) ex).getCode()) + .isEqualTo("ORDER_NOT_CANCELLABLE")); + } + + @Test + @DisplayName("취소 성공 → 재고 복원") + void success_restoresStock() { + OrderEntity order = orderWithItem("ORD-001"); + ProductEntity product = product("PROD-001", 10000, 90); + given(orderRepository.findByOrderId("ORD-001")).willReturn(Optional.of(order)); + given(productRepository.findByProductId("PROD-001")).willReturn(Optional.of(product)); + + orderService.cancelOrder("ORD-001"); + + assertThat(product.getStockQuantity()).isEqualTo(92); + } + + @Test + @DisplayName("쿠폰 있는 주문 취소 → 쿠폰 ACTIVE로 복원") + void success_restoresCoupon() { + OrderEntity order = orderWithItemAndResources("ORD-001", "COUPON-OK", null); + CouponEntity coupon = activeCoupon("COUPON-OK", 1000); + coupon.markUsed(); + given(orderRepository.findByOrderId("ORD-001")).willReturn(Optional.of(order)); + given(productRepository.findByProductId("PROD-001")) + .willReturn(Optional.of(product("PROD-001", 10000, 90))); + given(couponRepository.findByCouponId("COUPON-OK")).willReturn(Optional.of(coupon)); + + orderService.cancelOrder("ORD-001"); + + assertThat(coupon.getStatus()).isEqualTo(CouponStatus.ACTIVE); + } + + @Test + @DisplayName("포인트 있는 주문 취소 → 포인트 복원") + void success_restoresPoints() { + OrderEntity order = orderWithItemAndResources("ORD-001", null, 1000); + UserPointEntity userPoint = new UserPointEntity("USER-001", 4000); + given(orderRepository.findByOrderId("ORD-001")).willReturn(Optional.of(order)); + given(productRepository.findByProductId("PROD-001")) + .willReturn(Optional.of(product("PROD-001", 10000, 90))); + given(userPointRepository.findByUserId("USER-001")).willReturn(Optional.of(userPoint)); + + orderService.cancelOrder("ORD-001"); + + assertThat(userPoint.getBalance()).isEqualTo(5000); + } + + @Test + @DisplayName("쿠폰·포인트 없는 주문 취소 → 쿠폰/포인트 조회 안 함") + void noCouponNoPoints_skipsRestore() { + OrderEntity order = orderWithItem("ORD-001"); + given(orderRepository.findByOrderId("ORD-001")).willReturn(Optional.of(order)); + given(productRepository.findByProductId("PROD-001")) + .willReturn(Optional.of(product("PROD-001", 10000, 90))); + + orderService.cancelOrder("ORD-001"); + + verify(couponRepository, never()).findByCouponId(anyString()); + verify(userPointRepository, never()).findByUserId(anyString()); + } + } + + // ════════════════════════════════════════ + // cancelItem + // ════════════════════════════════════════ + + @Nested + @DisplayName("cancelItem") + class CancelItem { + + @Test + @DisplayName("주문 없음 → ORDER_NOT_FOUND") + void orderNotFound() { + givenOrderNotFound("ORD-NONE"); + assertThatThrownBy(() -> orderService.cancelItem("ORD-NONE", "PROD-001")) + .isInstanceOf(OrderException.class) + .satisfies(ex -> assertThat(((OrderException) ex).getCode()) + .isEqualTo("ORDER_NOT_FOUND")); + } + + @Test + @DisplayName("취소 불가 상태 → ORDER_NOT_CANCELLABLE") + void notCancellable() { + OrderEntity order = deliveredOrder("ORD-001"); + order.confirmPurchase(); + given(orderRepository.findByOrderId("ORD-001")).willReturn(Optional.of(order)); + + assertThatThrownBy(() -> orderService.cancelItem("ORD-001", "PROD-001")) + .isInstanceOf(OrderException.class) + .satisfies(ex -> assertThat(((OrderException) ex).getCode()) + .isEqualTo("ORDER_NOT_CANCELLABLE")); + + verify(productRepository, never()).findByProductId(anyString()); + } + + @Test + @DisplayName("해당 상품이 주문에 없음 → PRODUCT_NOT_FOUND") + void itemNotFound() { + OrderEntity order = orderWithItem("ORD-001"); + given(orderRepository.findByOrderId("ORD-001")).willReturn(Optional.of(order)); + + assertThatThrownBy(() -> orderService.cancelItem("ORD-001", "PROD-NONE")) + .isInstanceOf(OrderException.class) + .satisfies(ex -> assertThat(((OrderException) ex).getCode()) + .isEqualTo("PRODUCT_NOT_FOUND")); + } + + @Test + @DisplayName("개별 상품 취소 성공 → 해당 상품 재고만 복원, 주문은 유지") + void partialCancel_restoresStockOnly() { + OrderEntity order = orderWithTwoItems("ORD-001"); + ProductEntity product1 = product("PROD-001", 10000, 90); + given(orderRepository.findByOrderId("ORD-001")).willReturn(Optional.of(order)); + given(productRepository.findByProductId("PROD-001")).willReturn(Optional.of(product1)); + + OrderResponse response = orderService.cancelItem("ORD-001", "PROD-001"); + + assertThat(product1.getStockQuantity()).isEqualTo(92); + assertThat(response.status()).isNotEqualTo("CANCELLED"); + verify(couponRepository, never()).findByCouponId(anyString()); + } + + @Test + @DisplayName("마지막 상품 취소 → 전체 주문 취소 + 쿠폰/포인트 복원") + void lastItemCancel_cancelsOrder() { + OrderEntity order = orderWithItemAndResources("ORD-001", "COUPON-OK", 1000); + ProductEntity product = product("PROD-001", 10000, 90); + CouponEntity coupon = activeCoupon("COUPON-OK", 1000); + coupon.markUsed(); + UserPointEntity userPoint = new UserPointEntity("USER-001", 4000); + + given(orderRepository.findByOrderId("ORD-001")).willReturn(Optional.of(order)); + given(productRepository.findByProductId("PROD-001")).willReturn(Optional.of(product)); + given(couponRepository.findByCouponId("COUPON-OK")).willReturn(Optional.of(coupon)); + given(userPointRepository.findByUserId("USER-001")).willReturn(Optional.of(userPoint)); + + OrderResponse response = orderService.cancelItem("ORD-001", "PROD-001"); + + assertThat(response.status()).isEqualTo("CANCELLED"); + assertThat(coupon.getStatus()).isEqualTo(CouponStatus.ACTIVE); + assertThat(userPoint.getBalance()).isEqualTo(5000); + } + } + + // ════════════════════════════════════════ + // 헬퍼: 요청 생성 + // ════════════════════════════════════════ + + private static CreateOrderRequest request(String productId, int quantity) { + return new CreateOrderRequest("USER-001", + List.of(new OrderItemRequest(productId, quantity)), + "서울시", null, null); + } + + private static CreateOrderRequest requestWithCoupon(String couponId) { + return new CreateOrderRequest("USER-001", + List.of(new OrderItemRequest("PROD-001", 1)), + "서울시", couponId, null); + } + + private static CreateOrderRequest requestWithPoints(int points) { + return new CreateOrderRequest("USER-001", + List.of(new OrderItemRequest("PROD-001", 1)), + "서울시", null, points); + } + + // ════════════════════════════════════════ + // 헬퍼: 엔티티 생성 + // ════════════════════════════════════════ + + private static ProductEntity product(String productId, int price, int stock) { + return new ProductEntity(productId, "상품", price, stock); + } + + private static CouponEntity activeCoupon(String couponId, int discountValue) { + return new CouponEntity(couponId, CouponStatus.ACTIVE, CouponType.PLATFORM, + DiscountType.FIXED_AMOUNT, discountValue, null); + } + + private static OrderEntity orderWithItem(String orderId) { + OrderEntity order = OrderEntity.builder() + .orderId(orderId).userId("USER-001").totalAmount(20000).build(); + order.addItem(new OrderItemEntity("PROD-001", 2, 10000)); + return order; + } + + private static OrderEntity orderWithTwoItems(String orderId) { + OrderEntity order = OrderEntity.builder() + .orderId(orderId).userId("USER-001").totalAmount(30000).build(); + order.addItem(new OrderItemEntity("PROD-001", 2, 10000)); + order.addItem(new OrderItemEntity("PROD-002", 1, 10000)); + return order; + } + + private static OrderEntity orderWithItemAndResources(String orderId, String couponId, Integer points) { + OrderEntity order = OrderEntity.builder() + .orderId(orderId).userId("USER-001").totalAmount(20000) + .couponId(couponId).pointAmountToUse(points).build(); + order.addItem(new OrderItemEntity("PROD-001", 2, 10000)); + return order; + } + + private static OrderEntity deliveredOrder(String orderId) { + OrderEntity order = orderWithItem(orderId); + order.confirm(); + order.startShipping(); + order.markDelivered(); + return order; + } + + // ════════════════════════════════════════ + // 헬퍼: Mock 세팅 + // ════════════════════════════════════════ + + private void givenProduct(String productId, int price, int stock) { + given(productRepository.findByProductId(productId)) + .willReturn(Optional.of(product(productId, price, stock))); + } + + private void givenCoupon(String couponId, CouponStatus status, int discountValue) { + given(couponRepository.findByCouponId(couponId)).willReturn(Optional.of( + new CouponEntity(couponId, status, CouponType.PLATFORM, + DiscountType.FIXED_AMOUNT, discountValue, null))); + } + + private void givenDefaultPolicy() { + given(discountPolicyConfig.maxDiscountPercent()).willReturn(30); + given(discountPolicyConfig.minPgPaymentPercent()).willReturn(50); + given(deliveryFeeConfig.freeThreshold()).willReturn(0); + } + + private void givenOrderSave() { + given(orderRepository.save(any())).willAnswer(inv -> inv.getArgument(0)); + } + + private void givenOrderNotFound(String orderId) { + given(orderRepository.findByOrderId(orderId)).willReturn(Optional.empty()); + } + + // ════════════════════════════════════════ + // 헬퍼: 공통 검증 + // ════════════════════════════════════════ + + private void assertException(CreateOrderRequest request, String expectedCode) { + assertThatThrownBy(() -> orderService.createOrder(request)) + .isInstanceOf(OrderException.class) + .satisfies(ex -> assertThat(((OrderException) ex).getCode()) + .isEqualTo(expectedCode)); + } + + private void assertThrowsOrderNotFound(Runnable action) { + assertThatThrownBy(action::run) + .isInstanceOf(OrderException.class) + .satisfies(ex -> assertThat(((OrderException) ex).getCode()) + .isEqualTo("ORDER_NOT_FOUND")); + } +} diff --git a/order/src/test/resources/application.yml b/order/src/test/resources/application.yml new file mode 100644 index 0000000..77f6cc6 --- /dev/null +++ b/order/src/test/resources/application.yml @@ -0,0 +1,20 @@ +spring: + datasource: + url: jdbc:h2:mem:testdb + driver-class-name: org.h2.Driver + username: sa + password: + jpa: + hibernate: + ddl-auto: create-drop + open-in-view: false + +order: + discount: + max-discount-percent: 30 + min-pg-payment-percent: 50 + delivery: + fee: 3000 + free-threshold: 30000 + payment: + timeout-minutes: 30 diff --git a/payment/build.gradle b/payment/build.gradle index 9a9aeff..41fb4e9 100644 --- a/payment/build.gradle +++ b/payment/build.gradle @@ -6,7 +6,9 @@ dependencies { implementation project(':core') implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' - runtimeOnly 'com.h2database:h2' + runtimeOnly 'org.postgresql:postgresql' + runtimeOnly 'org.postgresql:postgresql' + testRuntimeOnly 'com.h2database:h2' testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.boot:spring-boot-starter-data-jpa-test' } diff --git a/payment/src/main/java/com/paybook/payment/controller/PaymentController.java b/payment/src/main/java/com/paybook/payment/controller/PaymentController.java index 9dd81c1..d9050b4 100644 --- a/payment/src/main/java/com/paybook/payment/controller/PaymentController.java +++ b/payment/src/main/java/com/paybook/payment/controller/PaymentController.java @@ -14,18 +14,20 @@ @RequiredArgsConstructor public class PaymentController { + private static final String ORDER_ID = "orderId"; + private final PaymentService paymentService; @PostMapping public ResponseEntity> processPayment(@RequestBody Map request) { - String orderId = (String) request.get("orderId"); + String orderId = (String) request.get(ORDER_ID); int pgPaymentAmount = (int) request.get("pgPaymentAmount"); PaymentEntity payment = paymentService.processPayment(orderId, pgPaymentAmount); return ResponseEntity.status(HttpStatus.OK).body(Map.of( "paymentId", payment.getPaymentId(), - "orderId", payment.getOrderId(), + ORDER_ID, payment.getOrderId(), "status", payment.getStatus().name(), "pgTransactionId", payment.getPgTransactionId() != null ? payment.getPgTransactionId() : "" )); @@ -37,7 +39,7 @@ public ResponseEntity> refundPayment(@PathVariable String or return ResponseEntity.ok(Map.of( "paymentId", payment.getPaymentId(), - "orderId", payment.getOrderId(), + ORDER_ID, payment.getOrderId(), "status", payment.getStatus().name() )); } diff --git a/payment/src/main/java/com/paybook/payment/service/PaymentService.java b/payment/src/main/java/com/paybook/payment/service/PaymentService.java index d625bf6..e60ee7a 100644 --- a/payment/src/main/java/com/paybook/payment/service/PaymentService.java +++ b/payment/src/main/java/com/paybook/payment/service/PaymentService.java @@ -10,23 +10,20 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.util.concurrent.atomic.AtomicLong; +import java.util.UUID; @Service @RequiredArgsConstructor public class PaymentService { - private static final int PAYMENT_ID_PAD_LENGTH = 6; - - private final AtomicLong sequence = new AtomicLong(1); - private final PaymentRepository paymentRepository; + private final PgClient pgClient; private final OrderServiceClient orderServiceClient; @Transactional public PaymentEntity processPayment(String orderId, int pgPaymentAmount) { - String paymentId = "PAY-" + String.format("%0" + PAYMENT_ID_PAD_LENGTH + "d", sequence.getAndIncrement()); + String paymentId = "PAY-" + UUID.randomUUID().toString().substring(0, 8); PaymentEntity payment = new PaymentEntity(paymentId, orderId, pgPaymentAmount); paymentRepository.save(payment); diff --git a/payment/src/main/resources/application.yml b/payment/src/main/resources/application.yml index 190f0b6..f154893 100644 --- a/payment/src/main/resources/application.yml +++ b/payment/src/main/resources/application.yml @@ -2,13 +2,13 @@ spring: application: name: payment-service datasource: - url: jdbc:h2:mem:paymentdb - driver-class-name: org.h2.Driver - username: sa - password: + url: jdbc:postgresql://localhost:5433/paymentdb + driver-class-name: org.postgresql.Driver + username: paybook + password: paybook jpa: hibernate: - ddl-auto: create-drop + ddl-auto: update open-in-view: false server: diff --git a/payment/src/test/java/com/paybook/payment/service/unit/PaymentServiceUnitTest.java b/payment/src/test/java/com/paybook/payment/service/unit/PaymentServiceUnitTest.java new file mode 100644 index 0000000..c6bf16a --- /dev/null +++ b/payment/src/test/java/com/paybook/payment/service/unit/PaymentServiceUnitTest.java @@ -0,0 +1,256 @@ +package com.paybook.payment.service.unit; + +import com.paybook.core.entity.PaymentEntity; +import com.paybook.core.entity.PaymentStatus; +import com.paybook.core.repository.PaymentRepository; +import com.paybook.payment.client.OrderServiceClient; +import com.paybook.payment.client.PgClient; +import com.paybook.payment.client.PgClient.PgPaymentResult; +import com.paybook.payment.client.PgClient.PgRefundResult; +import com.paybook.payment.exception.PaymentException; +import com.paybook.payment.service.PaymentService; +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.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +/** + * PaymentService 순수 유닛 테스트 (런던 학파). + * + * Spring 컨텍스트 없이, 모든 의존성을 Mock으로 대체하여 + * PaymentService의 모든 분기 로직을 검증한다. + */ +@ExtendWith(MockitoExtension.class) +@DisplayName("PaymentService 유닛 테스트") +class PaymentServiceUnitTest { + + @InjectMocks + private PaymentService paymentService; + + @Mock + private PaymentRepository paymentRepository; + + @Mock + private PgClient pgClient; + + @Mock + private OrderServiceClient orderServiceClient; + + // ════════════════════════════════════════ + // processPayment — PG 결과 분기 + // ════════════════════════════════════════ + + @Nested + @DisplayName("processPayment: PG 결제 결과 분기") + class ProcessPayment { + + @Test + @DisplayName("PG 성공 → SUCCESS 상태, confirmOrder 호출") + void pgSuccess_marksSuccessAndConfirms() { + given(paymentRepository.save(any())).willAnswer(inv -> inv.getArgument(0)); + given(pgClient.requestPayment(anyString(), eq(10000))) + .willReturn(new PgPaymentResult(true, "PG-TX-001", null)); + + PaymentEntity result = paymentService.processPayment("ORD-001", 10000); + + assertThat(result.getStatus()).isEqualTo(PaymentStatus.SUCCESS); + assertThat(result.getPgTransactionId()).isEqualTo("PG-TX-001"); + verify(orderServiceClient).confirmOrder("ORD-001"); + verify(orderServiceClient, never()).markPaymentFailed(anyString()); + } + + @Test + @DisplayName("PG 실패 → FAILED 상태, markPaymentFailed 호출") + void pgFailure_marksFailedAndNotifies() { + given(paymentRepository.save(any())).willAnswer(inv -> inv.getArgument(0)); + given(pgClient.requestPayment(anyString(), eq(10000))) + .willReturn(new PgPaymentResult(false, null, "잔액 부족")); + + PaymentEntity result = paymentService.processPayment("ORD-001", 10000); + + assertThat(result.getStatus()).isEqualTo(PaymentStatus.FAILED); + assertThat(result.getPgTransactionId()).isNull(); + verify(orderServiceClient).markPaymentFailed("ORD-001"); + verify(orderServiceClient, never()).confirmOrder(anyString()); + } + + @Test + @DisplayName("결제 엔티티가 DB에 저장된다") + void savesPaymentEntity() { + given(paymentRepository.save(any())).willAnswer(inv -> inv.getArgument(0)); + given(pgClient.requestPayment(anyString(), anyInt())) + .willReturn(new PgPaymentResult(true, "PG-TX", null)); + + paymentService.processPayment("ORD-001", 5000); + + verify(paymentRepository).save(any(PaymentEntity.class)); + } + + @Test + @DisplayName("PG 호출 전에 저장이 먼저 호출된다 (PENDING 상태로)") + void saveBeforePgCall() { + given(paymentRepository.save(any())).willAnswer(inv -> { + PaymentEntity saved = inv.getArgument(0); + assertThat(saved.getStatus()).isEqualTo(PaymentStatus.PENDING); + return saved; + }); + given(pgClient.requestPayment(anyString(), anyInt())) + .willReturn(new PgPaymentResult(true, "PG-TX", null)); + + paymentService.processPayment("ORD-001", 5000); + + verify(paymentRepository).save(any()); + } + + @Test + @DisplayName("paymentId가 PAY- 접두어로 생성된다") + void paymentIdStartsWithPrefix() { + given(paymentRepository.save(any())).willAnswer(inv -> inv.getArgument(0)); + given(pgClient.requestPayment(anyString(), anyInt())) + .willReturn(new PgPaymentResult(true, "PG-TX", null)); + + PaymentEntity result = paymentService.processPayment("ORD-001", 5000); + + assertThat(result.getPaymentId()).startsWith("PAY-"); + } + } + + // ════════════════════════════════════════ + // refundPayment — 검증 분기 + // ════════════════════════════════════════ + + @Nested + @DisplayName("refundPayment: 검증") + class RefundPayment_Validation { + + @Test + @DisplayName("결제 없음 → PAYMENT_NOT_FOUND, PG 환불 호출까지 가지 않는다") + void paymentNotFound_stopsEarly() { + given(paymentRepository.findByOrderId("ORD-NONE")).willReturn(Optional.empty()); + + assertThatThrownBy(() -> paymentService.refundPayment("ORD-NONE")) + .isInstanceOf(PaymentException.class) + .satisfies(ex -> assertThat(((PaymentException) ex).getCode()) + .isEqualTo("PAYMENT_NOT_FOUND")); + + verify(pgClient, never()).requestRefund(anyString(), anyInt()); + } + + @Test + @DisplayName("PENDING 상태 → NOT_REFUNDABLE, PG 환불 호출까지 가지 않는다") + void pendingStatus_notRefundable() { + PaymentEntity payment = new PaymentEntity("PAY-001", "ORD-001", 10000); + + given(paymentRepository.findByOrderId("ORD-001")).willReturn(Optional.of(payment)); + + assertThatThrownBy(() -> paymentService.refundPayment("ORD-001")) + .isInstanceOf(PaymentException.class) + .satisfies(ex -> assertThat(((PaymentException) ex).getCode()) + .isEqualTo("NOT_REFUNDABLE")); + + verify(pgClient, never()).requestRefund(anyString(), anyInt()); + } + + @Test + @DisplayName("FAILED 상태 → NOT_REFUNDABLE") + void failedStatus_notRefundable() { + PaymentEntity payment = new PaymentEntity("PAY-001", "ORD-001", 10000); + payment.markFailed(); + + given(paymentRepository.findByOrderId("ORD-001")).willReturn(Optional.of(payment)); + + assertThatThrownBy(() -> paymentService.refundPayment("ORD-001")) + .isInstanceOf(PaymentException.class) + .satisfies(ex -> assertThat(((PaymentException) ex).getCode()) + .isEqualTo("NOT_REFUNDABLE")); + } + + @Test + @DisplayName("REFUNDED 상태 → NOT_REFUNDABLE") + void refundedStatus_notRefundable() { + PaymentEntity payment = new PaymentEntity("PAY-001", "ORD-001", 10000); + payment.markSuccess("PG-TX-001"); + payment.markRefunded(); + + given(paymentRepository.findByOrderId("ORD-001")).willReturn(Optional.of(payment)); + + assertThatThrownBy(() -> paymentService.refundPayment("ORD-001")) + .isInstanceOf(PaymentException.class) + .satisfies(ex -> assertThat(((PaymentException) ex).getCode()) + .isEqualTo("NOT_REFUNDABLE")); + } + } + + // ════════════════════════════════════════ + // refundPayment — PG 환불 결과 분기 + // ════════════════════════════════════════ + + @Nested + @DisplayName("refundPayment: PG 환불 결과") + class RefundPayment_PgResult { + + @Test + @DisplayName("PG 환불 성공 → REFUNDED 상태") + void pgRefundSuccess_marksRefunded() { + PaymentEntity payment = successPayment(); + given(paymentRepository.findByOrderId("ORD-001")).willReturn(Optional.of(payment)); + given(pgClient.requestRefund("PG-TX-001", 10000)) + .willReturn(new PgRefundResult(true, "REFUND-001", null)); + + PaymentEntity result = paymentService.refundPayment("ORD-001"); + + assertThat(result.getStatus()).isEqualTo(PaymentStatus.REFUNDED); + } + + @Test + @DisplayName("PG 환불 실패 → PG_REFUND_FAILED, 상태 변경 없음") + void pgRefundFailure_throwsAndKeepsStatus() { + PaymentEntity payment = successPayment(); + given(paymentRepository.findByOrderId("ORD-001")).willReturn(Optional.of(payment)); + given(pgClient.requestRefund("PG-TX-001", 10000)) + .willReturn(new PgRefundResult(false, null, "PG 시스템 오류")); + + assertThatThrownBy(() -> paymentService.refundPayment("ORD-001")) + .isInstanceOf(PaymentException.class) + .satisfies(ex -> assertThat(((PaymentException) ex).getCode()) + .isEqualTo("PG_REFUND_FAILED")); + + assertThat(payment.getStatus()).isEqualTo(PaymentStatus.SUCCESS); + } + + @Test + @DisplayName("PG 환불 시 pgTransactionId와 금액이 전달된다") + void passesCorrectParams() { + PaymentEntity payment = successPayment(); + given(paymentRepository.findByOrderId("ORD-001")).willReturn(Optional.of(payment)); + given(pgClient.requestRefund("PG-TX-001", 10000)) + .willReturn(new PgRefundResult(true, "REFUND-001", null)); + + paymentService.refundPayment("ORD-001"); + + verify(pgClient).requestRefund("PG-TX-001", 10000); + } + } + + // ════════════════════════════════════════ + // 헬퍼 + // ════════════════════════════════════════ + + private static PaymentEntity successPayment() { + PaymentEntity payment = new PaymentEntity("PAY-001", "ORD-001", 10000); + payment.markSuccess("PG-TX-001"); + return payment; + } +} diff --git a/payment/src/test/resources/application.yml b/payment/src/test/resources/application.yml new file mode 100644 index 0000000..db15958 --- /dev/null +++ b/payment/src/test/resources/application.yml @@ -0,0 +1,14 @@ +spring: + datasource: + url: jdbc:h2:mem:testdb + driver-class-name: org.h2.Driver + username: sa + password: + jpa: + hibernate: + ddl-auto: create-drop + open-in-view: false + +payment: + order-service: + base-url: http://localhost:8080 diff --git a/settlement/build.gradle b/settlement/build.gradle index 52f6e6f..317f16d 100644 --- a/settlement/build.gradle +++ b/settlement/build.gradle @@ -5,4 +5,9 @@ plugins { dependencies { implementation project(':core') implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + runtimeOnly 'org.postgresql:postgresql' + testRuntimeOnly 'com.h2database:h2' + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.springframework.boot:spring-boot-starter-data-jpa-test' } diff --git a/settlement/src/main/java/com/paybook/settlement/SettlementApplication.java b/settlement/src/main/java/com/paybook/settlement/SettlementApplication.java index 2f99a3e..d1cd4fd 100644 --- a/settlement/src/main/java/com/paybook/settlement/SettlementApplication.java +++ b/settlement/src/main/java/com/paybook/settlement/SettlementApplication.java @@ -2,8 +2,12 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.ConfigurationPropertiesScan; +import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication +@ConfigurationPropertiesScan +@EnableScheduling public class SettlementApplication { public static void main(String[] args) { diff --git a/settlement/src/main/java/com/paybook/settlement/client/OrderServiceClient.java b/settlement/src/main/java/com/paybook/settlement/client/OrderServiceClient.java new file mode 100644 index 0000000..5a5f0f8 --- /dev/null +++ b/settlement/src/main/java/com/paybook/settlement/client/OrderServiceClient.java @@ -0,0 +1,78 @@ +package com.paybook.settlement.client; + +import com.paybook.settlement.exception.SettlementException; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; + +import java.util.List; +import java.util.Map; + +@Component +public class OrderServiceClient { + + private final RestClient restClient; + + public OrderServiceClient(@Value("${settlement.order-service.base-url}") String baseUrl) { + this.restClient = RestClient.builder().baseUrl(baseUrl).build(); + } + + @SuppressWarnings("unchecked") + public OrderData getOrder(String orderId) { + try { + Map response = restClient.get() + .uri("/api/orders/{orderId}", orderId) + .retrieve() + .body(Map.class); + + if (response == null) { + throw SettlementException.orderFetchFailed(orderId); + } + + List> items = (List>) response.get("items"); + List orderItems = items.stream() + .map(item -> new OrderItemData( + (String) item.get("productId"), + ((Number) item.get("quantity")).intValue(), + ((Number) item.get("price")).intValue(), + (String) item.get("itemStatus"))) + .toList(); + + return new OrderData( + (String) response.get("orderId"), + (String) response.get("userId"), + orderItems, + ((Number) response.get("totalAmount")).intValue(), + ((Number) response.get("couponDiscountAmount")).intValue(), + ((Number) response.get("pointDiscountAmount")).intValue(), + ((Number) response.get("pgPaymentAmount")).intValue(), + ((Number) response.get("deliveryFee")).intValue(), + (String) response.get("status"), + (String) response.get("couponId")); + } catch (SettlementException e) { + throw e; + } catch (Exception e) { + throw SettlementException.orderFetchFailed(orderId); + } + } + + public record OrderData( + String orderId, + String userId, + List items, + int totalAmount, + int couponDiscountAmount, + int pointDiscountAmount, + int pgPaymentAmount, + int deliveryFee, + String status, + String couponId + ) {} + + public record OrderItemData( + String productId, + int quantity, + int price, + String itemStatus + ) {} +} diff --git a/settlement/src/main/java/com/paybook/settlement/config/JpaConfig.java b/settlement/src/main/java/com/paybook/settlement/config/JpaConfig.java new file mode 100644 index 0000000..110f919 --- /dev/null +++ b/settlement/src/main/java/com/paybook/settlement/config/JpaConfig.java @@ -0,0 +1,11 @@ +package com.paybook.settlement.config; + +import org.springframework.boot.persistence.autoconfigure.EntityScan; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; + +@Configuration +@EntityScan("com.paybook") +@EnableJpaRepositories("com.paybook") +public class JpaConfig { +} diff --git a/settlement/src/main/java/com/paybook/settlement/config/SettlementPolicyConfig.java b/settlement/src/main/java/com/paybook/settlement/config/SettlementPolicyConfig.java new file mode 100644 index 0000000..5af1f2f --- /dev/null +++ b/settlement/src/main/java/com/paybook/settlement/config/SettlementPolicyConfig.java @@ -0,0 +1,10 @@ +package com.paybook.settlement.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "settlement.policy") +public record SettlementPolicyConfig( + int defaultCommissionRate, + int confirmDelayDays +) { +} diff --git a/settlement/src/main/java/com/paybook/settlement/controller/GlobalExceptionHandler.java b/settlement/src/main/java/com/paybook/settlement/controller/GlobalExceptionHandler.java new file mode 100644 index 0000000..3939077 --- /dev/null +++ b/settlement/src/main/java/com/paybook/settlement/controller/GlobalExceptionHandler.java @@ -0,0 +1,25 @@ +package com.paybook.settlement.controller; + +import com.paybook.settlement.dto.ErrorResponse; +import com.paybook.settlement.exception.SettlementException; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(HttpMessageNotReadableException.class) + public ResponseEntity handleMalformedJson(HttpMessageNotReadableException ex) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(new ErrorResponse("INVALID_JSON", "요청 본문을 파싱할 수 없습니다")); + } + + @ExceptionHandler(SettlementException.class) + public ResponseEntity handleSettlementException(SettlementException ex) { + return ResponseEntity.status(ex.getHttpStatus()) + .body(new ErrorResponse(ex.getCode(), ex.getMessage())); + } +} diff --git a/settlement/src/main/java/com/paybook/settlement/controller/SettlementController.java b/settlement/src/main/java/com/paybook/settlement/controller/SettlementController.java new file mode 100644 index 0000000..cd094f6 --- /dev/null +++ b/settlement/src/main/java/com/paybook/settlement/controller/SettlementController.java @@ -0,0 +1,85 @@ +package com.paybook.settlement.controller; + +import com.paybook.settlement.dto.SettlementResponse; +import com.paybook.settlement.dto.SettlementResponse.SettlementSummaryResponse; +import com.paybook.settlement.entity.SettlementStatus; +import com.paybook.settlement.service.SettlementService; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/settlements") +@RequiredArgsConstructor +public class SettlementController { + + private final SettlementService settlementService; + + @PostMapping + public ResponseEntity createSettlement(@RequestBody Map request) { + String orderId = request.get("orderId"); + SettlementResponse response = settlementService.createSettlement(orderId); + return ResponseEntity.status(HttpStatus.CREATED).body(response); + } + + @GetMapping("/{settlementId}") + public ResponseEntity getSettlement(@PathVariable String settlementId) { + return ResponseEntity.ok(settlementService.getSettlement(settlementId)); + } + + @GetMapping("/order/{orderId}") + public ResponseEntity getSettlementByOrderId(@PathVariable String orderId) { + return ResponseEntity.ok(settlementService.getSettlementByOrderId(orderId)); + } + + @GetMapping + public ResponseEntity> getSettlementsBySellerId( + @RequestParam String sellerId, + @RequestParam(required = false) SettlementStatus status, + Pageable pageable) { + return ResponseEntity.ok(settlementService.getSettlementsBySellerId(sellerId, status, pageable)); + } + + @GetMapping("/seller/{sellerId}/summary") + public ResponseEntity getSellerSummary( + @PathVariable String sellerId, + @RequestParam(required = false) SettlementStatus status) { + return ResponseEntity.ok(settlementService.getSellerSummary(sellerId, status)); + } + + @PatchMapping("/{settlementId}/confirm") + public ResponseEntity confirmSettlement(@PathVariable String settlementId) { + return ResponseEntity.ok(settlementService.confirmSettlement(settlementId)); + } + + @PatchMapping("/confirm-all") + public ResponseEntity> confirmAllPending() { + return ResponseEntity.ok(settlementService.confirmAllPending()); + } + + @PatchMapping("/{settlementId}/pay") + public ResponseEntity markPaid(@PathVariable String settlementId) { + return ResponseEntity.ok(settlementService.markPaid(settlementId)); + } + + @PatchMapping("/pay-all") + public ResponseEntity> payAllConfirmed() { + return ResponseEntity.ok(settlementService.payAllConfirmed()); + } + + @PatchMapping("/{settlementId}/cancel") + public ResponseEntity cancelSettlement(@PathVariable String settlementId) { + return ResponseEntity.ok(settlementService.cancelSettlement(settlementId)); + } + + @PatchMapping("/order/{orderId}/cancel") + public ResponseEntity cancelSettlementByOrderId(@PathVariable String orderId) { + return ResponseEntity.ok(settlementService.cancelSettlementByOrderId(orderId)); + } +} diff --git a/settlement/src/main/java/com/paybook/settlement/dto/ErrorResponse.java b/settlement/src/main/java/com/paybook/settlement/dto/ErrorResponse.java new file mode 100644 index 0000000..cc23381 --- /dev/null +++ b/settlement/src/main/java/com/paybook/settlement/dto/ErrorResponse.java @@ -0,0 +1,7 @@ +package com.paybook.settlement.dto; + +public record ErrorResponse( + String code, + String message +) { +} diff --git a/settlement/src/main/java/com/paybook/settlement/dto/SettlementResponse.java b/settlement/src/main/java/com/paybook/settlement/dto/SettlementResponse.java new file mode 100644 index 0000000..caf752b --- /dev/null +++ b/settlement/src/main/java/com/paybook/settlement/dto/SettlementResponse.java @@ -0,0 +1,31 @@ +package com.paybook.settlement.dto; + +import java.util.List; + +public record SettlementResponse( + String settlementId, + String orderId, + String sellerId, + int orderAmount, + int commissionRate, + int commissionAmount, + int platformCouponAmount, + int sellerCouponAmount, + int pointDiscountAmount, + int deliveryFee, + int settlementAmount, + String status, + String createdAt, + String confirmedAt, + String paidAt +) { + + public record SettlementSummaryResponse( + String sellerId, + int totalOrderAmount, + int totalCommissionAmount, + int totalSettlementAmount, + int count, + List settlements + ) {} +} diff --git a/settlement/src/main/java/com/paybook/settlement/entity/SettlementEntity.java b/settlement/src/main/java/com/paybook/settlement/entity/SettlementEntity.java new file mode 100644 index 0000000..4c9493b --- /dev/null +++ b/settlement/src/main/java/com/paybook/settlement/entity/SettlementEntity.java @@ -0,0 +1,113 @@ +package com.paybook.settlement.entity; + +import com.paybook.settlement.exception.SettlementException; +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "settlements") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Builder +public class SettlementEntity { + + private static final int PERCENT_DIVISOR = 100; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, unique = true) + private String settlementId; + + @Column(nullable = false, unique = true) + private String orderId; + + @Column(nullable = false) + private String sellerId; + + private int orderAmount; + + private int commissionRate; + + private int commissionAmount; + + private int platformCouponAmount; + + private int sellerCouponAmount; + + private int pointDiscountAmount; + + private int deliveryFee; + + private int settlementAmount; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + @Builder.Default + private SettlementStatus status = SettlementStatus.PENDING; + + @Column(nullable = false) + @Builder.Default + private LocalDateTime createdAt = LocalDateTime.now(); + + private LocalDateTime confirmedAt; + + private LocalDateTime paidAt; + + @Version + private Long version; + + public static SettlementEntity create(String settlementId, String orderId, String sellerId, + int orderAmount, int commissionRate, + int platformCouponAmount, int sellerCouponAmount, + int pointDiscountAmount, int deliveryFee) { + int commissionAmount = orderAmount * commissionRate / PERCENT_DIVISOR; + int settlementAmount = orderAmount - commissionAmount - sellerCouponAmount + deliveryFee; + + return SettlementEntity.builder() + .settlementId(settlementId) + .orderId(orderId) + .sellerId(sellerId) + .orderAmount(orderAmount) + .commissionRate(commissionRate) + .commissionAmount(commissionAmount) + .platformCouponAmount(platformCouponAmount) + .sellerCouponAmount(sellerCouponAmount) + .pointDiscountAmount(pointDiscountAmount) + .deliveryFee(deliveryFee) + .settlementAmount(settlementAmount) + .build(); + } + + public void confirm() { + if (this.status != SettlementStatus.PENDING) { + throw SettlementException.invalidStatusTransition( + settlementId, status.name(), SettlementStatus.CONFIRMED.name()); + } + this.status = SettlementStatus.CONFIRMED; + this.confirmedAt = LocalDateTime.now(); + } + + public void markPaid() { + if (this.status != SettlementStatus.CONFIRMED) { + throw SettlementException.invalidStatusTransition( + settlementId, status.name(), SettlementStatus.PAID.name()); + } + this.status = SettlementStatus.PAID; + this.paidAt = LocalDateTime.now(); + } + + public void cancel() { + if (this.status == SettlementStatus.PAID) { + throw SettlementException.alreadyPaid(settlementId); + } + if (this.status == SettlementStatus.CANCELLED) { + throw SettlementException.alreadyCancelled(settlementId); + } + this.status = SettlementStatus.CANCELLED; + } +} diff --git a/settlement/src/main/java/com/paybook/settlement/entity/SettlementStatus.java b/settlement/src/main/java/com/paybook/settlement/entity/SettlementStatus.java new file mode 100644 index 0000000..ffd0b7d --- /dev/null +++ b/settlement/src/main/java/com/paybook/settlement/entity/SettlementStatus.java @@ -0,0 +1,8 @@ +package com.paybook.settlement.entity; + +public enum SettlementStatus { + PENDING, + CONFIRMED, + PAID, + CANCELLED +} diff --git a/settlement/src/main/java/com/paybook/settlement/exception/SettlementException.java b/settlement/src/main/java/com/paybook/settlement/exception/SettlementException.java new file mode 100644 index 0000000..ceeedde --- /dev/null +++ b/settlement/src/main/java/com/paybook/settlement/exception/SettlementException.java @@ -0,0 +1,53 @@ +package com.paybook.settlement.exception; + +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +public class SettlementException extends RuntimeException { + + private final String code; + private final HttpStatus httpStatus; + + private SettlementException(String code, String message, HttpStatus httpStatus) { + super(message); + this.code = code; + this.httpStatus = httpStatus; + } + + public static SettlementException settlementNotFound(String id) { + return new SettlementException("SETTLEMENT_NOT_FOUND", + "정산 내역을 찾을 수 없습니다: " + id, HttpStatus.NOT_FOUND); + } + + public static SettlementException alreadySettled(String orderId) { + return new SettlementException("ALREADY_SETTLED", + "이미 정산된 주문입니다: " + orderId, HttpStatus.CONFLICT); + } + + public static SettlementException alreadyPaid(String settlementId) { + return new SettlementException("ALREADY_PAID", + "이미 지급 완료된 정산입니다: " + settlementId, HttpStatus.CONFLICT); + } + + public static SettlementException alreadyCancelled(String settlementId) { + return new SettlementException("ALREADY_CANCELLED", + "이미 취소된 정산입니다: " + settlementId, HttpStatus.CONFLICT); + } + + public static SettlementException invalidStatusTransition(String settlementId, String from, String to) { + return new SettlementException("INVALID_STATUS_TRANSITION", + "정산 상태를 변경할 수 없습니다: " + settlementId + " (" + from + " → " + to + ")", + HttpStatus.CONFLICT); + } + + public static SettlementException orderNotPurchaseConfirmed(String orderId) { + return new SettlementException("ORDER_NOT_PURCHASE_CONFIRMED", + "구매확정되지 않은 주문입니다: " + orderId, HttpStatus.CONFLICT); + } + + public static SettlementException orderFetchFailed(String orderId) { + return new SettlementException("ORDER_FETCH_FAILED", + "주문 정보 조회에 실패했습니다: " + orderId, HttpStatus.BAD_GATEWAY); + } +} diff --git a/settlement/src/main/java/com/paybook/settlement/repository/SettlementRepository.java b/settlement/src/main/java/com/paybook/settlement/repository/SettlementRepository.java new file mode 100644 index 0000000..f256123 --- /dev/null +++ b/settlement/src/main/java/com/paybook/settlement/repository/SettlementRepository.java @@ -0,0 +1,57 @@ +package com.paybook.settlement.repository; + +import com.paybook.settlement.entity.SettlementEntity; +import com.paybook.settlement.entity.SettlementStatus; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.LocalDateTime; +import java.util.Optional; + +public interface SettlementRepository extends JpaRepository { + + Optional findBySettlementId(String settlementId); + + Optional findByOrderId(String orderId); + + Page findBySellerIdOrderByCreatedAtDesc(String sellerId, Pageable pageable); + + Page findBySellerIdAndStatusOrderByCreatedAtDesc( + String sellerId, SettlementStatus status, Pageable pageable); + + Page findByStatusOrderByCreatedAtAsc(SettlementStatus status, Pageable pageable); + + Page findByStatusAndCreatedAtBeforeOrderByCreatedAtAsc( + SettlementStatus status, LocalDateTime cutoff, Pageable pageable); + + @Query(""" + SELECT COALESCE(SUM(s.orderAmount), 0) AS totalOrderAmount, + COALESCE(SUM(s.commissionAmount), 0) AS totalCommissionAmount, + COALESCE(SUM(s.settlementAmount), 0) AS totalSettlementAmount, + COUNT(s) AS count + FROM SettlementEntity s + WHERE s.sellerId = :sellerId + """) + SettlementSummaryProjection getSellerSummary(@Param("sellerId") String sellerId); + + @Query(""" + SELECT COALESCE(SUM(s.orderAmount), 0) AS totalOrderAmount, + COALESCE(SUM(s.commissionAmount), 0) AS totalCommissionAmount, + COALESCE(SUM(s.settlementAmount), 0) AS totalSettlementAmount, + COUNT(s) AS count + FROM SettlementEntity s + WHERE s.sellerId = :sellerId AND s.status = :status + """) + SettlementSummaryProjection getSellerSummaryByStatus( + @Param("sellerId") String sellerId, @Param("status") SettlementStatus status); + + interface SettlementSummaryProjection { + int getTotalOrderAmount(); + int getTotalCommissionAmount(); + int getTotalSettlementAmount(); + Long getCount(); + } +} diff --git a/settlement/src/main/java/com/paybook/settlement/service/SettlementScheduler.java b/settlement/src/main/java/com/paybook/settlement/service/SettlementScheduler.java new file mode 100644 index 0000000..b2e5bbf --- /dev/null +++ b/settlement/src/main/java/com/paybook/settlement/service/SettlementScheduler.java @@ -0,0 +1,55 @@ +package com.paybook.settlement.service; + +import com.paybook.settlement.entity.SettlementEntity; +import com.paybook.settlement.entity.SettlementStatus; +import com.paybook.settlement.repository.SettlementRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; + +@Slf4j +@Component +@RequiredArgsConstructor +public class SettlementScheduler { + + private static final int CONFIRM_DELAY_DAYS = 7; + private static final int BATCH_SIZE = 500; + + private final SettlementRepository settlementRepository; + + @Scheduled(cron = "0 0 2 * * *") + @Transactional + public void autoConfirmSettlements() { + LocalDateTime cutoff = LocalDateTime.now().minusDays(CONFIRM_DELAY_DAYS); + int confirmed = processAutoConfirm(cutoff); + log.info("자동 정산 확정 처리: {}건", confirmed); + } + + @Transactional + public void autoConfirmSettlementsManually(LocalDateTime cutoff) { + processAutoConfirm(cutoff); + } + + private int processAutoConfirm(LocalDateTime cutoff) { + int totalConfirmed = 0; + Page page; + + do { + page = settlementRepository.findByStatusAndCreatedAtBeforeOrderByCreatedAtAsc( + SettlementStatus.PENDING, cutoff, PageRequest.of(0, BATCH_SIZE)); + + for (SettlementEntity settlement : page.getContent()) { + settlement.confirm(); + totalConfirmed++; + } + } while (page.hasNext()); + + return totalConfirmed; + } +} diff --git a/settlement/src/main/java/com/paybook/settlement/service/SettlementService.java b/settlement/src/main/java/com/paybook/settlement/service/SettlementService.java new file mode 100644 index 0000000..68c735b --- /dev/null +++ b/settlement/src/main/java/com/paybook/settlement/service/SettlementService.java @@ -0,0 +1,257 @@ +package com.paybook.settlement.service; + +import com.paybook.core.entity.CouponEntity; +import com.paybook.core.entity.CouponType; +import com.paybook.core.repository.CouponRepository; +import com.paybook.settlement.client.OrderServiceClient; +import com.paybook.settlement.client.OrderServiceClient.OrderData; +import com.paybook.settlement.config.SettlementPolicyConfig; +import com.paybook.settlement.dto.SettlementResponse; +import com.paybook.settlement.dto.SettlementResponse.SettlementSummaryResponse; +import com.paybook.settlement.entity.SettlementEntity; +import com.paybook.settlement.entity.SettlementStatus; +import com.paybook.settlement.exception.SettlementException; +import com.paybook.settlement.repository.SettlementRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.orm.ObjectOptimisticLockingFailureException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +public class SettlementService { + + private static final String PURCHASE_CONFIRMED = "PURCHASE_CONFIRMED"; + private static final String DEFAULT_SELLER_ID = "SELLER-DEFAULT"; + private static final int BATCH_SIZE = 500; + + private final SettlementRepository settlementRepository; + private final CouponRepository couponRepository; + private final OrderServiceClient orderServiceClient; + private final SettlementPolicyConfig policyConfig; + + @Transactional + public SettlementResponse createSettlement(String orderId) { + validateNotAlreadySettled(orderId); + + OrderData orderData = orderServiceClient.getOrder(orderId); + validatePurchaseConfirmed(orderData); + + int couponDiscount = orderData.couponDiscountAmount(); + CouponAmounts couponAmounts = splitCouponAmounts(orderData.couponId(), couponDiscount); + + String settlementId = generateSettlementId(); + SettlementEntity settlement = SettlementEntity.create( + settlementId, + orderId, + resolveSellerId(orderData), + orderData.totalAmount(), + policyConfig.defaultCommissionRate(), + couponAmounts.platformAmount(), + couponAmounts.sellerAmount(), + orderData.pointDiscountAmount(), + orderData.deliveryFee()); + + try { + settlementRepository.save(settlement); + } catch (DataIntegrityViolationException e) { + throw SettlementException.alreadySettled(orderId); + } + + return toResponse(settlement); + } + + @Transactional(readOnly = true) + public SettlementResponse getSettlement(String settlementId) { + SettlementEntity settlement = findSettlementOrThrow(settlementId); + return toResponse(settlement); + } + + @Transactional(readOnly = true) + public SettlementResponse getSettlementByOrderId(String orderId) { + SettlementEntity settlement = settlementRepository.findByOrderId(orderId) + .orElseThrow(() -> SettlementException.settlementNotFound(orderId)); + return toResponse(settlement); + } + + @Transactional(readOnly = true) + public Page getSettlementsBySellerId(String sellerId, SettlementStatus status, + Pageable pageable) { + Page settlements = (status != null) + ? settlementRepository.findBySellerIdAndStatusOrderByCreatedAtDesc(sellerId, status, pageable) + : settlementRepository.findBySellerIdOrderByCreatedAtDesc(sellerId, pageable); + return settlements.map(this::toResponse); + } + + @Transactional(readOnly = true) + public SettlementSummaryResponse getSellerSummary(String sellerId, SettlementStatus status) { + SettlementRepository.SettlementSummaryProjection summary = (status != null) + ? settlementRepository.getSellerSummaryByStatus(sellerId, status) + : settlementRepository.getSellerSummary(sellerId); + + Page page = (status != null) + ? settlementRepository.findBySellerIdAndStatusOrderByCreatedAtDesc( + sellerId, status, PageRequest.of(0, 100)) + : settlementRepository.findBySellerIdOrderByCreatedAtDesc( + sellerId, PageRequest.of(0, 100)); + + return new SettlementSummaryResponse( + sellerId, + summary != null ? summary.getTotalOrderAmount() : 0, + summary != null ? summary.getTotalCommissionAmount() : 0, + summary != null ? summary.getTotalSettlementAmount() : 0, + summary != null ? summary.getCount().intValue() : 0, + page.getContent().stream().map(this::toResponse).toList()); + } + + @Transactional + public SettlementResponse confirmSettlement(String settlementId) { + SettlementEntity settlement = findSettlementOrThrow(settlementId); + settlement.confirm(); + return toResponse(settlement); + } + + @Transactional + public List confirmAllPending() { + List results = new ArrayList<>(); + Page page; + int pageNum = 0; + + do { + page = settlementRepository.findByStatusOrderByCreatedAtAsc( + SettlementStatus.PENDING, PageRequest.of(pageNum, BATCH_SIZE)); + + for (SettlementEntity settlement : page.getContent()) { + try { + settlement.confirm(); + results.add(toResponse(settlement)); + } catch (ObjectOptimisticLockingFailureException e) { + // 다른 스레드가 이미 처리 → 건너뜀 + } + } + pageNum++; + } while (page.hasNext()); + + return results; + } + + @Transactional + public SettlementResponse markPaid(String settlementId) { + SettlementEntity settlement = findSettlementOrThrow(settlementId); + settlement.markPaid(); + return toResponse(settlement); + } + + @Transactional + public List payAllConfirmed() { + List results = new ArrayList<>(); + Page page; + int pageNum = 0; + + do { + page = settlementRepository.findByStatusOrderByCreatedAtAsc( + SettlementStatus.CONFIRMED, PageRequest.of(pageNum, BATCH_SIZE)); + + for (SettlementEntity settlement : page.getContent()) { + try { + settlement.markPaid(); + results.add(toResponse(settlement)); + } catch (ObjectOptimisticLockingFailureException e) { + // 다른 스레드가 이미 처리 → 건너뜀 + } + } + pageNum++; + } while (page.hasNext()); + + return results; + } + + @Transactional + public SettlementResponse cancelSettlement(String settlementId) { + SettlementEntity settlement = findSettlementOrThrow(settlementId); + settlement.cancel(); + return toResponse(settlement); + } + + @Transactional + public SettlementResponse cancelSettlementByOrderId(String orderId) { + SettlementEntity settlement = settlementRepository.findByOrderId(orderId) + .orElseThrow(() -> SettlementException.settlementNotFound(orderId)); + settlement.cancel(); + return toResponse(settlement); + } + + // ── 내부 메서드 ── + + private void validateNotAlreadySettled(String orderId) { + settlementRepository.findByOrderId(orderId) + .ifPresent(s -> { + throw SettlementException.alreadySettled(orderId); + }); + } + + private void validatePurchaseConfirmed(OrderData orderData) { + if (!PURCHASE_CONFIRMED.equals(orderData.status())) { + throw SettlementException.orderNotPurchaseConfirmed(orderData.orderId()); + } + } + + private CouponAmounts splitCouponAmounts(String couponId, int totalCouponDiscount) { + if (totalCouponDiscount <= 0 || couponId == null) { + return new CouponAmounts(0, 0); + } + + CouponType couponType = couponRepository.findByCouponId(couponId) + .map(CouponEntity::getCouponType) + .orElse(CouponType.PLATFORM); + + return (couponType == CouponType.SELLER) + ? new CouponAmounts(0, totalCouponDiscount) + : new CouponAmounts(totalCouponDiscount, 0); + } + + private String resolveSellerId(OrderData orderData) { + return orderData.items().stream() + .findFirst() + .map(item -> DEFAULT_SELLER_ID) + .orElse(DEFAULT_SELLER_ID); + } + + private String generateSettlementId() { + return "STL-" + UUID.randomUUID().toString().substring(0, 8); + } + + private SettlementEntity findSettlementOrThrow(String settlementId) { + return settlementRepository.findBySettlementId(settlementId) + .orElseThrow(() -> SettlementException.settlementNotFound(settlementId)); + } + + private SettlementResponse toResponse(SettlementEntity settlement) { + return new SettlementResponse( + settlement.getSettlementId(), + settlement.getOrderId(), + settlement.getSellerId(), + settlement.getOrderAmount(), + settlement.getCommissionRate(), + settlement.getCommissionAmount(), + settlement.getPlatformCouponAmount(), + settlement.getSellerCouponAmount(), + settlement.getPointDiscountAmount(), + settlement.getDeliveryFee(), + settlement.getSettlementAmount(), + settlement.getStatus().name(), + settlement.getCreatedAt().toString(), + settlement.getConfirmedAt() != null ? settlement.getConfirmedAt().toString() : null, + settlement.getPaidAt() != null ? settlement.getPaidAt().toString() : null); + } + + private record CouponAmounts(int platformAmount, int sellerAmount) {} +} diff --git a/settlement/src/main/resources/application.yml b/settlement/src/main/resources/application.yml index 41d0ade..c6df747 100644 --- a/settlement/src/main/resources/application.yml +++ b/settlement/src/main/resources/application.yml @@ -1,6 +1,22 @@ spring: application: name: settlement-service + datasource: + url: jdbc:postgresql://localhost:5434/settlementdb + driver-class-name: org.postgresql.Driver + username: paybook + password: paybook + jpa: + hibernate: + ddl-auto: update + open-in-view: false server: port: 8082 + +settlement: + order-service: + base-url: http://localhost:8080 + policy: + default-commission-rate: 30 + confirm-delay-days: 7 diff --git a/settlement/src/test/java/com/paybook/settlement/service/unit/SettlementServiceUnitTest.java b/settlement/src/test/java/com/paybook/settlement/service/unit/SettlementServiceUnitTest.java new file mode 100644 index 0000000..93be7ef --- /dev/null +++ b/settlement/src/test/java/com/paybook/settlement/service/unit/SettlementServiceUnitTest.java @@ -0,0 +1,684 @@ +package com.paybook.settlement.service.unit; + +import com.paybook.core.entity.*; +import com.paybook.core.repository.CouponRepository; +import com.paybook.settlement.client.OrderServiceClient; +import com.paybook.settlement.client.OrderServiceClient.OrderData; +import com.paybook.settlement.client.OrderServiceClient.OrderItemData; +import com.paybook.settlement.config.SettlementPolicyConfig; +import com.paybook.settlement.dto.SettlementResponse; +import com.paybook.settlement.dto.SettlementResponse.SettlementSummaryResponse; +import com.paybook.settlement.entity.SettlementEntity; +import com.paybook.settlement.entity.SettlementStatus; +import com.paybook.settlement.exception.SettlementException; +import com.paybook.settlement.repository.SettlementRepository; +import com.paybook.settlement.repository.SettlementRepository.SettlementSummaryProjection; +import com.paybook.settlement.service.SettlementService; +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.dao.DataIntegrityViolationException; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.orm.ObjectOptimisticLockingFailureException; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.*; + +/** + * SettlementService 순수 유닛 테스트 (런던 학파). + * + * Spring 컨텍스트 없이, 모든 의존성을 Mock으로 대체하여 + * SettlementService의 모든 분기 로직을 검증한다. + */ +@ExtendWith(MockitoExtension.class) +@DisplayName("SettlementService 유닛 테스트") +class SettlementServiceUnitTest { + + @InjectMocks + private SettlementService settlementService; + + @Mock + private SettlementRepository settlementRepository; + + @Mock + private CouponRepository couponRepository; + + @Mock + private OrderServiceClient orderServiceClient; + + @Mock + private SettlementPolicyConfig policyConfig; + + // ════════════════════════════════════════ + // createSettlement — 검증 분기 + // ════════════════════════════════════════ + + @Nested + @DisplayName("createSettlement: 검증") + class CreateSettlement_Validation { + + @Test + @DisplayName("이미 정산된 주문 → ALREADY_SETTLED, 주문 조회까지 가지 않는다") + void alreadySettled_stopsEarly() { + given(settlementRepository.findByOrderId("ORD-001")) + .willReturn(Optional.of(settlement("ORD-001"))); + + assertException("ORD-001", "ALREADY_SETTLED"); + + verify(orderServiceClient, never()).getOrder(anyString()); + verify(settlementRepository, never()).save(any()); + } + + @Test + @DisplayName("구매확정 아닌 주문 → ORDER_NOT_PURCHASE_CONFIRMED, 저장까지 가지 않는다") + void notPurchaseConfirmed_stopsBeforeSave() { + given(settlementRepository.findByOrderId("ORD-001")).willReturn(Optional.empty()); + given(orderServiceClient.getOrder("ORD-001")) + .willReturn(orderData("ORD-001", "CONFIRMED")); + + assertException("ORD-001", "ORDER_NOT_PURCHASE_CONFIRMED"); + + verify(settlementRepository, never()).save(any()); + } + + @Test + @DisplayName("DB unique 제약 위반 → ALREADY_SETTLED (동시성 안전망)") + void uniqueViolation_throwsAlreadySettled() { + given(settlementRepository.findByOrderId("ORD-001")).willReturn(Optional.empty()); + given(orderServiceClient.getOrder("ORD-001")) + .willReturn(orderData("ORD-001", "PURCHASE_CONFIRMED")); + givenDefaultPolicy(); + given(settlementRepository.save(any())) + .willThrow(new DataIntegrityViolationException("unique violation")); + + assertException("ORD-001", "ALREADY_SETTLED"); + } + } + + // ════════════════════════════════════════ + // createSettlement — 쿠폰 분배 분기 + // ════════════════════════════════════════ + + @Nested + @DisplayName("createSettlement: 쿠폰 부담금 분배") + class CreateSettlement_CouponSplit { + + @Test + @DisplayName("쿠폰 null → 쿠폰 조회 자체를 하지 않고 부담금 0") + void nullCoupon_skipsCouponLookup() { + givenCreateSettlementSetup("ORD-001", 50000, 0, 0, null); + + SettlementResponse response = settlementService.createSettlement("ORD-001"); + + assertThat(response.platformCouponAmount()).isZero(); + assertThat(response.sellerCouponAmount()).isZero(); + verify(couponRepository, never()).findByCouponId(anyString()); + } + + @Test + @DisplayName("쿠폰 할인 0 → 쿠폰 조회 자체를 하지 않는다") + void zeroCouponDiscount_skipsCouponLookup() { + givenCreateSettlementSetup("ORD-001", 50000, 0, 0, "COUPON-001"); + + settlementService.createSettlement("ORD-001"); + + verify(couponRepository, never()).findByCouponId(anyString()); + } + + @Test + @DisplayName("플랫폼 쿠폰 → platformCouponAmount에 할당, sellerCouponAmount은 0") + void platformCoupon_assignsToPlatform() { + givenCreateSettlementSetup("ORD-001", 50000, 1000, 0, "COUPON-P"); + given(couponRepository.findByCouponId("COUPON-P")) + .willReturn(Optional.of(coupon("COUPON-P", CouponType.PLATFORM))); + + SettlementResponse response = settlementService.createSettlement("ORD-001"); + + assertThat(response.platformCouponAmount()).isEqualTo(1000); + assertThat(response.sellerCouponAmount()).isZero(); + } + + @Test + @DisplayName("셀러 쿠폰 → sellerCouponAmount에 할당, platformCouponAmount은 0") + void sellerCoupon_assignsToSeller() { + givenCreateSettlementSetup("ORD-001", 50000, 2000, 0, "COUPON-S"); + given(couponRepository.findByCouponId("COUPON-S")) + .willReturn(Optional.of(coupon("COUPON-S", CouponType.SELLER))); + + SettlementResponse response = settlementService.createSettlement("ORD-001"); + + assertThat(response.platformCouponAmount()).isZero(); + assertThat(response.sellerCouponAmount()).isEqualTo(2000); + } + + @Test + @DisplayName("존재하지 않는 쿠폰 → 기본값 PLATFORM으로 처리") + void unknownCoupon_defaultsToPlatform() { + givenCreateSettlementSetup("ORD-001", 50000, 1000, 0, "COUPON-GONE"); + given(couponRepository.findByCouponId("COUPON-GONE")).willReturn(Optional.empty()); + + SettlementResponse response = settlementService.createSettlement("ORD-001"); + + assertThat(response.platformCouponAmount()).isEqualTo(1000); + assertThat(response.sellerCouponAmount()).isZero(); + } + } + + // ════════════════════════════════════════ + // createSettlement — 성공 시 계산 검증 + // ════════════════════════════════════════ + + @Nested + @DisplayName("createSettlement: 정산금 계산") + class CreateSettlement_Calculation { + + @Test + @DisplayName("수수료 = 주문금액 × 수수료율, 정산금 = 주문금액 - 수수료 + 배송비") + void calculatesAmountsCorrectly() { + givenCreateSettlementSetup("ORD-001", 50000, 0, 0, null); + + SettlementResponse response = settlementService.createSettlement("ORD-001"); + + assertThat(response.commissionRate()).isEqualTo(30); + assertThat(response.commissionAmount()).isEqualTo(15000); + assertThat(response.settlementAmount()).isEqualTo(35000); + } + + @Test + @DisplayName("셀러 쿠폰이 있으면 정산금에서 차감된다") + void sellerCoupon_deductsFromSettlement() { + givenCreateSettlementSetup("ORD-001", 50000, 2000, 0, "COUPON-S"); + given(couponRepository.findByCouponId("COUPON-S")) + .willReturn(Optional.of(coupon("COUPON-S", CouponType.SELLER))); + + SettlementResponse response = settlementService.createSettlement("ORD-001"); + + // 50000 - 15000(수수료) - 2000(셀러쿠폰) = 33000 + assertThat(response.settlementAmount()).isEqualTo(33000); + } + + @Test + @DisplayName("저장이 호출된다") + void savesSettlement() { + givenCreateSettlementSetup("ORD-001", 50000, 0, 0, null); + + settlementService.createSettlement("ORD-001"); + + verify(settlementRepository).save(any(SettlementEntity.class)); + } + + @Test + @DisplayName("상태가 PENDING이다") + void statusIsPending() { + givenCreateSettlementSetup("ORD-001", 50000, 0, 0, null); + + SettlementResponse response = settlementService.createSettlement("ORD-001"); + + assertThat(response.status()).isEqualTo("PENDING"); + } + } + + // ════════════════════════════════════════ + // getSettlement + // ════════════════════════════════════════ + + @Nested + @DisplayName("getSettlement") + class GetSettlement { + + @Test + @DisplayName("정산 없음 → SETTLEMENT_NOT_FOUND") + void notFound() { + given(settlementRepository.findBySettlementId("STL-NONE")).willReturn(Optional.empty()); + + assertThatThrownBy(() -> settlementService.getSettlement("STL-NONE")) + .isInstanceOf(SettlementException.class) + .satisfies(ex -> assertCode(ex, "SETTLEMENT_NOT_FOUND")); + } + + @Test + @DisplayName("정산 있음 → 응답 반환") + void found_returnsResponse() { + given(settlementRepository.findBySettlementId("STL-001")) + .willReturn(Optional.of(settlement("ORD-001"))); + + SettlementResponse response = settlementService.getSettlement("STL-001"); + + assertThat(response.orderId()).isEqualTo("ORD-001"); + } + } + + // ════════════════════════════════════════ + // getSettlementByOrderId + // ════════════════════════════════════════ + + @Nested + @DisplayName("getSettlementByOrderId") + class GetSettlementByOrderId { + + @Test + @DisplayName("주문 없음 → SETTLEMENT_NOT_FOUND") + void notFound() { + given(settlementRepository.findByOrderId("ORD-NONE")).willReturn(Optional.empty()); + + assertThatThrownBy(() -> settlementService.getSettlementByOrderId("ORD-NONE")) + .isInstanceOf(SettlementException.class) + .satisfies(ex -> assertCode(ex, "SETTLEMENT_NOT_FOUND")); + } + + @Test + @DisplayName("주문 있음 → 응답 반환") + void found() { + given(settlementRepository.findByOrderId("ORD-001")) + .willReturn(Optional.of(settlement("ORD-001"))); + + SettlementResponse response = settlementService.getSettlementByOrderId("ORD-001"); + + assertThat(response.orderId()).isEqualTo("ORD-001"); + } + } + + // ════════════════════════════════════════ + // getSettlementsBySellerId — 쿼리 분기 + // ════════════════════════════════════════ + + @Nested + @DisplayName("getSettlementsBySellerId") + class GetSettlementsBySellerId { + + private final Pageable pageable = PageRequest.of(0, 10); + + @Test + @DisplayName("status null → 전체 조회 쿼리 호출, 상태 필터 쿼리 미호출") + void statusNull_callsFindBySellerId() { + given(settlementRepository.findBySellerIdOrderByCreatedAtDesc("SELLER-001", pageable)) + .willReturn(new PageImpl<>(List.of())); + + settlementService.getSettlementsBySellerId("SELLER-001", null, pageable); + + verify(settlementRepository).findBySellerIdOrderByCreatedAtDesc("SELLER-001", pageable); + verify(settlementRepository, never()).findBySellerIdAndStatusOrderByCreatedAtDesc( + anyString(), any(SettlementStatus.class), any(Pageable.class)); + } + + @Test + @DisplayName("status 지정 → 상태 필터 쿼리 호출, 전체 쿼리 미호출") + void statusGiven_callsFindBySellerIdAndStatus() { + given(settlementRepository.findBySellerIdAndStatusOrderByCreatedAtDesc( + "SELLER-001", SettlementStatus.PENDING, pageable)) + .willReturn(new PageImpl<>(List.of())); + + settlementService.getSettlementsBySellerId("SELLER-001", SettlementStatus.PENDING, pageable); + + verify(settlementRepository).findBySellerIdAndStatusOrderByCreatedAtDesc( + "SELLER-001", SettlementStatus.PENDING, pageable); + verify(settlementRepository, never()).findBySellerIdOrderByCreatedAtDesc( + anyString(), any(Pageable.class)); + } + } + + // ════════════════════════════════════════ + // getSellerSummary — null 분기 + // ════════════════════════════════════════ + + @Nested + @DisplayName("getSellerSummary") + class GetSellerSummary { + + @Test + @DisplayName("summary null → 모든 집계값 0 반환") + void summaryNull_returnsZeros() { + given(settlementRepository.getSellerSummary("SELLER-001")).willReturn(null); + given(settlementRepository.findBySellerIdOrderByCreatedAtDesc(eq("SELLER-001"), any(Pageable.class))) + .willReturn(new PageImpl<>(List.of())); + + SettlementSummaryResponse result = settlementService.getSellerSummary("SELLER-001", null); + + assertThat(result.totalOrderAmount()).isZero(); + assertThat(result.totalCommissionAmount()).isZero(); + assertThat(result.totalSettlementAmount()).isZero(); + assertThat(result.count()).isZero(); + } + + @Test + @DisplayName("status 지정 → 상태별 집계 쿼리 호출") + void statusGiven_callsStatusQuery() { + SettlementSummaryProjection projection = mock(SettlementSummaryProjection.class); + given(projection.getTotalOrderAmount()).willReturn(100000); + given(projection.getTotalCommissionAmount()).willReturn(30000); + given(projection.getTotalSettlementAmount()).willReturn(70000); + given(projection.getCount()).willReturn(2L); + + given(settlementRepository.getSellerSummaryByStatus("SELLER-001", SettlementStatus.CONFIRMED)) + .willReturn(projection); + given(settlementRepository.findBySellerIdAndStatusOrderByCreatedAtDesc( + eq("SELLER-001"), eq(SettlementStatus.CONFIRMED), any(Pageable.class))) + .willReturn(new PageImpl<>(List.of())); + + SettlementSummaryResponse result = settlementService.getSellerSummary( + "SELLER-001", SettlementStatus.CONFIRMED); + + assertThat(result.totalOrderAmount()).isEqualTo(100000); + assertThat(result.count()).isEqualTo(2); + verify(settlementRepository, never()).getSellerSummary(anyString()); + } + } + + // ════════════════════════════════════════ + // confirmSettlement + // ════════════════════════════════════════ + + @Nested + @DisplayName("confirmSettlement") + class ConfirmSettlement { + + @Test + @DisplayName("정산 없음 → SETTLEMENT_NOT_FOUND") + void notFound() { + given(settlementRepository.findBySettlementId("STL-NONE")).willReturn(Optional.empty()); + + assertThatThrownBy(() -> settlementService.confirmSettlement("STL-NONE")) + .isInstanceOf(SettlementException.class) + .satisfies(ex -> assertCode(ex, "SETTLEMENT_NOT_FOUND")); + } + + @Test + @DisplayName("PENDING → CONFIRMED 성공") + void success() { + SettlementEntity entity = settlement("ORD-001"); + given(settlementRepository.findBySettlementId("STL-001")).willReturn(Optional.of(entity)); + + SettlementResponse response = settlementService.confirmSettlement("STL-001"); + + assertThat(response.status()).isEqualTo("CONFIRMED"); + } + } + + // ════════════════════════════════════════ + // confirmAllPending — 배치 분기 + // ════════════════════════════════════════ + + @Nested + @DisplayName("confirmAllPending") + class ConfirmAllPending { + + @Test + @DisplayName("PENDING 건이 없으면 빈 리스트 반환") + void noPending_returnsEmpty() { + given(settlementRepository.findByStatusOrderByCreatedAtAsc( + eq(SettlementStatus.PENDING), any(Pageable.class))) + .willReturn(new PageImpl<>(List.of())); + + List results = settlementService.confirmAllPending(); + + assertThat(results).isEmpty(); + } + + @Test + @DisplayName("OptimisticLockException 발생 시 해당 건만 건너뛰고 계속 처리") + void optimisticLock_skipsAndContinues() { + SettlementEntity entity1 = mock(SettlementEntity.class); + SettlementEntity entity2 = settlement("ORD-002"); + + doThrow(new ObjectOptimisticLockingFailureException(SettlementEntity.class, 1L)) + .when(entity1).confirm(); + + given(settlementRepository.findByStatusOrderByCreatedAtAsc( + eq(SettlementStatus.PENDING), any(Pageable.class))) + .willReturn(new PageImpl<>(List.of(entity1, entity2))); + + List results = settlementService.confirmAllPending(); + + assertThat(results).hasSize(1); + } + } + + // ════════════════════════════════════════ + // markPaid + // ════════════════════════════════════════ + + @Nested + @DisplayName("markPaid") + class MarkPaid { + + @Test + @DisplayName("정산 없음 → SETTLEMENT_NOT_FOUND") + void notFound() { + given(settlementRepository.findBySettlementId("STL-NONE")).willReturn(Optional.empty()); + + assertThatThrownBy(() -> settlementService.markPaid("STL-NONE")) + .isInstanceOf(SettlementException.class) + .satisfies(ex -> assertCode(ex, "SETTLEMENT_NOT_FOUND")); + } + + @Test + @DisplayName("PENDING → PAID 시도 → INVALID_STATUS_TRANSITION") + void invalidFromPending() { + SettlementEntity entity = settlement("ORD-001"); + given(settlementRepository.findBySettlementId("STL-001")).willReturn(Optional.of(entity)); + + assertThatThrownBy(() -> settlementService.markPaid("STL-001")) + .isInstanceOf(SettlementException.class) + .satisfies(ex -> assertCode(ex, "INVALID_STATUS_TRANSITION")); + } + + @Test + @DisplayName("CONFIRMED → PAID 성공") + void success() { + SettlementEntity entity = settlement("ORD-001"); + entity.confirm(); + given(settlementRepository.findBySettlementId("STL-001")).willReturn(Optional.of(entity)); + + SettlementResponse response = settlementService.markPaid("STL-001"); + + assertThat(response.status()).isEqualTo("PAID"); + } + } + + // ════════════════════════════════════════ + // payAllConfirmed — 배치 분기 + // ════════════════════════════════════════ + + @Nested + @DisplayName("payAllConfirmed") + class PayAllConfirmed { + + @Test + @DisplayName("CONFIRMED 건이 없으면 빈 리스트 반환") + void noConfirmed_returnsEmpty() { + given(settlementRepository.findByStatusOrderByCreatedAtAsc( + eq(SettlementStatus.CONFIRMED), any(Pageable.class))) + .willReturn(new PageImpl<>(List.of())); + + List results = settlementService.payAllConfirmed(); + + assertThat(results).isEmpty(); + } + + @Test + @DisplayName("OptimisticLockException 발생 시 해당 건만 건너뛰고 계속 처리") + void optimisticLock_skipsAndContinues() { + SettlementEntity entity1 = mock(SettlementEntity.class); + SettlementEntity entity2 = settlement("ORD-002"); + entity2.confirm(); + + doThrow(new ObjectOptimisticLockingFailureException(SettlementEntity.class, 1L)) + .when(entity1).markPaid(); + + given(settlementRepository.findByStatusOrderByCreatedAtAsc( + eq(SettlementStatus.CONFIRMED), any(Pageable.class))) + .willReturn(new PageImpl<>(List.of(entity1, entity2))); + + List results = settlementService.payAllConfirmed(); + + assertThat(results).hasSize(1); + } + } + + // ════════════════════════════════════════ + // cancelSettlement + // ════════════════════════════════════════ + + @Nested + @DisplayName("cancelSettlement") + class CancelSettlement { + + @Test + @DisplayName("정산 없음 → SETTLEMENT_NOT_FOUND") + void notFound() { + given(settlementRepository.findBySettlementId("STL-NONE")).willReturn(Optional.empty()); + + assertThatThrownBy(() -> settlementService.cancelSettlement("STL-NONE")) + .isInstanceOf(SettlementException.class) + .satisfies(ex -> assertCode(ex, "SETTLEMENT_NOT_FOUND")); + } + + @Test + @DisplayName("PENDING → CANCELLED 성공") + void cancelFromPending() { + SettlementEntity entity = settlement("ORD-001"); + given(settlementRepository.findBySettlementId("STL-001")).willReturn(Optional.of(entity)); + + SettlementResponse response = settlementService.cancelSettlement("STL-001"); + + assertThat(response.status()).isEqualTo("CANCELLED"); + } + + @Test + @DisplayName("CONFIRMED → CANCELLED 성공") + void cancelFromConfirmed() { + SettlementEntity entity = settlement("ORD-001"); + entity.confirm(); + given(settlementRepository.findBySettlementId("STL-001")).willReturn(Optional.of(entity)); + + SettlementResponse response = settlementService.cancelSettlement("STL-001"); + + assertThat(response.status()).isEqualTo("CANCELLED"); + } + + @Test + @DisplayName("PAID → 취소 시도 → ALREADY_PAID") + void cancelFromPaid_throws() { + SettlementEntity entity = settlement("ORD-001"); + entity.confirm(); + entity.markPaid(); + given(settlementRepository.findBySettlementId("STL-001")).willReturn(Optional.of(entity)); + + assertThatThrownBy(() -> settlementService.cancelSettlement("STL-001")) + .isInstanceOf(SettlementException.class) + .satisfies(ex -> assertCode(ex, "ALREADY_PAID")); + } + + @Test + @DisplayName("CANCELLED → 중복 취소 → ALREADY_CANCELLED") + void cancelFromCancelled_throws() { + SettlementEntity entity = settlement("ORD-001"); + entity.cancel(); + given(settlementRepository.findBySettlementId("STL-001")).willReturn(Optional.of(entity)); + + assertThatThrownBy(() -> settlementService.cancelSettlement("STL-001")) + .isInstanceOf(SettlementException.class) + .satisfies(ex -> assertCode(ex, "ALREADY_CANCELLED")); + } + } + + // ════════════════════════════════════════ + // cancelSettlementByOrderId + // ════════════════════════════════════════ + + @Nested + @DisplayName("cancelSettlementByOrderId") + class CancelSettlementByOrderId { + + @Test + @DisplayName("주문 없음 → SETTLEMENT_NOT_FOUND") + void notFound() { + given(settlementRepository.findByOrderId("ORD-NONE")).willReturn(Optional.empty()); + + assertThatThrownBy(() -> settlementService.cancelSettlementByOrderId("ORD-NONE")) + .isInstanceOf(SettlementException.class) + .satisfies(ex -> assertCode(ex, "SETTLEMENT_NOT_FOUND")); + } + + @Test + @DisplayName("성공 → CANCELLED") + void success() { + given(settlementRepository.findByOrderId("ORD-001")) + .willReturn(Optional.of(settlement("ORD-001"))); + + SettlementResponse response = settlementService.cancelSettlementByOrderId("ORD-001"); + + assertThat(response.status()).isEqualTo("CANCELLED"); + } + } + + // ════════════════════════════════════════ + // 헬퍼: 엔티티 생성 + // ════════════════════════════════════════ + + private static SettlementEntity settlement(String orderId) { + return SettlementEntity.create("STL-001", orderId, "SELLER-DEFAULT", + 50000, 30, 0, 0, 0, 0); + } + + private static CouponEntity coupon(String couponId, CouponType type) { + return new CouponEntity(couponId, CouponStatus.USED, type, + DiscountType.FIXED_AMOUNT, 1000, null); + } + + private static OrderData orderData(String orderId, String status) { + return new OrderData(orderId, "USER-001", + List.of(new OrderItemData("PROD-001", 2, 25000, "ACTIVE")), + 50000, 0, 0, 50000, 0, status, null); + } + + // ════════════════════════════════════════ + // 헬퍼: Mock 세팅 + // ════════════════════════════════════════ + + private void givenDefaultPolicy() { + given(policyConfig.defaultCommissionRate()).willReturn(30); + } + + private void givenCreateSettlementSetup(String orderId, int totalAmount, + int couponDiscount, int pointDiscount, + String couponId) { + given(settlementRepository.findByOrderId(orderId)).willReturn(Optional.empty()); + int deliveryFee = (totalAmount >= 30000) ? 0 : 3000; + int pgPayment = totalAmount - couponDiscount - pointDiscount + deliveryFee; + given(orderServiceClient.getOrder(orderId)).willReturn(new OrderData( + orderId, "USER-001", + List.of(new OrderItemData("PROD-001", 2, totalAmount / 2, "ACTIVE")), + totalAmount, couponDiscount, pointDiscount, pgPayment, + deliveryFee, "PURCHASE_CONFIRMED", couponId)); + givenDefaultPolicy(); + given(settlementRepository.save(any())).willAnswer(inv -> inv.getArgument(0)); + } + + // ════════════════════════════════════════ + // 헬퍼: 공통 검증 + // ════════════════════════════════════════ + + private void assertException(String orderId, String expectedCode) { + assertThatThrownBy(() -> settlementService.createSettlement(orderId)) + .isInstanceOf(SettlementException.class) + .satisfies(ex -> assertCode(ex, expectedCode)); + } + + private static void assertCode(Throwable ex, String expectedCode) { + assertThat(((SettlementException) ex).getCode()).isEqualTo(expectedCode); + } +} diff --git a/settlement/src/test/resources/application.yml b/settlement/src/test/resources/application.yml new file mode 100644 index 0000000..db70ea4 --- /dev/null +++ b/settlement/src/test/resources/application.yml @@ -0,0 +1,17 @@ +spring: + datasource: + url: jdbc:h2:mem:testdb + driver-class-name: org.h2.Driver + username: sa + password: + jpa: + hibernate: + ddl-auto: create-drop + open-in-view: false + +settlement: + order-service: + base-url: http://localhost:8080 + policy: + default-commission-rate: 30 + confirm-delay-days: 7