Skip to content
Merged
1 change: 1 addition & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-webflux'
implementation 'org.springframework.boot:spring-boot-starter-validation' // 에러 핸들러 만들 때 씀

testImplementation 'org.springframework.boot:spring-boot-starter-test'
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.sumte.payment.controller;

import com.sumte.apiPayload.ApiResponse;
import com.sumte.payment.dto.KakaoPayApproveResponseDTO;
import com.sumte.payment.dto.PaymentRequestDTO;
import com.sumte.payment.dto.PaymentResponseDTO;
import com.sumte.payment.service.PaymentService;
Expand Down Expand Up @@ -33,11 +34,11 @@ public ResponseEntity<ApiResponse<PaymentResponseDTO.CreatePaymentDTO>> requestP
summary = "결제 승인 처리 API",
description = "PG사(예: 카카오페이) 결제 완료 후, 해당 결제 ID의 상태를 PAID로 변경합니다."
)
public ResponseEntity<ApiResponse<Void>> approvePayment(
public ResponseEntity<ApiResponse<KakaoPayApproveResponseDTO>> approvePayment(
@PathVariable("id") Long id,
@RequestParam("pg_token") String pgToken) {

paymentService.approvePayment(id, pgToken);
return ResponseEntity.ok(ApiResponse.success(null));
KakaoPayApproveResponseDTO response = paymentService.approvePayment(id, pgToken);
return ResponseEntity.ok(ApiResponse.success(response));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,14 @@
public class PaymentConverter {

public static Payment toEntity(PaymentRequestDTO.CreatePaymentDTO dto, Reservation reservation) {
PaymentMethod method = dto.getPaymentMethod() != null
? dto.getPaymentMethod()
: PaymentMethod.KAKAOPAY;

return Payment.builder()
.reservation(reservation)
.paidPrice(dto.getAmount())
.paymentMethod(PaymentMethod.valueOf(dto.getPaymentMethod()))
.paymentMethod(method)
.paymentStatus(PaymentStatus.PENDING)
.build();
}
Expand Down
14 changes: 14 additions & 0 deletions src/main/java/com/sumte/payment/dto/KakaoPayApproveRequestDTO.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.sumte.payment.dto;

import lombok.Builder;
import lombok.Getter;

@Getter
@Builder
public class KakaoPayApproveRequestDTO {
private String cid;
private String tid;
private String partner_order_id;
private String partner_user_id;
private String pg_token;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package com.sumte.payment.dto;

import lombok.Builder;
import lombok.Getter;

@Getter
@Builder
public class KakaoPayApproveResponseDTO {
private String aid;
private String tid;
private String cid;
private String partner_order_id;
private String partner_user_id;
private String payment_method_type;
private Amount amount;

@Getter
public static class Amount {
private int total;
private int tax_free;
private int vat;
}
}
21 changes: 21 additions & 0 deletions src/main/java/com/sumte/payment/dto/KakaoPayReadyRequestDTO.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package com.sumte.payment.dto;

import lombok.Builder;
import lombok.Getter;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;

@Getter
@Builder
public class KakaoPayReadyRequestDTO {
private String cid;
private String partner_order_id;
private String partner_user_id;
private String item_name;
private String quantity;
private String total_amount;
private String tax_free_amount;
private String approval_url;
private String cancel_url;
private String fail_url;
}
13 changes: 13 additions & 0 deletions src/main/java/com/sumte/payment/dto/KakaoPayReadyResponseDTO.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.sumte.payment.dto;

import lombok.Builder;
import lombok.Getter;

@Getter
@Builder
public class KakaoPayReadyResponseDTO {
private String tid;
private String next_redirect_app_url;
private String next_redirect_pc_url;
private String created_at;
}
3 changes: 2 additions & 1 deletion src/main/java/com/sumte/payment/dto/PaymentRequestDTO.java
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.sumte.payment.dto;

import com.sumte.payment.entity.PaymentMethod;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
Expand All @@ -14,6 +15,6 @@ public class PaymentRequestDTO {
public static class CreatePaymentDTO {
private Long reservationId;
private Long amount;
private String paymentMethod;
private PaymentMethod paymentMethod;
}
}
4 changes: 4 additions & 0 deletions src/main/java/com/sumte/payment/entity/Payment.java
Original file line number Diff line number Diff line change
Expand Up @@ -52,4 +52,8 @@ public void markAsFailed() {
public void markAsRefunded() {
this.paymentStatus = PaymentStatus.REFUNDED;
}

public void setTid(String tid) {
this.tid = tid;
}
}
44 changes: 44 additions & 0 deletions src/main/java/com/sumte/payment/kakaopay/KakaoPayClient.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package com.sumte.payment.kakaopay;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.sumte.payment.dto.KakaoPayApproveRequestDTO;
import com.sumte.payment.dto.KakaoPayApproveResponseDTO;
import com.sumte.payment.dto.KakaoPayReadyRequestDTO;
import com.sumte.payment.dto.KakaoPayReadyResponseDTO;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.client.WebClient;

@Component
@RequiredArgsConstructor
public class KakaoPayClient {

private final WebClient webClient;
private final ObjectMapper objectMapper;

@Value("${kakao.pay.secret-key}")
private String adminKey;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

${kakao.pay.secret-key} prod.yml에 반영 부탁드립니다! 키도 저한테 따로 보내주시면 깃허브시크릿으로 배포하겠습니다.

Copy link
Contributor

@99hyuk 99hyuk Aug 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

노션에 키 올려놓으신 거 확인해서 깃허브 시크릿에 등록은 해두었습니다.
다만 변수 이름을 KAKAOPAY_SECRET_KEY로 올려두어서
kakao:
pay:
secret-key: ${KAKAOPAY_SECRET_KEY}
이렇게 prod.yml에 반영 부탁드립니다!


public KakaoPayReadyResponseDTO requestPayment(KakaoPayReadyRequestDTO request) {
return webClient.post()
.uri("https://open-api.kakaopay.com/online/v1/payment/ready")
.header("Authorization", "SECRET_KEY " + adminKey)
.header("Content-Type", "application/json")
.bodyValue(request)
.retrieve()
.bodyToMono(KakaoPayReadyResponseDTO.class)
.block();
}

public KakaoPayApproveResponseDTO approvePayment(KakaoPayApproveRequestDTO request) {
return webClient.post()
.uri("https://open-api.kakaopay.com/online/v1/payment/approve")
.header("Authorization", "SECRET_KEY " + adminKey)
.header("Content-Type", "application/json")
.bodyValue(request)
.retrieve()
.bodyToMono(KakaoPayApproveResponseDTO.class)
.block();
}
}
13 changes: 13 additions & 0 deletions src/main/java/com/sumte/payment/kakaopay/WebClientConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.sumte.payment.kakaopay;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.reactive.function.client.WebClient;

@Configuration
public class WebClientConfig {
@Bean
public WebClient webClient() {
return WebClient.builder().build();
}
}
3 changes: 2 additions & 1 deletion src/main/java/com/sumte/payment/service/PaymentService.java
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
package com.sumte.payment.service;

import com.sumte.payment.dto.KakaoPayApproveResponseDTO;
import com.sumte.payment.dto.PaymentRequestDTO;
import com.sumte.payment.dto.PaymentResponseDTO;

public interface PaymentService {
PaymentResponseDTO.CreatePaymentDTO requestPayment(PaymentRequestDTO.CreatePaymentDTO dto);
void approvePayment(Long paymentId, String pgToken);
KakaoPayApproveResponseDTO approvePayment(Long paymentId, String pgToken);
}
142 changes: 88 additions & 54 deletions src/main/java/com/sumte/payment/service/PaymentServiceImpl.java
Original file line number Diff line number Diff line change
@@ -1,61 +1,95 @@
package com.sumte.payment.service;

import com.sumte.apiPayload.code.error.PaymentErrorCode;
import com.sumte.apiPayload.exception.SumteException;
import com.sumte.payment.converter.PaymentConverter;
import com.sumte.payment.dto.PaymentRequestDTO;
import com.sumte.payment.dto.PaymentResponseDTO;
import com.sumte.payment.entity.Payment;
import com.sumte.payment.entity.PaymentStatus;
import com.sumte.payment.repository.PaymentRepository;
import com.sumte.reservation.entity.Reservation;
import com.sumte.reservation.repository.ReservationRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
public class PaymentServiceImpl implements PaymentService {

private final ReservationRepository reservationRepository;
private final PaymentRepository paymentRepository;
private final PaymentTransactionHelper transactionHelper;

@Override
@Transactional
public PaymentResponseDTO.CreatePaymentDTO requestPayment(PaymentRequestDTO.CreatePaymentDTO dto) {
Reservation reservation = reservationRepository.findById(dto.getReservationId())
.orElseThrow(() -> new SumteException(PaymentErrorCode.RESERVATION_NOT_FOUND));

Payment payment;
try {
payment = PaymentConverter.toEntity(dto, reservation);
} catch (IllegalArgumentException e) {
throw new SumteException(PaymentErrorCode.INVALID_PAYMENT_METHOD);
}
paymentRepository.save(payment);
package com.sumte.payment.service;

String paymentUrl = "https://pay.mock.kakao.com/" + payment.getId();
return PaymentConverter.toCreateResponse(payment, paymentUrl);
}
import com.sumte.apiPayload.code.error.PaymentErrorCode;
import com.sumte.apiPayload.exception.SumteException;
import com.sumte.payment.converter.PaymentConverter;
import com.sumte.payment.dto.*;
import com.sumte.payment.entity.Payment;
import com.sumte.payment.entity.PaymentStatus;
import com.sumte.payment.kakaopay.KakaoPayClient;
import com.sumte.payment.repository.PaymentRepository;
import com.sumte.reservation.entity.Reservation;
import com.sumte.reservation.repository.ReservationRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Override
@Transactional
public void approvePayment(Long paymentId, String pgToken) {
Payment payment = paymentRepository.findById(paymentId)
.orElseThrow(() -> new SumteException(PaymentErrorCode.PAYMENT_NOT_FOUND));
@Service
@RequiredArgsConstructor
public class PaymentServiceImpl implements PaymentService {

if (payment.getPaymentStatus() == PaymentStatus.PAID) {
throw new SumteException(PaymentErrorCode.ALREADY_APPROVED_PAYMENT);
}
private final ReservationRepository reservationRepository;
private final PaymentRepository paymentRepository;
private final PaymentTransactionHelper transactionHelper;
private final KakaoPayClient kakaoPayClient;

@Value("${app.host}")
private String appHost;

@Override
@Transactional
public PaymentResponseDTO.CreatePaymentDTO requestPayment(PaymentRequestDTO.CreatePaymentDTO dto) {
Reservation reservation = reservationRepository.findById(dto.getReservationId())
.orElseThrow(() -> new SumteException(PaymentErrorCode.RESERVATION_NOT_FOUND));

Payment payment;
try {
payment = PaymentConverter.toEntity(dto, reservation);
} catch (IllegalArgumentException e) {
throw new SumteException(PaymentErrorCode.INVALID_PAYMENT_METHOD);
}
paymentRepository.save(payment);


String itemName = reservation.getRoom().getName();
Long totalAmount = reservation.getRoom().getPrice();

KakaoPayReadyRequestDTO request = KakaoPayReadyRequestDTO.builder()
.cid("TC0ONETIME")
.partner_order_id("reservation_" + reservation.getId())
.partner_user_id("user_" + reservation.getUser().getId())
.item_name(itemName)
.quantity("1")
.total_amount(String.valueOf(totalAmount))
.tax_free_amount("0")
.approval_url(appHost + "/pay/success")
.cancel_url(appHost + "/pay/cancel")
.fail_url(appHost + "/pay/fail")
.build();

if (pgToken == null || pgToken.isBlank()) {
transactionHelper.markPaymentFailed(payment);
throw new SumteException(PaymentErrorCode.PG_TOKEN_MISSING);
KakaoPayReadyResponseDTO kakaoResponse = kakaoPayClient.requestPayment(request);
payment.setTid(kakaoResponse.getTid());

return PaymentConverter.toCreateResponse(payment, kakaoResponse.getNext_redirect_pc_url());
}

payment.markAsPaid();
}
@Override
@Transactional
public KakaoPayApproveResponseDTO approvePayment(Long paymentId, String pgToken) {
Payment payment = paymentRepository.findById(paymentId)
.orElseThrow(() -> new SumteException(PaymentErrorCode.PAYMENT_NOT_FOUND));

if (payment.getPaymentStatus() == PaymentStatus.PAID) {
throw new SumteException(PaymentErrorCode.ALREADY_APPROVED_PAYMENT);
}

if (pgToken == null || pgToken.isBlank()) {
transactionHelper.markPaymentFailed(payment);
throw new SumteException(PaymentErrorCode.PG_TOKEN_MISSING);
}

}
KakaoPayApproveRequestDTO request = KakaoPayApproveRequestDTO.builder()
.cid("TC0ONETIME")
.tid(payment.getTid())
.partner_order_id("reservation_" + payment.getReservation().getId())
.partner_user_id("user_" + payment.getReservation().getUser().getId())
.pg_token(pgToken)
.build();

KakaoPayApproveResponseDTO response = kakaoPayClient.approvePayment(request);
payment.markAsPaid();

return response;
}
}
Loading