diff --git a/platform/src/main/java/com/commerce/platform/PlatformApplication.java b/platform/src/main/java/com/commerce/platform/PlatformApplication.java index e9617fb..eabe938 100644 --- a/platform/src/main/java/com/commerce/platform/PlatformApplication.java +++ b/platform/src/main/java/com/commerce/platform/PlatformApplication.java @@ -2,7 +2,9 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableScheduling; +@EnableScheduling @SpringBootApplication public class PlatformApplication { 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 a50e1b6..9c24ae8 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 @@ -32,7 +32,8 @@ public ResponseEntity createPayment(@Valid @RequestBody PaymentReques null, paymentRequest.installment(), paymentRequest.payMethod(), - paymentRequest.payProvider() + paymentRequest.payProvider(), + null ); paymentUseCase.doApproval(command); 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 index 8d43271..8231601 100644 --- 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 @@ -3,6 +3,7 @@ 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.PgPayCancelResponse; import com.commerce.platform.core.application.out.dto.PgPayResponse; import com.commerce.platform.core.domain.aggreate.*; import com.commerce.platform.core.domain.service.PaymentPgRouter; @@ -15,7 +16,6 @@ import org.springframework.transaction.annotation.Transactional; import java.util.List; -import java.util.Optional; import static com.commerce.platform.shared.exception.BusinessError.*; @@ -39,7 +39,7 @@ public void doApproval(PayOrderCommand payOrdercommand) { orderEntity.validForPay(); // pg사 라우팅 - PgStrategy pgStrategy = pgRouter.routPg(payOrdercommand.getPayMethod()); + PgStrategy pgStrategy = pgRouter.routePg(payOrdercommand.getPayMethod(), payOrdercommand.getPayProvider()); // 결재 entity 생성 payOrdercommand.setApprovedAmount(orderEntity.getResultAmt()); @@ -48,6 +48,8 @@ public void doApproval(PayOrderCommand payOrdercommand) { // pg 결제 응답 수신 PgPayResponse pgResponse = pgStrategy.processApproval(payOrdercommand); + validRequestAmount(payOrdercommand.getApprovedAmount(), pgResponse.amount().value()); + // 결제 결과에 따른 주문/결제 상태 변경 orderEntity.changeStatusAfterPay(pgResponse); paymentEntity.approved(pgResponse); @@ -85,14 +87,17 @@ public void doCancel(PayCancelCommand cancelCommand) { cancelCommand.setPayMethod(paymentEntity.getPayMethod()); cancelCommand.setPgProvider(paymentEntity.getPgProvider()); - PgStrategy pgStrategy = pgRouter.getPgStrategyByProvider(paymentEntity.getPgProvider()); - PgPayResponse pgResponse = pgStrategy.processCancel(cancelCommand); + PgStrategy pgStrategy = pgRouter.getPgStrategyByProvider(paymentEntity.getPgProvider(), paymentEntity.getPayMethod()); + PgPayCancelResponse pgResponse = pgStrategy.processCancel(cancelCommand); // PG 응답 반영 if (!pgResponse.isSuccess()) { throw new BusinessException(PG_RESPONSE_FAILED); } + // Pg취소금액, 요청취소금액 검증 + validRequestAmount(cancelCommand.getCanceledAmount(), pgResponse.cancelAmount()); + orderEntity.refund(); paymentEntity.canceled(pgResponse); paymentOutPort.savePayment(paymentEntity); @@ -158,20 +163,22 @@ public Long doPartCancel(PayCancelCommand cancelCommand) { cancelCommand.setPayMethod(paymentEntity.getPayMethod()); cancelCommand.setPgProvider(paymentEntity.getPgProvider()); - PgStrategy pgStrategy = pgRouter.getPgStrategyByProvider(paymentEntity.getPgProvider()); - PgPayResponse pgResponse = pgStrategy.processCancel(cancelCommand); + PgStrategy pgStrategy = pgRouter.getPgStrategyByProvider(paymentEntity.getPgProvider(), paymentEntity.getPayMethod()); + PgPayCancelResponse pgResponse = pgStrategy.processCancel(cancelCommand); if (!pgResponse.isSuccess()) { throw new BusinessException(PG_RESPONSE_FAILED); } + validRequestAmount(cancelCommand.getCanceledAmount(), pgResponse.cancelAmount()); + // 부분취소 내역 저장 PaymentPartCancel partCancel = PaymentPartCancel.create( paymentEntity.getPaymentId(), canceledAmt, refreshRemainAmt ); - partCancel.completed(pgResponse.pgTid()); + partCancel.completed(pgResponse.pgCcTid()); paymentOutPort.savePartCancel(partCancel); return partCancel.getId(); @@ -188,4 +195,14 @@ public void doApprovalWithCardId(Long cardId) { } + /** + * pg 요청금액 , 고객 요청금액 동일 검증 + * @param customerRequestAmt + * @param pgResponseAmt + */ + private void validRequestAmount(Money customerRequestAmt, Long pgResponseAmt) { + if(customerRequestAmt.value() != pgResponseAmt) { + throw new RuntimeException("요청금액 불일치"); + } + } } 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 index 281d3ca..504a1fa 100644 --- 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 @@ -7,25 +7,39 @@ 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; +import lombok.*; /** * 전체/부분 취소 처리 객체 */ @Setter @Getter +@AllArgsConstructor(access = AccessLevel.PROTECTED) @Builder public class PayCancelCommand { private OrderId orderId; private Long orderItemId; // 취소할 orderItem private Quantity canceledQuantity; // 해당 orderItem의 취소 개수 private PaymentStatus paymentStatus; + private String cancelReason; - // 이후 계산 및 db데이터 기반으로 세팅됨 + // 이후 계산 및 db데이터 기반으로 세팅됨] + private String pgTid; // pg 승인Tid private Money canceledAmount; private PayMethod payMethod; private PayProvider payProvider; private PgProvider pgProvider; + + private RefundReceiveAccount refundReceiveAccount; + + /** + * 환불 계좌 정보 (가상계좌 전용) + */ + @Getter + @AllArgsConstructor + public class RefundReceiveAccount { + private String bankCode; + private String accountNumber; + private String holderName; + } } 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 index 339c032..998542d 100644 --- 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 @@ -17,4 +17,5 @@ public class PayOrderCommand { private PayMethod payMethod; private PayProvider payProvider; private final PaymentStatus paymentStatus = PaymentStatus.APPROVED; + private String jsonSubData; // pg사 요구 데이터 } diff --git a/platform/src/main/java/com/commerce/platform/core/application/out/CardBinPromotionOutPort.java b/platform/src/main/java/com/commerce/platform/core/application/out/CardBinPromotionOutPort.java new file mode 100644 index 0000000..b799be8 --- /dev/null +++ b/platform/src/main/java/com/commerce/platform/core/application/out/CardBinPromotionOutPort.java @@ -0,0 +1,9 @@ +package com.commerce.platform.core.application.out; + +import com.commerce.platform.core.domain.aggreate.CardBinPromotion; + +import java.util.List; + +public interface CardBinPromotionOutPort { + List findActivePromotions(); +} 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 index 16650b9..bef2cbd 100644 --- 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 @@ -1,16 +1,40 @@ 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.PgPayCancelResponse; +import com.commerce.platform.core.application.out.dto.PgPayResponse; +import com.commerce.platform.core.domain.enums.PayMethod; import com.commerce.platform.core.domain.enums.PgProvider; +/** + * PG사별 결제를 위한 메소드 정의 + */ public abstract class PgStrategy { /** - * pg사별 요청에 따라 [Card | Easy | Phone]PayService 구현체 실행한다. - * @param request todo dto - * @return todo 결재응답dto + * 승인 */ - public abstract String process(String request); + public abstract PgPayResponse processApproval(PayOrderCommand command); + /** + * 취소 + */ + public abstract PgPayCancelResponse processCancel(PayCancelCommand command); + + /** + * PG사명 + */ public abstract PgProvider getPgProvider(); + /** + * 결제유형 + */ + public abstract PayMethod getPgPayMethod(); + + /** + * 결제창을 위한 초기화 + */ + public abstract Object initPayment(); + } diff --git a/platform/src/main/java/com/commerce/platform/core/application/out/dto/PgPayCancelResponse.java b/platform/src/main/java/com/commerce/platform/core/application/out/dto/PgPayCancelResponse.java new file mode 100644 index 0000000..946a124 --- /dev/null +++ b/platform/src/main/java/com/commerce/platform/core/application/out/dto/PgPayCancelResponse.java @@ -0,0 +1,13 @@ +package com.commerce.platform.core.application.out.dto; + +/** + * pg서 취소 응답 결과 DTO + */ +public record PgPayCancelResponse( + String pgCcTid, + String responseCode, // pg사 응답코드 + String responseMessage, // pg사 응답메시지 + String cancelReason, // 취소사유 + Long cancelAmount, // 취소 금액 + boolean isSuccess +) { } 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 index 7d6ea9c..9602b3b 100644 --- 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 @@ -1,8 +1,43 @@ package com.commerce.platform.core.application.out.dto; +import com.commerce.platform.core.domain.vo.Money; +import lombok.Builder; + +/** + * pg서 승인 응답 결과 DTO + * 결제유형에 따라 card/easyPay/virtualAccount 필드 세팅된다. + */ +@Builder public record PgPayResponse ( String pgTid, - String responseCode, // pg사 응답코드 - String responseMessage, // pg사 응답메시지 - boolean isSuccess -) {} + String responseCode, // pg사 응답코드 + String responseMessage, // pg사 응답메시지 + Money amount, + boolean isSuccess, + Card card, // 카드결제 응답 + EasyPay easyPay, // 간편결제 응답 + VirtualAccount virtualAccount // 가상계좌 응답 +) { + @Builder + public record Card( + String approveNo, + String issuerCode, + String cardType + ) {} + + @Builder + public record EasyPay( + String provider, + String amount, + String discountAmount + ) {} + + @Builder + public record VirtualAccount( + String accountType, + String accountNumber, + String bankCode, + String depositorName, + String dueDate + ) {} +} diff --git a/platform/src/main/java/com/commerce/platform/core/domain/aggreate/CardBinPromotion.java b/platform/src/main/java/com/commerce/platform/core/domain/aggreate/CardBinPromotion.java new file mode 100644 index 0000000..ee8edbe --- /dev/null +++ b/platform/src/main/java/com/commerce/platform/core/domain/aggreate/CardBinPromotion.java @@ -0,0 +1,93 @@ +package com.commerce.platform.core.domain.aggreate; + +import com.commerce.platform.core.domain.enums.PayProvider; +import com.commerce.platform.core.domain.vo.ValidPeriod; +import com.commerce.platform.core.domain.vo.promotion.BasePromotionData; +import com.commerce.platform.infrastructure.persistence.converter.PromotionDataConverter; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +import java.time.LocalDateTime; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "card_bin_promotion") +@Entity +public class CardBinPromotion { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "card_bin", nullable = false) + private String cardBin; + + @Column(name = "card_name", nullable = false) + private String cardName; + + @Enumerated(EnumType.STRING) + @Column(name = "pay_provider", nullable = false) + private PayProvider payProvider; + + /** + * JSON 프로모션 데이터 + * - Converter에서 임시 타입으로 변환 + * - 조회 후 PromotionDataPostProcessor에서 PayProvider에 맞게 재변환 + */ + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "promotion_data", nullable = false, columnDefinition = "json") + @Convert(converter = PromotionDataConverter.class) + private BasePromotionData promotionData; + + @Column(name = "is_active", nullable = false) + private boolean isActive = true; + + @Embedded + private ValidPeriod validPeriod; + + @Column(name = "last_updated_at", nullable = false) + private LocalDateTime lastUpdatedAt; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Builder + public CardBinPromotion( + String cardBin, + String cardName, + PayProvider payProvider, + BasePromotionData promotionData, + ValidPeriod validPeriod + ) { + this.cardBin = cardBin; + this.cardName = cardName; + this.payProvider = payProvider; + this.promotionData = promotionData; + this.validPeriod = validPeriod; + this.isActive = true; + } + + @PrePersist + protected void onCreate() { + this.createdAt = LocalDateTime.now(); + this.lastUpdatedAt = LocalDateTime.now(); + } + + @PreUpdate + protected void onUpdate() { + this.lastUpdatedAt = LocalDateTime.now(); + } + + public void activate() { + this.isActive = true; + } + + public void deactivate() { + this.isActive = false; + } +} 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 1f431fa..9be0cc8 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,6 +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.PgPayCancelResponse; 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; @@ -96,10 +97,10 @@ public void approved(PgPayResponse pgResponse) { this.paymentStatus = PaymentStatus.APPROVED; } - public void canceled(PgPayResponse pgResponse) { - this.pgCancelTid = pgResponse.pgTid(); + public void canceled(PgPayCancelResponse cancelResponse) { + this.pgCancelTid = cancelResponse.pgCcTid(); - if(!pgResponse.isSuccess()) { + if(!cancelResponse.isSuccess()) { this.paymentStatus = PaymentStatus.FAILED; return; } 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 c6b9f29..9de6952 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 @@ -8,7 +8,8 @@ public enum PayMethod { CARD("카드결제"), EASY_PAY("간편결제"), - PHONE("휴대폰결제"); + PHONE("휴대폰결제"), + VIRTUAL_ACCOUNT("가상계좌결제"); 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 9adffd5..10d8998 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 @@ -4,52 +4,18 @@ import lombok.Getter; import java.util.Arrays; -import java.util.List; -import java.util.Set; @Getter @AllArgsConstructor public enum PgProvider { - 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) - ), + TOSS, + NHN, + NICE_PAYMENTS, - DANAL( - Set.of(PayMethod.PHONE), - Set.of(PayProvider.LG, PayProvider.KT, PayProvider.SKT) - - ), - PAYLETTER( - Set.of(PayMethod.PHONE), - Set.of(PayProvider.LG, PayProvider.KT) - ) + DANAL, + PAYLETTER ; - 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)) 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 index 19841b2..eaaa8e4 100644 --- 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 @@ -5,11 +5,19 @@ 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.persistence.PgFeeInfo; +import com.commerce.platform.infrastructure.persistence.PgFeeInfoRepository; import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.context.event.ApplicationStartedEvent; +import org.springframework.context.event.EventListener; +import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; +import java.util.Comparator; import java.util.List; import java.util.Map; +import java.util.TreeSet; +import java.util.function.Function; import java.util.stream.Collectors; /** @@ -21,40 +29,72 @@ @Service public class PaymentPgRouter { - private final Map pgStrategies; + private final Map> pgStrategies; private final PgCacheService pgCacheService; + private final PgFeeInfoRepository feeInfoRepository; + //결제방식 + 카드사/통신사 별 수수료기준 정렬됨 + private Map>> pgFeeCache = null; - public PaymentPgRouter(List list, PgCacheService pgCacheService) { + public PaymentPgRouter(List list, PgCacheService pgCacheService, PgFeeInfoRepository feeInfoRepository) { this.pgStrategies = list.stream() - .collect(Collectors.toMap(PgStrategy::getPgProvider, pg -> pg)); + .collect(Collectors.groupingBy( + PgStrategy::getPgProvider, + Collectors.toMap( + PgStrategy::getPgPayMethod, + Function.identity() + ) + )); this.pgCacheService = pgCacheService; + this.feeInfoRepository = feeInfoRepository; + } + + @EventListener(ApplicationStartedEvent.class) + public void initPgCache() { + setPgFeeCache(); } /** - * 결제유형+카드사에 따라 PG 선택 - * Redis에서 캐싱 + * 결제유형 + 카드사 => 유효 PG 추출 + * redis 캐싱된 health check */ public PgStrategy routePg(PayMethod payMethod, PayProvider payProvider) { - - List supportedPgs = PgProvider.getByPayMethod(payMethod, payProvider); - - PgProvider selectedPg = pgCacheService.getBestPg(payMethod, payProvider, supportedPgs); + PgProvider selectedPg = pgFeeCache.get(payMethod).get(payProvider) + .stream() + .filter(pgFeeInfo -> pgCacheService.isHealthy(pgFeeInfo.getPgProvider())) + .toList() + .getFirst() + .getPgProvider(); if (selectedPg == null) { throw new IllegalStateException("현재 사용 가능한 PG사가 없습니다"); } - return pgStrategies.get(selectedPg); + return pgStrategies.get(selectedPg).get(payMethod); } /** * PG Provider => Strategy 조회 */ - public PgStrategy getPgStrategyByProvider(PgProvider pgProvider) { - PgStrategy strategy = pgStrategies.get(pgProvider); + public PgStrategy getPgStrategyByProvider(PgProvider pgProvider, PayMethod payMethod) { + PgStrategy strategy = pgStrategies.get(pgProvider).get(payMethod); if (strategy == null) { throw new IllegalArgumentException("존재하지 않는 PG: " + pgProvider); } return strategy; } + @Scheduled(cron = "0 * * * * *") + private void refreshPgCache() { + setPgFeeCache(); + } + + private void setPgFeeCache() { + pgFeeCache = feeInfoRepository.findAllActiveAndValid() + .stream() + .collect(Collectors.groupingBy(PgFeeInfo::getPayMethod, + Collectors.groupingBy(PgFeeInfo::getPayProvider, + Collectors.toCollection(() -> + new TreeSet<>(Comparator.comparing(PgFeeInfo::getFeeRate)) + ) + ))); + } } diff --git a/platform/src/main/java/com/commerce/platform/core/domain/vo/promotion/BasePromotionData.java b/platform/src/main/java/com/commerce/platform/core/domain/vo/promotion/BasePromotionData.java new file mode 100644 index 0000000..8eb9907 --- /dev/null +++ b/platform/src/main/java/com/commerce/platform/core/domain/vo/promotion/BasePromotionData.java @@ -0,0 +1,8 @@ +package com.commerce.platform.core.domain.vo.promotion; + +/** + * 카드사별 프로모션 데이터 인터페이스 + * PayProvider 값에 따라 구현체로 역직렬화됨 + */ +public interface BasePromotionData { +} diff --git a/platform/src/main/java/com/commerce/platform/core/domain/vo/promotion/BcPromotionData.java b/platform/src/main/java/com/commerce/platform/core/domain/vo/promotion/BcPromotionData.java new file mode 100644 index 0000000..70a44c7 --- /dev/null +++ b/platform/src/main/java/com/commerce/platform/core/domain/vo/promotion/BcPromotionData.java @@ -0,0 +1,13 @@ +package com.commerce.platform.core.domain.vo.promotion; + +import lombok.Builder; + +@Builder +public record BcPromotionData( + String bc_target, + String bc_payType, + String bc_card_name, + String bc_content, + String bc_condition +) implements BasePromotionData { +} diff --git a/platform/src/main/java/com/commerce/platform/core/domain/vo/promotion/HanaPromotionData.java b/platform/src/main/java/com/commerce/platform/core/domain/vo/promotion/HanaPromotionData.java new file mode 100644 index 0000000..00abb9f --- /dev/null +++ b/platform/src/main/java/com/commerce/platform/core/domain/vo/promotion/HanaPromotionData.java @@ -0,0 +1,13 @@ +package com.commerce.platform.core.domain.vo.promotion; + +import lombok.Builder; + +@Builder +public record HanaPromotionData( + String hana_target, + String hana_payType, + String hana_card_name, + String hana_content, + String hana_condition +) implements BasePromotionData { +} diff --git a/platform/src/main/java/com/commerce/platform/core/domain/vo/promotion/HyundaiPromotionData.java b/platform/src/main/java/com/commerce/platform/core/domain/vo/promotion/HyundaiPromotionData.java new file mode 100644 index 0000000..9376abe --- /dev/null +++ b/platform/src/main/java/com/commerce/platform/core/domain/vo/promotion/HyundaiPromotionData.java @@ -0,0 +1,13 @@ +package com.commerce.platform.core.domain.vo.promotion; + +import lombok.Builder; + +@Builder +public record HyundaiPromotionData( + String hyundai_target, + String hyundai_payType, + String hyundai_card_name, + String hyundai_content, + String hyundai_condition +) implements BasePromotionData { +} diff --git a/platform/src/main/java/com/commerce/platform/core/domain/vo/promotion/KbPromotionData.java b/platform/src/main/java/com/commerce/platform/core/domain/vo/promotion/KbPromotionData.java new file mode 100644 index 0000000..a63d3d3 --- /dev/null +++ b/platform/src/main/java/com/commerce/platform/core/domain/vo/promotion/KbPromotionData.java @@ -0,0 +1,13 @@ +package com.commerce.platform.core.domain.vo.promotion; + +import lombok.Builder; + +@Builder +public record KbPromotionData( + String kb_target, + String kb_payType, + String kb_card_name, + String kb_content, + String kb_condition +) implements BasePromotionData { +} diff --git a/platform/src/main/java/com/commerce/platform/core/domain/vo/promotion/LottePromotionData.java b/platform/src/main/java/com/commerce/platform/core/domain/vo/promotion/LottePromotionData.java new file mode 100644 index 0000000..1e8cdac --- /dev/null +++ b/platform/src/main/java/com/commerce/platform/core/domain/vo/promotion/LottePromotionData.java @@ -0,0 +1,13 @@ +package com.commerce.platform.core.domain.vo.promotion; + +import lombok.Builder; + +@Builder +public record LottePromotionData( + String lotte_target, + String lotte_payType, + String lotte_card_name, + String lotte_content, + String lotte_condition +) implements BasePromotionData { +} diff --git a/platform/src/main/java/com/commerce/platform/core/domain/vo/promotion/NhPromotionData.java b/platform/src/main/java/com/commerce/platform/core/domain/vo/promotion/NhPromotionData.java new file mode 100644 index 0000000..7200eba --- /dev/null +++ b/platform/src/main/java/com/commerce/platform/core/domain/vo/promotion/NhPromotionData.java @@ -0,0 +1,13 @@ +package com.commerce.platform.core.domain.vo.promotion; + +import lombok.Builder; + +@Builder +public record NhPromotionData( + String nh_target, + String nh_payType, + String nh_card_name, + String nh_content, + String nh_condition +) implements BasePromotionData { +} diff --git a/platform/src/main/java/com/commerce/platform/core/domain/vo/promotion/SamsungPromotionData.java b/platform/src/main/java/com/commerce/platform/core/domain/vo/promotion/SamsungPromotionData.java new file mode 100644 index 0000000..256b9bc --- /dev/null +++ b/platform/src/main/java/com/commerce/platform/core/domain/vo/promotion/SamsungPromotionData.java @@ -0,0 +1,13 @@ +package com.commerce.platform.core.domain.vo.promotion; + +import lombok.Builder; + +@Builder +public record SamsungPromotionData( + String samsung_target, + String samsung_payType, + String samsung_card_name, + String samsung_content, + String samsung_condition +) implements BasePromotionData { +} diff --git a/platform/src/main/java/com/commerce/platform/core/domain/vo/promotion/ShinhanPromotionData.java b/platform/src/main/java/com/commerce/platform/core/domain/vo/promotion/ShinhanPromotionData.java new file mode 100644 index 0000000..6034ec8 --- /dev/null +++ b/platform/src/main/java/com/commerce/platform/core/domain/vo/promotion/ShinhanPromotionData.java @@ -0,0 +1,13 @@ +package com.commerce.platform.core.domain.vo.promotion; + +import lombok.Builder; + +@Builder +public record ShinhanPromotionData( + String shinhan_target, + String shinhan_payType, + String shinhan_card_name, + String shinhan_content, + String shinhan_condition +) implements BasePromotionData { +} diff --git a/platform/src/main/java/com/commerce/platform/infrastructure/adaptor/CardBinPromotionAdaptor.java b/platform/src/main/java/com/commerce/platform/infrastructure/adaptor/CardBinPromotionAdaptor.java new file mode 100644 index 0000000..9304353 --- /dev/null +++ b/platform/src/main/java/com/commerce/platform/infrastructure/adaptor/CardBinPromotionAdaptor.java @@ -0,0 +1,36 @@ +package com.commerce.platform.infrastructure.adaptor; + +import com.commerce.platform.core.application.out.CardBinPromotionOutPort; +import com.commerce.platform.core.domain.aggreate.CardBinPromotion; +import com.commerce.platform.infrastructure.persistence.CardBinPromotionRepository; +import com.commerce.platform.infrastructure.persistence.processor.PromotionDataPostProcessor; +import lombok.RequiredArgsConstructor; +import lombok.extern.log4j.Log4j2; +import org.springframework.stereotype.Component; + +import java.util.List; + +/** + * promotionData 후처리 + * CardBinPromotion 반환 + */ +@Log4j2 +@RequiredArgsConstructor +@Component +public class CardBinPromotionAdaptor implements CardBinPromotionOutPort { + + private final CardBinPromotionRepository repository; + private final PromotionDataPostProcessor postProcessor; + + public List findActivePromotions() { + List results = repository.findAllByActive(); + results.forEach(postProcessor::process); + return results; + } + + public CardBinPromotion save(CardBinPromotion entity) { + CardBinPromotion saved = repository.save(entity); + postProcessor.process(saved); + return saved; + } +} 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 index 2ca4f98..56437f4 100644 --- a/platform/src/main/java/com/commerce/platform/infrastructure/adaptor/PgCacheService.java +++ b/platform/src/main/java/com/commerce/platform/infrastructure/adaptor/PgCacheService.java @@ -1,26 +1,15 @@ 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 jakarta.annotation.PostConstruct; 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는 제외 + * 장애 PG 캐싱 */ @Slf4j @Service @@ -28,91 +17,9 @@ 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:"; - - /** - * ZSet에 캐싱 확인 및 캐싱 - * key= pg:route:CARD:SHIN_HAN - * score: 수수료율 - */ - @PostConstruct - public void initPgCache() { - Set keys = redisTemplate.keys(ROUTE_KEY_PREFIX + "*"); - - if(!keys.isEmpty()) return; - - // 전체 캐싱 - feeInfoRepository.findAllActiveAndValid() - .forEach(feeInfo -> { - String key = buildRouteKey(feeInfo.getPayMethod(), feeInfo.getPayProvider()); - - redisTemplate.opsForZSet().add( - key, - feeInfo.getPgProvider().name(), - feeInfo.getFeeRate().doubleValue()); - }); - } - - 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; - } - } - - 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() - ); - } + private static final String HEALTH_KEY_PREFIX = "pg:health:"; - return configs.stream() - .sorted(Comparator.comparing(PgFeeInfo::getFeeRate)) - .map(pgFeeInfo -> pgFeeInfo.getPgProvider().name()) - .collect(Collectors.toSet()); - } - /** * PG 헬스 체크 */ @@ -129,11 +36,4 @@ 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/config/RestTemplateConfig.java b/platform/src/main/java/com/commerce/platform/infrastructure/config/RestTemplateConfig.java new file mode 100644 index 0000000..f8255a6 --- /dev/null +++ b/platform/src/main/java/com/commerce/platform/infrastructure/config/RestTemplateConfig.java @@ -0,0 +1,23 @@ +package com.commerce.platform.infrastructure.config; + +import org.springframework.boot.web.client.RestTemplateBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.converter.StringHttpMessageConverter; +import org.springframework.web.client.RestTemplate; + +import java.nio.charset.StandardCharsets; +import java.time.Duration; + +@Configuration +public class RestTemplateConfig { + + @Bean + public RestTemplate restTemplate(RestTemplateBuilder builder) { + return builder + .connectTimeout(Duration.ofSeconds(3)) + .readTimeout(Duration.ofSeconds(10)) + .additionalMessageConverters(new StringHttpMessageConverter(StandardCharsets.UTF_8)) + .build(); + } +} diff --git a/platform/src/main/java/com/commerce/platform/infrastructure/persistence/CardBinPromotionRepository.java b/platform/src/main/java/com/commerce/platform/infrastructure/persistence/CardBinPromotionRepository.java new file mode 100644 index 0000000..6ae72ae --- /dev/null +++ b/platform/src/main/java/com/commerce/platform/infrastructure/persistence/CardBinPromotionRepository.java @@ -0,0 +1,23 @@ +package com.commerce.platform.infrastructure.persistence; + +import com.commerce.platform.core.domain.aggreate.CardBinPromotion; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface CardBinPromotionRepository extends JpaRepository { + + /** + * 활성화된 카드 BIN 조회 + */ + @Query(""" + SELECT c FROM CardBinPromotion c + WHERE c.isActive = true + AND CURRENT_DATE BETWEEN c.validPeriod.frDt AND c.validPeriod.toDt + """) + List findAllByActive(); + +} diff --git a/platform/src/main/java/com/commerce/platform/infrastructure/persistence/converter/PromotionDataConverter.java b/platform/src/main/java/com/commerce/platform/infrastructure/persistence/converter/PromotionDataConverter.java new file mode 100644 index 0000000..2963542 --- /dev/null +++ b/platform/src/main/java/com/commerce/platform/infrastructure/persistence/converter/PromotionDataConverter.java @@ -0,0 +1,47 @@ +package com.commerce.platform.infrastructure.persistence.converter; + +import com.commerce.platform.core.domain.vo.promotion.BasePromotionData; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; +import lombok.extern.log4j.Log4j2; + +/** + * BasePromotionData <-> JSON String 변환 Converter + */ +@Log4j2 +@Converter +public class PromotionDataConverter implements AttributeConverter { + + private static final ObjectMapper objectMapper = new ObjectMapper() + .registerModule(new JavaTimeModule()) + .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + + @Override + public String convertToDatabaseColumn(BasePromotionData attribute) { + if (attribute == null) return null; + + try { + return objectMapper.writeValueAsString(attribute); + } catch (JsonProcessingException e) { + throw new IllegalStateException("프로모션 데이터 직렬화 실패", e); + } + } + + @Override + public BasePromotionData convertToEntityAttribute(String dbData) { + if (dbData == null || dbData.isBlank()) return null; + + // 임시 객체 JsonPromotionData 반환 + return new JsonPromotionData(dbData); + } + + /** + * JSON String을 보관하는 임시 래퍼 클래스 + */ + public record JsonPromotionData(String jsonData) + implements BasePromotionData {} +} diff --git a/platform/src/main/java/com/commerce/platform/infrastructure/persistence/processor/PromotionDataPostProcessor.java b/platform/src/main/java/com/commerce/platform/infrastructure/persistence/processor/PromotionDataPostProcessor.java new file mode 100644 index 0000000..d227858 --- /dev/null +++ b/platform/src/main/java/com/commerce/platform/infrastructure/persistence/processor/PromotionDataPostProcessor.java @@ -0,0 +1,78 @@ +package com.commerce.platform.infrastructure.persistence.processor; + +import com.commerce.platform.core.domain.aggreate.CardBinPromotion; +import com.commerce.platform.core.domain.enums.PayProvider; +import com.commerce.platform.core.domain.vo.promotion.*; +import com.commerce.platform.infrastructure.persistence.converter.PromotionDataConverter; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import lombok.extern.log4j.Log4j2; +import org.springframework.stereotype.Component; + +import java.lang.reflect.Field; + +/** + * CardBinPromotion 조회 후 PayProvider에 따른 promotionData 타입 변환 + */ +@Log4j2 +@Component +public class PromotionDataPostProcessor { + + private static final ObjectMapper objectMapper = new ObjectMapper() + .registerModule(new JavaTimeModule()) + .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + + public void process(CardBinPromotion entity) { + if (entity == null || entity.getPayProvider() == null) return; + + BasePromotionData currentData = entity.getPromotionData(); + if (currentData == null) return; + + // 카드사에 매핑되는 프로모션 dto 변환 + if (currentData instanceof PromotionDataConverter.JsonPromotionData(String jsonData)) { + try { + BasePromotionData convertedData = convertToProperType(jsonData, entity.getPayProvider()); + setPromotionData(entity, convertedData); + + } catch (Exception e) { + log.error("프로모션 데이터 후처리 실패 - PayProvider: {}", entity.getPayProvider(), e); + } + } + } + + public BasePromotionData convertToProperType(String json, PayProvider payProvider) throws JsonProcessingException { + if (json == null || json.isBlank()) return null; + + Class clazz = getPromotionDataClass(payProvider); + return objectMapper.readValue(json, clazz); + } + + private Class getPromotionDataClass(PayProvider payProvider) { + return switch (payProvider) { + case SAMSUNG -> SamsungPromotionData.class; + case SHIN_HAN -> ShinhanPromotionData.class; + case KB -> KbPromotionData.class; + case HYUNDAI -> HyundaiPromotionData.class; + case BC -> BcPromotionData.class; + case HANA -> HanaPromotionData.class; + case LOTTE -> LottePromotionData.class; + case NH -> NhPromotionData.class; + default -> throw new IllegalArgumentException( + "지원하지 않는 카드사입니다: " + payProvider + ); + }; + } + + private void setPromotionData(CardBinPromotion entity, BasePromotionData data) { + try { + Field field = CardBinPromotion.class.getDeclaredField("promotionData"); + field.setAccessible(true); + field.set(entity, data); + } catch (NoSuchFieldException | IllegalAccessException e) { + log.error("promotionData 필드 설정 실패", e); + throw new IllegalStateException("promotionData 필드 설정 실패", e); + } + } +} 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 deleted file mode 100644 index bc2bddd..0000000 --- a/platform/src/main/java/com/commerce/platform/infrastructure/pg/DanalStrategy.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.commerce.platform.infrastructure.pg; - -import com.commerce.platform.core.application.out.PgStrategy; -import com.commerce.platform.core.domain.enums.PgProvider; -import org.springframework.stereotype.Component; - -@Component -public class DanalStrategy extends PgStrategy { - - @Override - public String process(String request) { - return ""; - } - - @Override - public PgProvider getPgProvider() { - return PgProvider.DANAL; - } - -} 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 deleted file mode 100644 index d26e1a1..0000000 --- a/platform/src/main/java/com/commerce/platform/infrastructure/pg/NHNStrategy.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.commerce.platform.infrastructure.pg; - -import com.commerce.platform.core.application.out.PgStrategy; -import com.commerce.platform.core.domain.enums.PgProvider; -import org.springframework.stereotype.Component; - -@Component -public class NHNStrategy extends PgStrategy { - - @Override - public String process(String request) { - return ""; - } - - 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 deleted file mode 100644 index 0955da0..0000000 --- a/platform/src/main/java/com/commerce/platform/infrastructure/pg/TossStrategy.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.commerce.platform.infrastructure.pg; - -import com.commerce.platform.core.application.out.PgStrategy; -import com.commerce.platform.core.domain.enums.PgProvider; -import org.springframework.stereotype.Component; - -@Component -public class TossStrategy extends PgStrategy { - - @Override - public String process(String request) { - return ""; - } - - @Override - public PgProvider getPgProvider() { - return PgProvider.TOSS; - } -} diff --git a/platform/src/main/java/com/commerce/platform/infrastructure/pg/danal/DanalPhoneService.java b/platform/src/main/java/com/commerce/platform/infrastructure/pg/danal/DanalPhoneService.java new file mode 100644 index 0000000..da92ff4 --- /dev/null +++ b/platform/src/main/java/com/commerce/platform/infrastructure/pg/danal/DanalPhoneService.java @@ -0,0 +1,17 @@ +package com.commerce.platform.infrastructure.pg.danal; + +import com.commerce.platform.core.domain.enums.PayMethod; +import org.springframework.stereotype.Service; + +@Service +public class DanalPhoneService extends DanalStrategy{ + @Override + protected PayMethod getDanalPayMethod() { + return PayMethod.PHONE; + } + + @Override + public Object initPayment() { + return null; + } +} diff --git a/platform/src/main/java/com/commerce/platform/infrastructure/pg/danal/DanalStrategy.java b/platform/src/main/java/com/commerce/platform/infrastructure/pg/danal/DanalStrategy.java new file mode 100644 index 0000000..1a9970f --- /dev/null +++ b/platform/src/main/java/com/commerce/platform/infrastructure/pg/danal/DanalStrategy.java @@ -0,0 +1,43 @@ +package com.commerce.platform.infrastructure.pg.danal; + +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.dto.PgPayCancelResponse; +import com.commerce.platform.core.application.out.dto.PgPayResponse; +import com.commerce.platform.core.domain.enums.PayMethod; +import com.commerce.platform.core.domain.enums.PgProvider; + +/** + * 여기서 필요에 따라 다날의 결제수단별 프로세스를 추상화한다. + */ +public abstract class DanalStrategy extends PgStrategy { + + @Override + public PgPayResponse processApproval(PayOrderCommand command) { + return null; + } + + @Override + public PgPayCancelResponse processCancel(PayCancelCommand command) { + return null; + } + + + @Override + public PgProvider getPgProvider() { + return PgProvider.DANAL; + } + + @Override + public PayMethod getPgPayMethod() { + return getDanalPayMethod(); + } + + /** + * Danal 구현체 중 특정 결제서비스 빈 추출을 위함 + * @return + */ + protected abstract PayMethod getDanalPayMethod(); + +} diff --git a/platform/src/main/java/com/commerce/platform/infrastructure/pg/nhn/NHNStrategy.java b/platform/src/main/java/com/commerce/platform/infrastructure/pg/nhn/NHNStrategy.java new file mode 100644 index 0000000..0c77626 --- /dev/null +++ b/platform/src/main/java/com/commerce/platform/infrastructure/pg/nhn/NHNStrategy.java @@ -0,0 +1,143 @@ +package com.commerce.platform.infrastructure.pg.nhn; + +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.dto.PgPayCancelResponse; +import com.commerce.platform.core.application.out.dto.PgPayResponse; +import com.commerce.platform.core.domain.enums.PayMethod; +import com.commerce.platform.core.domain.enums.PaymentStatus; +import com.commerce.platform.core.domain.enums.PgProvider; +import com.commerce.platform.infrastructure.pg.nhn.dto.NhnApprovalRequest; +import com.commerce.platform.infrastructure.pg.nhn.dto.NhnApprovalResponse; +import com.commerce.platform.infrastructure.pg.nhn.dto.NhnCancelRequest; +import com.commerce.platform.infrastructure.pg.nhn.dto.NhnCancelResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; + +/** + * 결제유형별 특정 파라미터 값 구분 필요. + * 응답 개별 파싱 필요 + * + * 여기서 필요에 따라 NHN의 결제수단별 프로세스를 추상화한다. + */ +@Component +@RequiredArgsConstructor +public abstract class NHNStrategy extends PgStrategy { + private final RestTemplate restTemplate; + private static final String NHN_CONFIRM_URL = "https://stg-spl.kcp.co.kr/gw/enc/v1/payment"; + private static final String NHN_CANCEL_URL = "https://stg-spl.kcp.co.kr/gw/mod/v1/cancel"; + private final String SITE_CD = "T0000"; // 상점코드 + + /** + * 공통 승인 요청 + */ + @Override + public PgPayResponse processApproval(PayOrderCommand command) { + // 승인 요청객체 생성 + NhnApprovalRequest request = NhnApprovalRequest.builder() + .payType(getNhnPayType()) // 결제유형별 값 추출 + .tranCd("00100000") + .ordrNo(String.valueOf(command.getOrderId().id())) + .ordrMony(String.valueOf(command.getApprovedAmount().value())) + .build(); + + // API 호출 + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + HttpEntity httpRequest = + new HttpEntity<>(request, headers); + + // 결제유형별 응답 파싱 가능하도록 함 + T response = restTemplate.exchange( + NHN_CONFIRM_URL, + HttpMethod.POST, + httpRequest, + getResponseType() + ).getBody(); + + // 결제유형별 응답 생성 + return convertToResponse(response); + } + + + /** + * 공통 취소 요청 + */ + @Override + public PgPayCancelResponse processCancel(PayCancelCommand command) { + // API 호출 + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + NhnCancelRequest cancelRequest = NhnCancelRequest.builder() + .site_cd(SITE_CD) + .tno(command.getPgTid()) + .mod_type(command.getPaymentStatus().equals(PaymentStatus.PARTIAL_CANCELED) + ? "STPC" // 부분취소 코드 + : "STSC") // 전체취소 + .build(); + + HttpEntity httpRequest = + new HttpEntity<>(cancelRequest, headers); + + NhnCancelResponse response = restTemplate.exchange( + NHN_CANCEL_URL, + HttpMethod.POST, + httpRequest, + NhnCancelResponse.class + ).getBody(); + + return new PgPayCancelResponse( + response.tno(), + response.resCd(), + response.resMsg(), + null, + Long.parseLong(response.cardModMny()), + "0000".equals(response.resCd()) ? true : false + ); + } + + public PgProvider getPgProvider() { + return PgProvider.NHN; + } + + @Override + public PayMethod getPgPayMethod() { + return getNhnPayMethod(); + } + + @Override + public Object initPayment() { + return null; + } + + /** + * NHN 구현체 중 특정 결제서비스 빈 추출을 위함 + * @return + */ + protected abstract PayMethod getNhnPayMethod(); + + /** + * NHN 는 요청 시 결제유형별 pay_type값이 상이함. + * @return + */ + protected abstract String getNhnPayType(); + + /** + * 결제 응답 글래스 제공 + * */ + protected abstract Class getResponseType(); + + /** + * 결제수단별 승인 응답 메시지 파싱 + */ + protected abstract PgPayResponse convertToResponse(T response); + +} diff --git a/platform/src/main/java/com/commerce/platform/infrastructure/pg/nhn/NhnCardService.java b/platform/src/main/java/com/commerce/platform/infrastructure/pg/nhn/NhnCardService.java new file mode 100644 index 0000000..2e5101f --- /dev/null +++ b/platform/src/main/java/com/commerce/platform/infrastructure/pg/nhn/NhnCardService.java @@ -0,0 +1,49 @@ +package com.commerce.platform.infrastructure.pg.nhn; + +import com.commerce.platform.core.application.out.dto.PgPayResponse; +import com.commerce.platform.core.domain.enums.PayMethod; +import com.commerce.platform.core.domain.vo.Money; +import com.commerce.platform.infrastructure.pg.nhn.dto.NhnCardApprovalResponse; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; + +@Service +public class NhnCardService extends NHNStrategy{ + public NhnCardService(RestTemplate restTemplate) { + super(restTemplate); + } + + @Override + protected PayMethod getNhnPayMethod() { + return PayMethod.CARD; + } + + @Override + protected String getNhnPayType() { + return "PACA"; + } + + @Override + protected Class getResponseType() { + return NhnCardApprovalResponse.class; + } + + @Override + protected PgPayResponse convertToResponse(NhnCardApprovalResponse response) { + boolean isSuccess = "0000".equals(response.resCd()); + + return PgPayResponse.builder() + .pgTid(response.tno()) + .responseCode(response.resCd()) + .responseMessage(response.resMsg()) + .amount(Money.create(Long.parseLong(response.amount()))) + .isSuccess(isSuccess) + .card(PgPayResponse.Card.builder() + .approveNo(response.appNo()) + .issuerCode(response.cardCd()) + .cardType( response.cardBinType02()) + .build()) + .build(); + } + +} diff --git a/platform/src/main/java/com/commerce/platform/infrastructure/pg/nhn/NhnEasyService.java b/platform/src/main/java/com/commerce/platform/infrastructure/pg/nhn/NhnEasyService.java new file mode 100644 index 0000000..ed4fd3d --- /dev/null +++ b/platform/src/main/java/com/commerce/platform/infrastructure/pg/nhn/NhnEasyService.java @@ -0,0 +1,47 @@ +package com.commerce.platform.infrastructure.pg.nhn; + +import com.commerce.platform.core.application.out.dto.PgPayResponse; +import com.commerce.platform.core.domain.enums.PayMethod; +import com.commerce.platform.core.domain.vo.Money; +import com.commerce.platform.infrastructure.pg.nhn.dto.NhnEasyCardApprovalResponse; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; + +@Service +public class NhnEasyService extends NHNStrategy { + public NhnEasyService(RestTemplate restTemplate) { + super(restTemplate); + } + + @Override + protected PayMethod getNhnPayMethod() { + return PayMethod.EASY_PAY; + } + + @Override + protected String getNhnPayType() { + return "PACA"; + } + + @Override + protected Class getResponseType() { + return NhnEasyCardApprovalResponse.class; + } + + @Override + protected PgPayResponse convertToResponse(NhnEasyCardApprovalResponse response) { + boolean isSuccess = "0000".equals(response.resCd()); + + return PgPayResponse.builder() + .pgTid(response.tno()) + .responseCode(response.resCd()) + .responseMessage(response.resMsg()) + .amount(Money.create(Long.parseLong(response.amount()))) + .isSuccess(isSuccess) + .easyPay(PgPayResponse.EasyPay.builder() + .provider(response.cardOtherPayType()) + .build()) + .build(); + } + +} diff --git a/platform/src/main/java/com/commerce/platform/infrastructure/pg/nhn/dto/NhnApprovalRequest.java b/platform/src/main/java/com/commerce/platform/infrastructure/pg/nhn/dto/NhnApprovalRequest.java new file mode 100644 index 0000000..0252674 --- /dev/null +++ b/platform/src/main/java/com/commerce/platform/infrastructure/pg/nhn/dto/NhnApprovalRequest.java @@ -0,0 +1,34 @@ +package com.commerce.platform.infrastructure.pg.nhn.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Builder; + +/** + * NHN 결제 승인 요청 + */ +@Builder +public record NhnApprovalRequest( + @JsonProperty("site_cd") + String siteCd, // siteCd 사이트코드 (5자리, 영문대문자+숫자) + + @JsonProperty("kcp_cert_info") + String kcpCertInfo, // NHN KCP 서비스 인증서 (PEM 파일 직렬화) + + @JsonProperty("enc_data") + String encData, // 결제창 인증결과 암호화 정보 (결제창에서 받은 값 그대로 사용) + + @JsonProperty("enc_info") + String encInfo, // 결제창 인증결과 암호화 정보 (결제창에서 받은 값 그대로 사용) + + @JsonProperty("tran_cd") + String tranCd, // 요청코드 (고정값: "00100000") + + @JsonProperty("ordr_mony") + String ordrMony, // 실제 결제 요청 금액 + + @JsonProperty("ordr_no") + String ordrNo, // 실제 결제 주문번호 + + @JsonProperty("pay_type") + String payType // 결제수단 구분 신용카드 : PACA +) { } diff --git a/platform/src/main/java/com/commerce/platform/infrastructure/pg/nhn/dto/NhnApprovalResponse.java b/platform/src/main/java/com/commerce/platform/infrastructure/pg/nhn/dto/NhnApprovalResponse.java new file mode 100644 index 0000000..aaab2a3 --- /dev/null +++ b/platform/src/main/java/com/commerce/platform/infrastructure/pg/nhn/dto/NhnApprovalResponse.java @@ -0,0 +1,4 @@ +package com.commerce.platform.infrastructure.pg.nhn.dto; + +public interface NhnApprovalResponse { +} diff --git a/platform/src/main/java/com/commerce/platform/infrastructure/pg/nhn/dto/NhnCancelRequest.java b/platform/src/main/java/com/commerce/platform/infrastructure/pg/nhn/dto/NhnCancelRequest.java new file mode 100644 index 0000000..24bd4fa --- /dev/null +++ b/platform/src/main/java/com/commerce/platform/infrastructure/pg/nhn/dto/NhnCancelRequest.java @@ -0,0 +1,25 @@ +package com.commerce.platform.infrastructure.pg.nhn.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Builder; + +/** + * NHN 결제 취소 요청 + */ +@Builder +public record NhnCancelRequest ( + @JsonProperty("site_cd") + String site_cd, + + @JsonProperty("tno") + String tno, + + @JsonProperty("kcp_cert_info") + String kcp_cert_info, + + @JsonProperty("kcp_sign_data") + String kcp_sign_data, + + @JsonProperty("mod_type") + String mod_type +) {} diff --git a/platform/src/main/java/com/commerce/platform/infrastructure/pg/nhn/dto/NhnCancelResponse.java b/platform/src/main/java/com/commerce/platform/infrastructure/pg/nhn/dto/NhnCancelResponse.java new file mode 100644 index 0000000..2fefe1a --- /dev/null +++ b/platform/src/main/java/com/commerce/platform/infrastructure/pg/nhn/dto/NhnCancelResponse.java @@ -0,0 +1,42 @@ +package com.commerce.platform.infrastructure.pg.nhn.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public record NhnCancelResponse( + // 결과 정상 승인: "0000", 그 외 오류코드 + @JsonProperty("res_cd") + String resCd, + + // 결과 메시지 + @JsonProperty("res_msg") + String resMsg, + + // KCP 거래 고유번호 (14자리) + @JsonProperty("tno") + String tno, + + // 취소 처리 시각 yyyyMMddHHmmss + @JsonProperty("canc_time") + String cancTime, + + // 부분취소 금액 전체 취소 시: null 또는 "0" + @JsonProperty("mod_mny") + String modMny, + + // 부분취소 후 남은 금액 + @JsonProperty("rem_mny") + String remMny, + + // 부분취소 일련번호 + @JsonProperty("mod_pcan_seq_no") + String modPcanSeqNo, + + // 카드 취소금액 + @JsonProperty("card_mod_mny") + String cardModMny, + + // 쿠폰 취소금액 + @JsonProperty("coupon_mod_mny") + String couponModMny +) { +} diff --git a/platform/src/main/java/com/commerce/platform/infrastructure/pg/nhn/dto/NhnCardApprovalResponse.java b/platform/src/main/java/com/commerce/platform/infrastructure/pg/nhn/dto/NhnCardApprovalResponse.java new file mode 100644 index 0000000..16f7837 --- /dev/null +++ b/platform/src/main/java/com/commerce/platform/infrastructure/pg/nhn/dto/NhnCardApprovalResponse.java @@ -0,0 +1,54 @@ +package com.commerce.platform.infrastructure.pg.nhn.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * NHN 카드결제 승인응답 + */ +public record NhnCardApprovalResponse( + @JsonProperty("res_cd") + String resCd, // 결과코드 (정상: "0000") + + @JsonProperty("res_msg") + String resMsg, // 결과 메시지 + + @JsonProperty("res_en_msg") + String resEnMsg, // 영문 결과 메시지 + + @JsonProperty("pay_method") + String payMethod, // 결제수단 (PACA:카드, PABK:계좌이체, PAMC:휴대폰 등) + + @JsonProperty("order_no") + String orderNo, + + @JsonProperty("amount") + String amount, // 총 결제금액 (DB 금액 검증 필수) + + @JsonProperty("card_mny") + String cardMny, // 카드 실결제금액 (쿠폰제외, 100%할인시 0 가능) + + @JsonProperty("coupon_mny") + String couponMny, // 쿠폰/포인트 할인금액 + + @JsonProperty("card_no") + String cardNo, // 카드번호 (3번째 구간 마스킹) + + @JsonProperty("card_bin_type_02") + String cardBinType02, // 일반 : 0 / 체크 : 1 + + @JsonProperty("quota") + String quota, // 할부개월 (00:일시불, 03:3개월) + + @JsonProperty("tno") + String tno, // KCP 거래번호 (14자리, 전체 사용 필수) + + @JsonProperty("card_cd") + String cardCd, // 카드사 코드 (예: CCNH) + + @JsonProperty("card_name") + String cardName, // 카드사 명 (예: NH카드) + + @JsonProperty("app_no") + String appNo // 승인번호 (8자리) +) implements NhnApprovalResponse{ +} diff --git a/platform/src/main/java/com/commerce/platform/infrastructure/pg/nhn/dto/NhnEasyCardApprovalResponse.java b/platform/src/main/java/com/commerce/platform/infrastructure/pg/nhn/dto/NhnEasyCardApprovalResponse.java new file mode 100644 index 0000000..66ed5a6 --- /dev/null +++ b/platform/src/main/java/com/commerce/platform/infrastructure/pg/nhn/dto/NhnEasyCardApprovalResponse.java @@ -0,0 +1,27 @@ +package com.commerce.platform.infrastructure.pg.nhn.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * NHN 제휴간편결제 신용카드 결제응답 + */ +public record NhnEasyCardApprovalResponse( + @JsonProperty("res_cd") + String resCd, // 결과코드 (정상: "0000") + + @JsonProperty("res_msg") + String resMsg, // 결과 메시지 + + @JsonProperty("res_en_msg") + String resEnMsg, // 영문 결과 메시지 + + @JsonProperty("amount") + String amount, // 총 결제금액 (DB 금액 검증 필수) + + @JsonProperty("tno") + String tno, // KCP 거래번호 (14자리, 전체 사용 필수 + + @JsonProperty("card_other_pay_type") + String cardOtherPayType // 제휴간편결제유형 +) implements NhnApprovalResponse{ +} diff --git a/platform/src/main/java/com/commerce/platform/infrastructure/pg/toss/TossApprovalResponse.java b/platform/src/main/java/com/commerce/platform/infrastructure/pg/toss/TossApprovalResponse.java new file mode 100644 index 0000000..34f3375 --- /dev/null +++ b/platform/src/main/java/com/commerce/platform/infrastructure/pg/toss/TossApprovalResponse.java @@ -0,0 +1,78 @@ +package com.commerce.platform.infrastructure.pg.toss; + +import java.util.List; + +/** + * TOSS 결제유형에 따른 승인 응답 DTO + **/ +public record TossApprovalResponse( + String mId, // 가맹점 ID (상점 식별자, MID) + String version, // API 버전 (예: "2022-11-16") + String paymentKey, // 결제 키 (토스가 발급한 결제 고유 식별자, 승인/취소/조회 시 필수) + String orderId, // 주문 ID (상점에서 생성한 주문 고유 번호) + String orderName, // 주문명 (예: "토스 티셔츠 외 2건", 결제창에 표시됨) + String method, // 결제수단 ("카드", "가상계좌", "간편결제", "휴대폰" 등) + Long totalAmount, // 총 결제 금액 (원 단위, 최초 결제 요청 금액) + Long balanceAmount, // 취소 후 남은 금액 (부분 취소 시 감소, 전액 취소 시 0) + String status, // 결제 상태 (READY, IN_PROGRESS, WAITING_FOR_DEPOSIT, DONE, CANCELED, PARTIAL_CANCELED, ABORTED, EXPIRED) + String requestedAt, // 결제 요청 시각 (ISO 8601 형식: 2024-12-01T10:00:00+09:00) + String approvedAt, // 결제 승인 시각 (ISO 8601 형식, 승인 완료 후에만 존재) + CardInfo card, // 카드 결제 정보 (카드 결제 시에만 존재, 나머지 null) + EasyPayInfo easyPay, // 간편결제 정보 (간편결제 시에만 존재, 나머지 null) + VirtualAccountInfo virtualAccount, // 가상계좌 정보 (가상계좌 결제 시에만 존재, 나머지 null) + List cancels // 취소 정보 리스트 (취소 발생 시 배열에 누적, 없으면 빈 배열 또는 null) +) { + /** + * 카드결제 정보 + */ + public record CardInfo( + String issuerCode, // 발급사 코드 (카드를 발행한 회사) + String acquirerCode, // 매입사 코드 (가맹점과 계약한 카드사) + String number, // 마스킹된 카드번호 (앞 8자리, 뒤 1자리만 표시) + Integer installmentPlanMonths, // 할부 개월 (0=일시불, 2~12=할부) + String cardType, // 카드 타입 (신용/체크/기프트) + String ownerType, // 소유자 구분 (개인/법인) + String approveNo // 승인번호 (PG사가 아닌 카드사에서 발급) + ) {} + + /** + * 간편결제 정보 + */ + public record EasyPayInfo( + String provider, // 간편결제 제공자 (토스페이, 네이버페이, 카카오페이 등) + String amount, // 실제 결제된 금액 + String discountAmount // 간편결제 할인 금액 (프로모션 적용 시) + ) {} + + /** + * 가상계좌 정보 + */ + public record VirtualAccountInfo( + String accountNumber, // 발급된 가상계좌 번호 (구매자가 입금할 계좌) + String bankCode, // 은행 코드 (20=우리, 004=KB, 011=NH농협) + String customerName, // 입금자명 (주문 시 입력한 구매자 이름) + String dueDate, // 입금 기한 (이 시간까지 입금해야 함) + String accountType, // 계좌 유형 (일반=주문마다 새 계좌, 고정=항상 동일 계좌) + Boolean expired, // 만료 여부 (true=입금 기한 지남) + String settlementStatus, // 정산 상태 (INCOMPLETED=미정산, COMPLETED=정산완료) + String refundStatus // 환불 처리 상태 (취소 시 환불 진행 현황) + ) {} + + /** + * 결제 취소 정보 + * - Payment 객체의 cancels 필드에 배열로 담겨서 반환됨 + * - 부분 취소를 여러 번 하면 배열에 여러 개의 취소 객체가 쌓임 + */ + public record CancelInfo( + String transactionKey, // 취소 거래 키 (각 취소마다 고유) + String cancelReason, // 취소 사유 + Long cancelAmount, // 취소 금액 + Long taxFreeAmount, // 취소된 면세 금액 + Long taxExemptionAmount, // 과세 제외 금액 (복지, 교육비 등) + Long refundableAmount, // 남은 취소 가능 금액 + Long easyPayDiscountAmount, // 간편결제 할인 금액 + String canceledAt, // 취소 처리 시각 (ISO 8601) + String cancelStatus, // 취소 상태 (DONE, FAILED, IN_PROGRESS 등) + String receiptKey // 취소 영수증 키 + ) {} +} diff --git a/platform/src/main/java/com/commerce/platform/infrastructure/pg/toss/TossCancelResponse.java b/platform/src/main/java/com/commerce/platform/infrastructure/pg/toss/TossCancelResponse.java new file mode 100644 index 0000000..a562db9 --- /dev/null +++ b/platform/src/main/java/com/commerce/platform/infrastructure/pg/toss/TossCancelResponse.java @@ -0,0 +1,9 @@ +package com.commerce.platform.infrastructure.pg.toss; + +public record TossCancelResponse( + String transactionKey, + String cancelStatus, + Long cancelAmount, + String cancelReason +) { +} diff --git a/platform/src/main/java/com/commerce/platform/infrastructure/pg/toss/TossCardService.java b/platform/src/main/java/com/commerce/platform/infrastructure/pg/toss/TossCardService.java new file mode 100644 index 0000000..69f2f42 --- /dev/null +++ b/platform/src/main/java/com/commerce/platform/infrastructure/pg/toss/TossCardService.java @@ -0,0 +1,47 @@ +package com.commerce.platform.infrastructure.pg.toss; + +import com.commerce.platform.core.application.in.dto.PayCancelCommand; +import com.commerce.platform.core.application.out.dto.PgPayResponse; +import com.commerce.platform.core.domain.enums.PayMethod; +import com.commerce.platform.core.domain.vo.Money; +import com.commerce.platform.infrastructure.pg.toss.dto.TossTransResponse; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; + +import java.util.Map; + +@Service +public class TossCardService extends TossStrategy{ + public TossCardService(RestTemplate restTemplate) { + super(restTemplate); + } + + @Override + protected PayMethod getTossPayMethod() { + return PayMethod.CARD; + } + + @Override + protected PgPayResponse convertToResponse(TossTransResponse response) { + boolean isSuccess = "DONE".equals(response.status()); + + TossTransResponse.CardInfo cardResponse = response.card(); + return PgPayResponse.builder() + .pgTid(response.paymentKey()) + .responseCode(response.status()) + .responseMessage(response.status()) + .amount(Money.create(response.totalAmount())) + .isSuccess(isSuccess) + .card(PgPayResponse.Card.builder() + .approveNo(cardResponse.approveNo()) + .issuerCode(cardResponse.issuerCode()) + .cardType(cardResponse.cardType()) + .build()) + .build(); + } + + @Override + protected void generateCancelRequest(Map commonBody, PayCancelCommand command) { + // 추가 세팅 없음 + } +} diff --git a/platform/src/main/java/com/commerce/platform/infrastructure/pg/toss/TossEasyService.java b/platform/src/main/java/com/commerce/platform/infrastructure/pg/toss/TossEasyService.java new file mode 100644 index 0000000..a0d20d8 --- /dev/null +++ b/platform/src/main/java/com/commerce/platform/infrastructure/pg/toss/TossEasyService.java @@ -0,0 +1,54 @@ +package com.commerce.platform.infrastructure.pg.toss; + +import com.commerce.platform.core.application.in.dto.PayCancelCommand; +import com.commerce.platform.core.application.out.dto.PgPayResponse; +import com.commerce.platform.core.domain.enums.PayMethod; +import com.commerce.platform.core.domain.vo.Money; +import com.commerce.platform.infrastructure.pg.toss.dto.TossTransResponse; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; + +import java.util.Map; + +@Service +public class TossEasyService extends TossStrategy{ + public TossEasyService(RestTemplate restTemplate) { + super(restTemplate); + } + + @Override + protected PayMethod getTossPayMethod() { + return PayMethod.EASY_PAY; + } + + @Override + protected PgPayResponse convertToResponse(TossTransResponse response) { + boolean isSuccess = "DONE".equals(response.status()); + + TossTransResponse.EasyPayInfo easyPayInfo = response.easyPay(); + TossTransResponse.CardInfo card = response.card(); + + return PgPayResponse.builder() + .pgTid(response.paymentKey()) + .responseCode(response.status()) + .responseMessage(response.status()) + .amount(Money.create(response.totalAmount())) + .isSuccess(isSuccess) + .card(PgPayResponse.Card.builder() + .approveNo(card.approveNo()) + .issuerCode(card.issuerCode()) + .cardType(card.cardType()) + .build()) + .easyPay(PgPayResponse.EasyPay.builder() + .provider(easyPayInfo.provider()) + .amount(easyPayInfo.amount()) + .discountAmount(easyPayInfo.discountAmount()) + .build()) + .build(); + } + + @Override + protected void generateCancelRequest(Map commonBody, PayCancelCommand command) { + // 추가 세팅 없음 + } +} diff --git a/platform/src/main/java/com/commerce/platform/infrastructure/pg/toss/TossStrategy.java b/platform/src/main/java/com/commerce/platform/infrastructure/pg/toss/TossStrategy.java new file mode 100644 index 0000000..77ce61a --- /dev/null +++ b/platform/src/main/java/com/commerce/platform/infrastructure/pg/toss/TossStrategy.java @@ -0,0 +1,230 @@ +package com.commerce.platform.infrastructure.pg.toss; + +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.dto.PgPayCancelResponse; +import com.commerce.platform.core.application.out.dto.PgPayResponse; +import com.commerce.platform.core.domain.enums.PayMethod; +import com.commerce.platform.core.domain.enums.PgProvider; +import com.commerce.platform.infrastructure.pg.toss.dto.TossCancelResponse; +import com.commerce.platform.infrastructure.pg.toss.dto.TossTransResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.*; +import org.springframework.stereotype.Component; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.HttpServerErrorException; +import org.springframework.web.client.RestTemplate; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.HashMap; +import java.util.Map; + +import static com.commerce.platform.core.domain.enums.PaymentStatus.PARTIAL_CANCELED; + +/** + * TOSS PG + * 카드, 간편결제, 가상계좌 에 대해 동일한 승인/취소 API 사용 + * + * 여기서 필요에 따라 토스의 결제수단별 프로세스를 추상화한다. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public abstract class TossStrategy extends PgStrategy { + + private static final String TOSS_CONFIRM_URL = "https://api.tosspayments.com/v1/payments/confirm"; + private static final String TOSS_CANCEL_URL = "https://api.tosspayments.com/v1/payments/"; + private final RestTemplate restTemplate; + private final String secretKey = "tmp"; + + /** + * TOSS 결제 승인 처리 + * 카드, 간편결제, 가상계좌 모두 동일한 API 사용 + * @param command 승인요청dto + * @return toss -> 공통dto 변환 + */ + @Override + public PgPayResponse processApproval(PayOrderCommand command) { + TossTransResponse response = callTossConfirmApi( + command.getJsonSubData(), + command.getOrderId().id(), + command.getApprovedAmount().value() + ); + + return convertToResponse(response); + } + + /** + * TOSS 결제 취소 처리 + * 카드, 간편결제, 가상계좌 모두 동일한 취소 API 사용 + * @param command 취소요청dto + * @return toss -> 공통dto 변환 + */ + @Override + public PgPayCancelResponse processCancel(PayCancelCommand command) { + TossCancelResponse tossCancelResponse = callTossCancelApi(command); + + boolean isSuccess = "DONE".equals(tossCancelResponse.cancelStatus()); + + return new PgPayCancelResponse( + tossCancelResponse.transactionKey(), + tossCancelResponse.cancelStatus(), + tossCancelResponse.cancelStatus(), + tossCancelResponse.cancelReason(), + tossCancelResponse.cancelAmount(), + isSuccess + ); + } + + @Override + public PgProvider getPgProvider() { + return PgProvider.TOSS; + } + + /** + * TOSS 구현체 중 특정 결제서비스 빈 추출을 위함 + * @return + */ + @Override + public PayMethod getPgPayMethod() { + return getTossPayMethod(); + } + + @Override + public Object initPayment() { + return null; + } + + /** + * 승인 API 호출 + */ + private TossTransResponse callTossConfirmApi( + String paymentKey, + String orderId, + Long amount + ) { + try { + HttpHeaders headers = createHeaders(); + // 요청 바디 + Map requestBody = new HashMap<>(); + requestBody.put("paymentKey", paymentKey); + requestBody.put("orderId", orderId); + requestBody.put("amount", amount); + + HttpEntity> request = new HttpEntity<>(requestBody, headers); + // api 호출 + return restTemplate.exchange( + TOSS_CONFIRM_URL, + HttpMethod.POST, + request, + TossTransResponse.class + ).getBody(); + + } catch (HttpClientErrorException e) { + log.error("TOSS 결제 승인 실패 (4xx) - status: {}, body: {}", + e.getStatusCode(), e.getResponseBodyAsString()); + throw new RuntimeException(e.getResponseBodyAsString()); + + } catch (HttpServerErrorException e) { + log.error("TOSS 서버 오류 (5xx) - status: {}, body: {}", + e.getStatusCode(), e.getResponseBodyAsString()); + throw new RuntimeException(e.getResponseBodyAsString()); + + } catch (Exception e) { + log.error("TOSS 승인 API 호출 중 예외 발생", e); + throw new RuntimeException(e.getMessage()); + } + } + + /** + * TOSS 결제 취소 API 호출 (통합) + */ + private TossCancelResponse callTossCancelApi(PayCancelCommand command) { + try { + String paymentKey = command.getPgTid(); // 결제 키 + Long cancelAmount = command.getCanceledAmount().value(); // 취소 금액 (null이면 전액 취소) + String cancelReason = command.getCancelReason(); + + String url = TOSS_CANCEL_URL + paymentKey + "/cancel"; + + HttpHeaders headers = createHeaders(); + + // 취소 요청 데이터 공통부 + Map requestBody = new HashMap<>(); + requestBody.put("cancelReason", cancelReason); + + // 부분 취소인 경우 금액 추가 + if (command.getPaymentStatus().equals(PARTIAL_CANCELED)) { + requestBody.put("cancelAmount", cancelAmount); + } + + // 결제유혈병 요청 데이터 추가 세팅 + generateCancelRequest(requestBody, command); + + HttpEntity> request = new HttpEntity<>(requestBody, headers); + ResponseEntity response = restTemplate.exchange( + url, + HttpMethod.POST, + request, + TossTransResponse.class + ); + + TossTransResponse.CancelInfo lastCancel = response.getBody() + .cancels() + .get(response.getBody().cancels().size() - 1); + + return new TossCancelResponse( + lastCancel.transactionKey(), + lastCancel.cancelStatus(), + lastCancel.cancelAmount(), + lastCancel.cancelReason() + ); + + } catch (HttpClientErrorException e) { + log.error("토스 결제 취소 실패 (4xx) - status: {}, body: {}", + e.getStatusCode(), e.getResponseBodyAsString()); + throw new RuntimeException("결제 취소 실패: " + e.getResponseBodyAsString()); + + } catch (HttpServerErrorException e) { + log.error("토스페이먼츠 서버 오류 (5xx) - status: {}, body: {}", + e.getStatusCode(), e.getResponseBodyAsString()); + throw new RuntimeException("토스페이먼츠 서버 오류: " + e.getResponseBodyAsString()); + + } catch (Exception e) { + log.error("결제 취소 API 호출 중 예외 발생", e); + throw new RuntimeException("결제 취소 중 오류가 발생했습니다: " + e.getMessage()); + } + } + + private HttpHeaders createHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.set(HttpHeaders.AUTHORIZATION, "Basic " + encodeSecretKey()); + + return headers; + } + + /** + * Secret Key Base64 인코딩 + */ + private String encodeSecretKey() { + return Base64.getEncoder() + .encodeToString((secretKey + ":").getBytes(StandardCharsets.UTF_8)); + } + + protected abstract PayMethod getTossPayMethod(); + + /** + * 결제수단별 응답 메시지 파싱 + */ + protected abstract PgPayResponse convertToResponse(TossTransResponse response); + + /** + * 결제수단별 취소 요청 body 생성 + */ + protected abstract void generateCancelRequest(Map commonBody, PayCancelCommand command); +} + diff --git a/platform/src/main/java/com/commerce/platform/infrastructure/pg/toss/TossVirtualAccountService.java b/platform/src/main/java/com/commerce/platform/infrastructure/pg/toss/TossVirtualAccountService.java new file mode 100644 index 0000000..d1887d3 --- /dev/null +++ b/platform/src/main/java/com/commerce/platform/infrastructure/pg/toss/TossVirtualAccountService.java @@ -0,0 +1,59 @@ +package com.commerce.platform.infrastructure.pg.toss; + +import com.commerce.platform.core.application.in.dto.PayCancelCommand; +import com.commerce.platform.core.application.out.dto.PgPayResponse; +import com.commerce.platform.core.domain.enums.PayMethod; +import com.commerce.platform.core.domain.vo.Money; +import com.commerce.platform.infrastructure.pg.toss.dto.TossTransResponse; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; + +import java.util.Map; + +@Service +public class TossVirtualAccountService extends TossStrategy{ + + public TossVirtualAccountService(RestTemplate restTemplate) { + super(restTemplate); + } + + @Override + protected PayMethod getTossPayMethod() { + return PayMethod.VIRTUAL_ACCOUNT; + } + + @Override + protected PgPayResponse convertToResponse(TossTransResponse response) { + boolean isSuccess = "DONE".equals(response.status()) + || "WAITING_FOR_DEPOSIT".equals(response.status()); + + TossTransResponse.VirtualAccountInfo virtualAccountInfo = response.virtualAccount(); + return PgPayResponse.builder() + .pgTid(response.paymentKey()) + .responseCode(response.status()) + .responseMessage(response.status()) + .amount(Money.create(response.totalAmount())) + .isSuccess(isSuccess) + .virtualAccount(PgPayResponse.VirtualAccount.builder() + .accountType(virtualAccountInfo.accountType()) + .accountNumber(virtualAccountInfo.accountNumber()) + .bankCode(virtualAccountInfo.bankCode()) + .depositorName(virtualAccountInfo.customerName()) + .dueDate(virtualAccountInfo.dueDate()) + .build()) + .build(); + } + + @Override + protected void generateCancelRequest(Map commonBody, PayCancelCommand command) { + PayCancelCommand.RefundReceiveAccount refundAccount = command.getRefundReceiveAccount(); + + Map refundInfo = Map.of( + "bank", refundAccount.getBankCode(), + "accountNumber", refundAccount.getAccountNumber(), + "holderName", refundAccount.getHolderName() + ); + + commonBody.put("refundReceiveAccount", refundInfo); + } +} diff --git a/platform/src/main/java/com/commerce/platform/infrastructure/pg/toss/dto/TossCancelResponse.java b/platform/src/main/java/com/commerce/platform/infrastructure/pg/toss/dto/TossCancelResponse.java new file mode 100644 index 0000000..6712bf2 --- /dev/null +++ b/platform/src/main/java/com/commerce/platform/infrastructure/pg/toss/dto/TossCancelResponse.java @@ -0,0 +1,9 @@ +package com.commerce.platform.infrastructure.pg.toss.dto; + +public record TossCancelResponse( + String transactionKey, + String cancelStatus, + Long cancelAmount, + String cancelReason +) { +} diff --git a/platform/src/main/java/com/commerce/platform/infrastructure/pg/toss/dto/TossTransResponse.java b/platform/src/main/java/com/commerce/platform/infrastructure/pg/toss/dto/TossTransResponse.java new file mode 100644 index 0000000..36c0509 --- /dev/null +++ b/platform/src/main/java/com/commerce/platform/infrastructure/pg/toss/dto/TossTransResponse.java @@ -0,0 +1,78 @@ +package com.commerce.platform.infrastructure.pg.toss.dto; + +import java.util.List; + +/** + * TOSS 결제유형별 요청 응답 DTO + **/ +public record TossTransResponse( + String mId, // 가맹점 ID (상점 식별자, MID) + String version, // API 버전 (예: "2022-11-16") + String paymentKey, // 결제 키 (토스가 발급한 결제 고유 식별자, 승인/취소/조회 시 필수) + String orderId, // 주문 ID (상점에서 생성한 주문 고유 번호) + String orderName, // 주문명 (예: "토스 티셔츠 외 2건", 결제창에 표시됨) + String method, // 결제수단 ("카드", "가상계좌", "간편결제", "휴대폰" 등) + Long totalAmount, // 총 결제 금액 (원 단위, 최초 결제 요청 금액) + Long balanceAmount, // 취소 후 남은 금액 (부분 취소 시 감소, 전액 취소 시 0) + String status, // 결제 상태 (READY, IN_PROGRESS, WAITING_FOR_DEPOSIT, DONE, CANCELED, PARTIAL_CANCELED, ABORTED, EXPIRED) + String requestedAt, // 결제 요청 시각 (ISO 8601 형식: 2024-12-01T10:00:00+09:00) + String approvedAt, // 결제 승인 시각 (ISO 8601 형식, 승인 완료 후에만 존재) + CardInfo card, // 카드 결제 정보 (카드 결제 시에만 존재, 나머지 null) + EasyPayInfo easyPay, // 간편결제 정보 (간편결제 시에만 존재, 나머지 null) + VirtualAccountInfo virtualAccount, // 가상계좌 정보 (가상계좌 결제 시에만 존재, 나머지 null) + List cancels // 취소 정보 리스트 (취소 발생 시 배열에 누적, 없으면 빈 배열 또는 null) +) { + /** + * 카드결제 정보 + */ + public record CardInfo( + String issuerCode, // 발급사 코드 (카드를 발행한 회사) + String acquirerCode, // 매입사 코드 (가맹점과 계약한 카드사) + String number, // 마스킹된 카드번호 (앞 8자리, 뒤 1자리만 표시) + Integer installmentPlanMonths, // 할부 개월 (0=일시불, 2~12=할부) + String cardType, // 카드 타입 (신용/체크/기프트) + String ownerType, // 소유자 구분 (개인/법인) + String approveNo // 승인번호 (PG사가 아닌 카드사에서 발급) + ) {} + + /** + * 간편결제 정보 + */ + public record EasyPayInfo( + String provider, // 간편결제 제공자 (토스페이, 네이버페이, 카카오페이 등) + String amount, // 실제 결제된 금액 + String discountAmount // 간편결제 할인 금액 (프로모션 적용 시) + ) {} + + /** + * 가상계좌 정보 + */ + public record VirtualAccountInfo( + String accountNumber, // 발급된 가상계좌 번호 (구매자가 입금할 계좌) + String bankCode, // 은행 코드 (20=우리, 004=KB, 011=NH농협) + String customerName, // 입금자명 (주문 시 입력한 구매자 이름) + String dueDate, // 입금 기한 (이 시간까지 입금해야 함) + String accountType, // 계좌 유형 (일반=주문마다 새 계좌, 고정=항상 동일 계좌) + Boolean expired, // 만료 여부 (true=입금 기한 지남) + String settlementStatus, // 정산 상태 (INCOMPLETED=미정산, COMPLETED=정산완료) + String refundStatus // 환불 처리 상태 (취소 시 환불 진행 현황) + ) {} + + /** + * 결제 취소 정보 + * - Payment 객체의 cancels 필드에 배열로 담겨서 반환됨 + * - 부분 취소를 여러 번 하면 배열에 여러 개의 취소 객체가 쌓임 + */ + public record CancelInfo( + String transactionKey, // 취소 거래 키 (각 취소마다 고유) + String cancelReason, // 취소 사유 + Long cancelAmount, // 취소 금액 + Long taxFreeAmount, // 취소된 면세 금액 + Long taxExemptionAmount, // 과세 제외 금액 (복지, 교육비 등) + Long refundableAmount, // 남은 취소 가능 금액 + Long easyPayDiscountAmount, // 간편결제 할인 금액 + String canceledAt, // 취소 처리 시각 (ISO 8601) + String cancelStatus, // 취소 상태 (DONE, FAILED, IN_PROGRESS 등) + String receiptKey // 취소 영수증 키 + ) {} +} 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 index 80c7ae8..8c8ce3d 100644 --- 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 @@ -75,6 +75,19 @@ class PaymentUseCaseImplTest { "PG_TID_12345", "0000", "성공", + Money.create(35000), + true, + null, + null, + null + ); + + private PgPayCancelResponse cancelResponse = new PgPayCancelResponse( + "PG_CC_TID_12345", + "success", + "취소성공", + "고객취소", + 35000L, true ); @@ -110,7 +123,8 @@ void doApproval_successful() { null, null, PayMethod.CARD, - PayProvider.KB + PayProvider.KB, + null ); mockPgStrategy(); @@ -304,13 +318,10 @@ void doPartCancel_failed() { private void mockPgStrategy() { PgStrategy mockPgStrategy = mock(PgStrategy.class); - when(mockPaymentPgRouter.getPgStrategyByProvider(any())) + when(mockPaymentPgRouter.routePg(any(PayMethod.class), any(PayProvider.class))) .thenReturn(mockPgStrategy); - when(mockPgStrategy.processCancel(any())) - .thenReturn(success_pgResponse); - - when(mockPaymentPgRouter.routPg(PayMethod.CARD)) + when(mockPaymentPgRouter.getPgStrategyByProvider(any(), any())) .thenReturn(mockPgStrategy); when(mockPgStrategy.getPgProvider()) @@ -318,5 +329,8 @@ private void mockPgStrategy() { when(mockPgStrategy.processApproval(any())) .thenReturn(success_pgResponse); + + when(mockPgStrategy.processCancel(any())) + .thenReturn(cancelResponse); } } \ 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 index 9eb5ed6..dc7349d 100644 --- 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 @@ -1,55 +1,132 @@ package com.commerce.platform.core.domain.service; +import com.commerce.platform.PlatformApplication; 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 com.commerce.platform.infrastructure.persistence.PgFeeInfo; +import com.commerce.platform.infrastructure.persistence.PgFeeInfoRepository; +import com.commerce.platform.infrastructure.pg.nhn.NHNStrategy; +import com.commerce.platform.infrastructure.pg.toss.TossStrategy; +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.data.redis.core.StringRedisTemplate; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.ApplicationListener; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; -@SpringBootTest +/** + * PaymentPgRouter 단위테스트 + */ +@ExtendWith(MockitoExtension.class) class PaymentPgRouterTest { - @Autowired private PaymentPgRouter paymentPgRouter; - @Autowired + @Mock private PgCacheService pgCacheService; - @Autowired - private StringRedisTemplate redisTemplate; + @Mock + private PgFeeInfoRepository feeInfoRepository; + + @BeforeEach + void init_paymentPgRouter() { + // mock pgStrategy + TossStrategy tossMock = mock(TossStrategy.class); + NHNStrategy nhnMock = mock(NHNStrategy.class); + + when(tossMock.getPgProvider()).thenReturn(PgProvider.TOSS); + when(nhnMock.getPgProvider()).thenReturn(PgProvider.NHN); + + // PaymentPgRouter 생성 + paymentPgRouter = new PaymentPgRouter( + List.of(tossMock, nhnMock), + pgCacheService, + feeInfoRepository + ); + + // mock 수수료 + PgFeeInfo tossFee_kb = new PgFeeInfo( + PgProvider.TOSS, PayMethod.CARD, PayProvider.KB, + BigDecimal.valueOf(2.5), + LocalDate.now(), LocalDate.now().plusMonths(1) + ); + + PgFeeInfo tossFee_samsung = new PgFeeInfo( + PgProvider.TOSS, PayMethod.CARD, PayProvider.SAMSUNG, + BigDecimal.valueOf(2.6), + LocalDate.now(), LocalDate.now().plusMonths(1) + ); + PgFeeInfo nhnFee_samsung = new PgFeeInfo( + PgProvider.NHN, PayMethod.CARD, PayProvider.SAMSUNG, + BigDecimal.valueOf(2.3), + LocalDate.now(), LocalDate.now().plusMonths(1) + ); + + when(feeInfoRepository.findAllActiveAndValid()) + .thenReturn(List.of(tossFee_kb, tossFee_samsung, nhnFee_samsung)); + + // 캐시 초기화 @EventListener(ApplicationReadyEvent.class) + paymentPgRouter.initPgCache(); + } @DisplayName("결제유형에 따른 라우팅") @Test void routePg() { + // TOSS, NHN 모두 정상 + when(pgCacheService.isHealthy(PgProvider.TOSS)) + .thenReturn(true); + when(pgCacheService.isHealthy(PgProvider.NHN)) + .thenReturn(true); + assertThat(paymentPgRouter.routePg(PayMethod.CARD, PayProvider.KB)) - .as("KB 카드 결제는 TOSS 만 존재").isInstanceOf(TossStrategy.class); + .as("KB 카드 결제는 TOSS만 존재") + .isInstanceOf(TossStrategy.class); assertThat(paymentPgRouter.routePg(PayMethod.CARD, PayProvider.SAMSUNG)) - .as("SAMSUNG 카드 결제는 NHN 우선").isInstanceOf(NHNStrategy.class); - + .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); + // NHN 장애, TOSS 정상 + when(pgCacheService.isHealthy(PgProvider.NHN)).thenReturn(false); + when(pgCacheService.isHealthy(PgProvider.TOSS)).thenReturn(true); assertThat(paymentPgRouter.routePg(PayMethod.CARD, PayProvider.SAMSUNG)) - .as("SAMSUNG 카드 결제 : NHN 장애로 TOSS!").isInstanceOf(TossStrategy.class); + .as("SAMSUNG 카드 결제: NHN 장애로 TOSS로 폴백") + .isInstanceOf(TossStrategy.class); + } + + @DisplayName("ApplicationStartedEvent 동작 검증") + @Test + void initPgCache() { + SpringApplication application = new SpringApplication(PlatformApplication.class); + + application.addListeners( + (ApplicationListener) event -> { + PaymentPgRouter targetBean = (PaymentPgRouter) event.getApplicationContext() + .getBean("paymentPgRouter"); + +// assertThat(targetBean.pgFeeCache).isNotEmpty(); + } + ); + + application.run(); - // nhn 장애 원복 - redisTemplate.delete("pg:health:NHN"); } } \ No newline at end of file diff --git a/platform/src/test/java/com/commerce/platform/infrastructure/adaptor/CardBinPromotionAdaptorTest.java b/platform/src/test/java/com/commerce/platform/infrastructure/adaptor/CardBinPromotionAdaptorTest.java new file mode 100644 index 0000000..4375dec --- /dev/null +++ b/platform/src/test/java/com/commerce/platform/infrastructure/adaptor/CardBinPromotionAdaptorTest.java @@ -0,0 +1,37 @@ +package com.commerce.platform.infrastructure.adaptor; + +import com.commerce.platform.core.domain.aggreate.CardBinPromotion; +import com.commerce.platform.core.domain.enums.PayProvider; +import com.commerce.platform.core.domain.vo.promotion.ShinhanPromotionData; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest +class CardBinPromotionAdaptorTest { + @Autowired + private CardBinPromotionAdaptor cardBinPromotionAdaptor; + + @Test + void findActiveByCardBins() { + List activePromotions = cardBinPromotionAdaptor.findActivePromotions(); + + assertAll( + () -> { + assertThat(activePromotions.size()).isEqualTo(7); + assertThat(activePromotions.stream() + .filter(p -> p.getPayProvider().equals(PayProvider.SHIN_HAN)) + .map(CardBinPromotion::getPromotionData) + .findFirst() + .get() + ).isExactlyInstanceOf(ShinhanPromotionData.class); + } + ); + } + +} \ No newline at end of file