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
6 changes: 4 additions & 2 deletions payment-service/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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'
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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());
}
}
Original file line number Diff line number Diff line change
@@ -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
) {

}
Original file line number Diff line number Diff line change
Expand Up @@ -11,29 +11,41 @@
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
.csrf(csrf -> csrf.disable())
.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())
Expand Down
Original file line number Diff line number Diff line change
@@ -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<PaymentPreparationResponse> 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<Void> verifyPayment(@RequestBody @Valid PaymentVerificationRequest request) {
paymentService.verifyAndProcess(request);
return ResponseEntity.ok().build();
}

}
Original file line number Diff line number Diff line change
@@ -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<String> 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();
}
}
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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)
Expand All @@ -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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,10 @@
public enum PaymentStatus {
PAID, // 결제 완료
FAILED, // 결제 실패
CANCELLED // 결제 취소 (환불)
CANCELLED, // 결제 취소 (환불)
PENDING, // 결제 대기
PARTIAL_CANCELLED, // 부분 취소
PAY_PENDING, // 결제 완료 대기
READY, // 준비 상태
VIRTUAL_ACCOUNT_ISSUED // 가상계좌 발급 완료
}
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 PaymentRequestDto(
@NotBlank(message = "구독 티어는 필수입니다")
String tier
) {

}
Original file line number Diff line number Diff line change
@@ -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
) {

}
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.synapse.payment_service.dto.response;

import java.math.BigDecimal;

public record PaymentPreparationResponse(
String paymentId,
String orderName,
BigDecimal amount
) {

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

public class NotFoundException extends PaymentException {
public NotFoundException(ExceptionCode exceptionCode) {
super(exceptionCode);
}
}
Loading
Loading