From 28b79542d16d3db0aed5d3f34cbfd5bc850a0e38 Mon Sep 17 00:00:00 2001 From: Myeongseok-Kang Date: Sat, 28 Feb 2026 12:45:36 +0900 Subject: [PATCH 1/6] =?UTF-8?q?feat:=20=EA=B2=B0=EC=A0=9C=20=EB=8F=84?= =?UTF-8?q?=EB=A9=94=EC=9D=B8=20=EC=97=94=ED=8B=B0=ED=8B=B0/=EB=A0=88?= =?UTF-8?q?=ED=8F=AC=EC=A7=80=ED=86=A0=EB=A6=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/payment/PaymentRepository.java | 10 +++ .../domain/payment/entity/Payment.java | 62 +++++++++++-------- .../payment/entity/PaymentProvider.java | 5 ++ .../domain/payment/entity/PaymentStatus.java | 8 +++ 4 files changed, 60 insertions(+), 25 deletions(-) create mode 100644 src/main/java/app/dearobjet/backend/domain/payment/PaymentRepository.java create mode 100644 src/main/java/app/dearobjet/backend/domain/payment/entity/PaymentProvider.java create mode 100644 src/main/java/app/dearobjet/backend/domain/payment/entity/PaymentStatus.java diff --git a/src/main/java/app/dearobjet/backend/domain/payment/PaymentRepository.java b/src/main/java/app/dearobjet/backend/domain/payment/PaymentRepository.java new file mode 100644 index 0000000..3c494e4 --- /dev/null +++ b/src/main/java/app/dearobjet/backend/domain/payment/PaymentRepository.java @@ -0,0 +1,10 @@ +package app.dearobjet.backend.domain.payment; + +import app.dearobjet.backend.domain.payment.entity.Payment; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface PaymentRepository extends JpaRepository { + Optional findByPaymentKey(String paymentKey); +} \ No newline at end of file diff --git a/src/main/java/app/dearobjet/backend/domain/payment/entity/Payment.java b/src/main/java/app/dearobjet/backend/domain/payment/entity/Payment.java index 133f484..8873ee6 100644 --- a/src/main/java/app/dearobjet/backend/domain/payment/entity/Payment.java +++ b/src/main/java/app/dearobjet/backend/domain/payment/entity/Payment.java @@ -1,49 +1,61 @@ package app.dearobjet.backend.domain.payment.entity; -import app.dearobjet.backend.domain.classes.ClassReservation; import app.dearobjet.backend.domain.order.entity.Order; +import app.dearobjet.backend.global.common.entity.BaseTimeEntity; import jakarta.persistence.*; import lombok.*; +import java.time.OffsetDateTime; + @Entity @Table(name = "payments") @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor @Builder -public class Payment { +public class Payment extends BaseTimeEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) - @Column(name = "payments_id") - private Long paymentsId; + private Long paymentId; - @Column(name = "payment_status") - private String paymentStatus; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "orders_id", nullable = false) + private Order order; - @Column(name = "payment_method") - private String paymentMethod; + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 10) + private PaymentProvider provider; - @Column(name = "payment_amount") - private Double paymentAmount; + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 10) + private PaymentStatus status; - @Column(name = "pg_id") - private String pgId; + @Column(nullable = false) + private Long amount; - @Column(name = "approved_at") - private java.time.LocalDateTime approvedAt; + @Column(length = 200) + private String paymentKey; // 토스 paymentKey - @Column(name = "created_at") - private java.time.LocalDateTime createdAt; + private OffsetDateTime approvedAt; + private OffsetDateTime canceledAt; - @Column(name = "last_modified_at") - private java.time.LocalDateTime lastModifiedAt; + @Column(length = 100) + private String failReason; - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "reservation_id") - private ClassReservation reservation; + public void markDone(String paymentKey) { + this.status = PaymentStatus.DONE; + this.paymentKey = paymentKey; + this.approvedAt = OffsetDateTime.now(); + } - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "orders_id") - private Order order; -} + public void markCanceled() { + this.status = PaymentStatus.CANCELED; + this.canceledAt = OffsetDateTime.now(); + } + + public void markFailed(String reason) { + this.status = PaymentStatus.FAILED; + this.failReason = reason; + } +} \ No newline at end of file diff --git a/src/main/java/app/dearobjet/backend/domain/payment/entity/PaymentProvider.java b/src/main/java/app/dearobjet/backend/domain/payment/entity/PaymentProvider.java new file mode 100644 index 0000000..a664dab --- /dev/null +++ b/src/main/java/app/dearobjet/backend/domain/payment/entity/PaymentProvider.java @@ -0,0 +1,5 @@ +package app.dearobjet.backend.domain.payment.entity; + +public enum PaymentProvider { + TOSS +} \ No newline at end of file diff --git a/src/main/java/app/dearobjet/backend/domain/payment/entity/PaymentStatus.java b/src/main/java/app/dearobjet/backend/domain/payment/entity/PaymentStatus.java new file mode 100644 index 0000000..ea7486c --- /dev/null +++ b/src/main/java/app/dearobjet/backend/domain/payment/entity/PaymentStatus.java @@ -0,0 +1,8 @@ +package app.dearobjet.backend.domain.payment.entity; + +public enum PaymentStatus { + READY, + DONE, + CANCELED, + FAILED +} \ No newline at end of file From 4325efcdd8a64bf6b550928da11c32a9895898cf Mon Sep 17 00:00:00 2001 From: Myeongseok-Kang Date: Sat, 28 Feb 2026 14:19:29 +0900 Subject: [PATCH 2/6] =?UTF-8?q?chore:=20=EA=B2=B0=EC=A0=9C=20=EC=97=B0?= =?UTF-8?q?=EB=8F=99=20=EC=84=A4=EC=A0=95=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../payment/toss/TossPaymentsProperties.java | 14 ++++++++++++++ .../backend/global/config/PropertiesConfig.java | 10 ++++++++++ .../backend/global/config/RestClientConfig.java | 13 +++++++++++++ src/main/resources/application.yml | 5 +++++ 4 files changed, 42 insertions(+) create mode 100644 src/main/java/app/dearobjet/backend/domain/payment/toss/TossPaymentsProperties.java create mode 100644 src/main/java/app/dearobjet/backend/global/config/PropertiesConfig.java create mode 100644 src/main/java/app/dearobjet/backend/global/config/RestClientConfig.java diff --git a/src/main/java/app/dearobjet/backend/domain/payment/toss/TossPaymentsProperties.java b/src/main/java/app/dearobjet/backend/domain/payment/toss/TossPaymentsProperties.java new file mode 100644 index 0000000..64486ee --- /dev/null +++ b/src/main/java/app/dearobjet/backend/domain/payment/toss/TossPaymentsProperties.java @@ -0,0 +1,14 @@ +package app.dearobjet.backend.domain.payment.toss; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@Getter +@Setter +@ConfigurationProperties(prefix = "toss") +public class TossPaymentsProperties { + private String secretKey; + private String clientKey; + private String apiBase = "https://api.tosspayments.com"; +} \ No newline at end of file diff --git a/src/main/java/app/dearobjet/backend/global/config/PropertiesConfig.java b/src/main/java/app/dearobjet/backend/global/config/PropertiesConfig.java new file mode 100644 index 0000000..0abf49c --- /dev/null +++ b/src/main/java/app/dearobjet/backend/global/config/PropertiesConfig.java @@ -0,0 +1,10 @@ +package app.dearobjet.backend.global.config; + +import app.dearobjet.backend.domain.payment.toss.TossPaymentsProperties; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +@Configuration +@EnableConfigurationProperties(TossPaymentsProperties.class) +public class PropertiesConfig { +} \ No newline at end of file diff --git a/src/main/java/app/dearobjet/backend/global/config/RestClientConfig.java b/src/main/java/app/dearobjet/backend/global/config/RestClientConfig.java new file mode 100644 index 0000000..50b2630 --- /dev/null +++ b/src/main/java/app/dearobjet/backend/global/config/RestClientConfig.java @@ -0,0 +1,13 @@ +package app.dearobjet.backend.global.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.client.RestTemplate; + +@Configuration +public class RestClientConfig { + @Bean + public RestTemplate restTemplate() { + return new RestTemplate(); + } +} \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index ce167f3..815c694 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -80,3 +80,8 @@ cloud: credentials: access-key: ${AWS_ACCESS_KEY_ID} secret-key: ${AWS_SECRET_ACCESS_KEY} + +toss: + secretKey: ${TOSS_SECRET_KEY:} + clientKey: ${TOSS_CLIENT_KEY:} + apiBase: ${TOSS_API_BASE:https://api.tosspayments.com} \ No newline at end of file From 69956c9850dfbd89a66e6e392383f678c4f0871c Mon Sep 17 00:00:00 2001 From: Myeongseok-Kang Date: Sat, 28 Feb 2026 14:57:55 +0900 Subject: [PATCH 3/6] =?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=20API=20=ED=81=B4=EB=9D=BC=EC=9D=B4?= =?UTF-8?q?=EC=96=B8=ED=8A=B8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../payment/dto/TossCancelResponse.java | 11 ++++ .../payment/dto/TossConfirmResponse.java | 13 ++++ .../payment/dto/TossPaymentResponse.java | 13 ++++ .../payment/toss/TossPaymentsClient.java | 63 +++++++++++++++++++ 4 files changed, 100 insertions(+) create mode 100644 src/main/java/app/dearobjet/backend/domain/payment/dto/TossCancelResponse.java create mode 100644 src/main/java/app/dearobjet/backend/domain/payment/dto/TossConfirmResponse.java create mode 100644 src/main/java/app/dearobjet/backend/domain/payment/dto/TossPaymentResponse.java create mode 100644 src/main/java/app/dearobjet/backend/domain/payment/toss/TossPaymentsClient.java diff --git a/src/main/java/app/dearobjet/backend/domain/payment/dto/TossCancelResponse.java b/src/main/java/app/dearobjet/backend/domain/payment/dto/TossCancelResponse.java new file mode 100644 index 0000000..881a7c3 --- /dev/null +++ b/src/main/java/app/dearobjet/backend/domain/payment/dto/TossCancelResponse.java @@ -0,0 +1,11 @@ +package app.dearobjet.backend.domain.payment.dto; + +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class TossCancelResponse { + private String paymentKey; + private String status; +} \ No newline at end of file diff --git a/src/main/java/app/dearobjet/backend/domain/payment/dto/TossConfirmResponse.java b/src/main/java/app/dearobjet/backend/domain/payment/dto/TossConfirmResponse.java new file mode 100644 index 0000000..9e27eb1 --- /dev/null +++ b/src/main/java/app/dearobjet/backend/domain/payment/dto/TossConfirmResponse.java @@ -0,0 +1,13 @@ +package app.dearobjet.backend.domain.payment.dto; + +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class TossConfirmResponse { + private String paymentKey; + private String orderId; + private String status; + private Long totalAmount; +} \ No newline at end of file diff --git a/src/main/java/app/dearobjet/backend/domain/payment/dto/TossPaymentResponse.java b/src/main/java/app/dearobjet/backend/domain/payment/dto/TossPaymentResponse.java new file mode 100644 index 0000000..049c74b --- /dev/null +++ b/src/main/java/app/dearobjet/backend/domain/payment/dto/TossPaymentResponse.java @@ -0,0 +1,13 @@ +package app.dearobjet.backend.domain.payment.dto; + +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class TossPaymentResponse { + private String paymentKey; + private String orderId; + private String status; + private Long totalAmount; +} \ No newline at end of file diff --git a/src/main/java/app/dearobjet/backend/domain/payment/toss/TossPaymentsClient.java b/src/main/java/app/dearobjet/backend/domain/payment/toss/TossPaymentsClient.java new file mode 100644 index 0000000..e54c30c --- /dev/null +++ b/src/main/java/app/dearobjet/backend/domain/payment/toss/TossPaymentsClient.java @@ -0,0 +1,63 @@ +package app.dearobjet.backend.domain.payment.toss; +import app.dearobjet.backend.domain.payment.dto.TossCancelResponse; +import app.dearobjet.backend.domain.payment.dto.TossConfirmResponse; +import app.dearobjet.backend.domain.payment.dto.TossPaymentResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.*; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.Map; + +@Component +@RequiredArgsConstructor +public class TossPaymentsClient { + + private final RestTemplate restTemplate; + private final TossPaymentsProperties props; + + public TossConfirmResponse confirm(String paymentKey, String orderId, Long amount) { + String url = props.getApiBase() + "/v1/payments/confirm"; + + Map body = Map.of( + "paymentKey", paymentKey, + "orderId", orderId, + "amount", amount + ); + + HttpEntity> entity = new HttpEntity<>(body, headers()); + return restTemplate.exchange(url, HttpMethod.POST, entity, TossConfirmResponse.class).getBody(); + } + + public TossPaymentResponse getPayment(String paymentKey) { + String url = props.getApiBase() + "/v1/payments/" + paymentKey; + HttpEntity entity = new HttpEntity<>(headers()); + return restTemplate.exchange(url, HttpMethod.GET, entity, TossPaymentResponse.class).getBody(); + } + + public TossCancelResponse cancel(String paymentKey, Long cancelAmount, String reason) { + String url = props.getApiBase() + "/v1/payments/" + paymentKey + "/cancel"; + + Map body = Map.of( + "cancelAmount", cancelAmount, + "cancelReason", (reason == null || reason.isBlank()) ? "USER_CANCEL" : reason + ); + + HttpEntity> entity = new HttpEntity<>(body, headers()); + return restTemplate.exchange(url, HttpMethod.POST, entity, TossCancelResponse.class).getBody(); + } + + private HttpHeaders headers() { + HttpHeaders h = new HttpHeaders(); + h.setContentType(MediaType.APPLICATION_JSON); + h.set("Authorization", "Basic " + basicAuth()); + return h; + } + + private String basicAuth() { + String raw = props.getSecretKey() + ":"; + return Base64.getEncoder().encodeToString(raw.getBytes(StandardCharsets.UTF_8)); + } +} \ No newline at end of file From 304de003129096c0d7e2a280920afd560e477329 Mon Sep 17 00:00:00 2001 From: Myeongseok-Kang Date: Sat, 28 Feb 2026 16:48:22 +0900 Subject: [PATCH 4/6] =?UTF-8?q?feat:=20=EA=B2=B0=EC=A0=9C=20=EC=9A=94?= =?UTF-8?q?=EC=B2=AD/=EC=8A=B9=EC=9D=B8/=EC=B7=A8=EC=86=8C=20API=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../payment/PaymentBadRequestException.java | 7 + .../domain/payment/PaymentController.java | 44 ++++++ .../payment/PaymentNotFoundException.java | 7 + .../domain/payment/PaymentService.java | 135 ++++++++++++++++++ .../payment/dto/CancelPaymentRequest.java | 13 ++ .../payment/dto/ConfirmPaymentRequest.java | 14 ++ .../payment/dto/CreatePaymentRequest.java | 15 ++ .../payment/dto/CreatePaymentResponse.java | 13 ++ 8 files changed, 248 insertions(+) create mode 100644 src/main/java/app/dearobjet/backend/domain/payment/PaymentBadRequestException.java create mode 100644 src/main/java/app/dearobjet/backend/domain/payment/PaymentController.java create mode 100644 src/main/java/app/dearobjet/backend/domain/payment/PaymentNotFoundException.java create mode 100644 src/main/java/app/dearobjet/backend/domain/payment/PaymentService.java create mode 100644 src/main/java/app/dearobjet/backend/domain/payment/dto/CancelPaymentRequest.java create mode 100644 src/main/java/app/dearobjet/backend/domain/payment/dto/ConfirmPaymentRequest.java create mode 100644 src/main/java/app/dearobjet/backend/domain/payment/dto/CreatePaymentRequest.java create mode 100644 src/main/java/app/dearobjet/backend/domain/payment/dto/CreatePaymentResponse.java diff --git a/src/main/java/app/dearobjet/backend/domain/payment/PaymentBadRequestException.java b/src/main/java/app/dearobjet/backend/domain/payment/PaymentBadRequestException.java new file mode 100644 index 0000000..b46cce0 --- /dev/null +++ b/src/main/java/app/dearobjet/backend/domain/payment/PaymentBadRequestException.java @@ -0,0 +1,7 @@ +package app.dearobjet.backend.domain.payment; + +public class PaymentBadRequestException extends RuntimeException { + public PaymentBadRequestException(String msg) { + super(msg); + } +} \ No newline at end of file diff --git a/src/main/java/app/dearobjet/backend/domain/payment/PaymentController.java b/src/main/java/app/dearobjet/backend/domain/payment/PaymentController.java new file mode 100644 index 0000000..5a37a83 --- /dev/null +++ b/src/main/java/app/dearobjet/backend/domain/payment/PaymentController.java @@ -0,0 +1,44 @@ +package app.dearobjet.backend.domain.payment; + +import app.dearobjet.backend.domain.payment.dto.*; +import app.dearobjet.backend.global.api.ApiResponse; +import app.dearobjet.backend.global.auth.security.CustomUserDetails; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1") +public class PaymentController { + + private final PaymentService paymentService; + + @PostMapping("/payments") + public ApiResponse create( + @AuthenticationPrincipal CustomUserDetails userDetails, + @RequestBody CreatePaymentRequest req + ) { + return ApiResponse.of(paymentService.createPayment(userDetails.getUserId(), req)); + } + + @PostMapping("/payments/{paymentId}/confirm") + public ApiResponse confirm( + @AuthenticationPrincipal CustomUserDetails userDetails, + @PathVariable Long paymentId, + @RequestBody ConfirmPaymentRequest req + ) { + paymentService.confirmPayment(userDetails.getUserId(), paymentId, req); + return ApiResponse.of(null); + } + + @PostMapping("/payments/{paymentId}/cancel") + public ApiResponse cancel( + @AuthenticationPrincipal CustomUserDetails userDetails, + @PathVariable Long paymentId, + @RequestBody CancelPaymentRequest req + ) { + paymentService.cancelPayment(userDetails.getUserId(), paymentId, req); + return ApiResponse.of(null); + } +} \ No newline at end of file diff --git a/src/main/java/app/dearobjet/backend/domain/payment/PaymentNotFoundException.java b/src/main/java/app/dearobjet/backend/domain/payment/PaymentNotFoundException.java new file mode 100644 index 0000000..9d1aabe --- /dev/null +++ b/src/main/java/app/dearobjet/backend/domain/payment/PaymentNotFoundException.java @@ -0,0 +1,7 @@ +package app.dearobjet.backend.domain.payment; + +public class PaymentNotFoundException extends RuntimeException { + public PaymentNotFoundException(Long id) { + super("결제 정보를 찾을 수 없습니다. paymentId=" + id); + } +} \ No newline at end of file diff --git a/src/main/java/app/dearobjet/backend/domain/payment/PaymentService.java b/src/main/java/app/dearobjet/backend/domain/payment/PaymentService.java new file mode 100644 index 0000000..dd70f50 --- /dev/null +++ b/src/main/java/app/dearobjet/backend/domain/payment/PaymentService.java @@ -0,0 +1,135 @@ +package app.dearobjet.backend.domain.payment; + +import app.dearobjet.backend.domain.order.OrderAccessDeniedException; +import app.dearobjet.backend.domain.order.OrderNotFoundException; +import app.dearobjet.backend.domain.order.OrderRepository; +import app.dearobjet.backend.domain.order.entity.Order; +import app.dearobjet.backend.domain.order.entity.OrderStatus; +import app.dearobjet.backend.domain.payment.dto.*; +import app.dearobjet.backend.domain.payment.entity.*; +import app.dearobjet.backend.domain.payment.toss.TossPaymentsClient; +import app.dearobjet.backend.domain.payment.toss.TossPaymentsProperties; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class PaymentService { + + private final PaymentRepository paymentRepository; + private final OrderRepository orderRepository; + + private final TossPaymentsClient tossClient; + private final TossPaymentsProperties tossProps; + + @Transactional + public CreatePaymentResponse createPayment(Long userId, CreatePaymentRequest req) { + Order order = orderRepository.findById(req.getOrderId()) + .orElseThrow(() -> new OrderNotFoundException(req.getOrderId())); + + if (order.getUser() == null || !order.getUser().getId().equals(userId)) { + throw new OrderAccessDeniedException(order.getOrdersId()); + } + + if (order.getStatus() != OrderStatus.CREATED) { + throw new PaymentBadRequestException("결제 가능한 주문 상태가 아닙니다. status=" + order.getStatus()); + } + + if (!order.getTotalAmount().equals(req.getAmount())) { + throw new PaymentBadRequestException("결제 금액이 주문 금액과 다릅니다."); + } + + Payment payment = Payment.builder() + .order(order) + .provider(req.getProvider() == null ? PaymentProvider.TOSS : req.getProvider()) + .status(PaymentStatus.READY) + .amount(req.getAmount()) + .build(); + + Payment saved = paymentRepository.save(payment); + + return new CreatePaymentResponse( + saved.getPaymentId(), + order.getOrderNumber(), + saved.getAmount(), + tossProps.getClientKey() + ); + } + + @Transactional + public void confirmPayment(Long userId, Long paymentId, ConfirmPaymentRequest req) { + Payment payment = paymentRepository.findById(paymentId) + .orElseThrow(() -> new PaymentNotFoundException(paymentId)); + + Order order = payment.getOrder(); + if (order.getUser() == null || !order.getUser().getId().equals(userId)) { + throw new OrderAccessDeniedException(order.getOrdersId()); + } + + if (payment.getStatus() == PaymentStatus.DONE) return; + + if (!order.getOrderNumber().equals(req.getOrderId())) { + throw new PaymentBadRequestException("orderId가 주문번호와 다릅니다."); + } + + if (!payment.getAmount().equals(req.getPaidAmount())) { + throw new PaymentBadRequestException("승인 금액이 결제 금액과 다릅니다."); + } + + try { + tossClient.confirm(req.getPaymentKey(), req.getOrderId(), req.getPaidAmount()); + payment.markDone(req.getPaymentKey()); + order.updateStatus(OrderStatus.PAID); + } catch (Exception e) { + payment.markFailed("CONFIRM_FAILED"); + throw e; + } + } + + @Transactional + public void cancelPayment(Long userId, Long paymentId, CancelPaymentRequest req) { + Payment payment = paymentRepository.findById(paymentId) + .orElseThrow(() -> new PaymentNotFoundException(paymentId)); + + Order order = payment.getOrder(); + if (order.getUser() == null || !order.getUser().getId().equals(userId)) { + throw new OrderAccessDeniedException(order.getOrdersId()); + } + + if (payment.getStatus() != PaymentStatus.DONE) { + throw new PaymentBadRequestException("취소 가능한 결제 상태가 아닙니다. status=" + payment.getStatus()); + } + + Long cancelAmount = (req.getAmount() == null) ? payment.getAmount() : req.getAmount(); + + tossClient.cancel(payment.getPaymentKey(), cancelAmount, req.getReason()); + payment.markCanceled(); + order.cancel(req.getReason() == null ? "USER_CANCEL" : req.getReason()); + } + + @Transactional + public void handleWebhookToss(String paymentKey) { + if (paymentKey == null || paymentKey.isBlank()) return; + + Payment payment = paymentRepository.findByPaymentKey(paymentKey).orElse(null); + if (payment == null) return; + + if (payment.getStatus() == PaymentStatus.DONE) return; + + TossPaymentResponse tossPayment = tossClient.getPayment(paymentKey); + String status = tossPayment == null ? null : tossPayment.getStatus(); + String orderId = tossPayment == null ? null : tossPayment.getOrderId(); + Long totalAmount = tossPayment == null ? null : tossPayment.getTotalAmount(); + + if (status == null || orderId == null || totalAmount == null) return; + + if (!payment.getOrder().getOrderNumber().equals(orderId)) return; + if (!payment.getAmount().equals(totalAmount)) return; + + if ("DONE".equals(status)) { + payment.markDone(paymentKey); + payment.getOrder().updateStatus(OrderStatus.PAID); + } + } +} \ No newline at end of file diff --git a/src/main/java/app/dearobjet/backend/domain/payment/dto/CancelPaymentRequest.java b/src/main/java/app/dearobjet/backend/domain/payment/dto/CancelPaymentRequest.java new file mode 100644 index 0000000..bd11571 --- /dev/null +++ b/src/main/java/app/dearobjet/backend/domain/payment/dto/CancelPaymentRequest.java @@ -0,0 +1,13 @@ +package app.dearobjet.backend.domain.payment.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class CancelPaymentRequest { + private Long amount; + private String reason; +} \ No newline at end of file diff --git a/src/main/java/app/dearobjet/backend/domain/payment/dto/ConfirmPaymentRequest.java b/src/main/java/app/dearobjet/backend/domain/payment/dto/ConfirmPaymentRequest.java new file mode 100644 index 0000000..eda4f1c --- /dev/null +++ b/src/main/java/app/dearobjet/backend/domain/payment/dto/ConfirmPaymentRequest.java @@ -0,0 +1,14 @@ +package app.dearobjet.backend.domain.payment.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class ConfirmPaymentRequest { + private String paymentKey; + private String orderId; + private Long paidAmount; +} \ No newline at end of file diff --git a/src/main/java/app/dearobjet/backend/domain/payment/dto/CreatePaymentRequest.java b/src/main/java/app/dearobjet/backend/domain/payment/dto/CreatePaymentRequest.java new file mode 100644 index 0000000..772e6c0 --- /dev/null +++ b/src/main/java/app/dearobjet/backend/domain/payment/dto/CreatePaymentRequest.java @@ -0,0 +1,15 @@ +package app.dearobjet.backend.domain.payment.dto; + +import app.dearobjet.backend.domain.payment.entity.PaymentProvider; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class CreatePaymentRequest { + private Long orderId; + private PaymentProvider provider; + private Long amount; +} \ No newline at end of file diff --git a/src/main/java/app/dearobjet/backend/domain/payment/dto/CreatePaymentResponse.java b/src/main/java/app/dearobjet/backend/domain/payment/dto/CreatePaymentResponse.java new file mode 100644 index 0000000..9572b07 --- /dev/null +++ b/src/main/java/app/dearobjet/backend/domain/payment/dto/CreatePaymentResponse.java @@ -0,0 +1,13 @@ +package app.dearobjet.backend.domain.payment.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class CreatePaymentResponse { + private final Long paymentId; + private final String orderNo; + private final Long amount; + private final String clientKey; +} \ No newline at end of file From 841908631e726b81ea4edae63bd8760f8ac2c371 Mon Sep 17 00:00:00 2001 From: Myeongseok-Kang Date: Sat, 28 Feb 2026 18:40:52 +0900 Subject: [PATCH 5/6] =?UTF-8?q?feat:=20=ED=86=A0=EC=8A=A4=20=EC=9B=B9?= =?UTF-8?q?=ED=9B=85=20=EC=88=98=EC=8B=A0=20=EB=B0=8F=20=EA=B2=B0=EC=A0=9C?= =?UTF-8?q?=20=EA=B2=80=EC=A6=9D=20=EC=B2=98=EB=A6=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../payment/PaymentBadRequestException.java | 8 +++-- .../payment/PaymentWebhookController.java | 28 +++++++++++++++ .../payment/toss/TossPaymentsProperties.java | 2 ++ .../global/config/RestClientConfig.java | 28 +++++++++++++-- .../global/config/RestTemplateConfig.java | 35 ------------------- 5 files changed, 61 insertions(+), 40 deletions(-) create mode 100644 src/main/java/app/dearobjet/backend/domain/payment/PaymentWebhookController.java delete mode 100644 src/main/java/app/dearobjet/backend/global/config/RestTemplateConfig.java diff --git a/src/main/java/app/dearobjet/backend/domain/payment/PaymentBadRequestException.java b/src/main/java/app/dearobjet/backend/domain/payment/PaymentBadRequestException.java index b46cce0..afc3164 100644 --- a/src/main/java/app/dearobjet/backend/domain/payment/PaymentBadRequestException.java +++ b/src/main/java/app/dearobjet/backend/domain/payment/PaymentBadRequestException.java @@ -1,7 +1,11 @@ +// file: .../domain/payment/PaymentBadRequestException.java package app.dearobjet.backend.domain.payment; -public class PaymentBadRequestException extends RuntimeException { +import app.dearobjet.backend.global.exception.BusinessException; +import app.dearobjet.backend.global.exception.ErrorCode; + +public class PaymentBadRequestException extends BusinessException { public PaymentBadRequestException(String msg) { - super(msg); + super(ErrorCode.INVALID_INPUT, msg); } } \ No newline at end of file diff --git a/src/main/java/app/dearobjet/backend/domain/payment/PaymentWebhookController.java b/src/main/java/app/dearobjet/backend/domain/payment/PaymentWebhookController.java new file mode 100644 index 0000000..c4e14af --- /dev/null +++ b/src/main/java/app/dearobjet/backend/domain/payment/PaymentWebhookController.java @@ -0,0 +1,28 @@ +package app.dearobjet.backend.domain.payment; + +import app.dearobjet.backend.global.api.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1") +public class PaymentWebhookController { + + private final PaymentService paymentService; + + @PostMapping("/payments/webhooks/toss") + public ApiResponse tossWebhook(@RequestBody Map body) { + // paymentKey 뽑아서 결제 조회로 검증 후 반영 + Object paymentKey = body.get("paymentKey"); + if (paymentKey == null && body.get("data") instanceof Map data) { + Object pk = data.get("paymentKey"); + if (pk != null) paymentKey = pk; + } + + paymentService.handleWebhookToss(paymentKey == null ? null : paymentKey.toString()); + return ApiResponse.of(null); + } +} \ No newline at end of file diff --git a/src/main/java/app/dearobjet/backend/domain/payment/toss/TossPaymentsProperties.java b/src/main/java/app/dearobjet/backend/domain/payment/toss/TossPaymentsProperties.java index 64486ee..a7911ee 100644 --- a/src/main/java/app/dearobjet/backend/domain/payment/toss/TossPaymentsProperties.java +++ b/src/main/java/app/dearobjet/backend/domain/payment/toss/TossPaymentsProperties.java @@ -3,9 +3,11 @@ import lombok.Getter; import lombok.Setter; import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; @Getter @Setter +@Component @ConfigurationProperties(prefix = "toss") public class TossPaymentsProperties { private String secretKey; diff --git a/src/main/java/app/dearobjet/backend/global/config/RestClientConfig.java b/src/main/java/app/dearobjet/backend/global/config/RestClientConfig.java index 50b2630..df29b3e 100644 --- a/src/main/java/app/dearobjet/backend/global/config/RestClientConfig.java +++ b/src/main/java/app/dearobjet/backend/global/config/RestClientConfig.java @@ -1,13 +1,35 @@ package app.dearobjet.backend.global.config; +import java.time.Duration; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.web.client.RestTemplate; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.nio.charset.StandardCharsets; +import java.util.List; +import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; @Configuration public class RestClientConfig { + @Bean - public RestTemplate restTemplate() { - return new RestTemplate(); + public RestTemplate restTemplate(ObjectMapper objectMapper) { + + MappingJackson2HttpMessageConverter jsonConverter = + new MappingJackson2HttpMessageConverter(objectMapper); + + jsonConverter.setDefaultCharset(StandardCharsets.UTF_8); + + RestTemplate restTemplate = new RestTemplate(); + restTemplate.setMessageConverters(List.of(jsonConverter)); + + restTemplate.setRequestFactory( + new org.springframework.http.client.SimpleClientHttpRequestFactory() {{ + setConnectTimeout((int) Duration.ofSeconds(5).toMillis()); + setReadTimeout((int) Duration.ofSeconds(5).toMillis()); + }} + ); + + return restTemplate; } -} \ No newline at end of file +} diff --git a/src/main/java/app/dearobjet/backend/global/config/RestTemplateConfig.java b/src/main/java/app/dearobjet/backend/global/config/RestTemplateConfig.java deleted file mode 100644 index be892c8..0000000 --- a/src/main/java/app/dearobjet/backend/global/config/RestTemplateConfig.java +++ /dev/null @@ -1,35 +0,0 @@ -package app.dearobjet.backend.global.config; - -import java.time.Duration; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.web.client.RestTemplate; -import com.fasterxml.jackson.databind.ObjectMapper; -import java.nio.charset.StandardCharsets; -import java.util.List; -import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; - -@Configuration -public class RestTemplateConfig { - - @Bean - public RestTemplate restTemplate(ObjectMapper objectMapper) { - - MappingJackson2HttpMessageConverter jsonConverter = - new MappingJackson2HttpMessageConverter(objectMapper); - - jsonConverter.setDefaultCharset(StandardCharsets.UTF_8); - - RestTemplate restTemplate = new RestTemplate(); - restTemplate.setMessageConverters(List.of(jsonConverter)); - - restTemplate.setRequestFactory( - new org.springframework.http.client.SimpleClientHttpRequestFactory() {{ - setConnectTimeout((int) Duration.ofSeconds(5).toMillis()); - setReadTimeout((int) Duration.ofSeconds(5).toMillis()); - }} - ); - - return restTemplate; - } -} From 08b16479e59763fa02407fe0fe15998716655eb7 Mon Sep 17 00:00:00 2001 From: Myeongseok-Kang Date: Sat, 28 Feb 2026 18:43:59 +0900 Subject: [PATCH 6/6] =?UTF-8?q?fix:=20=ED=86=A0=EC=8A=A4=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=ED=94=84=EB=A1=9C=ED=8D=BC=ED=8B=B0=20=EB=B9=88=20?= =?UTF-8?q?=EC=A4=91=EB=B3=B5=20=EB=93=B1=EB=A1=9D=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/domain/payment/toss/TossPaymentsProperties.java | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main/java/app/dearobjet/backend/domain/payment/toss/TossPaymentsProperties.java b/src/main/java/app/dearobjet/backend/domain/payment/toss/TossPaymentsProperties.java index a7911ee..e2690ca 100644 --- a/src/main/java/app/dearobjet/backend/domain/payment/toss/TossPaymentsProperties.java +++ b/src/main/java/app/dearobjet/backend/domain/payment/toss/TossPaymentsProperties.java @@ -7,7 +7,6 @@ @Getter @Setter -@Component @ConfigurationProperties(prefix = "toss") public class TossPaymentsProperties { private String secretKey;