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..8d43271 --- /dev/null +++ b/platform/src/main/java/com/commerce/platform/core/application/in/PaymentUseCaseImpl.java @@ -0,0 +1,191 @@ +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 java.util.Optional; + +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.routPg(payOrdercommand.getPayMethod()); + + // 결재 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..3d2ace0 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,29 @@ import lombok.AllArgsConstructor; import lombok.Getter; +import java.util.Arrays; +import java.util.List; + @Getter @AllArgsConstructor public enum PgProvider { - // todo 여기서 pg사별 지원하는 결제유형을 관리해야되는지 - // todo 카드-수기, 인증, sms 위한 값을 만들지 이 부분은 생략할지 CardAuthType - TOSS("토스"), - OLIVE_NETWORKS("올리브네트웍스"), - NHN("NHN"), - NICE_PAYMENTS("나이스페이먼츠"); - - private final String value; + TOSS(List.of(PayMethod.CARD, PayMethod.EASY_PAY)), + NHN(List.of(PayMethod.CARD, PayMethod.EASY_PAY)), + NICE_PAYMENTS(List.of(PayMethod.CARD, PayMethod.EASY_PAY)), + + DANAL(List.of(PayMethod.PHONE)), + PAYLETTER(List.of(PayMethod.PHONE)) + ; + + private final List payMethods; + + public static List getByPayMethod(PayMethod payMethod) { + List pgProviders = Arrays.stream(PgProvider.values()) + .filter(pg -> pg.getPayMethods().contains(payMethod)) + .toList(); + + if(pgProviders.isEmpty()) throw new IllegalArgumentException("지원 PG사 없음"); + return pgProviders; + } } 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..f33a1a8 --- /dev/null +++ b/platform/src/main/java/com/commerce/platform/core/domain/service/PaymentPgRouter.java @@ -0,0 +1,34 @@ +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.PgProvider; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Service +public class PaymentPgRouter { + private final Map pgStrategies; + + public PaymentPgRouter(List list) { + this.pgStrategies = list.stream() + .collect(Collectors.toMap(PgStrategy::getPgProvider, pg -> pg)); + } + + public PgStrategy routPg(PayMethod payMethod) { + List availablePgList = PgProvider.getByPayMethod(payMethod); + + // todo 조건에 따른 pg사 선택 + PgProvider targetPg = availablePgList.get(0); + + return pgStrategies.get(targetPg); + } + + public PgStrategy getPgStrategyByProvider(PgProvider pgProvider) { + return pgStrategies.get(pgProvider); + } + +} 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/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/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/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..80c7ae8 --- /dev/null +++ b/platform/src/test/java/com/commerce/platform/core/application/in/PaymentUseCaseImplTest.java @@ -0,0 +1,322 @@ +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.adaptor.*; +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.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +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; + +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@Import({ + PaymentUseCaseImpl.class, + PaymentAdaptor.class, + OrderAdaptor.class, + OrderItemAdaptor.class, + ProductAdaptor.class, + CustomerCardAdaptor.class +}) +@DataJpaTest +class PaymentUseCaseImplTest { + + @Autowired + private PaymentUseCaseImpl paymentUseCase; + + @Autowired + private PaymentOutPort paymentOutPort; + + @Autowired + private OrderRepository orderRepository; + + @Autowired + private OrderItemRepository orderItemRepository; + + @Autowired + private PaymentPartCancelRepository paymentPartCancelRepository; + + @MockitoBean + 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.routPg(PayMethod.CARD)) + .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..6cc1b72 --- /dev/null +++ b/platform/src/test/java/com/commerce/platform/core/domain/service/PaymentPgRouterTest.java @@ -0,0 +1,29 @@ +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.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 static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.*; + +@SpringBootTest +class PaymentPgRouterTest { + @Autowired + private PaymentPgRouter paymentPgRouter; + + @DisplayName("결제유형에 따른 라우팅") + @Test + void routPg() { + PgStrategy pgStrategy = paymentPgRouter.routPg(PayMethod.CARD); + + assertThat(pgStrategy) + .as("카드결제는 토스, NHN 지원").isInstanceOfAny(TossStrategy.class, NHNStrategy.class); + + } +} \ 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..68464b7 --- /dev/null +++ b/platform/src/test/resources/application.yml @@ -0,0 +1,28 @@ +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 + + docker: + compose: + enabled: false + +aes256: + key: 61qDonoZcEtIEvUZVPkIKIYovHH82rXtK7T1g/rcc1k= \ No newline at end of file