diff --git a/.env b/.env index d75760c..0ed59a2 100644 --- a/.env +++ b/.env @@ -1,3 +1,5 @@ MYSQL_DATABASE=commerce_dev_db MYSQL_ROOT_PASSWORD=je1234 -MYSQL_PORT=3308 \ No newline at end of file +MYSQL_PORT=3308 +REDIS_PORT=6379 +REDIS_PASSWORD=je1234 \ No newline at end of file diff --git a/buildSrc/src/main/groovy/myproject-convention.gradle b/buildSrc/src/main/groovy/myproject-convention.gradle index 942c1ab..f24ce61 100644 --- a/buildSrc/src/main/groovy/myproject-convention.gradle +++ b/buildSrc/src/main/groovy/myproject-convention.gradle @@ -22,6 +22,7 @@ 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") + implementation 'org.springframework.boot:spring-boot-starter-data-redis' runtimeOnly("com.mysql:mysql-connector-j") compileOnly 'org.projectlombok:lombok' diff --git a/docker-compose.yml b/docker-compose.yml index 103cf10..653c16b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -16,6 +16,16 @@ services: volumes: - db-data:/var/lib/mysql + redis: + image: redis:7.2-alpine + container_name: commerce-redis + ports: + - "${REDIS_PORT}:6379" + command: redis-server --requirepass ${REDIS_PASSWORD} + volumes: + - redis-data:/data + # 데이터 영속성을 위함 volumes: db-data: + redis-data: diff --git a/platform/src/main/java/com/commerce/platform/bootstrap/customer/PaymentController.java b/platform/src/main/java/com/commerce/platform/bootstrap/customer/PaymentController.java index 6079843..a50e1b6 100644 --- a/platform/src/main/java/com/commerce/platform/bootstrap/customer/PaymentController.java +++ b/platform/src/main/java/com/commerce/platform/bootstrap/customer/PaymentController.java @@ -1,11 +1,100 @@ package com.commerce.platform.bootstrap.customer; +import com.commerce.platform.bootstrap.dto.payment.PaymentCancelRequest; +import com.commerce.platform.bootstrap.dto.payment.PaymentRequest; +import com.commerce.platform.core.application.in.PaymentUseCase; +import com.commerce.platform.core.application.in.dto.PayCancelCommand; +import com.commerce.platform.core.application.in.dto.PayOrderCommand; +import com.commerce.platform.core.application.in.dto.PayResult; +import com.commerce.platform.core.domain.enums.PaymentStatus; +import com.commerce.platform.core.domain.vo.OrderId; +import com.commerce.platform.shared.exception.BusinessException; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; @RequiredArgsConstructor -@RequestMapping("/payment") +@RequestMapping("/payments") @RestController public class PaymentController { + private final PaymentUseCase paymentUseCase; + + @PostMapping("/approval") + public ResponseEntity createPayment(@Valid @RequestBody PaymentRequest paymentRequest) { + PayResult result = null; + + try { + PayOrderCommand command = new PayOrderCommand( + OrderId.of(paymentRequest.orderId()), + null, + paymentRequest.installment(), + paymentRequest.payMethod(), + paymentRequest.payProvider() + ); + + paymentUseCase.doApproval(command); + } catch (BusinessException e) { + result = new PayResult.Failed(e.getCode(), e.getMessage()); + } catch (Exception e) { + result = new PayResult.Failed("9999", "승인 처리 중 오류가 발생했습니다"); + } + + return ResponseEntity.ok(result); + } + + @PatchMapping("/cancel") + public ResponseEntity fullCancel(@Valid @RequestBody PaymentCancelRequest cancelRequest) { + PayResult result = null; + + try { + PayCancelCommand cancelCommand = PayCancelCommand.builder() + .orderId(cancelRequest.orderId()) + .paymentStatus(PaymentStatus.FULL_CANCELED) + .build(); + + paymentUseCase.doCancel(cancelCommand); + } catch (BusinessException e) { + result = new PayResult.Failed(e.getCode(), e.getMessage()); + } catch (Exception e) { + result = new PayResult.Failed("9999", "전체취소 처리 중 오류가 발생했습니다"); + } + + return ResponseEntity.ok(result); + } + + @PatchMapping("/partial-cancel") + public ResponseEntity partialCancel(@Valid @RequestBody PaymentCancelRequest cancelRequest) { + PayResult result = null; + + try { + PayCancelCommand cancelCommand = PayCancelCommand.builder() + .orderId(cancelRequest.orderId()) + .orderItemId(cancelRequest.orderItemId()) + .canceledQuantity(cancelRequest.canceledQuantity()) + .paymentStatus(PaymentStatus.FULL_CANCELED) + .build(); + + paymentUseCase.doPartCancel(cancelCommand); + } catch (BusinessException e) { + result = new PayResult.Failed(e.getCode(), e.getMessage()); + } catch (Exception e) { + result = new PayResult.Failed("9999", "부분취소 처리 중 오류가 발생했습니다"); + } + + return ResponseEntity.ok(result); + } + + @PostMapping("/registry-card/{cardId}") + public ResponseEntity createPaymentWithRegistryCard( + @PathVariable Long cardId, + @RequestBody Map body) { + + // todo + paymentUseCase.doApprovalWithCardId(cardId); + + return ResponseEntity.ok("성공"); + } } diff --git a/platform/src/main/java/com/commerce/platform/bootstrap/dto/payment/PaymentCancelRequest.java b/platform/src/main/java/com/commerce/platform/bootstrap/dto/payment/PaymentCancelRequest.java new file mode 100644 index 0000000..d8ded64 --- /dev/null +++ b/platform/src/main/java/com/commerce/platform/bootstrap/dto/payment/PaymentCancelRequest.java @@ -0,0 +1,21 @@ +package com.commerce.platform.bootstrap.dto.payment; + +import com.commerce.platform.core.domain.enums.PaymentStatus; +import com.commerce.platform.core.domain.vo.OrderId; +import com.commerce.platform.core.domain.vo.Quantity; +import jakarta.validation.constraints.NotBlank; + +/** + * 전채/부분 취소 요청 dto + */ +public record PaymentCancelRequest( + @NotBlank + OrderId orderId, + + Long orderItemId, // 취소할 orderItem + + Quantity canceledQuantity, // 해당 orderItem의 취소 개수 + + @NotBlank + PaymentStatus paymentStatus +) {} \ No newline at end of file diff --git a/platform/src/main/java/com/commerce/platform/bootstrap/dto/payment/PaymentRequest.java b/platform/src/main/java/com/commerce/platform/bootstrap/dto/payment/PaymentRequest.java new file mode 100644 index 0000000..7bebb06 --- /dev/null +++ b/platform/src/main/java/com/commerce/platform/bootstrap/dto/payment/PaymentRequest.java @@ -0,0 +1,22 @@ +package com.commerce.platform.bootstrap.dto.payment; + +import com.commerce.platform.core.domain.enums.PayMethod; +import com.commerce.platform.core.domain.enums.PayProvider; +import jakarta.validation.constraints.NotBlank; + +/** + * 승인 요청 dto + */ +public record PaymentRequest( + @NotBlank + String orderId, + + @NotBlank + PayMethod payMethod, + + @NotBlank + PayProvider payProvider, + + @NotBlank + String installment +) { } diff --git a/platform/src/main/java/com/commerce/platform/core/application/in/OrderUseCaseImpl.java b/platform/src/main/java/com/commerce/platform/core/application/in/OrderUseCaseImpl.java index 0b74531..c09ea26 100644 --- a/platform/src/main/java/com/commerce/platform/core/application/in/OrderUseCaseImpl.java +++ b/platform/src/main/java/com/commerce/platform/core/application/in/OrderUseCaseImpl.java @@ -82,7 +82,7 @@ public OrderDetailResponse getOrder(OrderId orderId) { List orderItems = orderItemOutPort.findByOrderId(order.getOrderId()); List productIds = orderItems.stream() - .map(oi -> oi.getOrderItemId().productId()) + .map(oi -> oi.getProductId()) .toList(); Map productMap = productOutputPort.findByIdIn(productIds).stream() @@ -156,7 +156,7 @@ private Money applyCoupon(Order order, CouponId couponId) { */ private Money calculateTotalAmountFromProducts(List orderItems) { List productIds = orderItems.stream() - .map(oi -> oi.getOrderItemId().productId()) + .map(oi -> oi.getProductId()) .toList(); Map productMap = productOutputPort.findByIdIn(productIds).stream() @@ -164,7 +164,7 @@ private Money calculateTotalAmountFromProducts(List orderItems) { return orderItems.stream() .map(item -> { - Product product = productMap.get(item.getOrderItemId().productId()); + Product product = productMap.get(item.getProductId()); product.decreaseStock(item.getQuantity()); // todo 재고 소진 테스트 return product.getPrice() .multiply(item.getQuantity()); diff --git a/platform/src/main/java/com/commerce/platform/core/application/in/PaymentUseCase.java b/platform/src/main/java/com/commerce/platform/core/application/in/PaymentUseCase.java new file mode 100644 index 0000000..2529d21 --- /dev/null +++ b/platform/src/main/java/com/commerce/platform/core/application/in/PaymentUseCase.java @@ -0,0 +1,11 @@ +package com.commerce.platform.core.application.in; + +import com.commerce.platform.core.application.in.dto.PayCancelCommand; +import com.commerce.platform.core.application.in.dto.PayOrderCommand; + +public interface PaymentUseCase { + void doApproval(PayOrderCommand command); + void doCancel(PayCancelCommand cancelCommand); + Long doPartCancel(PayCancelCommand cancelCommand); + void doApprovalWithCardId(Long cardId); // 등록된 카드로 결제 +} diff --git a/platform/src/main/java/com/commerce/platform/core/application/in/PaymentUseCaseImpl.java b/platform/src/main/java/com/commerce/platform/core/application/in/PaymentUseCaseImpl.java new file mode 100644 index 0000000..780684b --- /dev/null +++ b/platform/src/main/java/com/commerce/platform/core/application/in/PaymentUseCaseImpl.java @@ -0,0 +1,190 @@ +package com.commerce.platform.core.application.in; + +import com.commerce.platform.core.application.in.dto.PayCancelCommand; +import com.commerce.platform.core.application.in.dto.PayOrderCommand; +import com.commerce.platform.core.application.out.*; +import com.commerce.platform.core.application.out.dto.PgPayResponse; +import com.commerce.platform.core.domain.aggreate.*; +import com.commerce.platform.core.domain.service.PaymentPgRouter; +import com.commerce.platform.core.domain.vo.Money; +import com.commerce.platform.shared.exception.BusinessError; +import com.commerce.platform.shared.exception.BusinessException; +import lombok.RequiredArgsConstructor; +import lombok.extern.log4j.Log4j2; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +import static com.commerce.platform.shared.exception.BusinessError.*; + +@Log4j2 +@RequiredArgsConstructor +@Service +public class PaymentUseCaseImpl implements PaymentUseCase{ + private final PaymentPgRouter pgRouter; + private final PaymentOutPort paymentOutPort; + private final OrderOutputPort orderOutputPort; + private final OrderItemOutPort orderItemOutPort; + private final ProductOutputPort productOutputPort; + private final CustomerCardOutPort customerCardOutPort; + + @Override + @Transactional + public void doApproval(PayOrderCommand payOrdercommand) { + // 주문 결제처리 + Order orderEntity = orderOutputPort.findById(payOrdercommand.getOrderId()) + .orElseThrow(() -> new BusinessException(INVALID_ORDER_ID)); + orderEntity.validForPay(); + + // pg사 라우팅 + PgStrategy pgStrategy = pgRouter.routePg(payOrdercommand.getPayMethod(), payOrdercommand.getPayProvider()); + + // 결재 entity 생성 + payOrdercommand.setApprovedAmount(orderEntity.getResultAmt()); + Payment paymentEntity = Payment.create(payOrdercommand, pgStrategy.getPgProvider()); + + // pg 결제 응답 수신 + PgPayResponse pgResponse = pgStrategy.processApproval(payOrdercommand); + + // 결제 결과에 따른 주문/결제 상태 변경 + orderEntity.changeStatusAfterPay(pgResponse); + paymentEntity.approved(pgResponse); + + paymentOutPort.savePayment(paymentEntity); + + // todo pg사 응답데이터/ 시간정보 저장? + } + + /** + * 전체취소 + */ + @Override + @Transactional + public void doCancel(PayCancelCommand cancelCommand) { + + // 주문 검증 + Order orderEntity = orderOutputPort.findById(cancelCommand.getOrderId()) + .orElseThrow(() -> new BusinessException(INVALID_ORDER_ID)); + orderEntity.validateForCancel(); + + // 결제 검증 + Payment paymentEntity = paymentOutPort.findByOrderId(orderEntity.getOrderId()) + .orElseThrow(() -> new BusinessException(INVALID_PAYMENT)); + paymentEntity.validateForCancel(); + + // 부분취소 존재여부 확인 + boolean hasPartialCancel = paymentOutPort.existsPartCancelByPaymentId(paymentEntity.getPaymentId()); + if (hasPartialCancel) { + throw new BusinessException(BusinessError.PAYMENT_HAS_PARTIAL_CANCEL); + } + + cancelCommand.setCanceledAmount(orderEntity.getResultAmt()); + cancelCommand.setPayProvider(paymentEntity.getPayProvider()); + cancelCommand.setPayMethod(paymentEntity.getPayMethod()); + cancelCommand.setPgProvider(paymentEntity.getPgProvider()); + + PgStrategy pgStrategy = pgRouter.getPgStrategyByProvider(paymentEntity.getPgProvider()); + PgPayResponse pgResponse = pgStrategy.processCancel(cancelCommand); + + // PG 응답 반영 + if (!pgResponse.isSuccess()) { + throw new BusinessException(PG_RESPONSE_FAILED); + } + + orderEntity.refund(); + paymentEntity.canceled(pgResponse); + paymentOutPort.savePayment(paymentEntity); + } + + /** + * 부분취소 + */ + @Override + @Transactional + public Long doPartCancel(PayCancelCommand cancelCommand) { + // 주문 검정 + Order orderEntity = orderOutputPort.findById(cancelCommand.getOrderId()) + .orElseThrow(() -> new BusinessException(INVALID_ORDER_ID)); + orderEntity.validateForCancel(); + + // 결제 검증 + Payment paymentEntity = paymentOutPort.findByOrderId(orderEntity.getOrderId()) + .orElseThrow(() -> new BusinessException(INVALID_PAYMENT)); + paymentEntity.validateForCancel(); + + // 부분취소 내역 조회 + Money remainAmt = null; + boolean hasPartialCancel = paymentOutPort.existsPartCancelByPaymentId(paymentEntity.getPaymentId()); + if (hasPartialCancel) { + remainAmt = paymentOutPort.getRemainAmount(paymentEntity.getPaymentId()); + } else { + remainAmt = paymentEntity.getApprovedAmt(); + } + + // 취소가능금액 검증 + if(remainAmt.value() == 0) { + throw new BusinessException(PAYMENT_CANCEL_AMOUNT_EXCEEDED); + } + + // 부분취소 가능수량 검증 + OrderItem orderItemEntity = orderItemOutPort.findById(cancelCommand.getOrderItemId()) + .orElseThrow(() -> new BusinessException(INVALID_ORDER_ITEM_ID)); + // 해당 건 삭제처리 + orderItemEntity.canceledItem(cancelCommand.getCanceledQuantity()); + // 새롭게 행 생성한다. + OrderItem refreshOrderItem = OrderItem.create(cancelCommand.getOrderId(), + orderItemEntity.getProductId(), + orderItemEntity.getQuantity().minus(cancelCommand.getCanceledQuantity())); + orderItemOutPort.saveAll(List.of(refreshOrderItem)); + + // 취소금액 계산 + Money canceledAmt = productOutputPort.findById(orderItemEntity.getProductId()) + .get() + .getPrice().multiply(cancelCommand.getCanceledQuantity()); + + cancelCommand.setCanceledAmount(canceledAmt); + cancelCommand.setPayProvider(paymentEntity.getPayProvider()); + cancelCommand.setPayMethod(paymentEntity.getPayMethod()); + cancelCommand.setPgProvider(paymentEntity.getPgProvider()); + + // 최종 남은금액 + Money refreshRemainAmt = remainAmt.subtract(canceledAmt); + + // 취소 요청 + cancelCommand.setCanceledAmount(paymentEntity.getApprovedAmt()); + cancelCommand.setPayProvider(paymentEntity.getPayProvider()); + cancelCommand.setPayMethod(paymentEntity.getPayMethod()); + cancelCommand.setPgProvider(paymentEntity.getPgProvider()); + + PgStrategy pgStrategy = pgRouter.getPgStrategyByProvider(paymentEntity.getPgProvider()); + PgPayResponse pgResponse = pgStrategy.processCancel(cancelCommand); + + if (!pgResponse.isSuccess()) { + throw new BusinessException(PG_RESPONSE_FAILED); + } + + // 부분취소 내역 저장 + PaymentPartCancel partCancel = PaymentPartCancel.create( + paymentEntity.getPaymentId(), + canceledAmt, + refreshRemainAmt + ); + partCancel.completed(pgResponse.pgTid()); + paymentOutPort.savePartCancel(partCancel); + + return partCancel.getId(); + } + + /** + * 등록된 카드로 결제 + * @param cardId + */ + @Override + public void doApprovalWithCardId(Long cardId) { + CustomerCard customerCard = customerCardOutPort.findActiveById(cardId) + .orElseThrow(() -> new BusinessException(INVALID_ORDER_ID)); + + } + +} diff --git a/platform/src/main/java/com/commerce/platform/core/application/in/dto/PayCancelCommand.java b/platform/src/main/java/com/commerce/platform/core/application/in/dto/PayCancelCommand.java new file mode 100644 index 0000000..281d3ca --- /dev/null +++ b/platform/src/main/java/com/commerce/platform/core/application/in/dto/PayCancelCommand.java @@ -0,0 +1,31 @@ +package com.commerce.platform.core.application.in.dto; + +import com.commerce.platform.core.domain.enums.PayMethod; +import com.commerce.platform.core.domain.enums.PayProvider; +import com.commerce.platform.core.domain.enums.PaymentStatus; +import com.commerce.platform.core.domain.enums.PgProvider; +import com.commerce.platform.core.domain.vo.Money; +import com.commerce.platform.core.domain.vo.OrderId; +import com.commerce.platform.core.domain.vo.Quantity; +import lombok.Builder; +import lombok.Getter; +import lombok.Setter; + +/** + * 전체/부분 취소 처리 객체 + */ +@Setter +@Getter +@Builder +public class PayCancelCommand { + private OrderId orderId; + private Long orderItemId; // 취소할 orderItem + private Quantity canceledQuantity; // 해당 orderItem의 취소 개수 + private PaymentStatus paymentStatus; + + // 이후 계산 및 db데이터 기반으로 세팅됨 + private Money canceledAmount; + private PayMethod payMethod; + private PayProvider payProvider; + private PgProvider pgProvider; +} diff --git a/platform/src/main/java/com/commerce/platform/core/application/in/dto/PayOrderCommand.java b/platform/src/main/java/com/commerce/platform/core/application/in/dto/PayOrderCommand.java new file mode 100644 index 0000000..339c032 --- /dev/null +++ b/platform/src/main/java/com/commerce/platform/core/application/in/dto/PayOrderCommand.java @@ -0,0 +1,20 @@ +package com.commerce.platform.core.application.in.dto; + +import com.commerce.platform.core.domain.enums.PayMethod; +import com.commerce.platform.core.domain.enums.PayProvider; +import com.commerce.platform.core.domain.enums.PaymentStatus; +import com.commerce.platform.core.domain.vo.Money; +import com.commerce.platform.core.domain.vo.OrderId; +import lombok.AllArgsConstructor; +import lombok.Data; + +@AllArgsConstructor +@Data +public class PayOrderCommand { + private OrderId orderId; + private Money approvedAmount; + private String installment; + private PayMethod payMethod; + private PayProvider payProvider; + private final PaymentStatus paymentStatus = PaymentStatus.APPROVED; +} diff --git a/platform/src/main/java/com/commerce/platform/core/application/in/dto/PayResult.java b/platform/src/main/java/com/commerce/platform/core/application/in/dto/PayResult.java new file mode 100644 index 0000000..25ced15 --- /dev/null +++ b/platform/src/main/java/com/commerce/platform/core/application/in/dto/PayResult.java @@ -0,0 +1,18 @@ +package com.commerce.platform.core.application.in.dto; + +/** + * 결제 응답 + */ +public interface PayResult { + + record Success( + String paymentId, + String message + ) implements PayResult{} + + record Failed( + String errorCode, + String message + ) implements PayResult{} + +} diff --git a/platform/src/main/java/com/commerce/platform/core/application/out/CardPay.java b/platform/src/main/java/com/commerce/platform/core/application/out/CardPay.java new file mode 100644 index 0000000..7ec9bb7 --- /dev/null +++ b/platform/src/main/java/com/commerce/platform/core/application/out/CardPay.java @@ -0,0 +1,10 @@ +package com.commerce.platform.core.application.out; + +import com.commerce.platform.core.application.in.dto.PayCancelCommand; +import com.commerce.platform.core.application.in.dto.PayOrderCommand; +import com.commerce.platform.core.application.out.dto.PgPayResponse; + +public interface CardPay { + PgPayResponse approveCard(PayOrderCommand command); + PgPayResponse cancelCard(PayCancelCommand command); +} diff --git a/platform/src/main/java/com/commerce/platform/core/application/out/CustomerCardOutPort.java b/platform/src/main/java/com/commerce/platform/core/application/out/CustomerCardOutPort.java index 188d402..cce339e 100644 --- a/platform/src/main/java/com/commerce/platform/core/application/out/CustomerCardOutPort.java +++ b/platform/src/main/java/com/commerce/platform/core/application/out/CustomerCardOutPort.java @@ -3,7 +3,10 @@ import com.commerce.platform.core.domain.aggreate.CustomerCard; import com.commerce.platform.core.domain.vo.CustomerId; +import java.util.Optional; + public interface CustomerCardOutPort { void save(CustomerCard customerCard); int countActiveCardByCustomerId(CustomerId customerId); + Optional findActiveById(Long cardId); } diff --git a/platform/src/main/java/com/commerce/platform/core/application/out/EasyPay.java b/platform/src/main/java/com/commerce/platform/core/application/out/EasyPay.java new file mode 100644 index 0000000..a4270a8 --- /dev/null +++ b/platform/src/main/java/com/commerce/platform/core/application/out/EasyPay.java @@ -0,0 +1,10 @@ +package com.commerce.platform.core.application.out; + +import com.commerce.platform.core.application.in.dto.PayCancelCommand; +import com.commerce.platform.core.application.in.dto.PayOrderCommand; +import com.commerce.platform.core.application.out.dto.PgPayResponse; + +public interface EasyPay { + PgPayResponse approveEasyPay(PayOrderCommand command); + PgPayResponse cancelEasyPay(PayCancelCommand command); +} diff --git a/platform/src/main/java/com/commerce/platform/core/application/out/OrderItemOutPort.java b/platform/src/main/java/com/commerce/platform/core/application/out/OrderItemOutPort.java index 9f580e6..8d69be3 100644 --- a/platform/src/main/java/com/commerce/platform/core/application/out/OrderItemOutPort.java +++ b/platform/src/main/java/com/commerce/platform/core/application/out/OrderItemOutPort.java @@ -4,8 +4,10 @@ import com.commerce.platform.core.domain.vo.OrderId; import java.util.List; +import java.util.Optional; public interface OrderItemOutPort { void saveAll(List orderItems); List findByOrderId(OrderId orderId); + Optional findById(Long orderItemId); } diff --git a/platform/src/main/java/com/commerce/platform/core/application/out/PaymentOutPort.java b/platform/src/main/java/com/commerce/platform/core/application/out/PaymentOutPort.java new file mode 100644 index 0000000..3781d90 --- /dev/null +++ b/platform/src/main/java/com/commerce/platform/core/application/out/PaymentOutPort.java @@ -0,0 +1,19 @@ +package com.commerce.platform.core.application.out; + +import com.commerce.platform.core.domain.aggreate.Payment; +import com.commerce.platform.core.domain.aggreate.PaymentPartCancel; +import com.commerce.platform.core.domain.vo.Money; +import com.commerce.platform.core.domain.vo.OrderId; +import com.commerce.platform.core.domain.vo.PaymentId; + +import java.util.Optional; + +public interface PaymentOutPort { + void savePayment(Payment payment); + Optional findByOrderId(OrderId orderId); + + // 부분취소 관련 + PaymentPartCancel savePartCancel(PaymentPartCancel partCancel); + boolean existsPartCancelByPaymentId(PaymentId paymentId); + Money getRemainAmount(PaymentId paymentId); +} diff --git a/platform/src/main/java/com/commerce/platform/core/application/out/PgStrategy.java b/platform/src/main/java/com/commerce/platform/core/application/out/PgStrategy.java new file mode 100644 index 0000000..3646e20 --- /dev/null +++ b/platform/src/main/java/com/commerce/platform/core/application/out/PgStrategy.java @@ -0,0 +1,59 @@ +package com.commerce.platform.core.application.out; + +import com.commerce.platform.core.application.in.dto.PayCancelCommand; +import com.commerce.platform.core.application.in.dto.PayOrderCommand; +import com.commerce.platform.core.application.out.dto.PgPayResponse; +import com.commerce.platform.core.domain.enums.PgProvider; + +public abstract class PgStrategy { + + public abstract PgProvider getPgProvider(); + + /** + * 라우팅된 pg사에서 해당 결제유형으로 승인 요청 + * @param command + * @return pg 응답 + */ + public final PgPayResponse processApproval(PayOrderCommand command) { + return switch (command.getPayMethod()) { + case CARD -> getCardPay().approveCard(command); + case EASY_PAY -> getEasyPay().approveEasyPay(command); + case PHONE -> getPhonePay().approvePhone(command); + }; + } + + /** + * 라우팅된 pg사에서 해당 결제유형으로 취소 요청 + * @param command + * @return pg 응답 + */ + public final PgPayResponse processCancel(PayCancelCommand command) { + return switch (command.getPayMethod()) { + case CARD -> getCardPay().cancelCard(command); + case EASY_PAY -> getEasyPay().cancelEasyPay(command); + case PHONE -> getPhonePay().cancelPhone(command); + }; + } + + private CardPay getCardPay() { + if (this instanceof CardPay cardPay) { + return cardPay; + } + throw new UnsupportedOperationException("카드결제 미지원 pg사"); + } + + private PhonePay getPhonePay() { + if (this instanceof PhonePay phonePay) { + return phonePay; + } + throw new UnsupportedOperationException("휴대폰결제 미지원 pg사"); + } + + private EasyPay getEasyPay() { + if (this instanceof EasyPay easyPay) { + return easyPay; + } + throw new UnsupportedOperationException("간편결제 미지원 pg사"); + } + +} diff --git a/platform/src/main/java/com/commerce/platform/core/application/out/PhonePay.java b/platform/src/main/java/com/commerce/platform/core/application/out/PhonePay.java new file mode 100644 index 0000000..70c7526 --- /dev/null +++ b/platform/src/main/java/com/commerce/platform/core/application/out/PhonePay.java @@ -0,0 +1,10 @@ +package com.commerce.platform.core.application.out; + +import com.commerce.platform.core.application.in.dto.PayCancelCommand; +import com.commerce.platform.core.application.in.dto.PayOrderCommand; +import com.commerce.platform.core.application.out.dto.PgPayResponse; + +public interface PhonePay { + PgPayResponse approvePhone(PayOrderCommand command); + PgPayResponse cancelPhone(PayCancelCommand command); +} diff --git a/platform/src/main/java/com/commerce/platform/core/application/out/dto/PgPayResponse.java b/platform/src/main/java/com/commerce/platform/core/application/out/dto/PgPayResponse.java new file mode 100644 index 0000000..7d6ea9c --- /dev/null +++ b/platform/src/main/java/com/commerce/platform/core/application/out/dto/PgPayResponse.java @@ -0,0 +1,8 @@ +package com.commerce.platform.core.application.out.dto; + +public record PgPayResponse ( + String pgTid, + String responseCode, // pg사 응답코드 + String responseMessage, // pg사 응답메시지 + boolean isSuccess +) {} diff --git a/platform/src/main/java/com/commerce/platform/core/domain/aggreate/Order.java b/platform/src/main/java/com/commerce/platform/core/domain/aggreate/Order.java index 9007242..1d758b0 100644 --- a/platform/src/main/java/com/commerce/platform/core/domain/aggreate/Order.java +++ b/platform/src/main/java/com/commerce/platform/core/domain/aggreate/Order.java @@ -1,5 +1,6 @@ package com.commerce.platform.core.domain.aggreate; +import com.commerce.platform.core.application.out.dto.PgPayResponse; import com.commerce.platform.core.domain.enums.OrderStatus; import com.commerce.platform.core.domain.vo.CouponId; import com.commerce.platform.core.domain.vo.CustomerId; @@ -125,16 +126,31 @@ public void cancel() { /** 주문 환불 **/ public void refund() { + updateOrderStatus(REFUND); + } + + /** 환불가능여부 확인 **/ + public void validateForCancel() { if(this.status != OrderStatus.PAID) { - throw new RuntimeException("환불처리 불가"); + throw new RuntimeException("환불 불가능한 주문 상태입니다"); } + } - updateOrderStatus(REFUND); + /** 주문 결제 **/ + public void validForPay() { + if(this.status != OrderStatus.CONFIRMED) { + throw new RuntimeException("결제처리 불가"); + } + } + + public void changeStatusAfterPay(PgPayResponse pgPayResponse) { + if(!pgPayResponse.isSuccess()) return; + + updateOrderStatus(PAID); } /** * 주문상태, 수정시간 변경 - * @param updateStatus */ private void updateOrderStatus(OrderStatus updateStatus) { this.updatedAt = LocalDateTime.now(); diff --git a/platform/src/main/java/com/commerce/platform/core/domain/aggreate/OrderItem.java b/platform/src/main/java/com/commerce/platform/core/domain/aggreate/OrderItem.java index 35144d3..88f755a 100644 --- a/platform/src/main/java/com/commerce/platform/core/domain/aggreate/OrderItem.java +++ b/platform/src/main/java/com/commerce/platform/core/domain/aggreate/OrderItem.java @@ -1,41 +1,63 @@ package com.commerce.platform.core.domain.aggreate; import com.commerce.platform.core.domain.vo.OrderId; -import com.commerce.platform.core.domain.vo.OrderItemId; import com.commerce.platform.core.domain.vo.ProductId; import com.commerce.platform.core.domain.vo.Quantity; +import com.commerce.platform.shared.exception.BusinessException; import jakarta.persistence.*; import lombok.AccessLevel; -import lombok.Builder; +import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; +import static com.commerce.platform.shared.exception.BusinessError.INVALID_CANCELED_QUANTITY; + @Getter @Entity +@AllArgsConstructor(access = AccessLevel.PRIVATE) @Table(name = "order_item") @NoArgsConstructor(access = AccessLevel.PROTECTED) public class OrderItem { - @EmbeddedId - private OrderItemId orderItemId; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Embedded + @AttributeOverride(name = "id", column = @Column(name = "order_id", nullable = false, length = 21)) + OrderId orderId; + + @Embedded + @AttributeOverride(name = "id", column = @Column(name = "product_id", nullable = false, length = 21)) + ProductId productId; @Embedded @AttributeOverride(name = "value", column = @Column(name = "quantity", nullable = false)) private Quantity quantity; + private boolean canceled; + public static OrderItem create( OrderId orderId, ProductId productId, Quantity quantity ) { - return OrderItem.builder() - .orderItemId(new OrderItemId(orderId, productId)) - .quantity(quantity) - .build(); + OrderItem oi = new OrderItem(); + oi.orderId = orderId; + oi.productId = productId; + oi.quantity = quantity; + oi.canceled = false; + return oi; } - @Builder - private OrderItem(OrderItemId orderItemId, Quantity quantity) { - this.orderItemId = orderItemId; - this.quantity = quantity; + /** + * 부분취소 시 해당건 canceld true. + * 수정된 행은 새로 추가된다. + */ + public void canceledItem(Quantity canceledQuantity) { + if(this.quantity.value() < canceledQuantity.value()) { + throw new BusinessException(INVALID_CANCELED_QUANTITY); + } + this.canceled = true; } } diff --git a/platform/src/main/java/com/commerce/platform/core/domain/aggreate/Payment.java b/platform/src/main/java/com/commerce/platform/core/domain/aggreate/Payment.java index 60b2540..1f431fa 100644 --- a/platform/src/main/java/com/commerce/platform/core/domain/aggreate/Payment.java +++ b/platform/src/main/java/com/commerce/platform/core/domain/aggreate/Payment.java @@ -1,5 +1,7 @@ package com.commerce.platform.core.domain.aggreate; +import com.commerce.platform.core.application.in.dto.PayOrderCommand; +import com.commerce.platform.core.application.out.dto.PgPayResponse; import com.commerce.platform.core.domain.enums.PayMethod; import com.commerce.platform.core.domain.enums.PayProvider; import com.commerce.platform.core.domain.enums.PaymentStatus; @@ -8,14 +10,14 @@ import com.commerce.platform.core.domain.vo.OrderId; import com.commerce.platform.core.domain.vo.PaymentId; import jakarta.persistence.*; -import lombok.AccessLevel; -import lombok.Getter; -import lombok.NoArgsConstructor; +import lombok.*; import java.time.LocalDateTime; @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PROTECTED) +@Builder(access = AccessLevel.PRIVATE) @Table(name = "payment") @Entity public class Payment { @@ -37,7 +39,7 @@ public class Payment { private PayMethod payMethod; @Column(name = "pay_provider", length = 10) - private PayProvider cardProvider; + private PayProvider payProvider; @Column(name = "installment", length = 2) private String installment; @@ -67,4 +69,50 @@ public class Payment { @Column(name = "canceled_at") private LocalDateTime canceledAt; + + public static Payment create(PayOrderCommand command, PgProvider targetPg) { + return Payment.builder() + .paymentId(PaymentId.create()) + .orderId(command.getOrderId()) + .approvedAmt(command.getApprovedAmount()) + .payMethod(command.getPayMethod()) + .payProvider(command.getPayProvider()) + .installment(command.getInstallment()) + .pgProvider(targetPg) + .paymentStatus(command.getPaymentStatus()) + .requestedAt(LocalDateTime.now()) + .build(); + } + + public void approved(PgPayResponse pgResponse) { + this.pgTid = pgResponse.pgTid(); + + if(!pgResponse.isSuccess()) { + this.paymentStatus = PaymentStatus.FAILED; + return; + } + + this.approvedAt = LocalDateTime.now(); + this.paymentStatus = PaymentStatus.APPROVED; + } + + public void canceled(PgPayResponse pgResponse) { + this.pgCancelTid = pgResponse.pgTid(); + + if(!pgResponse.isSuccess()) { + this.paymentStatus = PaymentStatus.FAILED; + return; + } + this.canceledAt = LocalDateTime.now(); + this.paymentStatus = PaymentStatus.FULL_CANCELED; + } + + /** + * 전체/부분 취소 가능 여부 검증 + */ + public void validateForCancel() { + if(this.paymentStatus != PaymentStatus.APPROVED) { + throw new RuntimeException("취소가 불가능한 결제 상태입니다"); + } + } } diff --git a/platform/src/main/java/com/commerce/platform/core/domain/aggreate/PaymentPartCancel.java b/platform/src/main/java/com/commerce/platform/core/domain/aggreate/PaymentPartCancel.java index 22b4234..d92f87c 100644 --- a/platform/src/main/java/com/commerce/platform/core/domain/aggreate/PaymentPartCancel.java +++ b/platform/src/main/java/com/commerce/platform/core/domain/aggreate/PaymentPartCancel.java @@ -15,7 +15,12 @@ @Entity public class PaymentPartCancel { - @EmbeddedId + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Embedded + @AttributeOverride(name = "id", column = @Column(name = "part_canceled_payment_id", nullable = false)) private PaymentId paymentPartCancelId; @Embedded @@ -38,4 +43,27 @@ public class PaymentPartCancel { @Column(name = "canceled_at") private LocalDateTime canceledAt; + + /** + * 부분 취소 생성 + */ + public static PaymentPartCancel create( + PaymentId approvedPaymentId, + Money canceledAmt, + Money remainAmt + ) { + PaymentPartCancel partCancel = new PaymentPartCancel(); + partCancel.paymentPartCancelId = PaymentId.create(); + partCancel.approvedPaymentId = approvedPaymentId; + partCancel.canceledAmt = canceledAmt; + partCancel.remainAmt = remainAmt; + partCancel.requestedAt = LocalDateTime.now(); + return partCancel; + } + + /**부분취소 완료 처리*/ + public void completed(String pgCancelTid) { + this.pgCancelTid = pgCancelTid; + this.canceledAt = LocalDateTime.now(); + } } diff --git a/platform/src/main/java/com/commerce/platform/core/domain/enums/OrderStatus.java b/platform/src/main/java/com/commerce/platform/core/domain/enums/OrderStatus.java index 356ce2a..2f8d33d 100644 --- a/platform/src/main/java/com/commerce/platform/core/domain/enums/OrderStatus.java +++ b/platform/src/main/java/com/commerce/platform/core/domain/enums/OrderStatus.java @@ -6,15 +6,11 @@ @Getter @AllArgsConstructor public enum OrderStatus { - CANCELED("취소완료"), - REFUND("환불완료"), PENDING("주문대기"), CONFIRMED("주문완료"), - PAID("결제완료"); -// PREPARING("상품준비중"), -// READY_TO_SHIP("배송준비중"), -// SHIPPING("배송중"), -// DELIVERED("배송완료"); + CANCELED("취소완료"), + PAID("결제완료"), + REFUND("환불완료"); private final String value; } diff --git a/platform/src/main/java/com/commerce/platform/core/domain/enums/PayMethod.java b/platform/src/main/java/com/commerce/platform/core/domain/enums/PayMethod.java index 52d00b9..c6b9f29 100644 --- a/platform/src/main/java/com/commerce/platform/core/domain/enums/PayMethod.java +++ b/platform/src/main/java/com/commerce/platform/core/domain/enums/PayMethod.java @@ -7,6 +7,7 @@ @AllArgsConstructor public enum PayMethod { CARD("카드결제"), + EASY_PAY("간편결제"), PHONE("휴대폰결제"); private final String value; diff --git a/platform/src/main/java/com/commerce/platform/core/domain/enums/PaymentStatus.java b/platform/src/main/java/com/commerce/platform/core/domain/enums/PaymentStatus.java index 3c338b0..2a2fcc2 100644 --- a/platform/src/main/java/com/commerce/platform/core/domain/enums/PaymentStatus.java +++ b/platform/src/main/java/com/commerce/platform/core/domain/enums/PaymentStatus.java @@ -8,7 +8,8 @@ public enum PaymentStatus { APPROVED("approved"), FULL_CANCELED("fullCanceled"), - PARTIAL_CANCELED("partialCanceled"); + PARTIAL_CANCELED("partialCanceled"), + FAILED("failed"); private final String value; } diff --git a/platform/src/main/java/com/commerce/platform/core/domain/enums/PgProvider.java b/platform/src/main/java/com/commerce/platform/core/domain/enums/PgProvider.java index 5410d31..9adffd5 100644 --- a/platform/src/main/java/com/commerce/platform/core/domain/enums/PgProvider.java +++ b/platform/src/main/java/com/commerce/platform/core/domain/enums/PgProvider.java @@ -3,16 +3,58 @@ import lombok.AllArgsConstructor; import lombok.Getter; +import java.util.Arrays; +import java.util.List; +import java.util.Set; + @Getter @AllArgsConstructor public enum PgProvider { - // todo 여기서 pg사별 지원하는 결제유형을 관리해야되는지 - // todo 카드-수기, 인증, sms 위한 값을 만들지 이 부분은 생략할지 CardAuthType - TOSS("토스"), - OLIVE_NETWORKS("올리브네트웍스"), - NHN("NHN"), - NICE_PAYMENTS("나이스페이먼츠"); - - private final String value; + TOSS( + Set.of(PayMethod.CARD, PayMethod.EASY_PAY), + Set.of(PayProvider.SHIN_HAN, PayProvider.KB, PayProvider.NH, + PayProvider.HYUNDAI, PayProvider.SAMSUNG, PayProvider.BC) + ), + NHN( + Set.of(PayMethod.CARD, PayMethod.EASY_PAY), + Set.of(PayProvider.NH, PayProvider.HYUNDAI, + PayProvider.SAMSUNG, PayProvider.BC) + ), + NICE_PAYMENTS( + Set.of(PayMethod.CARD, PayMethod.EASY_PAY), + Set.of(PayProvider.HANA, PayProvider.LOTTE, + PayProvider.SAMSUNG, PayProvider.BC) + ), + + DANAL( + Set.of(PayMethod.PHONE), + Set.of(PayProvider.LG, PayProvider.KT, PayProvider.SKT) + + ), + PAYLETTER( + Set.of(PayMethod.PHONE), + Set.of(PayProvider.LG, PayProvider.KT) + ) + ; + + private final Set payMethods; + private final Set payProviders; + + public static List getByPayMethod(PayMethod payMethod, PayProvider payProvider) { + List pgProviders = Arrays.stream(PgProvider.values()) + .filter(pg -> pg.getPayMethods().contains(payMethod)) + .filter(pg -> pg.getPayProviders().contains(payProvider)) + .toList(); + + if(pgProviders.isEmpty()) throw new IllegalArgumentException("지원 PG사 없음"); + return pgProviders; + } + + public static PgProvider getByPgName(String pgName) { + return Arrays.stream(PgProvider.values()) + .filter(pg -> pg.name().equalsIgnoreCase(pgName)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("미지원 PG사")); + } } diff --git a/platform/src/main/java/com/commerce/platform/core/domain/service/PaymentPgRouter.java b/platform/src/main/java/com/commerce/platform/core/domain/service/PaymentPgRouter.java new file mode 100644 index 0000000..19841b2 --- /dev/null +++ b/platform/src/main/java/com/commerce/platform/core/domain/service/PaymentPgRouter.java @@ -0,0 +1,60 @@ +package com.commerce.platform.core.domain.service; + +import com.commerce.platform.core.application.out.PgStrategy; +import com.commerce.platform.core.domain.enums.PayMethod; +import com.commerce.platform.core.domain.enums.PayProvider; +import com.commerce.platform.core.domain.enums.PgProvider; +import com.commerce.platform.infrastructure.adaptor.PgCacheService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * PG 라우팅 서비스 + * 결제방식 + 카드사/통신사 => PG + * return 서버 정상 + 수수료 가장 저렴한 PG + */ +@Slf4j +@Service +public class PaymentPgRouter { + + private final Map pgStrategies; + private final PgCacheService pgCacheService; + + public PaymentPgRouter(List list, PgCacheService pgCacheService) { + this.pgStrategies = list.stream() + .collect(Collectors.toMap(PgStrategy::getPgProvider, pg -> pg)); + this.pgCacheService = pgCacheService; + } + + /** + * 결제유형+카드사에 따라 PG 선택 + * Redis에서 캐싱 + */ + public PgStrategy routePg(PayMethod payMethod, PayProvider payProvider) { + + List supportedPgs = PgProvider.getByPayMethod(payMethod, payProvider); + + PgProvider selectedPg = pgCacheService.getBestPg(payMethod, payProvider, supportedPgs); + + if (selectedPg == null) { + throw new IllegalStateException("현재 사용 가능한 PG사가 없습니다"); + } + + return pgStrategies.get(selectedPg); + } + + /** + * PG Provider => Strategy 조회 + */ + public PgStrategy getPgStrategyByProvider(PgProvider pgProvider) { + PgStrategy strategy = pgStrategies.get(pgProvider); + if (strategy == null) { + throw new IllegalArgumentException("존재하지 않는 PG: " + pgProvider); + } + return strategy; + } +} diff --git a/platform/src/main/java/com/commerce/platform/core/domain/vo/OrderItemId.java b/platform/src/main/java/com/commerce/platform/core/domain/vo/OrderItemId.java deleted file mode 100644 index aef8d60..0000000 --- a/platform/src/main/java/com/commerce/platform/core/domain/vo/OrderItemId.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.commerce.platform.core.domain.vo; - -import jakarta.persistence.AttributeOverride; -import jakarta.persistence.Column; -import jakarta.persistence.Embeddable; -import jakarta.persistence.Embedded; - -import java.io.Serializable; - -@Embeddable -public record OrderItemId( - @Embedded - @AttributeOverride(name = "id", column = @Column(name = "order_id", nullable = false, length = 21)) - OrderId orderId, - - @Embedded - @AttributeOverride(name = "id", column = @Column(name = "product_id", nullable = false, length = 21)) - ProductId productId -) implements Serializable { -} diff --git a/platform/src/main/java/com/commerce/platform/infrastructure/adaptor/CustomerCardAdaptor.java b/platform/src/main/java/com/commerce/platform/infrastructure/adaptor/CustomerCardAdaptor.java index 9c03d11..7a27fed 100644 --- a/platform/src/main/java/com/commerce/platform/infrastructure/adaptor/CustomerCardAdaptor.java +++ b/platform/src/main/java/com/commerce/platform/infrastructure/adaptor/CustomerCardAdaptor.java @@ -7,6 +7,8 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; +import java.util.Optional; + @RequiredArgsConstructor @Component public class CustomerCardAdaptor implements CustomerCardOutPort { @@ -21,4 +23,9 @@ public void save(CustomerCard customerCard) { public int countActiveCardByCustomerId(CustomerId customerId) { return repository.countByActiveCustomerId(customerId); } + + @Override + public Optional findActiveById(Long cardId) { + return repository.findByIdAndActive(cardId); + } } diff --git a/platform/src/main/java/com/commerce/platform/infrastructure/adaptor/OrderItemAdaptor.java b/platform/src/main/java/com/commerce/platform/infrastructure/adaptor/OrderItemAdaptor.java index ed1146c..a64620d 100644 --- a/platform/src/main/java/com/commerce/platform/infrastructure/adaptor/OrderItemAdaptor.java +++ b/platform/src/main/java/com/commerce/platform/infrastructure/adaptor/OrderItemAdaptor.java @@ -8,6 +8,7 @@ import org.springframework.stereotype.Component; import java.util.List; +import java.util.Optional; @RequiredArgsConstructor @Component @@ -22,4 +23,9 @@ public void saveAll(List orderItems) { public List findByOrderId(OrderId orderId) { return repository.findByOrderId(orderId); } + + @Override + public Optional findById(Long orderItemId) { + return repository.findById(orderItemId); + } } diff --git a/platform/src/main/java/com/commerce/platform/infrastructure/adaptor/PaymentAdaptor.java b/platform/src/main/java/com/commerce/platform/infrastructure/adaptor/PaymentAdaptor.java new file mode 100644 index 0000000..7f1234b --- /dev/null +++ b/platform/src/main/java/com/commerce/platform/infrastructure/adaptor/PaymentAdaptor.java @@ -0,0 +1,46 @@ +package com.commerce.platform.infrastructure.adaptor; + +import com.commerce.platform.core.application.out.PaymentOutPort; +import com.commerce.platform.core.domain.aggreate.Payment; +import com.commerce.platform.core.domain.aggreate.PaymentPartCancel; +import com.commerce.platform.core.domain.vo.Money; +import com.commerce.platform.core.domain.vo.OrderId; +import com.commerce.platform.core.domain.vo.PaymentId; +import com.commerce.platform.infrastructure.persistence.PaymentPartCancelRepository; +import com.commerce.platform.infrastructure.persistence.PaymentRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +@RequiredArgsConstructor +@Component +public class PaymentAdaptor implements PaymentOutPort { + private final PaymentRepository paymentRepository; + private final PaymentPartCancelRepository paymentPartCancelRepository; + + @Override + public void savePayment(Payment payment) { + paymentRepository.save(payment); + } + + @Override + public Optional findByOrderId(OrderId orderId) { + return paymentRepository.findByOrderId(orderId); + } + + @Override + public PaymentPartCancel savePartCancel(PaymentPartCancel partCancel) { + return paymentPartCancelRepository.save(partCancel); + } + + @Override + public boolean existsPartCancelByPaymentId(PaymentId approvedpaymentId) { + return paymentPartCancelRepository.existsPaymentPartCancelByApprovedPaymentId(approvedpaymentId); + } + + @Override + public Money getRemainAmount(PaymentId paymentId) { + return paymentPartCancelRepository.selectRemainAmountByPaymentId(paymentId); + } +} diff --git a/platform/src/main/java/com/commerce/platform/infrastructure/adaptor/PgCacheService.java b/platform/src/main/java/com/commerce/platform/infrastructure/adaptor/PgCacheService.java new file mode 100644 index 0000000..79ba64b --- /dev/null +++ b/platform/src/main/java/com/commerce/platform/infrastructure/adaptor/PgCacheService.java @@ -0,0 +1,116 @@ +package com.commerce.platform.infrastructure.adaptor; + +import com.commerce.platform.core.domain.enums.PayMethod; +import com.commerce.platform.core.domain.enums.PayProvider; +import com.commerce.platform.core.domain.enums.PgProvider; +import com.commerce.platform.infrastructure.persistence.PgFeeInfo; +import com.commerce.platform.infrastructure.persistence.PgFeeInfoRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Service; + +import java.util.Comparator; +import java.util.List; +import java.util.Set; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +/** + * PG 라우팅을 위한 Redis 캐시 서비스 + * 수수료 낮은 순으로 정렬된 PG 목록 관리 + * 장애 PG는 자동으로 제외 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class PgCacheService { + + private final StringRedisTemplate redisTemplate; + private final PgFeeInfoRepository feeInfoRepository; + + private static final String ROUTE_KEY_PREFIX = "pg:route:"; + private static final String HEALTH_KEY_PREFIX = "pg:health:"; + + public PgProvider getBestPg(PayMethod payMethod, PayProvider payProvider, List supportedPgs) { + // redis 조회 + Set pgProviders = getAvailablePgsFromCache(payMethod, payProvider); + + // miss + if (pgProviders == null || pgProviders.isEmpty()) { + pgProviders = refreshCache(payMethod, payProvider); + } + + // 장애 PG 제외 첫번째 선택 + PgProvider bestPg = null; + for (String pgName : pgProviders) { + bestPg = PgProvider.getByPgName(pgName); + if (supportedPgs.contains(bestPg) && isHealthy(bestPg)) { + return bestPg; + } + } + + log.error("모든 PG 장애 중: payMethod={}, payProvider={}", payMethod, payProvider); + return null; + } + + /** + * ZSet 수수료 asc + */ + private Set getAvailablePgsFromCache(PayMethod payMethod, PayProvider payProvider) { + String key = buildRouteKey(payMethod, payProvider); + return redisTemplate.opsForZSet().range(key, 0, -1); + } + + /** + * DB에서 수수료 조회 및 Redis 캐싱 + * ZSet score :수수료율 + */ + public Set refreshCache(PayMethod payMethod, PayProvider payProvider) { + String key = buildRouteKey(payMethod, payProvider); + // DB 조회: 수수료 낮은 순 + List configs = feeInfoRepository + .findByPayMethodAndPayProvider(payMethod, payProvider); + + // todo 별도 스레드로 하는것이 좋을지 + // 기존 캐시 삭제 + redisTemplate.delete(key); + + for (PgFeeInfo config : configs) { + redisTemplate.opsForZSet().add( + key, + config.getPgProvider().name(), + config.getFeeRate().doubleValue() + ); + } + + return configs.stream() + .sorted(Comparator.comparing(PgFeeInfo::getFeeRate)) + .map(pgFeeInfo -> pgFeeInfo.getPgProvider().name()) + .collect(Collectors.toSet()); + } + + /** + * PG 헬스 체크 + */ + public boolean isHealthy(PgProvider pgProvider) { + String healthKey = HEALTH_KEY_PREFIX + pgProvider.name(); + return redisTemplate.opsForValue().get(healthKey) == null; + } + + /** + * PG 장애 + * TTL : 30m + */ + public void markPgAsUnhealthy(PgProvider pgProvider) { + String healthKey = HEALTH_KEY_PREFIX + pgProvider.name(); + redisTemplate.opsForValue().set(healthKey, "ERROR", 30, TimeUnit.MINUTES); + } + + /** + * Redis Key 생성: pg:route:CARD:SHIN_HAN + */ + private String buildRouteKey(PayMethod payMethod, PayProvider payProvider) { + return ROUTE_KEY_PREFIX + payMethod.name() + ":" + payProvider.name(); + } +} diff --git a/platform/src/main/java/com/commerce/platform/infrastructure/persistence/CustomerCardRepository.java b/platform/src/main/java/com/commerce/platform/infrastructure/persistence/CustomerCardRepository.java index 3fb76db..c904a47 100644 --- a/platform/src/main/java/com/commerce/platform/infrastructure/persistence/CustomerCardRepository.java +++ b/platform/src/main/java/com/commerce/platform/infrastructure/persistence/CustomerCardRepository.java @@ -6,9 +6,15 @@ import org.springframework.data.jpa.repository.Query; import org.springframework.stereotype.Repository; +import java.util.Optional; + @Repository public interface CustomerCardRepository extends JpaRepository { - @Query("SELECT COUNT(c) FROM CustomerCard c WHERE c.isActive = true AND c.customerId = :customerId") + @Query("SELECT COUNT(c) FROM CustomerCard c WHERE c.customerId = :customerId AND c.isActive = true") int countByActiveCustomerId(CustomerId customerId); + + @Query("SELECT c FROM CustomerCard c WHERE c.id = :cardId AND c.isActive = true") + Optional findByIdAndActive(Long cardId); + } diff --git a/platform/src/main/java/com/commerce/platform/infrastructure/persistence/OrderItemRepository.java b/platform/src/main/java/com/commerce/platform/infrastructure/persistence/OrderItemRepository.java index 1ade2a3..e540e70 100644 --- a/platform/src/main/java/com/commerce/platform/infrastructure/persistence/OrderItemRepository.java +++ b/platform/src/main/java/com/commerce/platform/infrastructure/persistence/OrderItemRepository.java @@ -2,16 +2,19 @@ import com.commerce.platform.core.domain.aggreate.OrderItem; import com.commerce.platform.core.domain.vo.OrderId; -import com.commerce.platform.core.domain.vo.OrderItemId; +import com.commerce.platform.core.domain.vo.ProductId; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.stereotype.Repository; import java.util.List; +import java.util.Optional; @Repository -public interface OrderItemRepository extends JpaRepository { +public interface OrderItemRepository extends JpaRepository { - @Query("SELECT oi FROM OrderItem oi WHERE oi.orderItemId.orderId = :orderId") + @Query("SELECT oi FROM OrderItem oi WHERE oi.orderId = :orderId") List findByOrderId(OrderId orderId); + + Optional findByOrderIdAndProductIdAndCanceled(OrderId orderId, ProductId productId, boolean b); } diff --git a/platform/src/main/java/com/commerce/platform/infrastructure/persistence/PaymentPartCancelRepository.java b/platform/src/main/java/com/commerce/platform/infrastructure/persistence/PaymentPartCancelRepository.java new file mode 100644 index 0000000..f5f25bb --- /dev/null +++ b/platform/src/main/java/com/commerce/platform/infrastructure/persistence/PaymentPartCancelRepository.java @@ -0,0 +1,19 @@ +package com.commerce.platform.infrastructure.persistence; + +import com.commerce.platform.core.domain.aggreate.PaymentPartCancel; +import com.commerce.platform.core.domain.vo.Money; +import com.commerce.platform.core.domain.vo.PaymentId; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface PaymentPartCancelRepository extends JpaRepository { + + boolean existsPaymentPartCancelByApprovedPaymentId(PaymentId paymentId); + + /** + * 최신 남은 금액 조회 + */ + @Query("SELECT p.remainAmt FROM PaymentPartCancel p WHERE p.approvedPaymentId = :paymentId ORDER BY p.id DESC LIMIT 1") + Money selectRemainAmountByPaymentId(@Param("payment") PaymentId paymentId); +} diff --git a/platform/src/main/java/com/commerce/platform/infrastructure/persistence/PaymentRepository.java b/platform/src/main/java/com/commerce/platform/infrastructure/persistence/PaymentRepository.java new file mode 100644 index 0000000..cedcf31 --- /dev/null +++ b/platform/src/main/java/com/commerce/platform/infrastructure/persistence/PaymentRepository.java @@ -0,0 +1,17 @@ +package com.commerce.platform.infrastructure.persistence; + +import com.commerce.platform.core.domain.aggreate.Payment; +import com.commerce.platform.core.domain.vo.OrderId; +import com.commerce.platform.core.domain.vo.PaymentId; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface PaymentRepository extends JpaRepository { + + @Query("SELECT p FROM Payment p WHERE p.orderId = :orderId") + Optional findByOrderId(OrderId orderId); +} diff --git a/platform/src/main/java/com/commerce/platform/infrastructure/persistence/PgFeeInfo.java b/platform/src/main/java/com/commerce/platform/infrastructure/persistence/PgFeeInfo.java new file mode 100644 index 0000000..af88443 --- /dev/null +++ b/platform/src/main/java/com/commerce/platform/infrastructure/persistence/PgFeeInfo.java @@ -0,0 +1,71 @@ +package com.commerce.platform.infrastructure.persistence; + +import com.commerce.platform.core.domain.enums.PayMethod; +import com.commerce.platform.core.domain.enums.PayProvider; +import com.commerce.platform.core.domain.enums.PgProvider; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; + +/** + * PG사별 결제방식 + 카드사/통신사 조합의 수수료율 저장 + */ +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +@Table(name = "pg_fee_info") +public class PgFeeInfo { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private PgProvider pgProvider; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private PayMethod payMethod; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private PayProvider payProvider; + + @Column(nullable = false, precision = 4, scale = 2) + private BigDecimal feeRate; + + @Column(nullable = false) + private boolean isActive; + + @Column(nullable = false, updatable = false) + private LocalDate frDt; + + @Column(nullable = false, updatable = false) + private LocalDate toDt; + + @Column(nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(nullable = false) + private LocalDateTime updatedAt; + + public PgFeeInfo(PgProvider pgProvider, PayMethod payMethod, + PayProvider payProvider, BigDecimal feeRate, + LocalDate frDt, LocalDate toDt) { + this.pgProvider = pgProvider; + this.payMethod = payMethod; + this.payProvider = payProvider; + this.feeRate = feeRate; + this.isActive = true; + this.frDt = frDt; + this.toDt = toDt; + this.createdAt = LocalDateTime.now(); + this.updatedAt = LocalDateTime.now(); + } +} diff --git a/platform/src/main/java/com/commerce/platform/infrastructure/persistence/PgFeeInfoRepository.java b/platform/src/main/java/com/commerce/platform/infrastructure/persistence/PgFeeInfoRepository.java new file mode 100644 index 0000000..3363f10 --- /dev/null +++ b/platform/src/main/java/com/commerce/platform/infrastructure/persistence/PgFeeInfoRepository.java @@ -0,0 +1,30 @@ +package com.commerce.platform.infrastructure.persistence; + +import com.commerce.platform.core.domain.enums.PayMethod; +import com.commerce.platform.core.domain.enums.PayProvider; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; + +public interface PgFeeInfoRepository extends JpaRepository { + + /** + * 결제방식 + 카드사/통신사로 활성화된 PG 수수료 조회 + * 수수료 asc + */ + @Query(""" + SELECT p FROM PgFeeInfo p + WHERE p.payMethod = :payMethod + AND p.payProvider = :payProvider + AND p.isActive = true + AND NOW() between p.frDt AND p.toDt + ORDER BY p.feeRate ASC + """) + List findByPayMethodAndPayProvider( + @Param("payMethod") PayMethod payMethod, + @Param("payProvider") PayProvider payProvider + ); + +} diff --git a/platform/src/main/java/com/commerce/platform/infrastructure/pg/DanalStrategy.java b/platform/src/main/java/com/commerce/platform/infrastructure/pg/DanalStrategy.java new file mode 100644 index 0000000..2ff17ce --- /dev/null +++ b/platform/src/main/java/com/commerce/platform/infrastructure/pg/DanalStrategy.java @@ -0,0 +1,28 @@ +package com.commerce.platform.infrastructure.pg; + +import com.commerce.platform.core.application.in.dto.PayCancelCommand; +import com.commerce.platform.core.application.in.dto.PayOrderCommand; +import com.commerce.platform.core.application.out.PgStrategy; +import com.commerce.platform.core.application.out.PhonePay; +import com.commerce.platform.core.application.out.dto.PgPayResponse; +import com.commerce.platform.core.domain.enums.PgProvider; +import org.springframework.stereotype.Component; + +@Component +public class DanalStrategy extends PgStrategy + implements PhonePay { + @Override + public PgProvider getPgProvider() { + return PgProvider.DANAL; + } + + @Override + public PgPayResponse approvePhone(PayOrderCommand command) { + return null; + } + + @Override + public PgPayResponse cancelPhone(PayCancelCommand command) { + return null; + } +} diff --git a/platform/src/main/java/com/commerce/platform/infrastructure/pg/NHNStrategy.java b/platform/src/main/java/com/commerce/platform/infrastructure/pg/NHNStrategy.java new file mode 100644 index 0000000..46d53ef --- /dev/null +++ b/platform/src/main/java/com/commerce/platform/infrastructure/pg/NHNStrategy.java @@ -0,0 +1,28 @@ +package com.commerce.platform.infrastructure.pg; + +import com.commerce.platform.core.application.in.dto.PayCancelCommand; +import com.commerce.platform.core.application.in.dto.PayOrderCommand; +import com.commerce.platform.core.application.out.CardPay; +import com.commerce.platform.core.application.out.PgStrategy; +import com.commerce.platform.core.application.out.dto.PgPayResponse; +import com.commerce.platform.core.domain.enums.PgProvider; +import org.springframework.stereotype.Component; + +@Component +public class NHNStrategy extends PgStrategy + implements CardPay { + @Override + public PgPayResponse approveCard(PayOrderCommand command) { + return null; + } + + @Override + public PgPayResponse cancelCard(PayCancelCommand command) { + return null; + } + + @Override + public PgProvider getPgProvider() { + return PgProvider.NHN; + } +} diff --git a/platform/src/main/java/com/commerce/platform/infrastructure/pg/TossStrategy.java b/platform/src/main/java/com/commerce/platform/infrastructure/pg/TossStrategy.java new file mode 100644 index 0000000..f11367f --- /dev/null +++ b/platform/src/main/java/com/commerce/platform/infrastructure/pg/TossStrategy.java @@ -0,0 +1,41 @@ +package com.commerce.platform.infrastructure.pg; + +import com.commerce.platform.core.application.in.dto.PayCancelCommand; +import com.commerce.platform.core.application.in.dto.PayOrderCommand; +import com.commerce.platform.core.application.out.CardPay; +import com.commerce.platform.core.application.out.EasyPay; +import com.commerce.platform.core.application.out.PgStrategy; +import com.commerce.platform.core.application.out.dto.PgPayResponse; +import com.commerce.platform.core.domain.enums.PgProvider; +import org.springframework.stereotype.Component; + +@Component +public class TossStrategy extends PgStrategy + implements CardPay, EasyPay { + + @Override + public PgProvider getPgProvider() { + return PgProvider.TOSS; + } + + + @Override + public PgPayResponse approveCard(PayOrderCommand command) { + return null; + } + + @Override + public PgPayResponse cancelCard(PayCancelCommand command) { + return null; + } + + @Override + public PgPayResponse approveEasyPay(PayOrderCommand command) { + return null; + } + + @Override + public PgPayResponse cancelEasyPay(PayCancelCommand command) { + return null; + } +} diff --git a/platform/src/main/java/com/commerce/platform/shared/exception/BusinessError.java b/platform/src/main/java/com/commerce/platform/shared/exception/BusinessError.java index e4123b2..8c88acd 100644 --- a/platform/src/main/java/com/commerce/platform/shared/exception/BusinessError.java +++ b/platform/src/main/java/com/commerce/platform/shared/exception/BusinessError.java @@ -15,11 +15,13 @@ public enum BusinessError { // Order 관련 INVALID_ORDER_ID("O001", "유효하지 않는 주문 ID"), + INVALID_ORDER_ITEM_ID("O002", "유효하지 주문건 ID"), // Quantity 관련 INVALID_QUANTITY("Q001", "유효하지 않은 수량입니다"), QUANTITY_BELOW_MINIMUM("Q002", "수량은 최소 1개 이상이어야 합니다"), QUANTITY_EXCEEDS_MAXIMUM("Q003", "수량이 최대 한도를 초과했습니다"), + INVALID_CANCELED_QUANTITY("Q004", "취소 불가능한 수량입니다."), // Stock 관련 INSUFFICIENT_STOCK("S001", "재고가 부족합니다"), @@ -28,7 +30,8 @@ public enum BusinessError { // Customer INVALID_CUSTOMER("M001", "고객ID 확인요망"), DUPLICATED_REGISTRY_CARD("M002", "이미 등록된 카드 존재"), - EXCEED_REGISTRY_CARD("M002", "카드는 최대 5개 등록 가능합니다."), + EXCEED_REGISTRY_CARD("M003", "카드는 최대 5개 등록 가능합니다."), + NOT_FOUND_REGISTRY_CARD("M004", "해당 카드가 존재하지 않습니다."), // Coupon INVALID_COUPON("C001", "유효하지 않는 쿠폰 ID"), @@ -43,7 +46,13 @@ public enum BusinessError { DUPLICATE_ISSUED_COUPON("I004", "이미 발급된 쿠폰입니다."), // Payment - INVALID_PAYMENT("T", "유효하지 않는 결제ID"), + INVALID_PAYMENT("T001", "유효하지 않는 결제ID"), + PAYMENT_ALREADY_CANCELED("T002", "이미 전체 취소된 결제입니다"), + PAYMENT_HAS_PARTIAL_CANCEL("T003", "부분 취소 내역이 존재하여 전체 취소가 불가능합니다"), + PAYMENT_CANCEL_AMOUNT_EXCEEDED("T004", "취소 가능 금액을 초과했습니다"), + PAYMENT_INVALID_STATUS_FOR_CANCEL("T005", "취소 불가능한 결제 상태입니다"), + PG_RESPONSE_FAILED("T006", "결제처리 결과 실패"), + INVALID_PARTIAL_CANCEL_AMOUNT("T007", "부분취소 불가능한 금액입니다. 전체취소로 요청하세요."), // Money INVALID_MONEY("M001", "금액 오류"), diff --git a/platform/src/main/resources/application.yml b/platform/src/main/resources/application.yml index ed766c6..4821882 100644 --- a/platform/src/main/resources/application.yml +++ b/platform/src/main/resources/application.yml @@ -16,6 +16,11 @@ spring: format_sql: true dialect: org.hibernate.dialect.MySQLDialect + data: + redis: + host: localhost + port: 6379 + docker: compose: enabled: true diff --git a/platform/src/test/java/com/commerce/platform/core/application/in/PaymentUseCaseImplTest.java b/platform/src/test/java/com/commerce/platform/core/application/in/PaymentUseCaseImplTest.java new file mode 100644 index 0000000..b979dab --- /dev/null +++ b/platform/src/test/java/com/commerce/platform/core/application/in/PaymentUseCaseImplTest.java @@ -0,0 +1,312 @@ +package com.commerce.platform.core.application.in; + +import com.commerce.platform.core.application.in.dto.PayCancelCommand; +import com.commerce.platform.core.application.in.dto.PayOrderCommand; +import com.commerce.platform.core.application.out.PaymentOutPort; +import com.commerce.platform.core.application.out.PgStrategy; +import com.commerce.platform.core.application.out.dto.PgPayResponse; +import com.commerce.platform.core.domain.aggreate.Order; +import com.commerce.platform.core.domain.aggreate.OrderItem; +import com.commerce.platform.core.domain.aggreate.Payment; +import com.commerce.platform.core.domain.enums.*; +import com.commerce.platform.core.domain.service.PaymentPgRouter; +import com.commerce.platform.core.domain.vo.*; +import com.commerce.platform.infrastructure.persistence.OrderItemRepository; +import com.commerce.platform.infrastructure.persistence.OrderRepository; +import com.commerce.platform.infrastructure.persistence.PaymentPartCancelRepository; +import com.commerce.platform.shared.exception.BusinessError; +import com.commerce.platform.shared.exception.BusinessException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +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.Mockito.mock; +import static org.mockito.Mockito.when; + +@SpringBootTest +@Transactional +class PaymentUseCaseImplTest { + + @Autowired + private PaymentUseCase paymentUseCase; + + @Autowired + private PaymentOutPort paymentOutPort; + + @Autowired + private OrderRepository orderRepository; + + @Autowired + private OrderItemRepository orderItemRepository; + + @Autowired + private PaymentPartCancelRepository paymentPartCancelRepository; + + @MockBean + private PaymentPgRouter mockPaymentPgRouter; + + private Order testOrder; + + private List testOrderItems; + + private final ProductId productId1 = ProductId.of("P20251110222600000001"); + private final ProductId productId2 = ProductId.of("P20251110222600000002"); + + private PgPayResponse success_pgResponse = new PgPayResponse( + "PG_TID_12345", + "0000", + "성공", + true + ); + + private PgPayResponse fail_pgResponse = new PgPayResponse( + "PG_TID_12345", + "errorCode", + "실패", + false + ); + + @BeforeEach + void init() { + // 주문완료 상태의 신규order 저장 + testOrder = Order.create(CustomerId.of("test1"), null); + testOrder.confirm(Money.create(35000), Money.create(0)); + orderRepository.save(testOrder); + orderRepository.flush(); + + testOrderItems = List.of( + OrderItem.create(testOrder.getOrderId(), productId1, Quantity.create(3)), + OrderItem.create(testOrder.getOrderId(), productId2, Quantity.create(1)) + ); + + orderItemRepository.saveAll(testOrderItems); + orderItemRepository.flush(); + } + + @DisplayName("주문 결제 성공") + @Test + void doApproval_successful() { + PayOrderCommand command = new PayOrderCommand( + testOrder.getOrderId(), + null, + null, + PayMethod.CARD, + PayProvider.KB + ); + + mockPgStrategy(); + + paymentUseCase.doApproval(command); + + // then 승인 성공 + Payment payment = paymentOutPort.findByOrderId(testOrder.getOrderId()) + .get(); + assertThat(payment).isNotNull(); + assertThat(payment.getPaymentStatus()).isEqualTo(PaymentStatus.APPROVED); + + // order 상태 검증 + assertThat(orderRepository.findById(testOrder.getOrderId()).get().getStatus()) + .isEqualTo(OrderStatus.PAID); + } + + @DisplayName("전체환불 성공") + @Test + void doCancel_successful() { + // 주문 결제 + doApproval_successful(); + + // 전체취소 요청 + PayCancelCommand command = PayCancelCommand.builder() + .orderId(testOrder.getOrderId()) + .paymentStatus(PaymentStatus.FULL_CANCELED) + .build(); + + mockPgStrategy(); + + paymentUseCase.doCancel(command); + + // payment 상태 + assertThat(paymentOutPort.findByOrderId(testOrder.getOrderId())).isNotNull(); + assertThat(paymentOutPort.findByOrderId(testOrder.getOrderId()).get().getPaymentStatus()) + .isEqualTo(PaymentStatus.FULL_CANCELED); + + // order 상태 검증 + Order refundedOrder = orderRepository.findById(testOrder.getOrderId()).orElseThrow(); + assertThat(refundedOrder.getStatus()).isEqualTo(OrderStatus.REFUND); + } + + @DisplayName("전체취소실패 : 부분취소내역 존재") + @Test + void doCancel_failed() { + // 결제 승인 + doApproval_successful(); + + mockPgStrategy(); + + // 부분취소 요청 + OrderItem canceledItem = testOrderItems.get(0); + + PayCancelCommand partCancelCommand = PayCancelCommand.builder() + .orderId(testOrder.getOrderId()) + .orderItemId(canceledItem.getId()) + .canceledQuantity(Quantity.create(2)) + .paymentStatus(PaymentStatus.PARTIAL_CANCELED) + .build(); + + mockPgStrategy(); + + // 부분취소 + Long partCancelId = paymentUseCase.doPartCancel(partCancelCommand); + + // 전체취소 + PayCancelCommand fullCancelCommand = PayCancelCommand.builder() + .orderId(testOrder.getOrderId()) + .paymentStatus(PaymentStatus.FULL_CANCELED) + .build(); + + // 전체취소 실패 + assertThatThrownBy(() -> paymentUseCase.doCancel(fullCancelCommand)) + .isInstanceOf(BusinessException.class) + .hasMessageContaining(BusinessError.PAYMENT_HAS_PARTIAL_CANCEL.getMessage()); + + // payment 상태 검증 + Payment payment = paymentOutPort.findByOrderId(testOrder.getOrderId()).orElseThrow(); + assertThat(payment.getPaymentStatus()) + .isEqualTo(PaymentStatus.APPROVED); + + // order 상태 검증 + Order order = orderRepository.findById(testOrder.getOrderId()).orElseThrow(); + assertThat(order.getStatus()).isEqualTo(OrderStatus.PAID); + } + + + @DisplayName("부분취소 성공") + @Test + void doPartCancel_successful() { + // 주문 결제 + doApproval_successful(); + + // 부분취소 요청 + OrderItem canceledItem = testOrderItems.get(0); + + PayCancelCommand partCancelCommand = PayCancelCommand.builder() + .orderId(testOrder.getOrderId()) + .orderItemId(canceledItem.getId()) + .canceledQuantity(Quantity.create(2)) + .paymentStatus(PaymentStatus.PARTIAL_CANCELED) + .build(); + + mockPgStrategy(); + + // 부분취소 + Long partCancelId = paymentUseCase.doPartCancel(partCancelCommand); + + // payment 상태 + assertThat(paymentOutPort.findByOrderId(testOrder.getOrderId()).get().getPaymentStatus()) + .as("원거래는 승인상태 유지") + .isEqualTo(PaymentStatus.APPROVED); + + assertThat(paymentPartCancelRepository.findById(partCancelId)) + .isPresent() + .get() + .satisfies(partCancel -> { + assertThat(partCancel) + .as("insert 부분취소") + .isNotNull(); + + assertThat(partCancel.getCanceledAmt().value()) + .as("부분취소 금액 검증") + .isEqualTo(2500 * 2) ; + }); + + // orderitem 검증 + assertThat(orderItemRepository.findById(canceledItem.getId())) + .isPresent() + .get() + .satisfies( + orderItem -> { + assertThat(orderItem.isCanceled()).isEqualTo(true); + } + ); + + assertThat(orderItemRepository.findByOrderIdAndProductIdAndCanceled(partCancelCommand.getOrderId(), canceledItem.getProductId(), false)) + .isPresent() + .get() + .satisfies(oi -> { + assertThat(oi.getQuantity().value()) + .as("새로 추가된 orderitem 수량 확인") + .isEqualTo(canceledItem.getQuantity().minus(partCancelCommand.getCanceledQuantity()).value()); + assertThat(oi.isCanceled()).isEqualTo(false); + }); + + // order 상태 검증 + Order refundedOrder = orderRepository.findById(testOrder.getOrderId()).orElseThrow(); + assertThat(refundedOrder.getStatus()) + .as("주문상태는 결제완료로 유지") + .isEqualTo(OrderStatus.PAID); + } + + @DisplayName("부분취소 실패 : 취소 가능 수량 초과") + @Test + void doPartCancel_failed() { + // 주문 결제 + doApproval_successful(); + + // 부분취소 요청 + PayCancelCommand partCancelCommand = PayCancelCommand.builder() + .orderId(testOrder.getOrderId()) + .orderItemId(testOrderItems.get(1).getId()) + .canceledQuantity(Quantity.create(2)) + .paymentStatus(PaymentStatus.PARTIAL_CANCELED) + .build(); + + mockPgStrategy(); + + // 부분취소 + assertThatThrownBy(() -> paymentUseCase.doPartCancel(partCancelCommand)) + .isInstanceOf(BusinessException.class) + .hasMessageContaining(BusinessError.INVALID_CANCELED_QUANTITY.getMessage()); + + assertThat(paymentOutPort.findByOrderId(testOrder.getOrderId()).get().getPaymentStatus()) + .as("원거래는 승인상태 유지") + .isEqualTo(PaymentStatus.APPROVED); + + PaymentId paymentId = paymentOutPort.findByOrderId(testOrder.getOrderId()).get().getPaymentId(); + assertThat(paymentPartCancelRepository.existsPaymentPartCancelByApprovedPaymentId(paymentId)) + .as("not insert 부분취소") + .isEqualTo(false); + + // order 상태 검증 + Order refundedOrder = orderRepository.findById(testOrder.getOrderId()).orElseThrow(); + assertThat(refundedOrder.getStatus()).isEqualTo(OrderStatus.PAID) + .as("주문상태는 결제완료로 유지"); + } + + private void mockPgStrategy() { + PgStrategy mockPgStrategy = mock(PgStrategy.class); + + when(mockPaymentPgRouter.getPgStrategyByProvider(any())) + .thenReturn(mockPgStrategy); + + when(mockPgStrategy.processCancel(any())) + .thenReturn(success_pgResponse); + + when(mockPaymentPgRouter.routePg(PayMethod.CARD, PayProvider.KB)) + .thenReturn(mockPgStrategy); + + when(mockPgStrategy.getPgProvider()) + .thenReturn(PgProvider.TOSS); + + when(mockPgStrategy.processApproval(any())) + .thenReturn(success_pgResponse); + } +} \ No newline at end of file diff --git a/platform/src/test/java/com/commerce/platform/core/domain/service/PaymentPgRouterTest.java b/platform/src/test/java/com/commerce/platform/core/domain/service/PaymentPgRouterTest.java new file mode 100644 index 0000000..9eb5ed6 --- /dev/null +++ b/platform/src/test/java/com/commerce/platform/core/domain/service/PaymentPgRouterTest.java @@ -0,0 +1,55 @@ +package com.commerce.platform.core.domain.service; + +import com.commerce.platform.core.domain.enums.PayMethod; +import com.commerce.platform.core.domain.enums.PayProvider; +import com.commerce.platform.core.domain.enums.PgProvider; +import com.commerce.platform.infrastructure.adaptor.PgCacheService; +import com.commerce.platform.infrastructure.pg.NHNStrategy; +import com.commerce.platform.infrastructure.pg.TossStrategy; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.redis.core.StringRedisTemplate; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +class PaymentPgRouterTest { + @Autowired + private PaymentPgRouter paymentPgRouter; + + @Autowired + private PgCacheService pgCacheService; + + @Autowired + private StringRedisTemplate redisTemplate; + + + @DisplayName("결제유형에 따른 라우팅") + @Test + void routePg() { + assertThat(paymentPgRouter.routePg(PayMethod.CARD, PayProvider.KB)) + .as("KB 카드 결제는 TOSS 만 존재").isInstanceOf(TossStrategy.class); + + assertThat(paymentPgRouter.routePg(PayMethod.CARD, PayProvider.SAMSUNG)) + .as("SAMSUNG 카드 결제는 NHN 우선").isInstanceOf(NHNStrategy.class); + + } + + @DisplayName("1위 장애시 2위 반환") + @Test + void routePg_health() { + // nhn 장애 + pgCacheService.markPgAsUnhealthy(PgProvider.NHN); + + assertThat(pgCacheService.isHealthy(PgProvider.NHN)) + .isEqualTo(false); + + assertThat(paymentPgRouter.routePg(PayMethod.CARD, PayProvider.SAMSUNG)) + .as("SAMSUNG 카드 결제 : NHN 장애로 TOSS!").isInstanceOf(TossStrategy.class); + + // nhn 장애 원복 + redisTemplate.delete("pg:health:NHN"); + } +} \ No newline at end of file diff --git a/platform/src/test/resources/application.yml b/platform/src/test/resources/application.yml new file mode 100644 index 0000000..b60eec6 --- /dev/null +++ b/platform/src/test/resources/application.yml @@ -0,0 +1,33 @@ +spring: + application: + name: platform + + datasource: + url: jdbc:mysql://localhost:3308/commerce_dev_db?useSSL=false&serverTimezone=Asia/Seoul&characterEncoding=UTF-8 + username: root + password: je1234 + driver-class-name: com.mysql.cj.jdbc.Driver + hikari: + maximum-pool-size: 10 + connection-timeout: 3000 + + jpa: + hibernate: + ddl-auto: update + show-sql: true + properties: + hibernate: + format_sql: true + dialect: org.hibernate.dialect.MySQLDialect + data: + redis: + host: 127.0.0.1 + port: 6379 + password: je1234 + + docker: + compose: + enabled: false + +aes256: + key: 61qDonoZcEtIEvUZVPkIKIYovHH82rXtK7T1g/rcc1k= \ No newline at end of file