diff --git a/payment-service/build.gradle b/payment-service/build.gradle index d5f0cdf..a1e163a 100644 --- a/payment-service/build.gradle +++ b/payment-service/build.gradle @@ -19,8 +19,8 @@ repositories { } dependencies { - // Iam port - implementation 'com.github.iamport:iamport-rest-client-java:0.2.23' + // PortOne + implementation 'io.portone:server-sdk:0.19.0' // Security implementation 'org.springframework.boot:spring-boot-starter-security' // Web @@ -31,6 +31,8 @@ dependencies { runtimeOnly 'org.postgresql:postgresql' // OAuth2 Resource Server implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server' + // WebFlux + implementation 'org.springframework.boot:spring-boot-starter-webflux' // Lombok compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' diff --git a/payment-service/src/main/java/com/synapse/payment_service/config/ObjectMapperConfig.java b/payment-service/src/main/java/com/synapse/payment_service/config/ObjectMapperConfig.java new file mode 100644 index 0000000..41f0f24 --- /dev/null +++ b/payment-service/src/main/java/com/synapse/payment_service/config/ObjectMapperConfig.java @@ -0,0 +1,18 @@ +package com.synapse.payment_service.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; + +@Configuration +public class ObjectMapperConfig { + + @Bean + public ObjectMapper objectMapper() { + ObjectMapper objectMapper = new ObjectMapper(); + objectMapper.registerModule(new JavaTimeModule()); + return objectMapper; + } +} diff --git a/payment-service/src/main/java/com/synapse/payment_service/config/PortOneClientConfig.java b/payment-service/src/main/java/com/synapse/payment_service/config/PortOneClientConfig.java new file mode 100644 index 0000000..04c8295 --- /dev/null +++ b/payment-service/src/main/java/com/synapse/payment_service/config/PortOneClientConfig.java @@ -0,0 +1,26 @@ +package com.synapse.payment_service.config; + +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import io.portone.sdk.server.PortOneClient; +import io.portone.sdk.server.webhook.WebhookVerifier; +import lombok.RequiredArgsConstructor; + +@Configuration +@EnableConfigurationProperties(PortOneClientProperties.class) +@RequiredArgsConstructor +public class PortOneClientConfig { + private final PortOneClientProperties properties; + + @Bean + public PortOneClient portOneClient() { + return new PortOneClient(properties.apiSecret(), properties.baseUrl(), properties.midKey()); + } + + @Bean + public WebhookVerifier webhookVerifier() { + return new WebhookVerifier(properties.webhookSecret()); + } +} 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 new file mode 100644 index 0000000..a9b1f44 --- /dev/null +++ b/payment-service/src/main/java/com/synapse/payment_service/config/PortOneClientProperties.java @@ -0,0 +1,13 @@ +package com.synapse.payment_service.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "iamport") +public record PortOneClientProperties( + String apiSecret, + String baseUrl, + String midKey, + String webhookSecret +) { + +} diff --git a/payment-service/src/main/java/com/synapse/payment_service/config/ResourceServerConfig.java b/payment-service/src/main/java/com/synapse/payment_service/config/ResourceServerConfig.java index 2534da3..807fcd0 100644 --- a/payment-service/src/main/java/com/synapse/payment_service/config/ResourceServerConfig.java +++ b/payment-service/src/main/java/com/synapse/payment_service/config/ResourceServerConfig.java @@ -11,14 +11,23 @@ import org.springframework.security.oauth2.server.resource.web.BearerTokenAuthenticationEntryPoint; import org.springframework.security.oauth2.server.resource.web.access.BearerTokenAccessDeniedHandler; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +import com.synapse.payment_service.filter.MemberAuthenticationFilter; + +import lombok.RequiredArgsConstructor; + import org.springframework.security.authorization.AuthorizationManagers; import org.springframework.security.authorization.AuthorityAuthorizationManager; @Configuration(proxyBeanMethods = false) @EnableMethodSecurity @EnableWebSecurity +@RequiredArgsConstructor public class ResourceServerConfig { - + + private final MemberAuthenticationFilter memberAuthenticationFilter; + @Bean public SecurityFilterChain securityResourceServerFilterChain(HttpSecurity http) throws Exception { http @@ -26,14 +35,17 @@ public SecurityFilterChain securityResourceServerFilterChain(HttpSecurity http) .formLogin(form -> form.disable()) .httpBasic(basic -> basic.disable()) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) - .securityMatcher("/api/internal/**") .authorizeHttpRequests(authorize -> authorize - .anyRequest().access(AuthorizationManagers.allOf( + .requestMatchers("/api/webhooks/**").permitAll() // 웹훅은 인증 없이 허용 + .requestMatchers("/api/payments/**").authenticated() // 결제 API는 인증 필요 + .requestMatchers("/api/internal/**").access(AuthorizationManagers.allOf( AuthorityAuthorizationManager.hasAuthority("SCOPE_api.internal"), AuthorityAuthorizationManager.hasAuthority("SCOPE_account:read") )) + .anyRequest().authenticated() ) .oauth2ResourceServer(oauth2 -> oauth2.jwt(withDefaults())) + .addFilterBefore(memberAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) .exceptionHandling(exceptions -> exceptions .authenticationEntryPoint(new BearerTokenAuthenticationEntryPoint()) .accessDeniedHandler(new BearerTokenAccessDeniedHandler()) 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 new file mode 100644 index 0000000..352aaf5 --- /dev/null +++ b/payment-service/src/main/java/com/synapse/payment_service/controller/PaymentController.java @@ -0,0 +1,50 @@ +package com.synapse.payment_service.controller; + +import java.util.UUID; + +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.synapse.payment_service.dto.request.PaymentRequestDto; +import com.synapse.payment_service.dto.request.PaymentVerificationRequest; +import com.synapse.payment_service.dto.response.PaymentPreparationResponse; +import com.synapse.payment_service.service.PaymentService; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping("/api/payments") +@RequiredArgsConstructor +public class PaymentController { + private final PaymentService paymentService; + + /** + * 프론트엔드에서 결제창을 띄우기 전에, + * 결제에 필요한 정보(주문번호, 금액 등)를 생성하고 반환합니다. + */ + @PostMapping("/request") + public ResponseEntity requestPayment( + @RequestBody @Valid PaymentRequestDto request, + @AuthenticationPrincipal UUID memberId + ) { + PaymentPreparationResponse response = paymentService.preparePayment(memberId, request); + return ResponseEntity.ok().body(response); + } + + /** + * 아임포트에서 결제 완료 후 호출되는 메서드로 실제로 결제가 되었는지 확인하는 API 입니다. + * @param request + * @return + */ + @PostMapping("/verify") + public ResponseEntity verifyPayment(@RequestBody @Valid PaymentVerificationRequest request) { + paymentService.verifyAndProcess(request); + return ResponseEntity.ok().build(); + } + +} diff --git a/payment-service/src/main/java/com/synapse/payment_service/controller/WebHookController.java b/payment-service/src/main/java/com/synapse/payment_service/controller/WebHookController.java new file mode 100644 index 0000000..b82687d --- /dev/null +++ b/payment-service/src/main/java/com/synapse/payment_service/controller/WebHookController.java @@ -0,0 +1,36 @@ +package com.synapse.payment_service.controller; + +import java.io.IOException; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.synapse.payment_service.service.PaymentService; + +import io.portone.sdk.server.errors.WebhookVerificationException; +import io.portone.sdk.server.webhook.WebhookVerifier; +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping("/api/webhooks") +@RequiredArgsConstructor +public class WebHookController { + private final WebhookVerifier webhookVerifier; + private final PaymentService paymentService; + + @PostMapping("/portone") + public ResponseEntity handlePortOneWebhook( + @RequestBody String requestBody, + @RequestHeader(WebhookVerifier.HEADER_ID) String webhookId, + @RequestHeader(WebhookVerifier.HEADER_SIGNATURE) String webhookSignature, + @RequestHeader(WebhookVerifier.HEADER_TIMESTAMP) String webhookTimestamp + ) throws WebhookVerificationException, IOException { + webhookVerifier.verify(requestBody, webhookId, webhookSignature, webhookTimestamp); + paymentService.verifyAndProcessWebhook(requestBody); + 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 cb58dfe..7cda86d 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 @@ -1,5 +1,6 @@ package com.synapse.payment_service.domain; +import java.math.BigDecimal; import java.time.ZonedDateTime; import com.synapse.payment_service.common.BaseEntity; @@ -24,11 +25,14 @@ public class Order extends BaseEntity { @JoinColumn(name = "subscription_id", nullable = false) private Subscription subscription; - @Column(nullable = false, unique = true) - private String iamportUid; // 아임포트에서 사용하는 결제 건별 고유 ID, 환불시 사용 + @Column(unique = true) + private String iamPortTransactionId; // 아임포트에서 사용하는 결제 건별 고유 ID, 환불시 사용 @Column(nullable = false, unique = true) - private String merchantUid; // 주문별 고유 ID. 중복 결제 방지 + private String paymentId; // 주문별 고유 ID. 중복 결제 방지 + + @Column(nullable = false) + private BigDecimal amount; @Enumerated(EnumType.STRING) @Column(nullable = false) @@ -37,11 +41,20 @@ public class Order extends BaseEntity { private ZonedDateTime paidAt; @Builder - public Order(Subscription subscription, String iamportUid, String merchantUid, PaymentStatus status, ZonedDateTime paidAt) { + public Order(Subscription subscription, String iamPortTransactionId, String paymentId, BigDecimal amount, PaymentStatus status, ZonedDateTime paidAt) { this.subscription = subscription; - this.iamportUid = iamportUid; - this.merchantUid = merchantUid; + this.iamPortTransactionId = iamPortTransactionId; + this.paymentId = paymentId; + this.amount = amount; this.status = status; this.paidAt = paidAt; } + + public void updateStatus(PaymentStatus status) { + this.status = status; + } + + public void updateIamPortTransactionId(String iamPortTransactionId) { + this.iamPortTransactionId = iamPortTransactionId; + } } 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 ca03959..1ed2465 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 @@ -50,4 +50,13 @@ public Subscription(UUID memberId, SubscriptionTier tier, int remainingChatCredi this.expiresAt = expiresAt; this.status = status; } + + public void activate(SubscriptionTier newTier) { + this.status = SubscriptionStatus.ACTIVE; + this.tier = newTier; + } + + public void deactivate() { + this.status = SubscriptionStatus.CANCELED; + } } 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 bcf3c98..5a2ce4c 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,5 +3,10 @@ public enum PaymentStatus { PAID, // 결제 완료 FAILED, // 결제 실패 - CANCELLED // 결제 취소 (환불) + CANCELLED, // 결제 취소 (환불) + PENDING, // 결제 대기 + PARTIAL_CANCELLED, // 부분 취소 + PAY_PENDING, // 결제 완료 대기 + READY, // 준비 상태 + VIRTUAL_ACCOUNT_ISSUED // 가상계좌 발급 완료 } diff --git a/payment-service/src/main/java/com/synapse/payment_service/dto/request/PaymentRequestDto.java b/payment-service/src/main/java/com/synapse/payment_service/dto/request/PaymentRequestDto.java new file mode 100644 index 0000000..8bbf75d --- /dev/null +++ b/payment-service/src/main/java/com/synapse/payment_service/dto/request/PaymentRequestDto.java @@ -0,0 +1,10 @@ +package com.synapse.payment_service.dto.request; + +import jakarta.validation.constraints.NotBlank; + +public record PaymentRequestDto( + @NotBlank(message = "구독 티어는 필수입니다") + String tier +) { + +} diff --git a/payment-service/src/main/java/com/synapse/payment_service/dto/request/PaymentVerificationRequest.java b/payment-service/src/main/java/com/synapse/payment_service/dto/request/PaymentVerificationRequest.java new file mode 100644 index 0000000..8d148b4 --- /dev/null +++ b/payment-service/src/main/java/com/synapse/payment_service/dto/request/PaymentVerificationRequest.java @@ -0,0 +1,13 @@ +package com.synapse.payment_service.dto.request; + +import jakarta.validation.constraints.NotBlank; + +public record PaymentVerificationRequest( + @NotBlank(message = "paymentId는 필수입니다") + String paymentId, + + @NotBlank(message = "iamPortTransactionId는 필수입니다") + String iamPortTransactionId +) { + +} 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 new file mode 100644 index 0000000..e0e0e79 --- /dev/null +++ b/payment-service/src/main/java/com/synapse/payment_service/dto/request/PaymentWebhookRequest.java @@ -0,0 +1,32 @@ +package com.synapse.payment_service.dto.request; + +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 +) { + 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은 항상 루트 레벨에 있음 + ); + } + + private static String extractText(JsonNode node, String fieldName) { + return Optional.ofNullable(node.get(fieldName)) + .map(JsonNode::asText) + .orElse(null); + } +} \ No newline at end of file diff --git a/payment-service/src/main/java/com/synapse/payment_service/dto/response/PaymentPreparationResponse.java b/payment-service/src/main/java/com/synapse/payment_service/dto/response/PaymentPreparationResponse.java new file mode 100644 index 0000000..5f5b4ce --- /dev/null +++ b/payment-service/src/main/java/com/synapse/payment_service/dto/response/PaymentPreparationResponse.java @@ -0,0 +1,11 @@ +package com.synapse.payment_service.dto.response; + +import java.math.BigDecimal; + +public record PaymentPreparationResponse( + String paymentId, + String orderName, + BigDecimal amount +) { + +} 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 new file mode 100644 index 0000000..bf3c82f --- /dev/null +++ b/payment-service/src/main/java/com/synapse/payment_service/exception/ExceptionCode.java @@ -0,0 +1,30 @@ +package com.synapse.payment_service.exception; + +import org.springframework.http.HttpStatus; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import static org.springframework.http.HttpStatus.INTERNAL_SERVER_ERROR; +import static org.springframework.http.HttpStatus.NOT_FOUND; +import static org.springframework.http.HttpStatus.CONFLICT; +import static org.springframework.http.HttpStatus.BAD_REQUEST; + +@Getter +@RequiredArgsConstructor +public enum ExceptionCode { + PORT_ONE_CLIENT_ERROR(INTERNAL_SERVER_ERROR, "P001", "포트원 클라이언트 오류"), + + SUBSCRIPTION_NOT_FOUND(NOT_FOUND, "P002", "구독 정보를 찾을 수 없습니다"), + ORDER_NOT_FOUND(NOT_FOUND, "P003", "주문 정보를 찾을 수 없습니다"), + + 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", "지원하지 않는 결제 상태입니다") + ; + + private final HttpStatus status; + private final String code; + private final String message; +} diff --git a/payment-service/src/main/java/com/synapse/payment_service/exception/NotFoundException.java b/payment-service/src/main/java/com/synapse/payment_service/exception/NotFoundException.java new file mode 100644 index 0000000..2fba169 --- /dev/null +++ b/payment-service/src/main/java/com/synapse/payment_service/exception/NotFoundException.java @@ -0,0 +1,7 @@ +package com.synapse.payment_service.exception; + +public class NotFoundException extends PaymentException { + public NotFoundException(ExceptionCode exceptionCode) { + super(exceptionCode); + } +} diff --git a/payment-service/src/main/java/com/synapse/payment_service/exception/PaymentException.java b/payment-service/src/main/java/com/synapse/payment_service/exception/PaymentException.java new file mode 100644 index 0000000..33facc9 --- /dev/null +++ b/payment-service/src/main/java/com/synapse/payment_service/exception/PaymentException.java @@ -0,0 +1,13 @@ +package com.synapse.payment_service.exception; + +import lombok.Getter; + +@Getter +public abstract class PaymentException extends RuntimeException { + private final ExceptionCode exceptionCode; + + protected PaymentException(ExceptionCode exceptionCode) { + super(exceptionCode.getMessage()); + this.exceptionCode = exceptionCode; + } +} 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 new file mode 100644 index 0000000..d0c8b0b --- /dev/null +++ b/payment-service/src/main/java/com/synapse/payment_service/exception/PaymentVerificationException.java @@ -0,0 +1,7 @@ +package com.synapse.payment_service.exception; + +public class PaymentVerificationException extends PaymentException { + public PaymentVerificationException(ExceptionCode exceptionCode) { + super(exceptionCode); + } +} \ No newline at end of file diff --git a/payment-service/src/main/java/com/synapse/payment_service/filter/MemberAuthenticationFilter.java b/payment-service/src/main/java/com/synapse/payment_service/filter/MemberAuthenticationFilter.java new file mode 100644 index 0000000..6d75d5c --- /dev/null +++ b/payment-service/src/main/java/com/synapse/payment_service/filter/MemberAuthenticationFilter.java @@ -0,0 +1,51 @@ +package com.synapse.payment_service.filter; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.UUID; +import java.util.stream.Collectors; + +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class MemberAuthenticationFilter extends OncePerRequestFilter { + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + + String memberId = request.getHeader("X-Authenticated-Member-Id"); + String memberRole = request.getHeader("X-Authenticated-Member-Role"); + + // 현재는 게이트웨이로부터 받은 데이터에 헤더가 존재할 경우 신뢰함 + if (memberId != null && !memberId.isBlank()) { + List authorities = new ArrayList<>(); + if (memberRole != null && !memberRole.isBlank()) { + authorities = Arrays.stream(memberRole.split(",")) + .map(SimpleGrantedAuthority::new) + .collect(Collectors.toList()); + } + + UUID principal = UUID.fromString(memberId); + UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(principal, null, authorities); + + SecurityContextHolder.getContext().setAuthentication(authentication); + } + + filterChain.doFilter(request, response); + } +} 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 80a4430..0558632 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 @@ -1,9 +1,11 @@ package com.synapse.payment_service.repository; +import java.util.Optional; + import org.springframework.data.jpa.repository.JpaRepository; import com.synapse.payment_service.domain.Order; public interface OrderRepository extends JpaRepository { - + Optional findByPaymentId(String paymentId); } 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 6f77edb..29420ca 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,9 +1,12 @@ package com.synapse.payment_service.repository; +import java.util.Optional; +import java.util.UUID; + import org.springframework.data.jpa.repository.JpaRepository; import com.synapse.payment_service.domain.Subscription; public interface SubscriptionRepository extends JpaRepository { - + Optional findByMemberId(UUID memberId); } 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 new file mode 100644 index 0000000..b0553f9 --- /dev/null +++ b/payment-service/src/main/java/com/synapse/payment_service/service/PaymentService.java @@ -0,0 +1,108 @@ +package com.synapse.payment_service.service; + +import java.io.IOException; +import java.math.BigDecimal; +import java.util.UUID; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +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.PaymentRequestDto; +import com.synapse.payment_service.dto.request.PaymentVerificationRequest; +import com.synapse.payment_service.dto.request.PaymentWebhookRequest; +import com.synapse.payment_service.dto.response.PaymentPreparationResponse; +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.repository.OrderRepository; +import com.synapse.payment_service.repository.SubscriptionRepository; +import com.synapse.payment_service.service.converter.PaymentStatusConverter; + +import io.portone.sdk.server.PortOneClient; +import io.portone.sdk.server.payment.Payment; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import com.fasterxml.jackson.databind.ObjectMapper; + +@Slf4j +@Transactional(readOnly = true) +@Service +@RequiredArgsConstructor +public class PaymentService { + + private final SubscriptionRepository subscriptionRepository; + private final OrderRepository orderRepository; + private final PortOneClient portOneClient; + private final PaymentStatusConverter paymentStatusConverter; + private final ObjectMapper objectMapper; + + @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(); + + orderRepository.save(order); + + return new PaymentPreparationResponse(paymentId, orderName, amount); + } + + @Transactional + public void verifyAndProcess(PaymentVerificationRequest request) { + processPaymentVerification(request.paymentId(), request.iamPortTransactionId()); + } + + + // 웹 훅 용입니다. + @Transactional + public void verifyAndProcessWebhook(String requestBody) throws IOException { + PaymentWebhookRequest webhookRequest = PaymentWebhookRequest.from(requestBody, objectMapper); + processPaymentVerification(webhookRequest.paymentId(), webhookRequest.transactionId()); + } + + private void processPaymentVerification(String paymentId, String iamPortTransactionId) { + Order order = orderRepository.findByPaymentId(paymentId) + .orElseThrow(() -> new NotFoundException(ExceptionCode.ORDER_NOT_FOUND)); + + if (order.getStatus() != PaymentStatus.PENDING) { + log.info("이미 처리된 결제입니다. paymentId={}", order.getPaymentId()); + return; + } + + Payment payment = portOneClient.getPayment().getPayment(iamPortTransactionId).join(); + + if (payment == null) { + throw new PaymentVerificationException(ExceptionCode.PAYMENT_VERIFICATION_FAILED); + } + + // 아임포트 결제 ID 설정 + order.updateIamPortTransactionId(iamPortTransactionId); + + if (!(payment instanceof Payment.Recognized recognizedPayment)) { + throw new PaymentVerificationException(ExceptionCode.PAYMENT_NOT_RECOGNIZED); + } + + if (order.getAmount().compareTo(BigDecimal.valueOf(recognizedPayment.getAmount().getTotal())) != 0) { + log.error("결제 금액 불일치. 주문금액={}, 실제결제금액={}, paymentId={}", + order.getAmount(), recognizedPayment.getAmount().getTotal(), order.getPaymentId()); + throw new PaymentVerificationException(ExceptionCode.PAYMENT_AMOUNT_MISMATCH); + } + + paymentStatusConverter.processPayment(order, payment); + } +} diff --git a/payment-service/src/main/java/com/synapse/payment_service/service/converter/DelegatingPaymentStatusConverter.java b/payment-service/src/main/java/com/synapse/payment_service/service/converter/DelegatingPaymentStatusConverter.java new file mode 100644 index 0000000..b495b7e --- /dev/null +++ b/payment-service/src/main/java/com/synapse/payment_service/service/converter/DelegatingPaymentStatusConverter.java @@ -0,0 +1,67 @@ +package com.synapse.payment_service.service.converter; + +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedList; +import java.util.List; + +import org.springframework.stereotype.Component; +import org.springframework.util.Assert; + +import com.synapse.payment_service.domain.Order; +import com.synapse.payment_service.exception.ExceptionCode; +import com.synapse.payment_service.exception.PaymentVerificationException; +import com.synapse.payment_service.service.converter.impl.CancelledPaymentConverter; +import com.synapse.payment_service.service.converter.impl.FailedPaymentConverter; +import com.synapse.payment_service.service.converter.impl.PaidPaymentConverter; +import com.synapse.payment_service.service.converter.impl.PartialCancelledPaymentConverter; +import com.synapse.payment_service.service.converter.impl.PayPendingPaymentConverter; +import com.synapse.payment_service.service.converter.impl.ReadyPaymentConverter; +import com.synapse.payment_service.service.converter.impl.VirtualAccountIssuedPaymentConverter; + +import io.portone.sdk.server.payment.Payment; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +public class DelegatingPaymentStatusConverter implements PaymentStatusConverter { + + private final List converters; + + public DelegatingPaymentStatusConverter() { + List paymentConverters = Arrays.asList( + new PaidPaymentConverter(), + new CancelledPaymentConverter(), + new PartialCancelledPaymentConverter(), + new PayPendingPaymentConverter(), + new ReadyPaymentConverter(), + new VirtualAccountIssuedPaymentConverter(), + new FailedPaymentConverter() + ); + this.converters = Collections.unmodifiableList(new LinkedList<>(paymentConverters)); + } + + @Override + public boolean canHandle(Class paymentStatus) { + return converters.stream() + .anyMatch(converter -> converter.canHandle(paymentStatus)); + } + + @Override + public void processPayment(Order order, Payment payment) { + Assert.notNull(order, "order cannot be null"); + Assert.notNull(payment, "payment cannot be null"); + + for (PaymentStatusConverter converter : this.converters) { + if (converter.canHandle(payment.getClass())) { + log.info("결제 상태 처리: {} -> {}", payment.getClass(), converter.getClass().getSimpleName()); + converter.processPayment(order, payment); + return; + } + } + + // 처리할 수 있는 컨버터가 없는 경우 + log.error("지원하지 않는 결제 상태: {}", payment.getClass()); + throw new PaymentVerificationException(ExceptionCode.UNSUPPORTED_PAYMENT_STATUS); + } +} diff --git a/payment-service/src/main/java/com/synapse/payment_service/service/converter/PaymentStatusConverter.java b/payment-service/src/main/java/com/synapse/payment_service/service/converter/PaymentStatusConverter.java new file mode 100644 index 0000000..f3b8e58 --- /dev/null +++ b/payment-service/src/main/java/com/synapse/payment_service/service/converter/PaymentStatusConverter.java @@ -0,0 +1,21 @@ +package com.synapse.payment_service.service.converter; + +import com.synapse.payment_service.domain.Order; + +import io.portone.sdk.server.payment.Payment; + +/** + * 결제 상태별 처리를 담당하는 컨버터 인터페이스 + */ +public interface PaymentStatusConverter { + + /** + * 해당 컨버터가 주어진 결제 상태를 처리할 수 있는지 확인 + */ + boolean canHandle(Class paymentStatus); + + /** + * 결제 상태에 따른 주문 처리 로직 실행 + */ + void processPayment(Order order, Payment payment); +} \ No newline at end of file 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 new file mode 100644 index 0000000..40150c9 --- /dev/null +++ b/payment-service/src/main/java/com/synapse/payment_service/service/converter/impl/CancelledPaymentConverter.java @@ -0,0 +1,34 @@ +package com.synapse.payment_service.service.converter.impl; + +import org.springframework.transaction.annotation.Transactional; + +import com.synapse.payment_service.domain.Order; +import com.synapse.payment_service.domain.enums.PaymentStatus; +import com.synapse.payment_service.service.converter.PaymentStatusConverter; + +import io.portone.sdk.server.payment.CancelledPayment; +import io.portone.sdk.server.payment.Payment; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class CancelledPaymentConverter implements PaymentStatusConverter { + + @Override + public boolean canHandle(Class paymentStatus) { + return paymentStatus.equals(CancelledPayment.class); + } + + @Override + @Transactional + public void processPayment(Order order, Payment payment) { + log.info("결제 취소 처리 시작. paymentId={}", order.getPaymentId()); + + // 주문 상태를 취소로 업데이트 + order.updateStatus(PaymentStatus.CANCELLED); + + // 구독 비활성화 + order.getSubscription().deactivate(); + + log.info("결제 취소 처리 완료. paymentId={}", order.getPaymentId()); + } +} diff --git a/payment-service/src/main/java/com/synapse/payment_service/service/converter/impl/FailedPaymentConverter.java b/payment-service/src/main/java/com/synapse/payment_service/service/converter/impl/FailedPaymentConverter.java new file mode 100644 index 0000000..92a4bb0 --- /dev/null +++ b/payment-service/src/main/java/com/synapse/payment_service/service/converter/impl/FailedPaymentConverter.java @@ -0,0 +1,27 @@ +package com.synapse.payment_service.service.converter.impl; + +import org.springframework.transaction.annotation.Transactional; + +import com.synapse.payment_service.domain.Order; +import com.synapse.payment_service.domain.enums.PaymentStatus; +import com.synapse.payment_service.service.converter.PaymentStatusConverter; + +import io.portone.sdk.server.payment.FailedPayment; +import io.portone.sdk.server.payment.Payment; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class FailedPaymentConverter implements PaymentStatusConverter { + + @Override + public boolean canHandle(Class paymentStatus) { + return paymentStatus.equals(FailedPayment.class); + } + + @Override + @Transactional + public void processPayment(Order order, Payment payment) { + // 주문 상태를 실패로 업데이트 + order.updateStatus(PaymentStatus.FAILED); + } +} 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 new file mode 100644 index 0000000..2f52ae6 --- /dev/null +++ b/payment-service/src/main/java/com/synapse/payment_service/service/converter/impl/PaidPaymentConverter.java @@ -0,0 +1,40 @@ +package com.synapse.payment_service.service.converter.impl; + +import org.springframework.transaction.annotation.Transactional; + +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.exception.ExceptionCode; +import com.synapse.payment_service.exception.PaymentVerificationException; +import com.synapse.payment_service.service.converter.PaymentStatusConverter; + +import io.portone.sdk.server.payment.PaidPayment; +import io.portone.sdk.server.payment.Payment; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class PaidPaymentConverter implements PaymentStatusConverter { + + @Override + public boolean canHandle(Class paymentStatus) { + return paymentStatus.equals(PaidPayment.class); + } + + @Override + @Transactional + public void processPayment(Order order, Payment payment) { + if (!(payment instanceof Payment.Recognized recognizedPayment)) { + throw new PaymentVerificationException(ExceptionCode.PAYMENT_NOT_RECOGNIZED); + } + log.info("결제 완료 처리 시작. paymentId={}, amount={}", + order.getPaymentId(), recognizedPayment.getAmount().getTotal()); + + order.updateStatus(PaymentStatus.PAID); + Subscription subscription = order.getSubscription(); + subscription.activate(SubscriptionTier.PRO); + + log.info("결제 완료 처리 완료. paymentId={}", order.getPaymentId()); + } +} diff --git a/payment-service/src/main/java/com/synapse/payment_service/service/converter/impl/PartialCancelledPaymentConverter.java b/payment-service/src/main/java/com/synapse/payment_service/service/converter/impl/PartialCancelledPaymentConverter.java new file mode 100644 index 0000000..468a2ab --- /dev/null +++ b/payment-service/src/main/java/com/synapse/payment_service/service/converter/impl/PartialCancelledPaymentConverter.java @@ -0,0 +1,32 @@ +package com.synapse.payment_service.service.converter.impl; + +import org.springframework.transaction.annotation.Transactional; + +import com.synapse.payment_service.domain.Order; +import com.synapse.payment_service.domain.enums.PaymentStatus; +import com.synapse.payment_service.service.converter.PaymentStatusConverter; + +import io.portone.sdk.server.payment.PartialCancelledPayment; +import io.portone.sdk.server.payment.Payment; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class PartialCancelledPaymentConverter implements PaymentStatusConverter { + + @Override + public boolean canHandle(Class paymentStatus) { + return paymentStatus.equals(PartialCancelledPayment.class); + } + + @Override + @Transactional + public void processPayment(Order order, Payment payment) { + log.info("부분 취소 처리 시작. paymentId={}", order.getPaymentId()); + + // 주문 상태를 부분 취소로 업데이트 + order.updateStatus(PaymentStatus.PARTIAL_CANCELLED); + + // 부분 취소 시에는 구독은 유지하되 크레딧 조정 등의 로직이 필요할 수 있음 + log.info("부분 취소 처리 완료. paymentId={}", order.getPaymentId()); + } +} diff --git a/payment-service/src/main/java/com/synapse/payment_service/service/converter/impl/PayPendingPaymentConverter.java b/payment-service/src/main/java/com/synapse/payment_service/service/converter/impl/PayPendingPaymentConverter.java new file mode 100644 index 0000000..4e97c0b --- /dev/null +++ b/payment-service/src/main/java/com/synapse/payment_service/service/converter/impl/PayPendingPaymentConverter.java @@ -0,0 +1,31 @@ +package com.synapse.payment_service.service.converter.impl; + +import org.springframework.transaction.annotation.Transactional; + +import com.synapse.payment_service.domain.Order; +import com.synapse.payment_service.domain.enums.PaymentStatus; +import com.synapse.payment_service.service.converter.PaymentStatusConverter; + +import io.portone.sdk.server.payment.PayPendingPayment; +import io.portone.sdk.server.payment.Payment; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class PayPendingPaymentConverter implements PaymentStatusConverter { + + @Override + public boolean canHandle(Class paymentStatus) { + return paymentStatus.equals(PayPendingPayment.class); + } + + @Override + @Transactional + public void processPayment(Order order, Payment payment) { + log.info("결제 완료 대기 처리 시작. paymentId={}", order.getPaymentId()); + + // 주문 상태를 결제 완료 대기로 업데이트 + order.updateStatus(PaymentStatus.PAY_PENDING); + + log.info("결제 완료 대기 처리 완료. paymentId={}", order.getPaymentId()); + } +} diff --git a/payment-service/src/main/java/com/synapse/payment_service/service/converter/impl/ReadyPaymentConverter.java b/payment-service/src/main/java/com/synapse/payment_service/service/converter/impl/ReadyPaymentConverter.java new file mode 100644 index 0000000..0480742 --- /dev/null +++ b/payment-service/src/main/java/com/synapse/payment_service/service/converter/impl/ReadyPaymentConverter.java @@ -0,0 +1,31 @@ +package com.synapse.payment_service.service.converter.impl; + +import org.springframework.transaction.annotation.Transactional; + +import com.synapse.payment_service.domain.Order; +import com.synapse.payment_service.domain.enums.PaymentStatus; +import com.synapse.payment_service.service.converter.PaymentStatusConverter; + +import io.portone.sdk.server.payment.Payment; +import io.portone.sdk.server.payment.ReadyPayment; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class ReadyPaymentConverter implements PaymentStatusConverter { + + @Override + public boolean canHandle(Class paymentStatus) { + return paymentStatus.equals(ReadyPayment.class); + } + + @Override + @Transactional + public void processPayment(Order order, Payment payment) { + log.info("결제 준비 상태 처리 시작. paymentId={}", order.getPaymentId()); + + // 주문 상태를 준비로 업데이트 + order.updateStatus(PaymentStatus.READY); + + log.info("결제 준비 상태 처리 완료. paymentId={}", order.getPaymentId()); + } +} diff --git a/payment-service/src/main/java/com/synapse/payment_service/service/converter/impl/VirtualAccountIssuedPaymentConverter.java b/payment-service/src/main/java/com/synapse/payment_service/service/converter/impl/VirtualAccountIssuedPaymentConverter.java new file mode 100644 index 0000000..ea30948 --- /dev/null +++ b/payment-service/src/main/java/com/synapse/payment_service/service/converter/impl/VirtualAccountIssuedPaymentConverter.java @@ -0,0 +1,31 @@ +package com.synapse.payment_service.service.converter.impl; + +import org.springframework.transaction.annotation.Transactional; + +import com.synapse.payment_service.domain.Order; +import com.synapse.payment_service.domain.enums.PaymentStatus; +import com.synapse.payment_service.service.converter.PaymentStatusConverter; + +import io.portone.sdk.server.payment.Payment; +import io.portone.sdk.server.payment.VirtualAccountIssuedPayment; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class VirtualAccountIssuedPaymentConverter implements PaymentStatusConverter { + + @Override + public boolean canHandle(Class paymentStatus) { + return paymentStatus.equals(VirtualAccountIssuedPayment.class); + } + + @Override + @Transactional + public void processPayment(Order order, Payment payment) { + log.info("가상계좌 발급 완료 처리 시작. paymentId={}", order.getPaymentId()); + + // 주문 상태를 가상계좌 발급 완료로 업데이트 + order.updateStatus(PaymentStatus.VIRTUAL_ACCOUNT_ISSUED); + + log.info("가상계좌 발급 완료 처리 완료. paymentId={}", order.getPaymentId()); + } +} diff --git a/payment-service/src/main/resources/application-local.yml b/payment-service/src/main/resources/application-local.yml index 0622468..5f9b488 100644 --- a/payment-service/src/main/resources/application-local.yml +++ b/payment-service/src/main/resources/application-local.yml @@ -18,6 +18,12 @@ spring: open-in-view: false show-sql: true +iamport: + api-secret: ${local-iamport.api-secret} + base-url: https://api.portone.io + mid-key: ${local-iamport.mid-key} + webhook-secret: ${local-iamport.webhook-secret} + logging: level: org: diff --git a/payment-service/src/main/resources/application.yml b/payment-service/src/main/resources/application.yml index bc76a47..3d6fe2b 100644 --- a/payment-service/src/main/resources/application.yml +++ b/payment-service/src/main/resources/application.yml @@ -15,3 +15,4 @@ spring: import: - security/application-db.yml - security/application-oauth2.yml + - security/application-iamport.yml diff --git a/payment-service/src/test/java/com/synapse/payment_service/integrationtest/PaymentIntegrationTest.java b/payment-service/src/test/java/com/synapse/payment_service/integrationtest/PaymentIntegrationTest.java new file mode 100644 index 0000000..39aff11 --- /dev/null +++ b/payment-service/src/test/java/com/synapse/payment_service/integrationtest/PaymentIntegrationTest.java @@ -0,0 +1,174 @@ +package com.synapse.payment_service.integrationtest; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; + +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.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.transaction.annotation.Transactional; + +import com.fasterxml.jackson.databind.ObjectMapper; +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.dto.request.PaymentRequestDto; +import com.synapse.payment_service.dto.response.PaymentPreparationResponse; +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.payment.Payment; +import io.portone.sdk.server.payment.PaymentAmount; +import io.portone.sdk.server.payment.PaymentClient; +import io.portone.sdk.server.payment.PaidPayment; +import io.portone.sdk.server.webhook.WebhookVerifier; + +@SpringBootTest +@AutoConfigureMockMvc +@Transactional +@ActiveProfiles("test") +public class PaymentIntegrationTest { + + @Autowired + private MockMvc mockMvc; + @Autowired + private ObjectMapper objectMapper; + @Autowired + private SubscriptionRepository subscriptionRepository; + @Autowired + private OrderRepository orderRepository; + + @MockitoBean + private PortOneClient portOneClient; + @MockitoBean + private WebhookVerifier webhookVerifier; + @MockitoBean + private PaymentClient paymentClient; + + private UUID memberId; + + @BeforeEach + void setUp() { + this.memberId = UUID.randomUUID(); + Subscription subscription = Subscription.builder() + .memberId(memberId) + .tier(SubscriptionTier.FREE) + .remainingChatCredits(10) + .status(SubscriptionStatus.CANCELED) + .build(); + subscriptionRepository.save(subscription); + } + + @Test + @DisplayName("결제 준비 API 성공: /api/payments/request 호출 시, PENDING 상태의 주문이 생성된다") + void requestPayment_success() throws Exception { + // given + PaymentRequestDto request = new PaymentRequestDto("PRO"); + + // when + ResultActions resultActions = mockMvc.perform(post("/api/payments/request") + .header("X-Authenticated-Member-Id", memberId.toString()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))); + + // then + resultActions + .andExpect(status().isOk()) + .andExpect(jsonPath("$.paymentId").exists()) + .andExpect(jsonPath("$.amount").exists()); + + // DB 검증 + assertThat(orderRepository.findAll()).hasSize(1); + assertThat(orderRepository.findAll().get(0).getStatus()).isEqualTo(PaymentStatus.PENDING); + } + + @Test + @DisplayName("웹훅 처리 성공: /api/webhooks/portone 호출 시, 결제 상태가 성공적으로 업데이트된다") + void handleWebhook_success() throws Exception { + // given + // 1. 먼저 /request API를 호출하여 PENDING 상태의 주문을 생성 + PaymentRequestDto request = new PaymentRequestDto("PRO"); + + ResultActions prepareResult = mockMvc.perform(post("/api/payments/request") + .header("X-Authenticated-Member-Id", memberId.toString()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))); + + // 2. 첫 번째 API 결과에서 paymentId와 amount 추출 + String responseJson = prepareResult + .andExpect(status().isOk()) + .andExpect(jsonPath("$.paymentId").exists()) + .andReturn() + .getResponse().getContentAsString(); + + PaymentPreparationResponse prepResponse = objectMapper.readValue(responseJson, PaymentPreparationResponse.class); + String paymentId = prepResponse.paymentId(); + BigDecimal amount = prepResponse.amount(); + String iamPortTransactionId = "imp_test_12345"; + + // 3. Mock PortOneClient가 위변조 없는 정상 데이터를 반환하도록 설정 + PaidPayment mockPaidPayment = mock(PaidPayment.class); + PaymentAmount mockAmount = mock(PaymentAmount.class); + + when(mockAmount.getTotal()).thenReturn(amount.longValue()); + when(mockPaidPayment.getAmount()).thenReturn(mockAmount); + + CompletableFuture mockFuture = CompletableFuture.completedFuture(mockPaidPayment); + given(portOneClient.getPayment()).willReturn(paymentClient); + given(paymentClient.getPayment(iamPortTransactionId)).willReturn(mockFuture); + + // 4. WebhookVerifier가 항상 검증에 성공하도록 설정 + + // 5. 포트원 웹훅 페이로드 생성 (실제 포트원 SDK 사용) + String webhookJson = """ + { + "type": "Transaction.Paid", + "data": { + "paymentId": "%s", + "transactionId": "%s" + } + } + """.formatted(paymentId, iamPortTransactionId); + + // when - 웹훅 API 호출 + ResultActions webhookResult = mockMvc.perform(post("/api/webhooks/portone") + .header(WebhookVerifier.HEADER_ID, "wh_test_id") + .header(WebhookVerifier.HEADER_SIGNATURE, "test_signature") + .header(WebhookVerifier.HEADER_TIMESTAMP, String.valueOf(Instant.now().getEpochSecond())) + .contentType(MediaType.APPLICATION_JSON) + .content(webhookJson)); + + // then + webhookResult.andExpect(status().isOk()); + + // DB 검증 - 트랜잭션이 커밋된 후 검증 + Order completedOrder = orderRepository.findByPaymentId(paymentId).get(); + assertThat(completedOrder.getStatus()).isEqualTo(PaymentStatus.PAID); + assertThat(completedOrder.getSubscription().getStatus()).isEqualTo(SubscriptionStatus.ACTIVE); + + Subscription updatedSubscription = subscriptionRepository.findByMemberId(memberId).get(); + assertThat(updatedSubscription.getStatus()).isEqualTo(SubscriptionStatus.ACTIVE); + assertThat(updatedSubscription.getTier()).isEqualTo(SubscriptionTier.PRO); + } +} diff --git a/payment-service/src/test/java/com/synapse/payment_service/integrationtest/ResourceServerIntegrationTest.java b/payment-service/src/test/java/com/synapse/payment_service/integrationtest/ResourceServerIntegrationTest.java index 03eeec9..347a1d1 100644 --- a/payment-service/src/test/java/com/synapse/payment_service/integrationtest/ResourceServerIntegrationTest.java +++ b/payment-service/src/test/java/com/synapse/payment_service/integrationtest/ResourceServerIntegrationTest.java @@ -15,11 +15,13 @@ import org.springframework.boot.test.context.SpringBootTest; import org.springframework.context.annotation.Import; import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.ResultActions; import org.springframework.transaction.annotation.Transactional; import com.synapse.payment_service.controller.test.InternalApiController; +import io.portone.sdk.server.PortOneClient; @SpringBootTest @AutoConfigureMockMvc @@ -30,6 +32,9 @@ public class ResourceServerIntegrationTest { @Autowired private MockMvc mockMvc; + + @MockitoBean + private PortOneClient portOneClient; @Test @DisplayName("리소스 접근 성공: 유효한 JWT와 올바른 scope으로 보호된 API 호출 시 200 OK를 응답한다") 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 new file mode 100644 index 0000000..0b7db65 --- /dev/null +++ b/payment-service/src/test/java/com/synapse/payment_service/service/PaymentServiceTest.java @@ -0,0 +1,179 @@ +package com.synapse.payment_service.service; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.*; + +import java.math.BigDecimal; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; + +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.fasterxml.jackson.databind.ObjectMapper; +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.dto.request.PaymentRequestDto; +import com.synapse.payment_service.dto.request.PaymentVerificationRequest; +import com.synapse.payment_service.dto.response.PaymentPreparationResponse; +import com.synapse.payment_service.repository.OrderRepository; +import com.synapse.payment_service.repository.SubscriptionRepository; +import com.synapse.payment_service.service.converter.PaymentStatusConverter; +import com.synapse.payment_service.service.converter.DelegatingPaymentStatusConverter; + +import io.portone.sdk.server.PortOneClient; +import io.portone.sdk.server.payment.Payment; +import io.portone.sdk.server.payment.PaymentAmount; +import io.portone.sdk.server.payment.PaymentClient; +import io.portone.sdk.server.payment.PaidPayment; + +@ExtendWith(MockitoExtension.class) +public class PaymentServiceTest { + @InjectMocks + private PaymentService paymentService; + + @Mock + private PortOneClient portOneClient; + @Mock + private PaymentClient paymentClient; + @Mock + private OrderRepository orderRepository; + @Mock + private SubscriptionRepository subscriptionRepository; + @Mock + private PaymentStatusConverter paymentStatusConverter; + @Mock + private ObjectMapper objectMapper; + + private UUID memberId; + private String paymentId; + private String iamPortTransactionId; + + @BeforeEach + void setUp() { + memberId = UUID.randomUUID(); + paymentId = "order_" + UUID.randomUUID(); + iamPortTransactionId = "imp_" + UUID.randomUUID(); + } + + @Test + @DisplayName("결제 사전 준비 성공: PENDING 상태의 주문이 생성되고, 프론트에 필요한 정보가 반환된다") + void preparePayment_success() { + // given + PaymentRequestDto request = new PaymentRequestDto("PRO"); + String orderName = "pro_subscription"; + Subscription mockSubscription = Subscription.builder().memberId(memberId).tier(SubscriptionTier.FREE).build(); + + given(subscriptionRepository.findByMemberId(memberId)).willReturn(Optional.of(mockSubscription)); + given(orderRepository.save(any(Order.class))).willAnswer(invocation -> invocation.getArgument(0)); + + // when + PaymentPreparationResponse response = paymentService.preparePayment(memberId, request); + + // then + assertEquals(response.orderName(), orderName); + assertEquals(response.amount(), new BigDecimal("100000")); + verify(orderRepository).save(argThat(order -> order.getStatus() == PaymentStatus.PENDING)); + assertThat(mockSubscription.getTier()).isEqualTo(SubscriptionTier.FREE); + } + + @Test + @DisplayName("결제 검증 성공: 위변조가 없는 결제 건에 대해 구독 상태를 성공적으로 업데이트한다") + void verifyAndProcess_success() { + Subscription mockSubscription = Subscription.builder().memberId(memberId).build(); + // given + Order pendingOrder = Order.builder() + .paymentId(paymentId) + .amount(new BigDecimal("100000")) + .status(PaymentStatus.PENDING) + .subscription(mockSubscription) + .build(); + + // PortOne API의 응답을 모의 처리 + Payment.Recognized mockApiResponse = mock(Payment.Recognized.class); + PaymentAmount mockAmount = mock(PaymentAmount.class); + + when(mockAmount.getTotal()).thenReturn(100000L); + when(mockApiResponse.getAmount()).thenReturn(mockAmount); + + given(orderRepository.findByPaymentId(paymentId)).willReturn(Optional.of(pendingOrder)); + given(portOneClient.getPayment()).willReturn(paymentClient); + + // CompletableFuture Mock 설정 + CompletableFuture mockFuture = CompletableFuture.completedFuture(mockApiResponse); + given(paymentClient.getPayment(iamPortTransactionId)).willReturn(mockFuture); + + doAnswer(invocation -> { + Order order = invocation.getArgument(0); + order.updateStatus(PaymentStatus.PAID); + order.getSubscription().activate(SubscriptionTier.PRO); + return null; + }).when(paymentStatusConverter).processPayment(any(Order.class), any(Payment.class)); + + // when + paymentService.verifyAndProcess(new PaymentVerificationRequest(paymentId, iamPortTransactionId)); + + // then + assertThat(pendingOrder.getStatus()).isEqualTo(PaymentStatus.PAID); + assertThat(pendingOrder.getIamPortTransactionId()).isEqualTo(iamPortTransactionId); + assertThat(pendingOrder.getSubscription().getStatus()).isEqualTo(SubscriptionStatus.ACTIVE); + assertThat(pendingOrder.getSubscription().getTier()).isEqualTo(SubscriptionTier.PRO); + } + + @Test + @DisplayName("실제 DelegatingPaymentStatusConverter를 사용한 결제 검증 테스트") + void verifyAndProcess_withRealDelegatingConverter() { + // given + Subscription mockSubscription = Subscription.builder().memberId(memberId).build(); + Order pendingOrder = Order.builder() + .paymentId(paymentId) + .amount(new BigDecimal("100000")) + .status(PaymentStatus.PENDING) + .subscription(mockSubscription) + .build(); + + // 실제 DelegatingPaymentStatusConverter 사용 (내부에 PaidPaymentConverter 포함) + PaymentStatusConverter realDelegatingConverter = new DelegatingPaymentStatusConverter(); + PaymentService paymentServiceWithRealConverter = new PaymentService( + subscriptionRepository, orderRepository, portOneClient, + realDelegatingConverter, objectMapper); + + // PaidPayment 타입으로 모킹 (실제 결제 완료 상태) + PaidPayment mockPaidPayment = mock(PaidPayment.class); + PaymentAmount mockAmount = mock(PaymentAmount.class); + + when(mockAmount.getTotal()).thenReturn(100000L); + when(mockPaidPayment.getAmount()).thenReturn(mockAmount); + + given(orderRepository.findByPaymentId(paymentId)).willReturn(Optional.of(pendingOrder)); + given(portOneClient.getPayment()).willReturn(paymentClient); + + // CompletableFuture Mock 설정 여기서는 .join()으로 테스트 불가능 -> .join 메서드는 런타임시에 동기화를 진행하는데 테스트중에 null이 들어가버린다. + CompletableFuture mockFuture = CompletableFuture.completedFuture(mockPaidPayment); + given(paymentClient.getPayment(iamPortTransactionId)).willReturn(mockFuture); + + // when + paymentServiceWithRealConverter.verifyAndProcess( + new PaymentVerificationRequest(paymentId, iamPortTransactionId)); + + // then - 실제 PaidPaymentConverter 로직에 의한 상태 변경 검증 + assertThat(pendingOrder.getStatus()).isEqualTo(PaymentStatus.PAID); + assertThat(pendingOrder.getIamPortTransactionId()).isEqualTo(iamPortTransactionId); + assertThat(pendingOrder.getSubscription().getStatus()).isEqualTo(SubscriptionStatus.ACTIVE); + assertThat(pendingOrder.getSubscription().getTier()).isEqualTo(SubscriptionTier.PRO); + } +} diff --git a/payment-service/src/test/resources/application-test.yml b/payment-service/src/test/resources/application-test.yml index c2bf88b..3e5f6ab 100644 --- a/payment-service/src/test/resources/application-test.yml +++ b/payment-service/src/test/resources/application-test.yml @@ -41,6 +41,12 @@ spring: open-in-view: false show-sql: true +iamport: + api-secret: dGVzdC1hcGktc2VjcmV0 + base-url: https://api.portone.io + mid-key: test-mid-key + webhook-secret: dGVzdC13ZWJob29rLXNlY3JldA== + logging: level: org: