Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
// file: .../domain/payment/PaymentBadRequestException.java
package app.dearobjet.backend.domain.payment;

import app.dearobjet.backend.global.exception.BusinessException;
import app.dearobjet.backend.global.exception.ErrorCode;

public class PaymentBadRequestException extends BusinessException {
public PaymentBadRequestException(String msg) {
super(ErrorCode.INVALID_INPUT, msg);
}
}
Original file line number Diff line number Diff line change
@@ -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<CreatePaymentResponse> create(
@AuthenticationPrincipal CustomUserDetails userDetails,
@RequestBody CreatePaymentRequest req
) {
return ApiResponse.of(paymentService.createPayment(userDetails.getUserId(), req));
}

@PostMapping("/payments/{paymentId}/confirm")
public ApiResponse<Void> 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<Void> cancel(
@AuthenticationPrincipal CustomUserDetails userDetails,
@PathVariable Long paymentId,
@RequestBody CancelPaymentRequest req
) {
paymentService.cancelPayment(userDetails.getUserId(), paymentId, req);
return ApiResponse.of(null);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package app.dearobjet.backend.domain.payment;

public class PaymentNotFoundException extends RuntimeException {
public PaymentNotFoundException(Long id) {
super("결제 정보를 찾을 수 없습니다. paymentId=" + id);
}
}
Original file line number Diff line number Diff line change
@@ -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<Payment, Long> {
Optional<Payment> findByPaymentKey(String paymentKey);
}
135 changes: 135 additions & 0 deletions src/main/java/app/dearobjet/backend/domain/payment/PaymentService.java
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
Original file line number Diff line number Diff line change
@@ -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<Void> tossWebhook(@RequestBody Map<String, Object> 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);
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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;
}
Loading