Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ public record PortOneClientProperties(
String apiSecret,
String baseUrl,
String midKey,
String webhookSecret
String webhookSecret,
String channelKey
) {

}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -42,9 +43,20 @@ public ResponseEntity<PaymentPreparationResponse> requestPayment(
* @return
*/
@PostMapping("/verify")
public ResponseEntity<Void> verifyPayment(@RequestBody @Valid PaymentVerificationRequest request) {
paymentService.verifyAndProcess(request);
public ResponseEntity<Void> verifyPayment(
@RequestBody @Valid PaymentVerificationRequest request,
@AuthenticationPrincipal UUID memberId
) {
paymentService.verifyAndProcess(request, memberId);
return ResponseEntity.ok().build();
}

@PostMapping("/subscriptions/cancel")
public ResponseEntity<Void> cancelSubscription(
@RequestBody @Valid CancelSubscriptionRequest request,
@AuthenticationPrincipal UUID memberId
) {
paymentService.cancelSubscription(memberId, request);
return ResponseEntity.ok().build();
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -42,21 +43,63 @@ 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;
this.tier = tier;
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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
public enum PaymentStatus {
PAID, // 결제 완료
FAILED, // 결제 실패
CANCELLED, // 결제 취소 (환불)
CANCELED, // 결제 취소 (환불)
PENDING, // 결제 대기
PARTIAL_CANCELLED, // 부분 취소
PAY_PENDING, // 결제 완료 대기
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.synapse.payment_service.dto.request;

import jakarta.validation.constraints.NotBlank;

public record CancelSubscriptionRequest(
@NotBlank(message = "취소 사유는 필수입니다.")
String reason
) {

}
Original file line number Diff line number Diff line change
@@ -1,32 +1,51 @@
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;

import java.io.IOException;
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.");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
package com.synapse.payment_service.exception;

public class PaymentVerificationException extends PaymentException {
public PaymentVerificationException(ExceptionCode exceptionCode) {
super(exceptionCode);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.synapse.payment_service.exception;

public class UnauthorizedException extends PaymentException {
public UnauthorizedException(ExceptionCode exceptionCode) {
super(exceptionCode);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<Order, Long> {
Optional<Order> findByPaymentId(String paymentId);
Optional<Order> findBySubscription(Subscription subscription);
}
Original file line number Diff line number Diff line change
@@ -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<Subscription, Long> {
Optional<Subscription> findByMemberId(UUID memberId);
Optional<Subscription> 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<Subscription> findActiveSubscriptionsDueForRenewal(@Param("startOfDay") ZonedDateTime startOfDay, @Param("endOfDay") ZonedDateTime endOfDay);

/**
* 만료 처리 대상 구독을 조회합니다.
* CANCELED 또는 PAYMENT_FAILED 상태이고, 만료일이 지난 구독을 반환합니다.
*
* @param statuses 조회할 구독 상태 목록 (CANCELED, PAYMENT_FAILED)
* @param currentTime 현재 시간
* @return 만료 처리 대상 구독 목록
*/
List<Subscription> findByStatusInAndExpiresAtBefore(List<SubscriptionStatus> statuses, ZonedDateTime currentTime);
}
Loading
Loading