From dc981b6e6a68abab3a46cf124fd87c4d6659f4e8 Mon Sep 17 00:00:00 2001 From: CHAN <150508884+zerochani@users.noreply.github.com> Date: Sat, 17 Jan 2026 14:38:21 +0900 Subject: [PATCH 1/8] =?UTF-8?q?[FIX]:=20=EA=B0=84=ED=8E=B8=EA=B2=B0?= =?UTF-8?q?=EC=A0=9C=EB=A7=8C=20=EB=86=94=EB=91=90=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../eatsfine/domain/payment/enums/PaymentMethod.java | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/main/java/com/eatsfine/eatsfine/domain/payment/enums/PaymentMethod.java b/src/main/java/com/eatsfine/eatsfine/domain/payment/enums/PaymentMethod.java index 07f4734..62f47c7 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/payment/enums/PaymentMethod.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/payment/enums/PaymentMethod.java @@ -6,10 +6,7 @@ @Getter @AllArgsConstructor public enum PaymentMethod { - CARD("카드"), - VIRTUAL_ACCOUNT("가상계좌"), - SIMPLE_PAYMENT("간편결제"), - PHONE("휴대폰"); + SIMPLE_PAYMENT("간편결제"); private final String description; } From 171348c31e3af3a30ab7e33e53de783371b71524 Mon Sep 17 00:00:00 2001 From: CHAN <150508884+zerochani@users.noreply.github.com> Date: Sun, 18 Jan 2026 00:39:17 +0900 Subject: [PATCH 2/8] =?UTF-8?q?[FEAT]:=20=ED=86=A0=EC=8A=A4=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EB=A8=BC=EC=B8=A0=20=EA=B2=B0=EC=A0=9C=EC=9C=84?= =?UTF-8?q?=EC=A0=AF=20=EC=97=B0=EB=8F=99=20=EB=B0=8F=20=EC=8A=B9=EC=9D=B8?= =?UTF-8?q?=20=ED=94=84=EB=A1=9C=EC=84=B8=EC=8A=A4=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 5 +- .../payment/controller/PaymentController.java | 10 +- .../dto/request/PaymentConfirmDTO.java | 11 ++ .../dto/request/PaymentRequestDTO.java | 6 +- .../dto/response/PaymentResponseDTO.java | 5 +- .../dto/response/TossPaymentResponse.java | 29 ++++ .../domain/payment/entity/Payment.java | 6 +- .../payment/repository/PaymentRepository.java | 3 + .../payment/service/PaymentService.java | 131 ++++++++++++------ .../apiPayload/code/status/ErrorStatus.java | 7 +- .../global/config/TossPaymentConfig.java | 26 ++++ src/main/resources/application-local.yml | 4 + 12 files changed, 185 insertions(+), 58 deletions(-) create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/payment/dto/request/PaymentConfirmDTO.java create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/payment/dto/response/TossPaymentResponse.java create mode 100644 src/main/java/com/eatsfine/eatsfine/global/config/TossPaymentConfig.java diff --git a/.gitignore b/.gitignore index fcdd271..a0b8034 100644 --- a/.gitignore +++ b/.gitignore @@ -49,4 +49,7 @@ src/main/resources/application.yml build.gradle # QueryDSL generated sources -/build/generated/ \ No newline at end of file +/build/generated/ + +src/main/resources/static/payment-test.html +src/main/resources/static/payment-success.html diff --git a/src/main/java/com/eatsfine/eatsfine/domain/payment/controller/PaymentController.java b/src/main/java/com/eatsfine/eatsfine/domain/payment/controller/PaymentController.java index d328440..dc3d4a1 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/payment/controller/PaymentController.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/payment/controller/PaymentController.java @@ -1,5 +1,6 @@ package com.eatsfine.eatsfine.domain.payment.controller; +import com.eatsfine.eatsfine.domain.payment.dto.request.PaymentConfirmDTO; import com.eatsfine.eatsfine.domain.payment.dto.request.PaymentRequestDTO; import com.eatsfine.eatsfine.domain.payment.dto.response.PaymentResponseDTO; import com.eatsfine.eatsfine.domain.payment.service.PaymentService; @@ -21,10 +22,17 @@ public class PaymentController { private final PaymentService paymentService; - @Operation(summary = "결제 요청", description = "예약 ID와 결제 제공자를 받아 결제를 요청합니다.") + @Operation(summary = "결제 요청", description = "예약 ID를 받아 주문 ID를 생성하고 결제 정보를 초기화합니다.") @PostMapping("/request") public ApiResponse requestPayment( @RequestBody @Valid PaymentRequestDTO.RequestPaymentDTO dto) { return ApiResponse.onSuccess(paymentService.requestPayment(dto)); } + + @Operation(summary = "결제 승인", description = "토스페이먼츠 결제 승인을 요청합니다.") + @PostMapping("/confirm") + public ApiResponse confirmPayment( + @RequestBody @Valid PaymentConfirmDTO dto) { + return ApiResponse.onSuccess(paymentService.confirmPayment(dto)); + } } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/payment/dto/request/PaymentConfirmDTO.java b/src/main/java/com/eatsfine/eatsfine/domain/payment/dto/request/PaymentConfirmDTO.java new file mode 100644 index 0000000..863c4a6 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/payment/dto/request/PaymentConfirmDTO.java @@ -0,0 +1,11 @@ +package com.eatsfine.eatsfine.domain.payment.dto.request; + +import jakarta.validation.constraints.NotNull; +import lombok.Builder; + +@Builder +public record PaymentConfirmDTO( + @NotNull String paymentKey, + @NotNull String orderId, + @NotNull Integer amount) { +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/payment/dto/request/PaymentRequestDTO.java b/src/main/java/com/eatsfine/eatsfine/domain/payment/dto/request/PaymentRequestDTO.java index 199e4f0..a7dc788 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/payment/dto/request/PaymentRequestDTO.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/payment/dto/request/PaymentRequestDTO.java @@ -1,14 +1,10 @@ package com.eatsfine.eatsfine.domain.payment.dto.request; -import com.eatsfine.eatsfine.domain.payment.enums.PaymentProvider; import jakarta.validation.constraints.NotNull; public class PaymentRequestDTO { public record RequestPaymentDTO( - @NotNull Long bookingId, - @NotNull PaymentProvider provider, - @NotNull String successUrl, - @NotNull String failUrl) { + @NotNull Long bookingId) { } } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/payment/dto/response/PaymentResponseDTO.java b/src/main/java/com/eatsfine/eatsfine/domain/payment/dto/response/PaymentResponseDTO.java index 5f4fc62..8f100ed 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/payment/dto/response/PaymentResponseDTO.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/payment/dto/response/PaymentResponseDTO.java @@ -10,11 +10,8 @@ public class PaymentResponseDTO { public record PaymentRequestResultDTO( Long paymentId, Long bookingId, - PaymentMethod paymentMethod, - String tid, + String orderId, Integer amount, - PaymentStatus paymentStatus, - String nextRedirectUrl, LocalDateTime requestedAt) { } } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/payment/dto/response/TossPaymentResponse.java b/src/main/java/com/eatsfine/eatsfine/domain/payment/dto/response/TossPaymentResponse.java new file mode 100644 index 0000000..5e41d6f --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/payment/dto/response/TossPaymentResponse.java @@ -0,0 +1,29 @@ +package com.eatsfine.eatsfine.domain.payment.dto.response; + +import java.time.OffsetDateTime; + +public record TossPaymentResponse( + String paymentKey, + String type, + String orderId, + String orderName, + String mId, + String currency, + String method, + Integer totalAmount, + Integer balanceAmount, + String status, + OffsetDateTime requestedAt, + OffsetDateTime approvedAt, + Boolean useEscrow, + String lastTransactionKey, + Integer suppliedAmount, + Integer vat, + EasyPay easyPay) { + + public record EasyPay( + String provider, + Integer amount, + Integer discountAmount) { + } +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/payment/entity/Payment.java b/src/main/java/com/eatsfine/eatsfine/domain/payment/entity/Payment.java index 8e2f8f8..ae339a7 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/payment/entity/Payment.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/payment/entity/Payment.java @@ -38,7 +38,7 @@ public class Payment extends BaseEntity { private String paymentKey; @Enumerated(EnumType.STRING) - @Column(name = "payment_provider", nullable = false) + @Column(name = "payment_provider") private PaymentProvider paymentProvider; @Enumerated(EnumType.STRING) @@ -63,10 +63,12 @@ public void setPaymentKey(String paymentKey) { this.paymentKey = paymentKey; } - public void completePayment(LocalDateTime approvedAt, PaymentMethod method, String paymentKey) { + public void completePayment(LocalDateTime approvedAt, PaymentMethod method, String paymentKey, + PaymentProvider provider) { this.paymentStatus = PaymentStatus.COMPLETED; this.approvedAt = approvedAt; this.paymentMethod = method; this.paymentKey = paymentKey; + this.paymentProvider = provider; } } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/payment/repository/PaymentRepository.java b/src/main/java/com/eatsfine/eatsfine/domain/payment/repository/PaymentRepository.java index 3c2c17c..39567ac 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/payment/repository/PaymentRepository.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/payment/repository/PaymentRepository.java @@ -3,5 +3,8 @@ import com.eatsfine.eatsfine.domain.payment.entity.Payment; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.Optional; + public interface PaymentRepository extends JpaRepository { + Optional findByOrderId(String orderId); } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/payment/service/PaymentService.java b/src/main/java/com/eatsfine/eatsfine/domain/payment/service/PaymentService.java index b8ec2f2..4414afe 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/payment/service/PaymentService.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/payment/service/PaymentService.java @@ -2,18 +2,22 @@ import com.eatsfine.eatsfine.domain.booking.entity.Booking; import com.eatsfine.eatsfine.domain.booking.repository.BookingRepository; -import com.eatsfine.eatsfine.global.apiPayload.code.status.ErrorStatus; -import com.eatsfine.eatsfine.global.apiPayload.exception.GeneralException; +import com.eatsfine.eatsfine.domain.payment.dto.request.PaymentConfirmDTO; import com.eatsfine.eatsfine.domain.payment.dto.request.PaymentRequestDTO; import com.eatsfine.eatsfine.domain.payment.dto.response.PaymentResponseDTO; +import com.eatsfine.eatsfine.domain.payment.dto.response.TossPaymentResponse; import com.eatsfine.eatsfine.domain.payment.entity.Payment; - +import com.eatsfine.eatsfine.domain.payment.enums.PaymentMethod; +import com.eatsfine.eatsfine.domain.payment.enums.PaymentProvider; import com.eatsfine.eatsfine.domain.payment.enums.PaymentStatus; import com.eatsfine.eatsfine.domain.payment.enums.PaymentType; import com.eatsfine.eatsfine.domain.payment.repository.PaymentRepository; +import com.eatsfine.eatsfine.global.apiPayload.code.status.ErrorStatus; +import com.eatsfine.eatsfine.global.apiPayload.exception.GeneralException; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.client.RestClient; import java.time.LocalDateTime; import java.util.UUID; @@ -22,46 +26,85 @@ @RequiredArgsConstructor public class PaymentService { - private final PaymentRepository paymentRepository; - private final BookingRepository bookingRepository; - - @Transactional - public PaymentResponseDTO.PaymentRequestResultDTO requestPayment(PaymentRequestDTO.RequestPaymentDTO dto) { - Booking booking = bookingRepository.findById(dto.bookingId()) - .orElseThrow(() -> new IllegalArgumentException("Booking not found")); - - // 주문 ID 생성 - String orderId = UUID.randomUUID().toString(); - - // 예약금 검증 - if (booking.getDepositAmount() == null || booking.getDepositAmount() <= 0) { - throw new GeneralException(ErrorStatus.PAYMENT_INVALID_DEPOSIT); - } - - Payment payment = Payment.builder() - .booking(booking) - .orderId(orderId) - .amount(booking.getDepositAmount()) - .paymentProvider(dto.provider()) - .paymentStatus(PaymentStatus.PENDING) - .paymentType(PaymentType.DEPOSIT) - .requestedAt(LocalDateTime.now()) - .build(); - - Payment savedPayment = paymentRepository.save(payment); - - // 외부 결제 제공자 응답 모의 처리 - String tid = "T" + UUID.randomUUID().toString().replace("-", "").substring(0, 10); - String nextRedirectUrl = "https://mock.api.kakaopay.com/online/v1/payment/ready/" + tid; - - return new PaymentResponseDTO.PaymentRequestResultDTO( - savedPayment.getId(), - booking.getId(), - savedPayment.getPaymentMethod(), - tid, - savedPayment.getAmount(), - savedPayment.getPaymentStatus(), - nextRedirectUrl, - savedPayment.getRequestedAt()); + private final PaymentRepository paymentRepository; + private final BookingRepository bookingRepository; + private final RestClient tossPaymentClient; + + @Transactional + public PaymentResponseDTO.PaymentRequestResultDTO requestPayment(PaymentRequestDTO.RequestPaymentDTO dto) { + Booking booking = bookingRepository.findById(dto.bookingId()) + .orElseThrow(() -> new GeneralException(ErrorStatus.BOOKING_NOT_FOUND)); + + // 주문 ID 생성 + String orderId = UUID.randomUUID().toString(); + + // 예약금 검증 + if (booking.getDepositAmount() == null || booking.getDepositAmount() <= 0) { + throw new GeneralException(ErrorStatus.PAYMENT_INVALID_DEPOSIT); } + + Payment payment = Payment.builder() + .booking(booking) + .orderId(orderId) + .amount(booking.getDepositAmount()) + .paymentStatus(PaymentStatus.PENDING) + .paymentType(PaymentType.DEPOSIT) + .requestedAt(LocalDateTime.now()) + .build(); + + Payment savedPayment = paymentRepository.save(payment); + + return new PaymentResponseDTO.PaymentRequestResultDTO( + savedPayment.getId(), + booking.getId(), + savedPayment.getOrderId(), + savedPayment.getAmount(), + savedPayment.getRequestedAt()); + } + + @Transactional + public PaymentResponseDTO.PaymentRequestResultDTO confirmPayment(PaymentConfirmDTO dto) { + Payment payment = paymentRepository.findByOrderId(dto.orderId()) + .orElseThrow(() -> new GeneralException(ErrorStatus.PAYMENT_NOT_FOUND)); + + if (!payment.getAmount().equals(dto.amount())) { + throw new GeneralException(ErrorStatus.PAYMENT_INVALID_AMOUNT); + } + + // 토스 API 호출 + TossPaymentResponse response = tossPaymentClient.post() + .uri("/v1/payments/confirm") + .body(dto) + .retrieve() + .body(TossPaymentResponse.class); + + if (response == null || !"DONE".equals(response.status())) { + throw new GeneralException(ErrorStatus._INTERNAL_SERVER_ERROR); + } + + // Provider 파싱 + PaymentProvider provider = null; + if (response.easyPay() != null) { + String providerCode = response.easyPay().provider(); + if ("토스페이".equals(providerCode)) { + provider = PaymentProvider.TOSS; + } else if ("카카오페이".equals(providerCode)) { + provider = PaymentProvider.KAKAOPAY; + } + } + + payment.completePayment( + response.approvedAt() != null ? response.approvedAt().toLocalDateTime() : LocalDateTime.now(), + PaymentMethod.SIMPLE_PAYMENT, + response.paymentKey(), + provider + ); + + return new PaymentResponseDTO.PaymentRequestResultDTO( + payment.getId(), + payment.getBooking().getId(), + payment.getOrderId(), + payment.getAmount(), + payment.getRequestedAt()); + } } diff --git a/src/main/java/com/eatsfine/eatsfine/global/apiPayload/code/status/ErrorStatus.java b/src/main/java/com/eatsfine/eatsfine/global/apiPayload/code/status/ErrorStatus.java index fc94377..f10d8c8 100644 --- a/src/main/java/com/eatsfine/eatsfine/global/apiPayload/code/status/ErrorStatus.java +++ b/src/main/java/com/eatsfine/eatsfine/global/apiPayload/code/status/ErrorStatus.java @@ -16,7 +16,12 @@ public enum ErrorStatus implements BaseErrorCode { _NOT_FOUND(HttpStatus.NOT_FOUND, "COMMON404", "존재하지 않는 요청입니다."), // 예약금 관련 에러 - PAYMENT_INVALID_DEPOSIT(HttpStatus.BAD_REQUEST, "PAYMENT4001", "예약금이 유효하지 않습니다."); + PAYMENT_INVALID_DEPOSIT(HttpStatus.BAD_REQUEST, "PAYMENT4001", "예약금이 유효하지 않습니다."), + PAYMENT_INVALID_AMOUNT(HttpStatus.BAD_REQUEST, "PAYMENT4002", "결제 금액이 일치하지 않습니다."), + PAYMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "PAYMENT4003", "결제 정보를 찾을 수 없습니다."), + + // 예약 관련 에러 + BOOKING_NOT_FOUND(HttpStatus.NOT_FOUND, "BOOKING4001", "예약을 찾을 수 없습니다."); private final HttpStatus httpStatus; private final String code; diff --git a/src/main/java/com/eatsfine/eatsfine/global/config/TossPaymentConfig.java b/src/main/java/com/eatsfine/eatsfine/global/config/TossPaymentConfig.java new file mode 100644 index 0000000..d210a39 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/global/config/TossPaymentConfig.java @@ -0,0 +1,26 @@ +package com.eatsfine.eatsfine.global.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.client.RestClient; + +import java.util.Base64; + +@Configuration +public class TossPaymentConfig { + + @Value("${payment.toss.widget-secret-key}") + private String widgetSecretKey; + + @Bean + public RestClient tossPaymentClient() { + String encodedSecretKey = Base64.getEncoder().encodeToString((widgetSecretKey + ":").getBytes()); + + return RestClient.builder() + .baseUrl("https://api.tosspayments.com") + .defaultHeader("Authorization", "Basic " + encodedSecretKey) + .defaultHeader("Content-Type", "application/json") + .build(); + } +} diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index ef35d7c..6feee07 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -23,3 +23,7 @@ spring: properties: hibernate: format_sql: true + +payment: + toss: + widget-secret-key: test_gsk_docs_OaPz8L5KdmQXkzRz3y47BMw6 From 19a42d2594e7027ffdfd1b2503adff2bb15d6a7c Mon Sep 17 00:00:00 2001 From: CHAN <150508884+zerochani@users.noreply.github.com> Date: Sun, 18 Jan 2026 01:14:13 +0900 Subject: [PATCH 3/8] =?UTF-8?q?[FEAT]:=20=EB=8F=85=EB=A6=BD=EC=A0=81?= =?UTF-8?q?=EC=9D=B8=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=ED=99=98=EA=B2=BD=20?= =?UTF-8?q?=EA=B5=AC=EC=84=B1=EC=9D=84=20=EC=9C=84=ED=95=9C=20H2=20DB=20?= =?UTF-8?q?=EB=B0=8F=20=EC=84=A4=EC=A0=95=20=EC=B6=94=EA=B0=80=ED=95=B4?= =?UTF-8?q?=EC=84=9C=20=EB=B9=8C=EB=93=9C=20=EC=8B=A4=ED=8C=A8=20=ED=95=B4?= =?UTF-8?q?=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/test/resources/application.yml | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index ce96af2..920ac33 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -1,5 +1,18 @@ spring: - application: - name: Eatsfine - profiles: - active: test \ No newline at end of file + datasource: + url: jdbc:h2:mem:testdb;MODE=MySQL + driver-class-name: org.h2.Driver + username: sa + password: + jpa: + hibernate: + ddl-auto: create-drop + show-sql: true + properties: + hibernate: + format_sql: true + dialect: org.hibernate.dialect.H2Dialect + +payment: + toss: + widget-secret-key: test_sk_sample_key_for_testing \ No newline at end of file From 106291468188a86ee6e11271636efa36da6cd5ab Mon Sep 17 00:00:00 2001 From: CHAN <150508884+zerochani@users.noreply.github.com> Date: Sun, 18 Jan 2026 16:50:19 +0900 Subject: [PATCH 4/8] =?UTF-8?q?[FIX]:=20username=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/test/resources/application.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index 920ac33..c005f2e 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -2,7 +2,7 @@ spring: datasource: url: jdbc:h2:mem:testdb;MODE=MySQL driver-class-name: org.h2.Driver - username: sa + username: password: jpa: hibernate: From 08eebc6fb4d28c894e99224f358f3b78cf8faabc Mon Sep 17 00:00:00 2001 From: CHAN <150508884+zerochani@users.noreply.github.com> Date: Tue, 20 Jan 2026 10:01:50 +0900 Subject: [PATCH 5/8] =?UTF-8?q?[FEAT]:=20=EA=B2=B0=EC=A0=9C=20=EC=8A=B9?= =?UTF-8?q?=EC=9D=B8=20=EA=B2=B0=EA=B3=BC(=EC=84=B1=EA=B3=B5/=EC=8B=A4?= =?UTF-8?q?=ED=8C=A8)=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/payment/entity/Payment.java | 4 ++ .../payment/service/PaymentService.java | 46 +++++++++++++------ 2 files changed, 35 insertions(+), 15 deletions(-) diff --git a/src/main/java/com/eatsfine/eatsfine/domain/payment/entity/Payment.java b/src/main/java/com/eatsfine/eatsfine/domain/payment/entity/Payment.java index ae339a7..f52933a 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/payment/entity/Payment.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/payment/entity/Payment.java @@ -71,4 +71,8 @@ public void completePayment(LocalDateTime approvedAt, PaymentMethod method, Stri this.paymentKey = paymentKey; this.paymentProvider = provider; } + + public void failPayment() { + this.paymentStatus = PaymentStatus.FAILED; + } } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/payment/service/PaymentService.java b/src/main/java/com/eatsfine/eatsfine/domain/payment/service/PaymentService.java index 4414afe..d94b7b5 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/payment/service/PaymentService.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/payment/service/PaymentService.java @@ -12,9 +12,12 @@ import com.eatsfine.eatsfine.domain.payment.enums.PaymentStatus; import com.eatsfine.eatsfine.domain.payment.enums.PaymentType; import com.eatsfine.eatsfine.domain.payment.repository.PaymentRepository; +import com.eatsfine.eatsfine.domain.payment.exception.PaymentException; +import com.eatsfine.eatsfine.domain.payment.status.PaymentErrorStatus; import com.eatsfine.eatsfine.global.apiPayload.code.status.ErrorStatus; import com.eatsfine.eatsfine.global.apiPayload.exception.GeneralException; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.client.RestClient; @@ -22,6 +25,7 @@ import java.time.LocalDateTime; import java.util.UUID; +@Slf4j @Service @RequiredArgsConstructor public class PaymentService { @@ -33,14 +37,14 @@ public class PaymentService { @Transactional public PaymentResponseDTO.PaymentRequestResultDTO requestPayment(PaymentRequestDTO.RequestPaymentDTO dto) { Booking booking = bookingRepository.findById(dto.bookingId()) - .orElseThrow(() -> new GeneralException(ErrorStatus.BOOKING_NOT_FOUND)); + .orElseThrow(() -> new PaymentException(PaymentErrorStatus._BOOKING_NOT_FOUND)); // 주문 ID 생성 String orderId = UUID.randomUUID().toString(); // 예약금 검증 if (booking.getDepositAmount() == null || booking.getDepositAmount() <= 0) { - throw new GeneralException(ErrorStatus.PAYMENT_INVALID_DEPOSIT); + throw new PaymentException(PaymentErrorStatus._PAYMENT_INVALID_DEPOSIT); } Payment payment = Payment.builder() @@ -62,27 +66,37 @@ public PaymentResponseDTO.PaymentRequestResultDTO requestPayment(PaymentRequestD savedPayment.getRequestedAt()); } - @Transactional + @Transactional(noRollbackFor = GeneralException.class) public PaymentResponseDTO.PaymentRequestResultDTO confirmPayment(PaymentConfirmDTO dto) { Payment payment = paymentRepository.findByOrderId(dto.orderId()) - .orElseThrow(() -> new GeneralException(ErrorStatus.PAYMENT_NOT_FOUND)); + .orElseThrow(() -> new PaymentException(PaymentErrorStatus._PAYMENT_NOT_FOUND)); if (!payment.getAmount().equals(dto.amount())) { - throw new GeneralException(ErrorStatus.PAYMENT_INVALID_AMOUNT); + payment.failPayment(); + throw new PaymentException(PaymentErrorStatus._PAYMENT_INVALID_AMOUNT); } - // 토스 API 호출 - TossPaymentResponse response = tossPaymentClient.post() - .uri("/v1/payments/confirm") - .body(dto) - .retrieve() - .body(TossPaymentResponse.class); - - if (response == null || !"DONE".equals(response.status())) { - throw new GeneralException(ErrorStatus._INTERNAL_SERVER_ERROR); + // 토스 API 호출 + TossPaymentResponse response; + try { + response = tossPaymentClient.post() + .uri("/v1/payments/confirm") + .body(dto) + .retrieve() + .body(TossPaymentResponse.class); + + if (response == null || !"DONE".equals(response.status())) { + log.error("Toss Payment Confirmation Failed: Status is not DONE"); + payment.failPayment(); + throw new GeneralException(ErrorStatus._INTERNAL_SERVER_ERROR); + } + } catch (Exception e) { + log.error("Toss Payment API Error", e); + payment.failPayment(); + throw new GeneralException(ErrorStatus._INTERNAL_SERVER_ERROR); } - // Provider 파싱 + // Provider 파싱 PaymentProvider provider = null; if (response.easyPay() != null) { String providerCode = response.easyPay().provider(); @@ -100,6 +114,8 @@ public PaymentResponseDTO.PaymentRequestResultDTO confirmPayment(PaymentConfirmD provider ); + log.info("Payment confirmed for OrderID: {}", dto.orderId()); + return new PaymentResponseDTO.PaymentRequestResultDTO( payment.getId(), payment.getBooking().getId(), From c9d6a8ce06c89b1d65b28c895cda69b27117d4e5 Mon Sep 17 00:00:00 2001 From: CHAN <150508884+zerochani@users.noreply.github.com> Date: Tue, 20 Jan 2026 10:02:22 +0900 Subject: [PATCH 6/8] =?UTF-8?q?[REFACTOR]:=20Payment=20=EC=98=88=EC=99=B8?= =?UTF-8?q?=20=EC=B2=98=EB=A6=AC=20=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../payment/exception/PaymentException.java | 11 +++++ .../payment/status/PaymentErrorStatus.java | 40 +++++++++++++++++++ .../apiPayload/code/status/ErrorStatus.java | 10 +---- 3 files changed, 52 insertions(+), 9 deletions(-) create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/payment/exception/PaymentException.java create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/payment/status/PaymentErrorStatus.java diff --git a/src/main/java/com/eatsfine/eatsfine/domain/payment/exception/PaymentException.java b/src/main/java/com/eatsfine/eatsfine/domain/payment/exception/PaymentException.java new file mode 100644 index 0000000..c6d2580 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/payment/exception/PaymentException.java @@ -0,0 +1,11 @@ +package com.eatsfine.eatsfine.domain.payment.exception; + +import com.eatsfine.eatsfine.global.apiPayload.code.BaseErrorCode; +import com.eatsfine.eatsfine.global.apiPayload.exception.GeneralException; + +public class PaymentException extends GeneralException { + + public PaymentException(BaseErrorCode code) { + super(code); + } +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/payment/status/PaymentErrorStatus.java b/src/main/java/com/eatsfine/eatsfine/domain/payment/status/PaymentErrorStatus.java new file mode 100644 index 0000000..e6dddaa --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/payment/status/PaymentErrorStatus.java @@ -0,0 +1,40 @@ +package com.eatsfine.eatsfine.domain.payment.status; + +import com.eatsfine.eatsfine.global.apiPayload.code.BaseErrorCode; +import com.eatsfine.eatsfine.global.apiPayload.code.ErrorReasonDto; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum PaymentErrorStatus implements BaseErrorCode { + + _PAYMENT_INVALID_DEPOSIT(HttpStatus.BAD_REQUEST, "PAYMENT4001", "예약금이 유효하지 않습니다."), + _PAYMENT_INVALID_AMOUNT(HttpStatus.BAD_REQUEST, "PAYMENT4002", "결제 금액이 일치하지 않습니다."), + _PAYMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "PAYMENT4003", "결제 정보를 찾을 수 없습니다."), + _BOOKING_NOT_FOUND(HttpStatus.NOT_FOUND, "BOOKING4001", "예약을 찾을 수 없습니다."); + + private final HttpStatus httpStatus; + private final String code; + private final String message; + + @Override + public ErrorReasonDto getReason() { + return ErrorReasonDto.builder() + .isSuccess(false) + .code(code) + .message(message) + .build(); + } + + @Override + public ErrorReasonDto getReasonHttpStatus() { + return ErrorReasonDto.builder() + .httpStatus(httpStatus) + .isSuccess(false) + .code(code) + .message(message) + .build(); + } +} diff --git a/src/main/java/com/eatsfine/eatsfine/global/apiPayload/code/status/ErrorStatus.java b/src/main/java/com/eatsfine/eatsfine/global/apiPayload/code/status/ErrorStatus.java index f10d8c8..2d63881 100644 --- a/src/main/java/com/eatsfine/eatsfine/global/apiPayload/code/status/ErrorStatus.java +++ b/src/main/java/com/eatsfine/eatsfine/global/apiPayload/code/status/ErrorStatus.java @@ -13,15 +13,7 @@ public enum ErrorStatus implements BaseErrorCode { _BAD_REQUEST(HttpStatus.BAD_REQUEST, "COMMON400", "잘못된 요청입니다."), _UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "COMMON401", "인증이 필요합니다."), _FORBIDDEN(HttpStatus.FORBIDDEN, "COMMON403", "금지된 요청입니다."), - _NOT_FOUND(HttpStatus.NOT_FOUND, "COMMON404", "존재하지 않는 요청입니다."), - - // 예약금 관련 에러 - PAYMENT_INVALID_DEPOSIT(HttpStatus.BAD_REQUEST, "PAYMENT4001", "예약금이 유효하지 않습니다."), - PAYMENT_INVALID_AMOUNT(HttpStatus.BAD_REQUEST, "PAYMENT4002", "결제 금액이 일치하지 않습니다."), - PAYMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "PAYMENT4003", "결제 정보를 찾을 수 없습니다."), - - // 예약 관련 에러 - BOOKING_NOT_FOUND(HttpStatus.NOT_FOUND, "BOOKING4001", "예약을 찾을 수 없습니다."); + _NOT_FOUND(HttpStatus.NOT_FOUND, "COMMON404", "존재하지 않는 요청입니다."); private final HttpStatus httpStatus; private final String code; From 35be75bda2e1b44f7c6210c3e70dc2f7cc297934 Mon Sep 17 00:00:00 2001 From: CHAN <150508884+zerochani@users.noreply.github.com> Date: Tue, 20 Jan 2026 10:32:58 +0900 Subject: [PATCH 7/8] =?UTF-8?q?[FEAT]:=20=EA=B2=B0=EC=A0=9C=20=EC=B7=A8?= =?UTF-8?q?=EC=86=8C(=ED=99=98=EB=B6=88)=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../payment/controller/PaymentController.java | 9 + .../dto/request/PaymentRequestDTO.java | 12 +- .../dto/response/PaymentResponseDTO.java | 8 + .../domain/payment/entity/Payment.java | 4 + .../payment/repository/PaymentRepository.java | 1 + .../payment/service/PaymentService.java | 175 +++++++++++------- 6 files changed, 135 insertions(+), 74 deletions(-) diff --git a/src/main/java/com/eatsfine/eatsfine/domain/payment/controller/PaymentController.java b/src/main/java/com/eatsfine/eatsfine/domain/payment/controller/PaymentController.java index dc3d4a1..e5d26ed 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/payment/controller/PaymentController.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/payment/controller/PaymentController.java @@ -13,6 +13,7 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.PathVariable; @Tag(name = "Payment", description = "결제 관련 API") @RestController @@ -35,4 +36,12 @@ public ApiResponse confirmPayment( @RequestBody @Valid PaymentConfirmDTO dto) { return ApiResponse.onSuccess(paymentService.confirmPayment(dto)); } + + @Operation(summary = "결제 취소", description = "결제 키를 받아 결제를 취소합니다.") + @PostMapping("/{paymentKey}/cancel") + public ApiResponse cancelPayment( + @PathVariable String paymentKey, + @RequestBody @Valid PaymentRequestDTO.CancelPaymentDTO dto) { + return ApiResponse.onSuccess(paymentService.cancelPayment(paymentKey, dto)); + } } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/payment/dto/request/PaymentRequestDTO.java b/src/main/java/com/eatsfine/eatsfine/domain/payment/dto/request/PaymentRequestDTO.java index a7dc788..12929ef 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/payment/dto/request/PaymentRequestDTO.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/payment/dto/request/PaymentRequestDTO.java @@ -4,7 +4,13 @@ public class PaymentRequestDTO { - public record RequestPaymentDTO( - @NotNull Long bookingId) { - } + public record RequestPaymentDTO( + @NotNull Long bookingId) { + } + + public record CancelPaymentDTO( + @NotNull String cancelReason) { + + } + } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/payment/dto/response/PaymentResponseDTO.java b/src/main/java/com/eatsfine/eatsfine/domain/payment/dto/response/PaymentResponseDTO.java index 8f100ed..f919352 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/payment/dto/response/PaymentResponseDTO.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/payment/dto/response/PaymentResponseDTO.java @@ -14,4 +14,12 @@ public record PaymentRequestResultDTO( Integer amount, LocalDateTime requestedAt) { } + + public record CancelPaymentResultDTO( + Long paymentId, + String orderId, + String paymentKey, + String status, + LocalDateTime canceledAt) { + } } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/payment/entity/Payment.java b/src/main/java/com/eatsfine/eatsfine/domain/payment/entity/Payment.java index f52933a..59bf788 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/payment/entity/Payment.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/payment/entity/Payment.java @@ -75,4 +75,8 @@ public void completePayment(LocalDateTime approvedAt, PaymentMethod method, Stri public void failPayment() { this.paymentStatus = PaymentStatus.FAILED; } + + public void cancelPayment() { + this.paymentStatus = PaymentStatus.REFUNDED; + } } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/payment/repository/PaymentRepository.java b/src/main/java/com/eatsfine/eatsfine/domain/payment/repository/PaymentRepository.java index 39567ac..e98709a 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/payment/repository/PaymentRepository.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/payment/repository/PaymentRepository.java @@ -7,4 +7,5 @@ public interface PaymentRepository extends JpaRepository { Optional findByOrderId(String orderId); + Optional findByPaymentKey(String paymentKey); } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/payment/service/PaymentService.java b/src/main/java/com/eatsfine/eatsfine/domain/payment/service/PaymentService.java index d94b7b5..983de43 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/payment/service/PaymentService.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/payment/service/PaymentService.java @@ -30,97 +30,130 @@ @RequiredArgsConstructor public class PaymentService { - private final PaymentRepository paymentRepository; - private final BookingRepository bookingRepository; - private final RestClient tossPaymentClient; - - @Transactional - public PaymentResponseDTO.PaymentRequestResultDTO requestPayment(PaymentRequestDTO.RequestPaymentDTO dto) { - Booking booking = bookingRepository.findById(dto.bookingId()) - .orElseThrow(() -> new PaymentException(PaymentErrorStatus._BOOKING_NOT_FOUND)); - - // 주문 ID 생성 - String orderId = UUID.randomUUID().toString(); - - // 예약금 검증 - if (booking.getDepositAmount() == null || booking.getDepositAmount() <= 0) { - throw new PaymentException(PaymentErrorStatus._PAYMENT_INVALID_DEPOSIT); + private final PaymentRepository paymentRepository; + private final BookingRepository bookingRepository; + private final RestClient tossPaymentClient; + + @Transactional + public PaymentResponseDTO.PaymentRequestResultDTO requestPayment(PaymentRequestDTO.RequestPaymentDTO dto) { + Booking booking = bookingRepository.findById(dto.bookingId()) + .orElseThrow(() -> new PaymentException(PaymentErrorStatus._BOOKING_NOT_FOUND)); + + // 주문 ID 생성 + String orderId = UUID.randomUUID().toString(); + + // 예약금 검증 + if (booking.getDepositAmount() == null || booking.getDepositAmount() <= 0) { + throw new PaymentException(PaymentErrorStatus._PAYMENT_INVALID_DEPOSIT); + } + + Payment payment = Payment.builder() + .booking(booking) + .orderId(orderId) + .amount(booking.getDepositAmount()) + .paymentStatus(PaymentStatus.PENDING) + .paymentType(PaymentType.DEPOSIT) + .requestedAt(LocalDateTime.now()) + .build(); + + Payment savedPayment = paymentRepository.save(payment); + + return new PaymentResponseDTO.PaymentRequestResultDTO( + savedPayment.getId(), + booking.getId(), + savedPayment.getOrderId(), + savedPayment.getAmount(), + savedPayment.getRequestedAt()); } - Payment payment = Payment.builder() - .booking(booking) - .orderId(orderId) - .amount(booking.getDepositAmount()) - .paymentStatus(PaymentStatus.PENDING) - .paymentType(PaymentType.DEPOSIT) - .requestedAt(LocalDateTime.now()) - .build(); - - Payment savedPayment = paymentRepository.save(payment); - - return new PaymentResponseDTO.PaymentRequestResultDTO( - savedPayment.getId(), - booking.getId(), - savedPayment.getOrderId(), - savedPayment.getAmount(), - savedPayment.getRequestedAt()); - } - + @Transactional(noRollbackFor = GeneralException.class) + public PaymentResponseDTO.PaymentRequestResultDTO confirmPayment(PaymentConfirmDTO dto) { + Payment payment = paymentRepository.findByOrderId(dto.orderId()) + .orElseThrow(() -> new PaymentException(PaymentErrorStatus._PAYMENT_NOT_FOUND)); + + if (!payment.getAmount().equals(dto.amount())) { + payment.failPayment(); + throw new PaymentException(PaymentErrorStatus._PAYMENT_INVALID_AMOUNT); + } + + // 토스 API 호출 + TossPaymentResponse response; + try { + response = tossPaymentClient.post() + .uri("/v1/payments/confirm") + .body(dto) + .retrieve() + .body(TossPaymentResponse.class); + + if (response == null || !"DONE".equals(response.status())) { + log.error("Toss Payment Confirmation Failed: Status is not DONE"); + payment.failPayment(); + throw new GeneralException(ErrorStatus._INTERNAL_SERVER_ERROR); + } + } catch (Exception e) { + log.error("Toss Payment API Error", e); + payment.failPayment(); + throw new GeneralException(ErrorStatus._INTERNAL_SERVER_ERROR); + } + + // Provider 파싱 + PaymentProvider provider = null; + if (response.easyPay() != null) { + String providerCode = response.easyPay().provider(); + if ("토스페이".equals(providerCode)) { + provider = PaymentProvider.TOSS; + } else if ("카카오페이".equals(providerCode)) { + provider = PaymentProvider.KAKAOPAY; + } + } + + payment.completePayment( + response.approvedAt() != null ? response.approvedAt().toLocalDateTime() + : LocalDateTime.now(), + PaymentMethod.SIMPLE_PAYMENT, + response.paymentKey(), + provider); + + log.info("Payment confirmed for OrderID: {}", dto.orderId()); + + return new PaymentResponseDTO.PaymentRequestResultDTO( + payment.getId(), + payment.getBooking().getId(), + payment.getOrderId(), + payment.getAmount(), + payment.getRequestedAt()); + } @Transactional(noRollbackFor = GeneralException.class) - public PaymentResponseDTO.PaymentRequestResultDTO confirmPayment(PaymentConfirmDTO dto) { - Payment payment = paymentRepository.findByOrderId(dto.orderId()) + public PaymentResponseDTO.CancelPaymentResultDTO cancelPayment(String paymentKey, PaymentRequestDTO.CancelPaymentDTO dto) { + Payment payment = paymentRepository.findByPaymentKey(paymentKey) .orElseThrow(() -> new PaymentException(PaymentErrorStatus._PAYMENT_NOT_FOUND)); - if (!payment.getAmount().equals(dto.amount())) { - payment.failPayment(); - throw new PaymentException(PaymentErrorStatus._PAYMENT_INVALID_AMOUNT); - } - - // 토스 API 호출 + // 토스 결제 취소 API 호출 TossPaymentResponse response; try { response = tossPaymentClient.post() - .uri("/v1/payments/confirm") + .uri("/v1/payments/" + paymentKey + "/cancel") .body(dto) .retrieve() .body(TossPaymentResponse.class); - if (response == null || !"DONE".equals(response.status())) { - log.error("Toss Payment Confirmation Failed: Status is not DONE"); - payment.failPayment(); + if (response == null || !"CANCELED".equals(response.status())) { + log.error("Toss Payment Cancel Failed: {}", response); throw new GeneralException(ErrorStatus._INTERNAL_SERVER_ERROR); } } catch (Exception e) { - log.error("Toss Payment API Error", e); - payment.failPayment(); + log.error("Toss Payment Cancel API Error", e); throw new GeneralException(ErrorStatus._INTERNAL_SERVER_ERROR); } - // Provider 파싱 - PaymentProvider provider = null; - if (response.easyPay() != null) { - String providerCode = response.easyPay().provider(); - if ("토스페이".equals(providerCode)) { - provider = PaymentProvider.TOSS; - } else if ("카카오페이".equals(providerCode)) { - provider = PaymentProvider.KAKAOPAY; - } - } + payment.cancelPayment(); - payment.completePayment( - response.approvedAt() != null ? response.approvedAt().toLocalDateTime() : LocalDateTime.now(), - PaymentMethod.SIMPLE_PAYMENT, - response.paymentKey(), - provider - ); - - log.info("Payment confirmed for OrderID: {}", dto.orderId()); - - return new PaymentResponseDTO.PaymentRequestResultDTO( + return new PaymentResponseDTO.CancelPaymentResultDTO( payment.getId(), - payment.getBooking().getId(), payment.getOrderId(), - payment.getAmount(), - payment.getRequestedAt()); + payment.getPaymentKey(), + payment.getPaymentStatus().name(), + LocalDateTime.now() + ); } } From 3db6f18f64c5b591577064de8ecea0f1f96a41c0 Mon Sep 17 00:00:00 2001 From: CHAN <150508884+zerochani@users.noreply.github.com> Date: Tue, 20 Jan 2026 17:28:23 +0900 Subject: [PATCH 8/8] =?UTF-8?q?[FEAT]:=20=EA=B2=B0=EC=A0=9C=20=EB=82=B4?= =?UTF-8?q?=EC=97=AD=20=EC=A1=B0=ED=9A=8C=20=EB=B0=8F=20=EC=83=81=EC=84=B8?= =?UTF-8?q?=20=EC=A1=B0=ED=9A=8C=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../payment/controller/PaymentController.java | 20 +++ .../dto/response/PaymentResponseDTO.java | 39 +++++ .../dto/response/TossPaymentResponse.java | 7 +- .../domain/payment/entity/Payment.java | 6 +- .../payment/repository/PaymentRepository.java | 8 + .../payment/service/PaymentService.java | 141 ++++++++++++++---- 6 files changed, 186 insertions(+), 35 deletions(-) diff --git a/src/main/java/com/eatsfine/eatsfine/domain/payment/controller/PaymentController.java b/src/main/java/com/eatsfine/eatsfine/domain/payment/controller/PaymentController.java index e5d26ed..83c10d6 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/payment/controller/PaymentController.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/payment/controller/PaymentController.java @@ -14,6 +14,8 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.GetMapping; @Tag(name = "Payment", description = "결제 관련 API") @RestController @@ -44,4 +46,22 @@ public ApiResponse cancelPayment( @RequestBody @Valid PaymentRequestDTO.CancelPaymentDTO dto) { return ApiResponse.onSuccess(paymentService.cancelPayment(paymentKey, dto)); } + + @Operation(summary = "결제 내역 조회", description = "로그인한 사용자의 결제 내역을 조회합니다.") + @GetMapping + public ApiResponse getPaymentList( + @RequestParam(name = "userId", required = false, defaultValue = "1") Long userId, + @RequestParam(name = "page", defaultValue = "1") Integer page, + @RequestParam(name = "limit", defaultValue = "10") Integer limit, + @RequestParam(name = "status", required = false) String status) { + // TODO: userId는 추후 Security Context에서 가져오도록 수정 + return ApiResponse.onSuccess(paymentService.getPaymentList(userId, page, limit, status)); + } + + @Operation(summary = "결제 상세 조회", description = "특정 결제 건의 상세 내역을 조회합니다.") + @GetMapping("/{paymentId}") + public ApiResponse getPaymentDetail( + @PathVariable Long paymentId) { + return ApiResponse.onSuccess(paymentService.getPaymentDetail(paymentId)); + } } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/payment/dto/response/PaymentResponseDTO.java b/src/main/java/com/eatsfine/eatsfine/domain/payment/dto/response/PaymentResponseDTO.java index f919352..9a00648 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/payment/dto/response/PaymentResponseDTO.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/payment/dto/response/PaymentResponseDTO.java @@ -4,6 +4,7 @@ import com.eatsfine.eatsfine.domain.payment.enums.PaymentStatus; import java.time.LocalDateTime; +import java.util.List; public class PaymentResponseDTO { @@ -22,4 +23,42 @@ public record CancelPaymentResultDTO( String status, LocalDateTime canceledAt) { } + + public record PaymentHistoryResultDTO( + Long paymentId, + Long bookingId, + String restaurantName, + Integer amount, + String paymentType, + String paymentMethod, + String paymentProvider, + String status, + LocalDateTime approvedAt) { + } + + public record PaymentListResponseDTO( + List payments, + PaginationDTO pagination) { + } + + public record PaginationDTO( + Integer currentPage, + Integer totalPages, + Long totalCount) { + } + + public record PaymentDetailResultDTO( + Long paymentId, + Long bookingId, + String restaurantName, + String paymentMethod, + String paymentProvider, + Integer amount, + String paymentType, + String status, + LocalDateTime requestedAt, + LocalDateTime approvedAt, + String receiptUrl, + String refundInfo) { + } } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/payment/dto/response/TossPaymentResponse.java b/src/main/java/com/eatsfine/eatsfine/domain/payment/dto/response/TossPaymentResponse.java index 5e41d6f..bc6fc4e 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/payment/dto/response/TossPaymentResponse.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/payment/dto/response/TossPaymentResponse.java @@ -19,11 +19,16 @@ public record TossPaymentResponse( String lastTransactionKey, Integer suppliedAmount, Integer vat, - EasyPay easyPay) { + EasyPay easyPay, + Receipt receipt) { public record EasyPay( String provider, Integer amount, Integer discountAmount) { } + + public record Receipt( + String url) { + } } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/payment/entity/Payment.java b/src/main/java/com/eatsfine/eatsfine/domain/payment/entity/Payment.java index 59bf788..ebf4415 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/payment/entity/Payment.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/payment/entity/Payment.java @@ -59,17 +59,21 @@ public class Payment extends BaseEntity { @Column(name = "payment_type", nullable = false) private PaymentType paymentType; + @Column(name = "receipt_url") + private String receiptUrl; + public void setPaymentKey(String paymentKey) { this.paymentKey = paymentKey; } public void completePayment(LocalDateTime approvedAt, PaymentMethod method, String paymentKey, - PaymentProvider provider) { + PaymentProvider provider, String receiptUrl) { this.paymentStatus = PaymentStatus.COMPLETED; this.approvedAt = approvedAt; this.paymentMethod = method; this.paymentKey = paymentKey; this.paymentProvider = provider; + this.receiptUrl = receiptUrl; } public void failPayment() { diff --git a/src/main/java/com/eatsfine/eatsfine/domain/payment/repository/PaymentRepository.java b/src/main/java/com/eatsfine/eatsfine/domain/payment/repository/PaymentRepository.java index e98709a..91cf70b 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/payment/repository/PaymentRepository.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/payment/repository/PaymentRepository.java @@ -1,11 +1,19 @@ package com.eatsfine.eatsfine.domain.payment.repository; import com.eatsfine.eatsfine.domain.payment.entity.Payment; +import com.eatsfine.eatsfine.domain.payment.enums.PaymentStatus; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import java.util.Optional; public interface PaymentRepository extends JpaRepository { Optional findByOrderId(String orderId); + Optional findByPaymentKey(String paymentKey); + + Page findAllByBooking_User_Id(Long userId, Pageable pageable); + + Page findAllByBooking_User_IdAndPaymentStatus(Long userId, PaymentStatus status, Pageable pageable); } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/payment/service/PaymentService.java b/src/main/java/com/eatsfine/eatsfine/domain/payment/service/PaymentService.java index 983de43..8d4c57d 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/payment/service/PaymentService.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/payment/service/PaymentService.java @@ -18,12 +18,16 @@ import com.eatsfine.eatsfine.global.apiPayload.exception.GeneralException; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.client.RestClient; import java.time.LocalDateTime; import java.util.UUID; +import java.util.List; +import java.util.stream.Collectors; @Slf4j @Service @@ -112,7 +116,8 @@ public PaymentResponseDTO.PaymentRequestResultDTO confirmPayment(PaymentConfirmD : LocalDateTime.now(), PaymentMethod.SIMPLE_PAYMENT, response.paymentKey(), - provider); + provider, + response.receipt() != null ? response.receipt().url() : null); log.info("Payment confirmed for OrderID: {}", dto.orderId()); @@ -123,37 +128,107 @@ public PaymentResponseDTO.PaymentRequestResultDTO confirmPayment(PaymentConfirmD payment.getAmount(), payment.getRequestedAt()); } - @Transactional(noRollbackFor = GeneralException.class) - public PaymentResponseDTO.CancelPaymentResultDTO cancelPayment(String paymentKey, PaymentRequestDTO.CancelPaymentDTO dto) { - Payment payment = paymentRepository.findByPaymentKey(paymentKey) - .orElseThrow(() -> new PaymentException(PaymentErrorStatus._PAYMENT_NOT_FOUND)); - - // 토스 결제 취소 API 호출 - TossPaymentResponse response; - try { - response = tossPaymentClient.post() - .uri("/v1/payments/" + paymentKey + "/cancel") - .body(dto) - .retrieve() - .body(TossPaymentResponse.class); - - if (response == null || !"CANCELED".equals(response.status())) { - log.error("Toss Payment Cancel Failed: {}", response); - throw new GeneralException(ErrorStatus._INTERNAL_SERVER_ERROR); - } - } catch (Exception e) { - log.error("Toss Payment Cancel API Error", e); - throw new GeneralException(ErrorStatus._INTERNAL_SERVER_ERROR); + + @Transactional(noRollbackFor = GeneralException.class) + public PaymentResponseDTO.CancelPaymentResultDTO cancelPayment(String paymentKey, + PaymentRequestDTO.CancelPaymentDTO dto) { + Payment payment = paymentRepository.findByPaymentKey(paymentKey) + .orElseThrow(() -> new PaymentException(PaymentErrorStatus._PAYMENT_NOT_FOUND)); + + // 토스 결제 취소 API 호출 + TossPaymentResponse response; + try { + response = tossPaymentClient.post() + .uri("/v1/payments/" + paymentKey + "/cancel") + .body(dto) + .retrieve() + .body(TossPaymentResponse.class); + + if (response == null || !"CANCELED".equals(response.status())) { + log.error("Toss Payment Cancel Failed: {}", response); + throw new GeneralException(ErrorStatus._INTERNAL_SERVER_ERROR); + } + } catch (Exception e) { + log.error("Toss Payment Cancel API Error", e); + throw new GeneralException(ErrorStatus._INTERNAL_SERVER_ERROR); + } + + payment.cancelPayment(); + + return new PaymentResponseDTO.CancelPaymentResultDTO( + payment.getId(), + payment.getOrderId(), + payment.getPaymentKey(), + payment.getPaymentStatus().name(), + LocalDateTime.now()); } - payment.cancelPayment(); - - return new PaymentResponseDTO.CancelPaymentResultDTO( - payment.getId(), - payment.getOrderId(), - payment.getPaymentKey(), - payment.getPaymentStatus().name(), - LocalDateTime.now() - ); - } -} + @Transactional(readOnly = true) + public PaymentResponseDTO.PaymentListResponseDTO getPaymentList(Long userId, Integer page, Integer limit, + String status) { + // limit 기본값 처리 (만약 null이면 10) + int size = (limit != null) ? limit : 10; + // page 기본값 처리 (만약 null이면 1, 0보다 작으면 1로 보정). Spring Data는 0-based index이므로 -1 + int pageNumber = (page != null && page > 0) ? page - 1 : 0; + + Pageable pageable = org.springframework.data.domain.PageRequest.of(pageNumber, size); + + Page paymentPage; + if (status != null && !status.isEmpty()) { + PaymentStatus paymentStatus; + try { + paymentStatus = PaymentStatus.valueOf(status.toUpperCase()); + } catch (IllegalArgumentException e) { + // 유효하지 않은 status가 들어오면 BadRequest 예외 발생 + throw new GeneralException(ErrorStatus._BAD_REQUEST); + } + paymentPage = paymentRepository.findAllByBooking_User_IdAndPaymentStatus(userId, paymentStatus, + pageable); + } else { + paymentPage = paymentRepository.findAllByBooking_User_Id(userId, pageable); + } + + List payments = paymentPage.getContent().stream() + .map(payment -> new PaymentResponseDTO.PaymentHistoryResultDTO( + payment.getId(), + payment.getBooking().getId(), + payment.getBooking().getStore().getStoreName(), + payment.getAmount(), + payment.getPaymentType().name(), + payment.getPaymentMethod() != null ? payment.getPaymentMethod().name() + : null, + payment.getPaymentProvider() != null ? payment.getPaymentProvider().name() + : null, + payment.getPaymentStatus().name(), + payment.getApprovedAt())) + .collect(Collectors.toList()); + + PaymentResponseDTO.PaginationDTO pagination = new PaymentResponseDTO.PaginationDTO( + paymentPage.getNumber() + 1, // 0-based -> 1-based + paymentPage.getTotalPages(), + paymentPage.getTotalElements()); + + return new PaymentResponseDTO.PaymentListResponseDTO(payments, pagination); + } + + @Transactional(readOnly = true) + public PaymentResponseDTO.PaymentDetailResultDTO getPaymentDetail(Long paymentId) { + Payment payment = paymentRepository.findById(paymentId) + .orElseThrow(() -> new PaymentException(PaymentErrorStatus._PAYMENT_NOT_FOUND)); + + return new PaymentResponseDTO.PaymentDetailResultDTO( + payment.getId(), + payment.getBooking().getId(), + payment.getBooking().getStore().getStoreName(), + payment.getPaymentMethod() != null ? payment.getPaymentMethod().name() : null, + payment.getPaymentProvider() != null ? payment.getPaymentProvider().name() : null, + payment.getAmount(), + payment.getPaymentType().name(), + payment.getPaymentStatus().name(), + payment.getRequestedAt(), + payment.getApprovedAt(), + payment.getReceiptUrl(), + null // 환불 상세 정보는 현재 null 처리 + ); + } +} \ No newline at end of file