diff --git a/payment-service/src/main/java/com/synapse/payment_service/PaymentServiceApplication.java b/payment-service/src/main/java/com/synapse/payment_service/PaymentServiceApplication.java index d553e39..7f26790 100644 --- a/payment-service/src/main/java/com/synapse/payment_service/PaymentServiceApplication.java +++ b/payment-service/src/main/java/com/synapse/payment_service/PaymentServiceApplication.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 PaymentServiceApplication { diff --git a/payment-service/src/main/java/com/synapse/payment_service/config/PortOneClientProperties.java b/payment-service/src/main/java/com/synapse/payment_service/config/PortOneClientProperties.java index a9b1f44..896f58b 100644 --- a/payment-service/src/main/java/com/synapse/payment_service/config/PortOneClientProperties.java +++ b/payment-service/src/main/java/com/synapse/payment_service/config/PortOneClientProperties.java @@ -7,7 +7,8 @@ public record PortOneClientProperties( String apiSecret, String baseUrl, String midKey, - String webhookSecret + String webhookSecret, + String channelKey ) { } diff --git a/payment-service/src/main/java/com/synapse/payment_service/controller/PaymentController.java b/payment-service/src/main/java/com/synapse/payment_service/controller/PaymentController.java index 352aaf5..e738159 100644 --- a/payment-service/src/main/java/com/synapse/payment_service/controller/PaymentController.java +++ b/payment-service/src/main/java/com/synapse/payment_service/controller/PaymentController.java @@ -9,6 +9,7 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import com.synapse.payment_service.dto.request.CancelSubscriptionRequest; import com.synapse.payment_service.dto.request.PaymentRequestDto; import com.synapse.payment_service.dto.request.PaymentVerificationRequest; import com.synapse.payment_service.dto.response.PaymentPreparationResponse; @@ -42,9 +43,20 @@ public ResponseEntity requestPayment( * @return */ @PostMapping("/verify") - public ResponseEntity verifyPayment(@RequestBody @Valid PaymentVerificationRequest request) { - paymentService.verifyAndProcess(request); + public ResponseEntity verifyPayment( + @RequestBody @Valid PaymentVerificationRequest request, + @AuthenticationPrincipal UUID memberId + ) { + paymentService.verifyAndProcess(request, memberId); + return ResponseEntity.ok().build(); + } + + @PostMapping("/subscriptions/cancel") + public ResponseEntity cancelSubscription( + @RequestBody @Valid CancelSubscriptionRequest request, + @AuthenticationPrincipal UUID memberId + ) { + paymentService.cancelSubscription(memberId, request); return ResponseEntity.ok().build(); } - } diff --git a/payment-service/src/main/java/com/synapse/payment_service/domain/Order.java b/payment-service/src/main/java/com/synapse/payment_service/domain/Order.java index 7cda86d..5b8907f 100644 --- a/payment-service/src/main/java/com/synapse/payment_service/domain/Order.java +++ b/payment-service/src/main/java/com/synapse/payment_service/domain/Order.java @@ -2,9 +2,14 @@ import java.math.BigDecimal; import java.time.ZonedDateTime; +import java.util.UUID; import com.synapse.payment_service.common.BaseEntity; import com.synapse.payment_service.domain.enums.PaymentStatus; +import com.synapse.payment_service.domain.enums.SubscriptionTier; +import com.synapse.payment_service.exception.ExceptionCode; +import com.synapse.payment_service.exception.PaymentVerificationException; +import io.portone.sdk.server.payment.Payment; import jakarta.persistence.*; import lombok.AccessLevel; @@ -57,4 +62,50 @@ public void updateStatus(PaymentStatus status) { public void updateIamPortTransactionId(String iamPortTransactionId) { this.iamPortTransactionId = iamPortTransactionId; } + + // 도메인 비즈니스 메서드들 + public boolean isAlreadyProcessed() { + return this.status != PaymentStatus.PENDING; + } + + public void validatePaymentAmount(Payment.Recognized recognizedPayment) { + if (this.amount.compareTo(BigDecimal.valueOf(recognizedPayment.getAmount().getTotal())) != 0) { + throw new PaymentVerificationException(ExceptionCode.PAYMENT_AMOUNT_MISMATCH); + } + } + + public boolean hasBillingKey(Payment.Recognized recognizedPayment) { + return this.status == PaymentStatus.PAID && recognizedPayment.getBillingKey() != null; + } + + public void markAsPaid() { + this.status = PaymentStatus.PAID; + this.paidAt = ZonedDateTime.now(); + } + + public void cancel() { + this.status = PaymentStatus.CANCELED; + } + + // 정적 팩토리 메서드 + public static Order createForSubscription(Subscription subscription, SubscriptionTier tier) { + BigDecimal amount = tier.getMonthlyPrice(); + String orderName = tier.getTierName() + "_subscription"; + String paymentId = orderName + "_" + UUID.randomUUID(); + + return Order.builder() + .subscription(subscription) + .paymentId(paymentId) + .amount(amount) + .status(PaymentStatus.PENDING) + .build(); + } + + public String getOrderName() { + String[] parts = this.paymentId.split("_"); + if (parts.length >= 2) { + return parts[0] + "_" + parts[1]; // tier_subscription 형태 + } + return this.paymentId; + } } diff --git a/payment-service/src/main/java/com/synapse/payment_service/domain/Subscription.java b/payment-service/src/main/java/com/synapse/payment_service/domain/Subscription.java index 1ed2465..b18e558 100644 --- a/payment-service/src/main/java/com/synapse/payment_service/domain/Subscription.java +++ b/payment-service/src/main/java/com/synapse/payment_service/domain/Subscription.java @@ -1,6 +1,7 @@ package com.synapse.payment_service.domain; import java.time.ZonedDateTime; +import java.time.temporal.TemporalAdjusters; import java.util.UUID; import com.synapse.payment_service.common.BaseEntity; @@ -42,6 +43,12 @@ public class Subscription extends BaseEntity { @Column(nullable = false) private SubscriptionStatus status; + @Column(nullable = false) + private boolean autoRenew = true; + + @Column(nullable = false) + private Integer retryCount = 0; + @Builder public Subscription(UUID memberId, SubscriptionTier tier, int remainingChatCredits, ZonedDateTime expiresAt, SubscriptionStatus status) { this.memberId = memberId; @@ -49,14 +56,50 @@ public Subscription(UUID memberId, SubscriptionTier tier, int remainingChatCredi this.remainingChatCredits = remainingChatCredits; this.expiresAt = expiresAt; this.status = status; + this.autoRenew = true; + this.retryCount = 0; + } + + public void deactivate() { + this.status = SubscriptionStatus.CANCELED; + this.billingKey = null; + this.autoRenew = false; + } + + public void updateBillingKey(String billingKey) { + this.billingKey = billingKey; + this.remainingChatCredits = this.tier.getMaxRequestCount(); } - public void activate(SubscriptionTier newTier) { + public void renewSubscription(SubscriptionTier newTier) { + // 기존 구독 갱신 - 기존 만료일에서 1달 연장 + ZonedDateTime currentExpiresAt = this.expiresAt != null ? this.expiresAt : ZonedDateTime.now(); + + // 현재 만료일이 해당 월의 마지막 날인지 확인 + boolean isLastDayOfMonth = currentExpiresAt.getDayOfMonth() == currentExpiresAt.toLocalDate().lengthOfMonth(); + + if (isLastDayOfMonth) { + // 만약 마지막 날이었다면, 다음 달의 마지막 날로 설정 + this.expiresAt = currentExpiresAt.plusMonths(1).with(TemporalAdjusters.lastDayOfMonth()); + } else { + // 그렇지 않다면, 단순히 한 달을 더 한다 (예: 15일 -> 다음 달 15일) + this.expiresAt = currentExpiresAt.plusMonths(1); + } + + // 갱신 시 크레딧도 해당 티어의 기본 크레딧으로 초기화 + this.remainingChatCredits = this.tier.getMaxRequestCount(); this.status = SubscriptionStatus.ACTIVE; this.tier = newTier; + this.autoRenew = true; } - public void deactivate() { - this.status = SubscriptionStatus.CANCELED; + public void handlePaymentFailure() { + this.status = SubscriptionStatus.PAYMENT_FAILED; + this.retryCount++; + } + + public void expireSubscription() { + this.status = SubscriptionStatus.EXPIRED; + this.autoRenew = false; } } diff --git a/payment-service/src/main/java/com/synapse/payment_service/domain/enums/PaymentStatus.java b/payment-service/src/main/java/com/synapse/payment_service/domain/enums/PaymentStatus.java index 5a2ce4c..d6afbe1 100644 --- a/payment-service/src/main/java/com/synapse/payment_service/domain/enums/PaymentStatus.java +++ b/payment-service/src/main/java/com/synapse/payment_service/domain/enums/PaymentStatus.java @@ -3,7 +3,7 @@ public enum PaymentStatus { PAID, // 결제 완료 FAILED, // 결제 실패 - CANCELLED, // 결제 취소 (환불) + CANCELED, // 결제 취소 (환불) PENDING, // 결제 대기 PARTIAL_CANCELLED, // 부분 취소 PAY_PENDING, // 결제 완료 대기 diff --git a/payment-service/src/main/java/com/synapse/payment_service/dto/request/CancelSubscriptionRequest.java b/payment-service/src/main/java/com/synapse/payment_service/dto/request/CancelSubscriptionRequest.java new file mode 100644 index 0000000..aa8a191 --- /dev/null +++ b/payment-service/src/main/java/com/synapse/payment_service/dto/request/CancelSubscriptionRequest.java @@ -0,0 +1,10 @@ +package com.synapse.payment_service.dto.request; + +import jakarta.validation.constraints.NotBlank; + +public record CancelSubscriptionRequest( + @NotBlank(message = "취소 사유는 필수입니다.") + String reason +) { + +} diff --git a/payment-service/src/main/java/com/synapse/payment_service/dto/request/PaymentWebhookRequest.java b/payment-service/src/main/java/com/synapse/payment_service/dto/request/PaymentWebhookRequest.java index e0e0e79..366092c 100644 --- a/payment-service/src/main/java/com/synapse/payment_service/dto/request/PaymentWebhookRequest.java +++ b/payment-service/src/main/java/com/synapse/payment_service/dto/request/PaymentWebhookRequest.java @@ -1,5 +1,6 @@ package com.synapse.payment_service.dto.request; +import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; @@ -7,26 +8,44 @@ import java.util.Optional; public record PaymentWebhookRequest( - String paymentId, - String transactionId, - String type + @JsonProperty("type") + String type, + @JsonProperty("timestamp") + String timestamp, + @JsonProperty("data") + JsonNode data ) { + public static PaymentWebhookRequest from(String requestBody, ObjectMapper objectMapper) throws IOException { - JsonNode root = objectMapper.readTree(requestBody); - - // data 노드가 있으면 그 안에서, 없으면 루트에서 찾기 - JsonNode dataNode = Optional.ofNullable(root.get("data")).orElse(root); - - return new PaymentWebhookRequest( - extractText(dataNode, "paymentId"), - extractText(dataNode, "transactionId"), - extractText(root, "type") // type은 항상 루트 레벨에 있음 - ); + return objectMapper.readValue(requestBody, PaymentWebhookRequest.class); + } + + public String getPaymentId() { + if (data == null) return null; + return extractText(data, "paymentId"); } - private static String extractText(JsonNode node, String fieldName) { + public String getTransactionId() { + if (data == null) return null; + return extractText(data, "transactionId"); + } + + public String getBillingKey() { + if (data == null) return null; + return extractText(data, "billingKey"); + } + + private String extractText(JsonNode node, String fieldName) { return Optional.ofNullable(node.get(fieldName)) .map(JsonNode::asText) .orElse(null); } + + public boolean isTransactionWebhook() { + return type != null && type.startsWith("Transaction."); + } + + public boolean isBillingKeyWebhook() { + return type != null && type.startsWith("BillingKey."); + } } \ No newline at end of file diff --git a/payment-service/src/main/java/com/synapse/payment_service/exception/ExceptionCode.java b/payment-service/src/main/java/com/synapse/payment_service/exception/ExceptionCode.java index bf3c82f..f20a004 100644 --- a/payment-service/src/main/java/com/synapse/payment_service/exception/ExceptionCode.java +++ b/payment-service/src/main/java/com/synapse/payment_service/exception/ExceptionCode.java @@ -7,6 +7,7 @@ import static org.springframework.http.HttpStatus.INTERNAL_SERVER_ERROR; import static org.springframework.http.HttpStatus.NOT_FOUND; +import static org.springframework.http.HttpStatus.UNAUTHORIZED; import static org.springframework.http.HttpStatus.CONFLICT; import static org.springframework.http.HttpStatus.BAD_REQUEST; @@ -17,11 +18,14 @@ public enum ExceptionCode { SUBSCRIPTION_NOT_FOUND(NOT_FOUND, "P002", "구독 정보를 찾을 수 없습니다"), ORDER_NOT_FOUND(NOT_FOUND, "P003", "주문 정보를 찾을 수 없습니다"), + BILLING_KEY_NOT_FOUND(NOT_FOUND, "P008", "빌링키 정보를 찾을 수 없습니다"), PAYMENT_VERIFICATION_FAILED(BAD_REQUEST, "P004", "존재하지 않는 거래입니다"), PAYMENT_NOT_RECOGNIZED(INTERNAL_SERVER_ERROR, "P005", "결제 정보를 인식할 수 없습니다"), PAYMENT_AMOUNT_MISMATCH(CONFLICT, "P006", "결제 금액이 불일치합니다"), - UNSUPPORTED_PAYMENT_STATUS(INTERNAL_SERVER_ERROR, "P007", "지원하지 않는 결제 상태입니다") + UNSUPPORTED_PAYMENT_STATUS(INTERNAL_SERVER_ERROR, "P007", "지원하지 않는 결제 상태입니다"), + + UNAUTHORIZED_USER(UNAUTHORIZED, "P009", "권한이 없습니다"), ; private final HttpStatus status; diff --git a/payment-service/src/main/java/com/synapse/payment_service/exception/PaymentVerificationException.java b/payment-service/src/main/java/com/synapse/payment_service/exception/PaymentVerificationException.java index d0c8b0b..02db0e3 100644 --- a/payment-service/src/main/java/com/synapse/payment_service/exception/PaymentVerificationException.java +++ b/payment-service/src/main/java/com/synapse/payment_service/exception/PaymentVerificationException.java @@ -1,5 +1,5 @@ package com.synapse.payment_service.exception; - + public class PaymentVerificationException extends PaymentException { public PaymentVerificationException(ExceptionCode exceptionCode) { super(exceptionCode); diff --git a/payment-service/src/main/java/com/synapse/payment_service/exception/UnauthorizedException.java b/payment-service/src/main/java/com/synapse/payment_service/exception/UnauthorizedException.java new file mode 100644 index 0000000..7ff57b7 --- /dev/null +++ b/payment-service/src/main/java/com/synapse/payment_service/exception/UnauthorizedException.java @@ -0,0 +1,7 @@ +package com.synapse.payment_service.exception; + +public class UnauthorizedException extends PaymentException { + public UnauthorizedException(ExceptionCode exceptionCode) { + super(exceptionCode); + } +} diff --git a/payment-service/src/main/java/com/synapse/payment_service/repository/OrderRepository.java b/payment-service/src/main/java/com/synapse/payment_service/repository/OrderRepository.java index 0558632..c472b5f 100644 --- a/payment-service/src/main/java/com/synapse/payment_service/repository/OrderRepository.java +++ b/payment-service/src/main/java/com/synapse/payment_service/repository/OrderRepository.java @@ -5,7 +5,9 @@ import org.springframework.data.jpa.repository.JpaRepository; import com.synapse.payment_service.domain.Order; +import com.synapse.payment_service.domain.Subscription; public interface OrderRepository extends JpaRepository { Optional findByPaymentId(String paymentId); + Optional findBySubscription(Subscription subscription); } diff --git a/payment-service/src/main/java/com/synapse/payment_service/repository/SubscriptionRepository.java b/payment-service/src/main/java/com/synapse/payment_service/repository/SubscriptionRepository.java index 29420ca..a0196ee 100644 --- a/payment-service/src/main/java/com/synapse/payment_service/repository/SubscriptionRepository.java +++ b/payment-service/src/main/java/com/synapse/payment_service/repository/SubscriptionRepository.java @@ -1,12 +1,31 @@ package com.synapse.payment_service.repository; +import java.time.ZonedDateTime; +import java.util.List; import java.util.Optional; import java.util.UUID; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import com.synapse.payment_service.domain.Subscription; +import com.synapse.payment_service.domain.enums.SubscriptionStatus; public interface SubscriptionRepository extends JpaRepository { Optional findByMemberId(UUID memberId); + Optional findByBillingKey(String billingKey); + + @Query("SELECT s FROM Subscription s WHERE s.status = 'ACTIVE' AND s.autoRenew = true AND s.billingKey IS NOT NULL AND s.expiresAt >= :startOfDay AND s.expiresAt < :endOfDay") + List findActiveSubscriptionsDueForRenewal(@Param("startOfDay") ZonedDateTime startOfDay, @Param("endOfDay") ZonedDateTime endOfDay); + + /** + * 만료 처리 대상 구독을 조회합니다. + * CANCELED 또는 PAYMENT_FAILED 상태이고, 만료일이 지난 구독을 반환합니다. + * + * @param statuses 조회할 구독 상태 목록 (CANCELED, PAYMENT_FAILED) + * @param currentTime 현재 시간 + * @return 만료 처리 대상 구독 목록 + */ + List findByStatusInAndExpiresAtBefore(List statuses, ZonedDateTime currentTime); } diff --git a/payment-service/src/main/java/com/synapse/payment_service/service/PaymentService.java b/payment-service/src/main/java/com/synapse/payment_service/service/PaymentService.java index b0553f9..2857cc4 100644 --- a/payment-service/src/main/java/com/synapse/payment_service/service/PaymentService.java +++ b/payment-service/src/main/java/com/synapse/payment_service/service/PaymentService.java @@ -1,7 +1,6 @@ package com.synapse.payment_service.service; import java.io.IOException; -import java.math.BigDecimal; import java.util.UUID; import org.springframework.stereotype.Service; @@ -9,8 +8,8 @@ import com.synapse.payment_service.domain.Order; import com.synapse.payment_service.domain.Subscription; -import com.synapse.payment_service.domain.enums.PaymentStatus; import com.synapse.payment_service.domain.enums.SubscriptionTier; +import com.synapse.payment_service.dto.request.CancelSubscriptionRequest; import com.synapse.payment_service.dto.request.PaymentRequestDto; import com.synapse.payment_service.dto.request.PaymentVerificationRequest; import com.synapse.payment_service.dto.request.PaymentWebhookRequest; @@ -18,6 +17,7 @@ import com.synapse.payment_service.exception.ExceptionCode; import com.synapse.payment_service.exception.NotFoundException; import com.synapse.payment_service.exception.PaymentVerificationException; +import com.synapse.payment_service.exception.UnauthorizedException; import com.synapse.payment_service.repository.OrderRepository; import com.synapse.payment_service.repository.SubscriptionRepository; import com.synapse.payment_service.service.converter.PaymentStatusConverter; @@ -43,28 +43,23 @@ public class PaymentService { @Transactional public PaymentPreparationResponse preparePayment(UUID memberId, PaymentRequestDto request) { SubscriptionTier tier = SubscriptionTier.valueOf(request.tier().toUpperCase()); - BigDecimal amount = tier.getMonthlyPrice(); - String orderName = tier.getTierName() + "_" + "subscription"; - String paymentId = orderName + "_" + UUID.randomUUID(); - + Subscription subscription = subscriptionRepository.findByMemberId(memberId) .orElseThrow(() -> new NotFoundException(ExceptionCode.SUBSCRIPTION_NOT_FOUND)); // 현재 인증 서버와 연동이 안되어있기 때문에 테스트로 검증 - Order order = Order.builder() - .subscription(subscription) - .paymentId(paymentId) - .amount(amount) - .status(PaymentStatus.PENDING) - .build(); - + // 도메인 객체의 팩토리 메서드 사용 + Order order = Order.createForSubscription(subscription, tier); orderRepository.save(order); - return new PaymentPreparationResponse(paymentId, orderName, amount); + return new PaymentPreparationResponse(order.getPaymentId(), order.getOrderName(), order.getAmount()); } + /** + * 결제 후 검증 api 요청 + */ @Transactional - public void verifyAndProcess(PaymentVerificationRequest request) { - processPaymentVerification(request.paymentId(), request.iamPortTransactionId()); + public void verifyAndProcess(PaymentVerificationRequest request, UUID memberId) { + processPaymentVerification(request.paymentId(), request.iamPortTransactionId(), memberId); } @@ -72,20 +67,31 @@ public void verifyAndProcess(PaymentVerificationRequest request) { @Transactional public void verifyAndProcessWebhook(String requestBody) throws IOException { PaymentWebhookRequest webhookRequest = PaymentWebhookRequest.from(requestBody, objectMapper); - processPaymentVerification(webhookRequest.paymentId(), webhookRequest.transactionId()); + if(webhookRequest.isTransactionWebhook()) { + String paymentId = webhookRequest.getPaymentId(); + String transactionId = webhookRequest.getTransactionId(); + processPaymentVerification(paymentId, transactionId, null); // 웹훅은 memberId null로 전달 + } } - private void processPaymentVerification(String paymentId, String iamPortTransactionId) { + // 결제 검증 (memberId가 null이면 웹훅용, 아니면 클라이언트용) + private void processPaymentVerification(String paymentId, String iamPortTransactionId, UUID memberId) { Order order = orderRepository.findByPaymentId(paymentId) .orElseThrow(() -> new NotFoundException(ExceptionCode.ORDER_NOT_FOUND)); - if (order.getStatus() != PaymentStatus.PENDING) { + // 클라이언트 요청인 경우에만 권한 검증 (웹훅은 memberId가 null) + if (memberId != null && !order.getSubscription().getMemberId().equals(memberId)) { + throw new UnauthorizedException(ExceptionCode.UNAUTHORIZED_USER); + } + + // 도메인 객체를 통한 중복 처리 검증 + if (order.isAlreadyProcessed()) { log.info("이미 처리된 결제입니다. paymentId={}", order.getPaymentId()); return; } Payment payment = portOneClient.getPayment().getPayment(iamPortTransactionId).join(); - + if (payment == null) { throw new PaymentVerificationException(ExceptionCode.PAYMENT_VERIFICATION_FAILED); } @@ -97,12 +103,43 @@ private void processPaymentVerification(String paymentId, String iamPortTransact throw new PaymentVerificationException(ExceptionCode.PAYMENT_NOT_RECOGNIZED); } - if (order.getAmount().compareTo(BigDecimal.valueOf(recognizedPayment.getAmount().getTotal())) != 0) { + // 도메인 객체를 통한 결제 금액 검증 + try { + order.validatePaymentAmount(recognizedPayment); + } catch (PaymentVerificationException e) { log.error("결제 금액 불일치. 주문금액={}, 실제결제금액={}, paymentId={}", order.getAmount(), recognizedPayment.getAmount().getTotal(), order.getPaymentId()); - throw new PaymentVerificationException(ExceptionCode.PAYMENT_AMOUNT_MISMATCH); + throw e; } paymentStatusConverter.processPayment(order, payment); + + // 도메인 객체를 통한 빌링키 처리 + if (order.hasBillingKey(recognizedPayment)) { + Subscription subscription = order.getSubscription(); + String billingKey = recognizedPayment.getBillingKey(); + subscription.updateBillingKey(billingKey); + subscriptionRepository.save(subscription); + } + } + + @Transactional + public void cancelSubscription(UUID memberId, CancelSubscriptionRequest request) { + Subscription subscription = subscriptionRepository.findByMemberId(memberId) + .orElseThrow(() -> new NotFoundException(ExceptionCode.SUBSCRIPTION_NOT_FOUND)); + + Order order = orderRepository.findBySubscription(subscription) + .orElseThrow(() -> new NotFoundException(ExceptionCode.ORDER_NOT_FOUND)); + + String paymentId = order.getPaymentId(); + + portOneClient.getPayment().cancelPayment(paymentId, null, null, null, request.reason(), null, null, null, null).join(); + + // 도메인 객체의 비즈니스 메서드 사용 + order.cancel(); + subscription.deactivate(); + + orderRepository.save(order); + subscriptionRepository.save(subscription); } } diff --git a/payment-service/src/main/java/com/synapse/payment_service/service/SubscriptionBillingService.java b/payment-service/src/main/java/com/synapse/payment_service/service/SubscriptionBillingService.java new file mode 100644 index 0000000..787adba --- /dev/null +++ b/payment-service/src/main/java/com/synapse/payment_service/service/SubscriptionBillingService.java @@ -0,0 +1,91 @@ +package com.synapse.payment_service.service; + +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.synapse.payment_service.config.PortOneClientProperties; +import com.synapse.payment_service.domain.Order; +import com.synapse.payment_service.domain.Subscription; +import com.synapse.payment_service.domain.enums.PaymentStatus; +import com.synapse.payment_service.repository.OrderRepository; +import com.synapse.payment_service.repository.SubscriptionRepository; + +import io.portone.sdk.server.PortOneClient; +import io.portone.sdk.server.common.PaymentAmountInput; +import io.portone.sdk.server.payment.PayWithBillingKeyResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Transactional(readOnly = true) +@Service +@RequiredArgsConstructor +@Slf4j +public class SubscriptionBillingService { + private final SubscriptionRepository subscriptionRepository; + private final OrderRepository orderRepository; + private final PortOneClient portOneClient; + private final PortOneClientProperties portOneClientProperties; + + @Transactional + public void processDailySubscriptions() { + // 오늘이 다음 결제일인 모든 활성 구독을 찾는다. + LocalDate today = LocalDate.now(); + ZonedDateTime startOfDay = today.atStartOfDay(ZoneId.systemDefault()); + ZonedDateTime endOfDay = today.plusDays(1).atStartOfDay(ZoneId.systemDefault()); + + List targets = subscriptionRepository.findActiveSubscriptionsDueForRenewal(startOfDay, endOfDay); + for (Subscription subscription : targets) { + chargeWithBillingKey(subscription); + } + } + + private void chargeWithBillingKey(Subscription subscription) { + // Order 객체를 먼저 생성하여 일관된 로직 사용 + Order order = Order.createForSubscription(subscription, subscription.getTier()); + orderRepository.save(order); // PENDING 상태로 저장 + + String billingKey = subscription.getBillingKey(); + PaymentAmountInput amount = new PaymentAmountInput(subscription.getTier().getMonthlyPrice().longValue(), 0L, 0L); + + // 빌링키 결제 요청 + try { + PayWithBillingKeyResponse response = portOneClient.getPayment().payWithBillingKey( + order.getPaymentId(), + billingKey, + portOneClientProperties.channelKey(), + order.getOrderName(), + null, null, amount, null, null, null, null, null, null, null, null, null, null, null, null, null, null + ).join(); + successHandler(response, order, subscription); + log.info("구독 결제 성공. paymentId={}, orderName={}, subscriptionId={}", order.getPaymentId(), order.getOrderName(), subscription.getId()); + } catch (Exception e) { + failureHandler(order, subscription); + log.error("구독 결제 실패. paymentId={}, orderName={}, subscriptionId={}", order.getPaymentId(), order.getOrderName(), subscription.getId()); + } + } + + private void successHandler(PayWithBillingKeyResponse response, Order order, Subscription subscription) { + // 기존 Order 객체 업데이트 + order.updateIamPortTransactionId(response.getPayment().getPgTxId()); + order.markAsPaid(); + + subscription.renewSubscription(subscription.getTier()); + + orderRepository.save(order); + } + + private void failureHandler(Order order, Subscription subscription) { + // 기존 Order 객체 상태를 FAILED로 업데이트 + order.updateStatus(PaymentStatus.FAILED); + orderRepository.save(order); + + // 구독 상태를 PAYMENT_FAILED로 변경하고 retryCount 증가 + subscription.handlePaymentFailure(); + subscriptionRepository.save(subscription); + } +} diff --git a/payment-service/src/main/java/com/synapse/payment_service/service/converter/impl/CancelledPaymentConverter.java b/payment-service/src/main/java/com/synapse/payment_service/service/converter/impl/CancelledPaymentConverter.java index 40150c9..546bf64 100644 --- a/payment-service/src/main/java/com/synapse/payment_service/service/converter/impl/CancelledPaymentConverter.java +++ b/payment-service/src/main/java/com/synapse/payment_service/service/converter/impl/CancelledPaymentConverter.java @@ -24,7 +24,7 @@ public void processPayment(Order order, Payment payment) { log.info("결제 취소 처리 시작. paymentId={}", order.getPaymentId()); // 주문 상태를 취소로 업데이트 - order.updateStatus(PaymentStatus.CANCELLED); + order.updateStatus(PaymentStatus.CANCELED); // 구독 비활성화 order.getSubscription().deactivate(); diff --git a/payment-service/src/main/java/com/synapse/payment_service/service/converter/impl/PaidPaymentConverter.java b/payment-service/src/main/java/com/synapse/payment_service/service/converter/impl/PaidPaymentConverter.java index 2f52ae6..04b1a45 100644 --- a/payment-service/src/main/java/com/synapse/payment_service/service/converter/impl/PaidPaymentConverter.java +++ b/payment-service/src/main/java/com/synapse/payment_service/service/converter/impl/PaidPaymentConverter.java @@ -33,7 +33,8 @@ public void processPayment(Order order, Payment payment) { order.updateStatus(PaymentStatus.PAID); Subscription subscription = order.getSubscription(); - subscription.activate(SubscriptionTier.PRO); + subscription.updateBillingKey(recognizedPayment.getBillingKey()); + subscription.renewSubscription(SubscriptionTier.PRO); log.info("결제 완료 처리 완료. paymentId={}", order.getPaymentId()); } diff --git a/payment-service/src/main/java/com/synapse/payment_service/service/scheduler/SubscriptionScheduler.java b/payment-service/src/main/java/com/synapse/payment_service/service/scheduler/SubscriptionScheduler.java new file mode 100644 index 0000000..39105e8 --- /dev/null +++ b/payment-service/src/main/java/com/synapse/payment_service/service/scheduler/SubscriptionScheduler.java @@ -0,0 +1,62 @@ +package com.synapse.payment_service.service.scheduler; + +import java.time.ZonedDateTime; +import java.util.List; + +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import com.synapse.payment_service.domain.Subscription; +import com.synapse.payment_service.domain.enums.SubscriptionStatus; +import com.synapse.payment_service.repository.SubscriptionRepository; +import com.synapse.payment_service.service.SubscriptionBillingService; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Component +@RequiredArgsConstructor +@Slf4j +public class SubscriptionScheduler { + private final SubscriptionBillingService subscriptionBillingService; + private final SubscriptionRepository subscriptionRepository; + + @Scheduled(cron = "0 0 4 * * *") + public void runDailyBilling() { + log.info("일일 정기 결제 스케줄러를 시작합니다."); + subscriptionBillingService.processDailySubscriptions(); + log.info("일일 정기 결제 스케줄러를 종료합니다."); + } + + @Scheduled(cron = "0 0 0 * * ?") + public void expireSubscriptions() { + log.info("구독 만료 처리 스케줄러를 시작합니다."); + + ZonedDateTime currentTime = ZonedDateTime.now(); + + // CANCELED 또는 PAYMENT_FAILED 상태이며 만료일이 지난 구독들을 조회 + List expiredSubscriptions = subscriptionRepository.findByStatusInAndExpiresAtBefore( + List.of(SubscriptionStatus.CANCELED, SubscriptionStatus.PAYMENT_FAILED), + currentTime + ); + + if (expiredSubscriptions.isEmpty()) { + log.info("만료 처리할 구독이 없습니다."); + return; + } + + log.info("만료 처리 대상 구독 수: {}", expiredSubscriptions.size()); + + // 각 구독의 상태를 EXPIRED로 변경하고 autoRenew를 false로 설정 + for (Subscription subscription : expiredSubscriptions) { + subscription.expireSubscription(); + log.debug("구독 ID {} 상태를 EXPIRED로 변경하고 자동 갱신을 비활성화했습니다. (회원 ID: {})", + subscription.getId(), subscription.getMemberId()); + } + + // 변경사항을 데이터베이스에 저장 + subscriptionRepository.saveAll(expiredSubscriptions); + + log.info("구독 만료 처리 스케줄러를 종료합니다. 처리된 구독 수: {}", expiredSubscriptions.size()); + } +} diff --git a/payment-service/src/main/resources/application-local.yml b/payment-service/src/main/resources/application-local.yml index 5f9b488..287bf4d 100644 --- a/payment-service/src/main/resources/application-local.yml +++ b/payment-service/src/main/resources/application-local.yml @@ -23,6 +23,7 @@ iamport: base-url: https://api.portone.io mid-key: ${local-iamport.mid-key} webhook-secret: ${local-iamport.webhook-secret} + channel-key: ${local-iamport.channel-key} logging: level: diff --git a/payment-service/src/test/java/com/synapse/payment_service/domain/SubscriptionTest.java b/payment-service/src/test/java/com/synapse/payment_service/domain/SubscriptionTest.java new file mode 100644 index 0000000..ac62dca --- /dev/null +++ b/payment-service/src/test/java/com/synapse/payment_service/domain/SubscriptionTest.java @@ -0,0 +1,110 @@ +package com.synapse.payment_service.domain; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.ZonedDateTime; +import java.util.UUID; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import com.synapse.payment_service.domain.enums.SubscriptionStatus; +import com.synapse.payment_service.domain.enums.SubscriptionTier; + +@DisplayName("Subscription 엔티티 테스트") +public class SubscriptionTest { + + private Subscription subscription; + private UUID memberId; + + @BeforeEach + void setUp() { + memberId = UUID.randomUUID(); + subscription = Subscription.builder() + .memberId(memberId) + .tier(SubscriptionTier.PRO) + .remainingChatCredits(100) + .expiresAt(ZonedDateTime.now().plusMonths(1)) + .status(SubscriptionStatus.ACTIVE) + .build(); + } + + @Test + @DisplayName("handlePaymentFailure() 호출 시 상태가 PAYMENT_FAILED로 변경되고 retryCount가 1 증가한다") + void handlePaymentFailure_shouldChangeStatusAndIncrementRetryCount() { + // given + assertThat(subscription.getStatus()).isEqualTo(SubscriptionStatus.ACTIVE); + assertThat(subscription.getRetryCount()).isEqualTo(0); + + // when + subscription.handlePaymentFailure(); + + // then + assertThat(subscription.getStatus()).isEqualTo(SubscriptionStatus.PAYMENT_FAILED); + assertThat(subscription.getRetryCount()).isEqualTo(1); + } + + @Test + @DisplayName("handlePaymentFailure() 여러 번 호출 시 retryCount가 누적된다") + void handlePaymentFailure_multipleCallsShouldAccumulateRetryCount() { + // given + assertThat(subscription.getRetryCount()).isEqualTo(0); + + // when + subscription.handlePaymentFailure(); + subscription.handlePaymentFailure(); + subscription.handlePaymentFailure(); + + // then + assertThat(subscription.getStatus()).isEqualTo(SubscriptionStatus.PAYMENT_FAILED); + assertThat(subscription.getRetryCount()).isEqualTo(3); + } + + @Test + @DisplayName("이미 PAYMENT_FAILED 상태인 구독에서 handlePaymentFailure() 호출 시 retryCount만 증가한다") + void handlePaymentFailure_alreadyFailedStatus_shouldOnlyIncrementRetryCount() { + // given + subscription.handlePaymentFailure(); // 첫 번째 실패 + assertThat(subscription.getStatus()).isEqualTo(SubscriptionStatus.PAYMENT_FAILED); + assertThat(subscription.getRetryCount()).isEqualTo(1); + + // when + subscription.handlePaymentFailure(); // 두 번째 실패 + + // then + assertThat(subscription.getStatus()).isEqualTo(SubscriptionStatus.PAYMENT_FAILED); + assertThat(subscription.getRetryCount()).isEqualTo(2); + } + + @Test + @DisplayName("새로 생성된 구독의 retryCount 기본값은 0이다") + void newSubscription_shouldHaveZeroRetryCount() { + // given & when + Subscription newSubscription = Subscription.builder() + .memberId(UUID.randomUUID()) + .tier(SubscriptionTier.FREE) + .remainingChatCredits(10) + .expiresAt(ZonedDateTime.now().plusMonths(1)) + .status(SubscriptionStatus.ACTIVE) + .build(); + + // then + assertThat(newSubscription.getRetryCount()).isEqualTo(0); + } + + @Test + @DisplayName("CANCELED 상태에서 handlePaymentFailure() 호출 시 상태가 PAYMENT_FAILED로 변경된다") + void handlePaymentFailure_fromCanceledStatus_shouldChangeToPaymentFailed() { + // given + subscription.deactivate(); // CANCELED 상태로 변경 + assertThat(subscription.getStatus()).isEqualTo(SubscriptionStatus.CANCELED); + + // when + subscription.handlePaymentFailure(); + + // then + assertThat(subscription.getStatus()).isEqualTo(SubscriptionStatus.PAYMENT_FAILED); + assertThat(subscription.getRetryCount()).isEqualTo(1); + } +} \ No newline at end of file diff --git a/payment-service/src/test/java/com/synapse/payment_service/repository/SubscriptionRepositoryTest.java b/payment-service/src/test/java/com/synapse/payment_service/repository/SubscriptionRepositoryTest.java new file mode 100644 index 0000000..d589755 --- /dev/null +++ b/payment-service/src/test/java/com/synapse/payment_service/repository/SubscriptionRepositoryTest.java @@ -0,0 +1,386 @@ +package com.synapse.payment_service.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.LocalDate; +import java.time.ZonedDateTime; +import java.util.List; +import java.util.UUID; + +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.orm.jpa.DataJpaTest; +import org.springframework.test.context.ActiveProfiles; + +import com.synapse.payment_service.domain.Subscription; +import com.synapse.payment_service.domain.enums.SubscriptionStatus; +import com.synapse.payment_service.domain.enums.SubscriptionTier; + +@DataJpaTest +@ActiveProfiles("test") +@DisplayName("SubscriptionRepository 테스트") +class SubscriptionRepositoryTest { + + @Autowired + private SubscriptionRepository subscriptionRepository; + + private LocalDate targetDate; + private LocalDate pastDate; + private LocalDate futureDate; + + @BeforeEach + void setUp() { + targetDate = LocalDate.of(2024, 1, 15); + pastDate = targetDate.minusDays(1); // 2024-01-14 + futureDate = targetDate.plusDays(1); // 2024-01-16 + } + + @Test + @DisplayName("정확히 해당 날짜에 만료되는 활성 구독만 조회한다") + void findActiveSubscriptionsDueForRenewal_shouldReturnOnlyExactDateMatches() { + // given + // 정확히 targetDate에 만료되는 구독 (조회되어야 함) + Subscription exactDateSubscription = createActiveSubscription(targetDate.atStartOfDay().atZone(ZonedDateTime.now().getZone())); + + // 과거 날짜에 만료된 구독 (조회되지 않아야 함) + Subscription pastDateSubscription = createActiveSubscription(pastDate.atStartOfDay().atZone(ZonedDateTime.now().getZone())); + + // 미래 날짜에 만료되는 구독 (조회되지 않아야 함) + Subscription futureDateSubscription = createActiveSubscription(futureDate.atStartOfDay().atZone(ZonedDateTime.now().getZone())); + + subscriptionRepository.saveAll(List.of(exactDateSubscription, pastDateSubscription, futureDateSubscription)); + + // when + ZonedDateTime startOfDay = targetDate.atStartOfDay(ZonedDateTime.now().getZone()); + ZonedDateTime endOfDay = targetDate.plusDays(1).atStartOfDay(ZonedDateTime.now().getZone()); + List result = subscriptionRepository.findActiveSubscriptionsDueForRenewal(startOfDay, endOfDay); + + // then + assertThat(result).hasSize(1); + assertThat(result.get(0).getId()).isEqualTo(exactDateSubscription.getId()); + } + + @Test + @DisplayName("과거 날짜에 만료된 구독은 조회되지 않는다") + void findActiveSubscriptionsDueForRenewal_shouldNotReturnPastDueSubscriptions() { + // given + // 과거 여러 날짜에 만료된 구독들 + Subscription pastSubscription1 = createActiveSubscription(pastDate.minusDays(5).atStartOfDay().atZone(ZonedDateTime.now().getZone())); + Subscription pastSubscription2 = createActiveSubscription(pastDate.minusDays(10).atStartOfDay().atZone(ZonedDateTime.now().getZone())); + Subscription pastSubscription3 = createActiveSubscription(pastDate.atStartOfDay().atZone(ZonedDateTime.now().getZone())); + + subscriptionRepository.saveAll(List.of(pastSubscription1, pastSubscription2, pastSubscription3)); + + // when + ZonedDateTime startOfDay = targetDate.atStartOfDay(ZonedDateTime.now().getZone()); + ZonedDateTime endOfDay = targetDate.plusDays(1).atStartOfDay(ZonedDateTime.now().getZone()); + List result = subscriptionRepository.findActiveSubscriptionsDueForRenewal(startOfDay, endOfDay); + + // then + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("미래 날짜에 만료되는 구독은 조회되지 않는다") + void findActiveSubscriptionsDueForRenewal_shouldNotReturnFutureSubscriptions() { + // given + // 미래 여러 날짜에 만료되는 구독들 + Subscription futureSubscription1 = createActiveSubscription(futureDate.plusDays(1).atStartOfDay().atZone(ZonedDateTime.now().getZone())); + Subscription futureSubscription2 = createActiveSubscription(futureDate.plusDays(10).atStartOfDay().atZone(ZonedDateTime.now().getZone())); + Subscription futureSubscription3 = createActiveSubscription(futureDate.atStartOfDay().atZone(ZonedDateTime.now().getZone())); + + subscriptionRepository.saveAll(List.of(futureSubscription1, futureSubscription2, futureSubscription3)); + + // when + ZonedDateTime startOfDay = targetDate.atStartOfDay(ZonedDateTime.now().getZone()); + ZonedDateTime endOfDay = targetDate.plusDays(1).atStartOfDay(ZonedDateTime.now().getZone()); + List result = subscriptionRepository.findActiveSubscriptionsDueForRenewal(startOfDay, endOfDay); + + // then + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("ACTIVE 상태가 아닌 구독은 조회되지 않는다") + void findActiveSubscriptionsDueForRenewal_shouldNotReturnInactiveSubscriptions() { + // given + Subscription canceledSubscription = Subscription.builder() + .memberId(UUID.randomUUID()) + .tier(SubscriptionTier.PRO) + .remainingChatCredits(SubscriptionTier.PRO.getMaxRequestCount()) + .expiresAt(targetDate.atStartOfDay().atZone(ZonedDateTime.now().getZone())) + .status(SubscriptionStatus.CANCELED) + .build(); + canceledSubscription.updateBillingKey("test-billing-key"); + + Subscription paymentFailedSubscription = Subscription.builder() + .memberId(UUID.randomUUID()) + .tier(SubscriptionTier.PRO) + .remainingChatCredits(SubscriptionTier.PRO.getMaxRequestCount()) + .expiresAt(targetDate.atStartOfDay().atZone(ZonedDateTime.now().getZone())) + .status(SubscriptionStatus.PAYMENT_FAILED) + .build(); + paymentFailedSubscription.updateBillingKey("test-billing-key-2"); + + subscriptionRepository.saveAll(List.of(canceledSubscription, paymentFailedSubscription)); + + // when + ZonedDateTime startOfDay = targetDate.atStartOfDay(ZonedDateTime.now().getZone()); + ZonedDateTime endOfDay = targetDate.plusDays(1).atStartOfDay(ZonedDateTime.now().getZone()); + List result = subscriptionRepository.findActiveSubscriptionsDueForRenewal(startOfDay, endOfDay); + + // then + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("autoRenew가 false인 구독은 조회되지 않는다") + void findActiveSubscriptionsDueForRenewal_shouldNotReturnNonAutoRenewSubscriptions() { + // given + // autoRenew가 false인 구독 생성 (CANCELED 상태) + Subscription subscription = createActiveSubscription(targetDate.atStartOfDay().atZone(ZonedDateTime.now().getZone())); + subscription.deactivate(); // autoRenew를 false로 설정하고 상태를 CANCELED로 변경 + + subscriptionRepository.save(subscription); + + // when + ZonedDateTime startOfDay = targetDate.atStartOfDay(ZonedDateTime.now().getZone()); + ZonedDateTime endOfDay = targetDate.plusDays(1).atStartOfDay(ZonedDateTime.now().getZone()); + List result = subscriptionRepository.findActiveSubscriptionsDueForRenewal(startOfDay, endOfDay); + + // then + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("billingKey가 null인 구독은 조회되지 않는다") + void findActiveSubscriptionsDueForRenewal_shouldNotReturnSubscriptionsWithoutBillingKey() { + // given + Subscription subscriptionWithoutBillingKey = Subscription.builder() + .memberId(UUID.randomUUID()) + .tier(SubscriptionTier.PRO) + .remainingChatCredits(SubscriptionTier.PRO.getMaxRequestCount()) + .expiresAt(targetDate.atStartOfDay().atZone(ZonedDateTime.now().getZone())) + .status(SubscriptionStatus.ACTIVE) + .build(); + // billingKey를 설정하지 않음 (null 상태) + + subscriptionRepository.save(subscriptionWithoutBillingKey); + + // when + ZonedDateTime startOfDay = targetDate.atStartOfDay(ZonedDateTime.now().getZone()); + ZonedDateTime endOfDay = targetDate.plusDays(1).atStartOfDay(ZonedDateTime.now().getZone()); + List result = subscriptionRepository.findActiveSubscriptionsDueForRenewal(startOfDay, endOfDay); + + // then + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("모든 조건을 만족하는 여러 구독이 모두 조회된다") + void findActiveSubscriptionsDueForRenewal_shouldReturnAllValidSubscriptions() { + // given + Subscription subscription1 = createActiveSubscription(targetDate.atStartOfDay().atZone(ZonedDateTime.now().getZone())); + Subscription subscription2 = createActiveSubscription(targetDate.atTime(12, 30).atZone(ZonedDateTime.now().getZone())); + Subscription subscription3 = createActiveSubscription(targetDate.atTime(23, 59).atZone(ZonedDateTime.now().getZone())); + + subscriptionRepository.saveAll(List.of(subscription1, subscription2, subscription3)); + + // when + ZonedDateTime startOfDay = targetDate.atStartOfDay(ZonedDateTime.now().getZone()); + ZonedDateTime endOfDay = targetDate.plusDays(1).atStartOfDay(ZonedDateTime.now().getZone()); + List result = subscriptionRepository.findActiveSubscriptionsDueForRenewal(startOfDay, endOfDay); + + // then + assertThat(result).hasSize(3); + assertThat(result).extracting(Subscription::getId) + .containsExactlyInAnyOrder(subscription1.getId(), subscription2.getId(), subscription3.getId()); + } + + @Test + @DisplayName("CANCELED 상태이고 만료일이 지난 구독을 조회한다") + void findByStatusInAndExpiresAtBefore_shouldReturnCanceledExpiredSubscriptions() { + // given + ZonedDateTime currentTime = ZonedDateTime.now(); + ZonedDateTime pastTime = currentTime.minusDays(1); + ZonedDateTime futureTime = currentTime.plusDays(1); + + // CANCELED 상태이고 만료일이 지난 구독 (조회되어야 함) + Subscription canceledExpiredSubscription = createSubscriptionWithStatus(SubscriptionStatus.CANCELED, pastTime); + + // CANCELED 상태이지만 아직 만료되지 않은 구독 (조회되지 않아야 함) + Subscription canceledNotExpiredSubscription = createSubscriptionWithStatus(SubscriptionStatus.CANCELED, futureTime); + + // ACTIVE 상태이고 만료일이 지난 구독 (조회되지 않아야 함) + Subscription activeExpiredSubscription = createSubscriptionWithStatus(SubscriptionStatus.ACTIVE, pastTime); + + subscriptionRepository.saveAll(List.of(canceledExpiredSubscription, canceledNotExpiredSubscription, activeExpiredSubscription)); + + // when + List result = subscriptionRepository.findByStatusInAndExpiresAtBefore( + List.of(SubscriptionStatus.CANCELED, SubscriptionStatus.PAYMENT_FAILED), + currentTime + ); + + // then + assertThat(result).hasSize(1); + assertThat(result.get(0).getId()).isEqualTo(canceledExpiredSubscription.getId()); + assertThat(result.get(0).getStatus()).isEqualTo(SubscriptionStatus.CANCELED); + } + + @Test + @DisplayName("PAYMENT_FAILED 상태이고 만료일이 지난 구독을 조회한다") + void findByStatusInAndExpiresAtBefore_shouldReturnPaymentFailedExpiredSubscriptions() { + // given + ZonedDateTime currentTime = ZonedDateTime.now(); + ZonedDateTime pastTime = currentTime.minusDays(1); + ZonedDateTime futureTime = currentTime.plusDays(1); + + // PAYMENT_FAILED 상태이고 만료일이 지난 구독 (조회되어야 함) + Subscription paymentFailedExpiredSubscription = createSubscriptionWithStatus(SubscriptionStatus.PAYMENT_FAILED, pastTime); + + // PAYMENT_FAILED 상태이지만 아직 만료되지 않은 구독 (조회되지 않아야 함) + Subscription paymentFailedNotExpiredSubscription = createSubscriptionWithStatus(SubscriptionStatus.PAYMENT_FAILED, futureTime); + + subscriptionRepository.saveAll(List.of(paymentFailedExpiredSubscription, paymentFailedNotExpiredSubscription)); + + // when + List result = subscriptionRepository.findByStatusInAndExpiresAtBefore( + List.of(SubscriptionStatus.CANCELED, SubscriptionStatus.PAYMENT_FAILED), + currentTime + ); + + // then + assertThat(result).hasSize(1); + assertThat(result.get(0).getId()).isEqualTo(paymentFailedExpiredSubscription.getId()); + assertThat(result.get(0).getStatus()).isEqualTo(SubscriptionStatus.PAYMENT_FAILED); + } + + @Test + @DisplayName("CANCELED와 PAYMENT_FAILED 상태 모두에서 만료일이 지난 구독들을 조회한다") + void findByStatusInAndExpiresAtBefore_shouldReturnBothCanceledAndPaymentFailedExpiredSubscriptions() { + // given + ZonedDateTime currentTime = ZonedDateTime.now(); + ZonedDateTime pastTime1 = currentTime.minusDays(1); + ZonedDateTime pastTime2 = currentTime.minusDays(2); + ZonedDateTime pastTime3 = currentTime.minusDays(3); + + // 다양한 상태와 만료일을 가진 구독들 + Subscription canceledExpired1 = createSubscriptionWithStatus(SubscriptionStatus.CANCELED, pastTime1); + Subscription canceledExpired2 = createSubscriptionWithStatus(SubscriptionStatus.CANCELED, pastTime2); + Subscription paymentFailedExpired1 = createSubscriptionWithStatus(SubscriptionStatus.PAYMENT_FAILED, pastTime1); + Subscription paymentFailedExpired2 = createSubscriptionWithStatus(SubscriptionStatus.PAYMENT_FAILED, pastTime3); + + subscriptionRepository.saveAll(List.of(canceledExpired1, canceledExpired2, paymentFailedExpired1, paymentFailedExpired2)); + + // when + List result = subscriptionRepository.findByStatusInAndExpiresAtBefore( + List.of(SubscriptionStatus.CANCELED, SubscriptionStatus.PAYMENT_FAILED), + currentTime + ); + + // then + assertThat(result).hasSize(4); + assertThat(result).extracting(Subscription::getId) + .containsExactlyInAnyOrder( + canceledExpired1.getId(), + canceledExpired2.getId(), + paymentFailedExpired1.getId(), + paymentFailedExpired2.getId() + ); + } + + @Test + @DisplayName("다른 상태의 구독은 조회되지 않는다") + void findByStatusInAndExpiresAtBefore_shouldNotReturnOtherStatusSubscriptions() { + // given + ZonedDateTime currentTime = ZonedDateTime.now(); + ZonedDateTime pastTime = currentTime.minusDays(1); + + // 다른 상태의 만료된 구독들 (조회되지 않아야 함) + Subscription activeExpiredSubscription = createSubscriptionWithStatus(SubscriptionStatus.ACTIVE, pastTime); + Subscription expiredExpiredSubscription = createSubscriptionWithStatus(SubscriptionStatus.EXPIRED, pastTime); + + subscriptionRepository.saveAll(List.of(activeExpiredSubscription, expiredExpiredSubscription)); + + // when + List result = subscriptionRepository.findByStatusInAndExpiresAtBefore( + List.of(SubscriptionStatus.CANCELED, SubscriptionStatus.PAYMENT_FAILED), + currentTime + ); + + // then + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("만료일이 현재 시간과 같거나 이후인 구독은 조회되지 않는다") + void findByStatusInAndExpiresAtBefore_shouldNotReturnCurrentOrFutureSubscriptions() { + // given + ZonedDateTime currentTime = ZonedDateTime.now(); + ZonedDateTime futureTime = currentTime.plusDays(1); + + // 현재 시간과 같은 만료일을 가진 구독 + Subscription canceledCurrentSubscription = createSubscriptionWithStatus(SubscriptionStatus.CANCELED, currentTime); + + // 미래 만료일을 가진 구독 + Subscription canceledFutureSubscription = createSubscriptionWithStatus(SubscriptionStatus.CANCELED, futureTime); + + subscriptionRepository.saveAll(List.of(canceledCurrentSubscription, canceledFutureSubscription)); + + // when + List result = subscriptionRepository.findByStatusInAndExpiresAtBefore( + List.of(SubscriptionStatus.CANCELED, SubscriptionStatus.PAYMENT_FAILED), + currentTime + ); + + // then + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("빈 상태 목록으로 조회하면 빈 결과를 반환한다") + void findByStatusInAndExpiresAtBefore_shouldReturnEmptyForEmptyStatusList() { + // given + ZonedDateTime currentTime = ZonedDateTime.now(); + ZonedDateTime pastTime = currentTime.minusDays(1); + + Subscription canceledExpiredSubscription = createSubscriptionWithStatus(SubscriptionStatus.CANCELED, pastTime); + subscriptionRepository.save(canceledExpiredSubscription); + + // when + List result = subscriptionRepository.findByStatusInAndExpiresAtBefore( + List.of(), + currentTime + ); + + // then + assertThat(result).isEmpty(); + } + + private Subscription createActiveSubscription(ZonedDateTime expiresAt) { + Subscription subscription = Subscription.builder() + .memberId(UUID.randomUUID()) + .tier(SubscriptionTier.PRO) + .remainingChatCredits(SubscriptionTier.PRO.getMaxRequestCount()) + .expiresAt(expiresAt) + .status(SubscriptionStatus.ACTIVE) + .build(); + subscription.updateBillingKey("test-billing-key-" + UUID.randomUUID()); + return subscription; + } + + private Subscription createSubscriptionWithStatus(SubscriptionStatus status, ZonedDateTime expiresAt) { + Subscription subscription = Subscription.builder() + .memberId(UUID.randomUUID()) + .tier(SubscriptionTier.PRO) + .remainingChatCredits(SubscriptionTier.PRO.getMaxRequestCount()) + .expiresAt(expiresAt) + .status(status) + .build(); + subscription.updateBillingKey("test-billing-key-" + UUID.randomUUID()); + return subscription; + } +} \ No newline at end of file diff --git a/payment-service/src/test/java/com/synapse/payment_service/service/PaymentServiceTest.java b/payment-service/src/test/java/com/synapse/payment_service/service/PaymentServiceTest.java index 0b7db65..8dabbd1 100644 --- a/payment-service/src/test/java/com/synapse/payment_service/service/PaymentServiceTest.java +++ b/payment-service/src/test/java/com/synapse/payment_service/service/PaymentServiceTest.java @@ -94,7 +94,11 @@ void preparePayment_success() { @Test @DisplayName("결제 검증 성공: 위변조가 없는 결제 건에 대해 구독 상태를 성공적으로 업데이트한다") void verifyAndProcess_success() { - Subscription mockSubscription = Subscription.builder().memberId(memberId).build(); + Subscription mockSubscription = Subscription.builder() + .memberId(memberId) + .tier(SubscriptionTier.FREE) + .build(); + // given Order pendingOrder = Order.builder() .paymentId(paymentId) @@ -120,12 +124,12 @@ void verifyAndProcess_success() { doAnswer(invocation -> { Order order = invocation.getArgument(0); order.updateStatus(PaymentStatus.PAID); - order.getSubscription().activate(SubscriptionTier.PRO); + order.getSubscription().renewSubscription(SubscriptionTier.PRO); return null; }).when(paymentStatusConverter).processPayment(any(Order.class), any(Payment.class)); // when - paymentService.verifyAndProcess(new PaymentVerificationRequest(paymentId, iamPortTransactionId)); + paymentService.verifyAndProcess(new PaymentVerificationRequest(paymentId, iamPortTransactionId), memberId); // then assertThat(pendingOrder.getStatus()).isEqualTo(PaymentStatus.PAID); @@ -138,7 +142,11 @@ void verifyAndProcess_success() { @DisplayName("실제 DelegatingPaymentStatusConverter를 사용한 결제 검증 테스트") void verifyAndProcess_withRealDelegatingConverter() { // given - Subscription mockSubscription = Subscription.builder().memberId(memberId).build(); + Subscription mockSubscription = Subscription.builder() + .memberId(memberId) + .tier(SubscriptionTier.FREE) + .build(); + Order pendingOrder = Order.builder() .paymentId(paymentId) .amount(new BigDecimal("100000")) @@ -168,7 +176,7 @@ void verifyAndProcess_withRealDelegatingConverter() { // when paymentServiceWithRealConverter.verifyAndProcess( - new PaymentVerificationRequest(paymentId, iamPortTransactionId)); + new PaymentVerificationRequest(paymentId, iamPortTransactionId), memberId); // then - 실제 PaidPaymentConverter 로직에 의한 상태 변경 검증 assertThat(pendingOrder.getStatus()).isEqualTo(PaymentStatus.PAID); diff --git a/payment-service/src/test/java/com/synapse/payment_service/service/SubscriptionBillingServiceTest.java b/payment-service/src/test/java/com/synapse/payment_service/service/SubscriptionBillingServiceTest.java new file mode 100644 index 0000000..1773920 --- /dev/null +++ b/payment-service/src/test/java/com/synapse/payment_service/service/SubscriptionBillingServiceTest.java @@ -0,0 +1,222 @@ +package com.synapse.payment_service.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.never; + +import java.time.ZonedDateTime; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +import com.synapse.payment_service.config.PortOneClientProperties; +import com.synapse.payment_service.domain.Order; +import com.synapse.payment_service.domain.Subscription; +import com.synapse.payment_service.domain.enums.PaymentStatus; +import com.synapse.payment_service.domain.enums.SubscriptionStatus; +import com.synapse.payment_service.domain.enums.SubscriptionTier; +import com.synapse.payment_service.repository.OrderRepository; +import com.synapse.payment_service.repository.SubscriptionRepository; + +import io.portone.sdk.server.PortOneClient; +import io.portone.sdk.server.common.PaymentAmountInput; +import io.portone.sdk.server.payment.PaymentClient; +import io.portone.sdk.server.payment.BillingKeyPaymentSummary; +import io.portone.sdk.server.payment.PayWithBillingKeyResponse; + +@ExtendWith(MockitoExtension.class) +public class SubscriptionBillingServiceTest { + @InjectMocks + private SubscriptionBillingService subscriptionBillingService; + + @Mock + private SubscriptionRepository subscriptionRepository; + @Mock + private OrderRepository orderRepository; + @Mock + private PortOneClient portoneClient; + @Mock + private PaymentClient paymentClient; + @Mock + private PortOneClientProperties portOneClientProperties; + + @Test + @DisplayName("정기 결제 성공: 만료일이 된 구독에 대해 빌링키 결제를 성공하고 상태를 갱신한다") + void processDailySubscriptions_success() { + // given + Subscription mockSubscription = mock(Subscription.class); + given(mockSubscription.getBillingKey()).willReturn("test-billing-key"); + given(mockSubscription.getTier()).willReturn(SubscriptionTier.PRO); + given(mockSubscription.getId()).willReturn(1L); + + List targets = List.of(mockSubscription); + given(subscriptionRepository.findActiveSubscriptionsDueForRenewal(any(ZonedDateTime.class), any(ZonedDateTime.class))).willReturn(targets); + + // PortOne SDK의 응답을 모의 처리 + PayWithBillingKeyResponse mockResponse = mock(PayWithBillingKeyResponse.class); + BillingKeyPaymentSummary mockPaymentResult = mock(BillingKeyPaymentSummary.class); + + given(portoneClient.getPayment()).willReturn(paymentClient); + given(portOneClientProperties.channelKey()).willReturn("test-channel-key"); + + // CompletableFuture가 성공적으로 완료되도록 설정 + CompletableFuture successFuture = CompletableFuture.completedFuture(mockResponse); + + // payWithBillingKey 호출이 성공하는 CompletableFuture를 반환하도록 설정 + given(paymentClient.payWithBillingKey( + anyString(), anyString(), anyString(), anyString(), + any(), any(), any(PaymentAmountInput.class), any(), + any(), any(), any(), any(), + any(), any(), any(), any(), + any(), any(), any(), any(), any() + )).willReturn(successFuture); + + // mock 응답 설정 - 모든 필요한 값들을 완벽하게 설정 + given(mockResponse.getPayment()).willReturn(mockPaymentResult); + given(mockPaymentResult.getPgTxId()).willReturn("test-pg-tx-id"); + + // when + subscriptionBillingService.processDailySubscriptions(); + + // then + // 성공 핸들러가 호출되어 renewSubscription이 호출되었는지 확인 + verify(mockSubscription, times(1)).renewSubscription(SubscriptionTier.PRO); + + // Order가 2번 저장되었는지 확인 (PENDING -> PAID) + verify(orderRepository, times(2)).save(any(Order.class)); + + // 실패 핸들러는 호출되지 않았음을 확인 + verify(mockSubscription, never()).handlePaymentFailure(); + verify(subscriptionRepository, never()).save(mockSubscription); + } + + @Test + @DisplayName("정기 결제 실패: PortOne API 호출이 실패하면, 실패 주문을 저장하고 구독 상태를 PAYMENT_FAILED로 변경한다") + void processDailySubscriptions_fail() { + // given + Subscription mockSubscription = mock(Subscription.class); + given(mockSubscription.getBillingKey()).willReturn("test-billing-key"); + given(mockSubscription.getTier()).willReturn(SubscriptionTier.PRO); + given(mockSubscription.getId()).willReturn(1L); + + List targets = List.of(mockSubscription); + given(subscriptionRepository.findActiveSubscriptionsDueForRenewal(any(ZonedDateTime.class), any(ZonedDateTime.class))).willReturn(targets); + + // PortOne SDK가 예외를 던지는 상황을 모의 처리 + given(portoneClient.getPayment()).willReturn(paymentClient); + given(portOneClientProperties.channelKey()).willReturn("test-channel-key"); + given(paymentClient.payWithBillingKey( + anyString(), anyString(), anyString(), anyString(), + any(), anyString(), any(PaymentAmountInput.class), any(), + any(), any(), any(), any(), + any(), any(), any(), any(), + any(), any(), anyString(), any(), any() + )).willReturn(CompletableFuture.failedFuture(new RuntimeException("PG사 연동 실패"))); + + // when + subscriptionBillingService.processDailySubscriptions(); + + // then + // Order가 2번 저장되었는지 확인 (PENDING -> FAILED) + verify(orderRepository, times(2)).save(any(Order.class)); + + // handlePaymentFailure() 메서드가 호출되었는지 검증 + verify(mockSubscription, times(1)).handlePaymentFailure(); + + // 변경된 subscription이 저장되었는지 검증 + verify(subscriptionRepository, times(1)).save(mockSubscription); + + // renewSubscription은 호출되지 않았음을 검증 (실패했으므로) + verify(mockSubscription, never()).renewSubscription(any(SubscriptionTier.class)); + } +} + +/** + * 실제 데이터베이스를 사용하는 통합 테스트 + */ +@SpringBootTest +@ActiveProfiles("test") +@Transactional +class SubscriptionBillingServiceIntegrationTest { + + @Autowired + private SubscriptionRepository subscriptionRepository; + + @Autowired + private OrderRepository orderRepository; + + @Test + @DisplayName("통합 테스트: failureHandler 호출 시 구독 상태가 PAYMENT_FAILED로 변경되고 retryCount가 1 증가한다") + void failureHandler_integrationTest_shouldUpdateSubscriptionStatusAndRetryCount() { + // given + UUID memberId = UUID.randomUUID(); + Subscription subscription = Subscription.builder() + .memberId(memberId) + .tier(SubscriptionTier.PRO) + .remainingChatCredits(SubscriptionTier.PRO.getMaxRequestCount()) + .status(SubscriptionStatus.ACTIVE) + .expiresAt(ZonedDateTime.now().plusDays(30)) + .build(); + + // billingKey 설정 + subscription.updateBillingKey("test-billing-key"); + + Subscription savedSubscription = subscriptionRepository.save(subscription); + + // 초기 상태 확인 + assertThat(savedSubscription.getStatus()).isEqualTo(SubscriptionStatus.ACTIVE); + assertThat(savedSubscription.getRetryCount()).isEqualTo(0); + + // when - failureHandler 로직을 직접 실행 + String paymentId = "test-payment-id"; + + // 결제 실패 정보 저장 (failureHandler 로직과 동일) + Order order = Order.builder() + .subscription(savedSubscription) + .paymentId(paymentId) + .amount(savedSubscription.getTier().getMonthlyPrice()) + .status(PaymentStatus.FAILED) + .build(); + orderRepository.save(order); + + // 구독 상태를 PAYMENT_FAILED로 변경하고 retryCount 증가 (failureHandler 로직과 동일) + savedSubscription.handlePaymentFailure(); + subscriptionRepository.save(savedSubscription); + + // then + // 데이터베이스에서 다시 조회하여 상태 확인 + Subscription updatedSubscription = subscriptionRepository.findById(savedSubscription.getId()).orElseThrow(); + + // 구독 상태가 PAYMENT_FAILED로 변경되었는지 확인 + assertThat(updatedSubscription.getStatus()).isEqualTo(SubscriptionStatus.PAYMENT_FAILED); + + // retryCount가 1 증가했는지 확인 + assertThat(updatedSubscription.getRetryCount()).isEqualTo(1); + + // 실패한 Order가 저장되었는지 확인 + List orders = orderRepository.findAll(); + assertThat(orders).hasSize(1); + + Order failedOrder = orders.get(0); + assertThat(failedOrder.getStatus()).isEqualTo(PaymentStatus.FAILED); + assertThat(failedOrder.getSubscription().getId()).isEqualTo(savedSubscription.getId()); + assertThat(failedOrder.getAmount()).isEqualTo(SubscriptionTier.PRO.getMonthlyPrice()); + assertThat(failedOrder.getPaymentId()).isEqualTo(paymentId); + } +} diff --git a/payment-service/src/test/java/com/synapse/payment_service/service/scheduler/SubscriptionSchedulerIntegrationTest.java b/payment-service/src/test/java/com/synapse/payment_service/service/scheduler/SubscriptionSchedulerIntegrationTest.java new file mode 100644 index 0000000..59d9395 --- /dev/null +++ b/payment-service/src/test/java/com/synapse/payment_service/service/scheduler/SubscriptionSchedulerIntegrationTest.java @@ -0,0 +1,153 @@ +package com.synapse.payment_service.service.scheduler; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.ZonedDateTime; +import java.util.List; +import java.util.UUID; + +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.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +import com.synapse.payment_service.domain.Subscription; +import com.synapse.payment_service.domain.enums.SubscriptionStatus; +import com.synapse.payment_service.domain.enums.SubscriptionTier; +import com.synapse.payment_service.repository.SubscriptionRepository; + +@SpringBootTest +@ActiveProfiles("test") +@Transactional +@DisplayName("SubscriptionScheduler 통합 테스트") +class SubscriptionSchedulerIntegrationTest { + + @Autowired + private SubscriptionScheduler subscriptionScheduler; + + @Autowired + private SubscriptionRepository subscriptionRepository; + + private Subscription canceledSubscription; + private Subscription paymentFailedSubscription; + private Subscription activeSubscription; + + @BeforeEach + void setUp() { + // 테스트 데이터 정리 + subscriptionRepository.deleteAll(); + + ZonedDateTime pastTime = ZonedDateTime.now().minusDays(1); + ZonedDateTime futureTime = ZonedDateTime.now().plusDays(30); + + // 만료된 CANCELED 구독 생성 + canceledSubscription = Subscription.builder() + .memberId(UUID.randomUUID()) + .tier(SubscriptionTier.PRO) + .remainingChatCredits(100) + .expiresAt(pastTime) + .status(SubscriptionStatus.CANCELED) + .build(); + canceledSubscription.deactivate(); // autoRenew를 false로 설정하지만 테스트를 위해 다시 true로 설정 + + // 만료된 PAYMENT_FAILED 구독 생성 + paymentFailedSubscription = Subscription.builder() + .memberId(UUID.randomUUID()) + .tier(SubscriptionTier.PRO) + .remainingChatCredits(200) + .expiresAt(pastTime) + .status(SubscriptionStatus.PAYMENT_FAILED) + .build(); + + // 만료되지 않은 ACTIVE 구독 생성 (비교 대상) + activeSubscription = Subscription.builder() + .memberId(UUID.randomUUID()) + .tier(SubscriptionTier.FREE) + .remainingChatCredits(100) + .expiresAt(futureTime) + .status(SubscriptionStatus.ACTIVE) + .build(); + + subscriptionRepository.saveAll(List.of(canceledSubscription, paymentFailedSubscription, activeSubscription)); + } + + @Test + @DisplayName("만료된 구독들의 상태를 EXPIRED로 변경하고 autoRenew를 false로 설정한다") + void expireSubscriptions_shouldUpdateExpiredSubscriptionsInDatabase() { + // given + // setUp에서 만료된 구독들이 준비됨 + + // when + subscriptionScheduler.expireSubscriptions(); + + // then + // 만료된 구독들이 EXPIRED 상태로 변경되고 autoRenew가 false가 되었는지 확인 + Subscription updatedCanceledSubscription = subscriptionRepository.findById(canceledSubscription.getId()).orElseThrow(); + Subscription updatedPaymentFailedSubscription = subscriptionRepository.findById(paymentFailedSubscription.getId()).orElseThrow(); + Subscription updatedActiveSubscription = subscriptionRepository.findById(activeSubscription.getId()).orElseThrow(); + + // 만료된 구독들은 EXPIRED 상태가 되고 autoRenew가 false가 되어야 함 + assertThat(updatedCanceledSubscription.getStatus()).isEqualTo(SubscriptionStatus.EXPIRED); + assertThat(updatedCanceledSubscription.isAutoRenew()).isFalse(); + + assertThat(updatedPaymentFailedSubscription.getStatus()).isEqualTo(SubscriptionStatus.EXPIRED); + assertThat(updatedPaymentFailedSubscription.isAutoRenew()).isFalse(); + + // ACTIVE 구독은 변경되지 않아야 함 + assertThat(updatedActiveSubscription.getStatus()).isEqualTo(SubscriptionStatus.ACTIVE); + assertThat(updatedActiveSubscription.isAutoRenew()).isTrue(); + } + + @Test + @DisplayName("만료 대상 구독이 없으면 아무것도 변경하지 않는다") + void expireSubscriptions_shouldNotChangeAnythingWhenNoExpiredSubscriptions() { + // given + subscriptionRepository.deleteAll(); + + // 만료되지 않은 구독만 생성 + Subscription futureSubscription = Subscription.builder() + .memberId(UUID.randomUUID()) + .tier(SubscriptionTier.FREE) + .remainingChatCredits(100) + .expiresAt(ZonedDateTime.now().plusDays(30)) + .status(SubscriptionStatus.CANCELED) + .build(); + subscriptionRepository.save(futureSubscription); + + // when + subscriptionScheduler.expireSubscriptions(); + + // then + Subscription unchangedSubscription = subscriptionRepository.findById(futureSubscription.getId()).orElseThrow(); + assertThat(unchangedSubscription.getStatus()).isEqualTo(SubscriptionStatus.CANCELED); + assertThat(unchangedSubscription.isAutoRenew()).isTrue(); + } + + @Test + @DisplayName("ACTIVE 상태의 만료된 구독은 처리하지 않는다") + void expireSubscriptions_shouldNotProcessActiveExpiredSubscriptions() { + // given + subscriptionRepository.deleteAll(); + + // 만료된 ACTIVE 구독 생성 (이는 처리 대상이 아님) + Subscription expiredActiveSubscription = Subscription.builder() + .memberId(UUID.randomUUID()) + .tier(SubscriptionTier.PRO) + .remainingChatCredits(100) + .expiresAt(ZonedDateTime.now().minusDays(1)) + .status(SubscriptionStatus.ACTIVE) + .build(); + subscriptionRepository.save(expiredActiveSubscription); + + // when + subscriptionScheduler.expireSubscriptions(); + + // then + Subscription unchangedSubscription = subscriptionRepository.findById(expiredActiveSubscription.getId()).orElseThrow(); + assertThat(unchangedSubscription.getStatus()).isEqualTo(SubscriptionStatus.ACTIVE); + assertThat(unchangedSubscription.isAutoRenew()).isTrue(); + } +} \ No newline at end of file diff --git a/payment-service/src/test/java/com/synapse/payment_service/service/scheduler/SubscriptionSchedulerTest.java b/payment-service/src/test/java/com/synapse/payment_service/service/scheduler/SubscriptionSchedulerTest.java new file mode 100644 index 0000000..9fd0eac --- /dev/null +++ b/payment-service/src/test/java/com/synapse/payment_service/service/scheduler/SubscriptionSchedulerTest.java @@ -0,0 +1,116 @@ +package com.synapse.payment_service.service.scheduler; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.time.ZonedDateTime; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.UUID; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.synapse.payment_service.domain.Subscription; +import com.synapse.payment_service.domain.enums.SubscriptionStatus; +import com.synapse.payment_service.domain.enums.SubscriptionTier; +import com.synapse.payment_service.repository.SubscriptionRepository; +import com.synapse.payment_service.service.SubscriptionBillingService; + +@ExtendWith(MockitoExtension.class) +@DisplayName("SubscriptionScheduler 테스트") +class SubscriptionSchedulerTest { + + @Mock + private SubscriptionBillingService subscriptionBillingService; + + @Mock + private SubscriptionRepository subscriptionRepository; + + @InjectMocks + private SubscriptionScheduler subscriptionScheduler; + + private Subscription canceledSubscription; + private Subscription paymentFailedSubscription; + + @BeforeEach + void setUp() { + ZonedDateTime pastTime = ZonedDateTime.now().minusDays(1); + + canceledSubscription = Subscription.builder() + .memberId(UUID.randomUUID()) + .tier(SubscriptionTier.PRO) + .remainingChatCredits(SubscriptionTier.PRO.getMaxRequestCount()) + .expiresAt(pastTime) + .status(SubscriptionStatus.CANCELED) + .build(); + + paymentFailedSubscription = Subscription.builder() + .memberId(UUID.randomUUID()) + .tier(SubscriptionTier.PRO) + .remainingChatCredits(SubscriptionTier.PRO.getMaxRequestCount()) + .expiresAt(pastTime) + .status(SubscriptionStatus.PAYMENT_FAILED) + .build(); + } + + @Test + @DisplayName("만료된 구독들의 상태를 EXPIRED로 변경하고 autoRenew를 false로 설정한다") + void expireSubscriptions_shouldUpdateExpiredSubscriptionsToExpiredStatusAndDisableAutoRenew() { + // given + List expiredSubscriptions = Arrays.asList(canceledSubscription, paymentFailedSubscription); + when(subscriptionRepository.findByStatusInAndExpiresAtBefore(anyList(), any(ZonedDateTime.class))) + .thenReturn(expiredSubscriptions); + + // when + subscriptionScheduler.expireSubscriptions(); + + // then + verify(subscriptionRepository, times(1)).findByStatusInAndExpiresAtBefore( + anyList(), + any(ZonedDateTime.class) + ); + verify(subscriptionRepository, times(1)).saveAll(expiredSubscriptions); + + // 각 구독의 expireSubscription 메서드가 호출되었는지 확인 + // (실제로는 mock 객체이므로 상태 변경을 직접 검증할 수는 없지만, 메서드 호출 로직은 검증됨) + } + + @Test + @DisplayName("만료 대상 구독이 없으면 저장 작업을 수행하지 않는다") + void expireSubscriptions_shouldNotSaveWhenNoExpiredSubscriptions() { + // given + when(subscriptionRepository.findByStatusInAndExpiresAtBefore(anyList(), any(ZonedDateTime.class))) + .thenReturn(Collections.emptyList()); + + // when + subscriptionScheduler.expireSubscriptions(); + + // then + verify(subscriptionRepository, times(1)).findByStatusInAndExpiresAtBefore( + anyList(), + any(ZonedDateTime.class) + ); + verify(subscriptionRepository, never()).saveAll(anyList()); + } + + @Test + @DisplayName("runDailyBilling 메서드가 SubscriptionBillingService를 호출한다") + void runDailyBilling_shouldCallSubscriptionBillingService() { + // when + subscriptionScheduler.runDailyBilling(); + + // then + verify(subscriptionBillingService, times(1)).processDailySubscriptions(); + } +} \ No newline at end of file diff --git a/payment-service/src/test/resources/application-test.yml b/payment-service/src/test/resources/application-test.yml index 3e5f6ab..4e0cac9 100644 --- a/payment-service/src/test/resources/application-test.yml +++ b/payment-service/src/test/resources/application-test.yml @@ -33,11 +33,11 @@ spring: hibernate: format: sql: true - highlight: - sql: true - hbm2ddl: - auto: create - dialect: org.hibernate.dialect.PostgreSQLDialect + highlight: + sql: true + hbm2ddl: + auto: create + dialect: org.hibernate.dialect.PostgreSQLDialect open-in-view: false show-sql: true