diff --git a/roome/src/main/java/com/roome/domain/payment/dto/TossPaymentInfo.java b/roome/src/main/java/com/roome/domain/payment/dto/TossPaymentInfo.java index c7874eba..d9030b81 100644 --- a/roome/src/main/java/com/roome/domain/payment/dto/TossPaymentInfo.java +++ b/roome/src/main/java/com/roome/domain/payment/dto/TossPaymentInfo.java @@ -1,5 +1,6 @@ package com.roome.domain.payment.dto; +import java.time.LocalDateTime; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; @@ -13,4 +14,5 @@ public class TossPaymentInfo { private final String paymentKey; private final String status; // DONE, CANCELED, READY, IN_PROGRESS, EXPIRED, ABORTED 등 private final int totalAmount; + private final LocalDateTime approvedAt; // Toss가 확정한 승인 시각 (미승인 상태면 null) } diff --git a/roome/src/main/java/com/roome/domain/payment/service/PaymentReconciliationService.java b/roome/src/main/java/com/roome/domain/payment/service/PaymentReconciliationService.java index 2f8ffb20..0435d9a2 100644 --- a/roome/src/main/java/com/roome/domain/payment/service/PaymentReconciliationService.java +++ b/roome/src/main/java/com/roome/domain/payment/service/PaymentReconciliationService.java @@ -80,7 +80,7 @@ private void reconcile(Long paymentId, String orderId) { orderId, payment.getAmount(), info.getTotalAmount()); return; } - paymentService.completePayment(payment, info.getPaymentKey()); + paymentService.completePayment(payment, info.getPaymentKey(), info.getApprovedAt()); log.warn("[대사 복구] 승인됐지만 미완결이던 결제를 완결: orderId={}, amount={}, points={}", orderId, payment.getAmount(), payment.getPurchasedPoints()); } diff --git a/roome/src/main/java/com/roome/domain/payment/service/PaymentService.java b/roome/src/main/java/com/roome/domain/payment/service/PaymentService.java index d1480326..da8d4b91 100644 --- a/roome/src/main/java/com/roome/domain/payment/service/PaymentService.java +++ b/roome/src/main/java/com/roome/domain/payment/service/PaymentService.java @@ -140,6 +140,7 @@ public PaymentResponseDto verifyPayment(Long userId, PaymentVerifyDto verifyDto) } log.info("✅ Step 5: Toss 결제 승인 요청 시작"); + LocalDateTime approvedAt; try { ResponseEntity response = tossPaymentClient.requestConfirm(verifyDto); log.info("✅ Step 6: Toss API 응답 수신 - Status={}, Body={}", @@ -161,7 +162,11 @@ public PaymentResponseDto verifyPayment(Long userId, PaymentVerifyDto verifyDto) throw new BusinessException(ErrorCode.PAYMENT_VERIFICATION_FAILED); } + // Toss가 확정한 승인 시각을 원장에 기록하기 위해 추출 (없으면 null로 두고 완결 시 서버 시각으로 대체) + approvedAt = TossPaymentClient.parseTossDateTime(jsonResponse.path("approvedAt").asText(null)); log.info("✅ Step 10: 결제 승인 성공 및 상태 확인 완료"); + } catch (BusinessException e) { + throw e; } catch (Exception e) { log.error("❌ Step 11: 결제 승인 중 예외 발생: {}", e.getMessage()); throw new BusinessException(ErrorCode.PAYMENT_VERIFICATION_FAILED); @@ -184,7 +189,7 @@ public PaymentResponseDto verifyPayment(Long userId, PaymentVerifyDto verifyDto) // 결제 완결 처리 (상태 변경 + 포인트 지급 + 로그 저장) - completePayment(payment, verifyDto.getPaymentKey()); + completePayment(payment, verifyDto.getPaymentKey(), approvedAt); log.info("결제 성공 및 포인트 지급 완료: orderId={}, userId={}, pointsAdded={}", verifyDto.getOrderId(), userId, payment.getPurchasedPoints()); @@ -205,13 +210,14 @@ private PaymentResponseDto toResponse(Payment payment) { // 승인이 확인된 결제를 완결 처리한다 (상태 변경 + 포인트 지급 + 로그 저장) // verifyPayment(사용자 콜백)와 PaymentReconciliationService(대사 배치)가 공유하는 단일 완결 경로 @Transactional - public void completePayment(Payment payment, String paymentKey) { + public void completePayment(Payment payment, String paymentKey, LocalDateTime approvedAt) { if (payment.getStatus() == PaymentStatus.SUCCESS) { log.warn("이미 완결된 결제 - 중복 완결 방지: orderId={}", payment.getOrderId()); return; } - payment.markApproved(paymentKey, LocalDateTime.now()); + // 승인 시각은 Toss가 확정한 값을 원장에 기록하고, 값이 없으면 서버 시각으로 대체 + payment.markApproved(paymentKey, approvedAt != null ? approvedAt : LocalDateTime.now()); // Toss가 승인한 결제 금액(payment.amount)을 기준으로 카탈로그에서 지급 사유를 파생 PointProduct product = PointProduct.findByPrice(payment.getAmount()) diff --git a/roome/src/main/java/com/roome/domain/payment/service/TossPaymentClient.java b/roome/src/main/java/com/roome/domain/payment/service/TossPaymentClient.java index 17bd99e0..cc9efaed 100644 --- a/roome/src/main/java/com/roome/domain/payment/service/TossPaymentClient.java +++ b/roome/src/main/java/com/roome/domain/payment/service/TossPaymentClient.java @@ -21,6 +21,9 @@ import java.net.http.HttpClient; import java.net.http.HttpResponse; import java.nio.charset.StandardCharsets; +import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.time.format.DateTimeParseException; import java.util.Base64; import java.util.HashMap; import java.util.Map; @@ -153,6 +156,7 @@ public Optional findPaymentByOrderId(String orderId) { .status(json.path("status").asText(null)) // 금액이 없으면 -1로 두어 어떤 결제 금액과도 일치하지 않게 함 .totalAmount(json.path("totalAmount").asInt(-1)) + .approvedAt(parseTossDateTime(json.path("approvedAt").asText(null))) .build()); } catch (HttpClientErrorException.NotFound e) { return Optional.empty(); @@ -196,6 +200,19 @@ public boolean cancelPayment(String paymentKey, String cancelReason, Integer can } + // Toss의 ISO-8601 오프셋 시각(예: 2024-02-13T12:17:57+09:00)을 LocalDateTime으로 변환 + // 값이 없거나 형식이 잘못되면 null을 반환해 호출부가 서버 시각으로 대체하도록 함 + public static LocalDateTime parseTossDateTime(String isoDateTime) { + if (isoDateTime == null || isoDateTime.isBlank()) { + return null; + } + try { + return OffsetDateTime.parse(isoDateTime).toLocalDateTime(); + } catch (DateTimeParseException e) { + return null; + } + } + // Secret Key를 Base64 인코딩하여 반환 private String encodeSecretKey() { log.info("현재 사용 중인 Toss Secret Key: {}", secretKey); diff --git a/roome/src/test/java/com/roome/domain/payment/service/PaymentReconciliationServiceTest.java b/roome/src/test/java/com/roome/domain/payment/service/PaymentReconciliationServiceTest.java index 25c832e7..5f859be8 100644 --- a/roome/src/test/java/com/roome/domain/payment/service/PaymentReconciliationServiceTest.java +++ b/roome/src/test/java/com/roome/domain/payment/service/PaymentReconciliationServiceTest.java @@ -70,14 +70,15 @@ void reconcile_TossDone_CompletesPayment() { Payment payment = pendingPayment(1L, "order1"); givenStalePayments(payment); when(paymentRepository.findById(1L)).thenReturn(Optional.of(payment)); + LocalDateTime tossApprovedAt = LocalDateTime.of(2026, 7, 15, 10, 0); when(tossPaymentClient.findPaymentByOrderId("order1")) - .thenReturn(Optional.of(new TossPaymentInfo("pk1", "DONE", 5_000))); + .thenReturn(Optional.of(new TossPaymentInfo("pk1", "DONE", 5_000, tossApprovedAt))); // when reconciliationService.reconcilePendingPayments(); - // then - verify(paymentService).completePayment(payment, "pk1"); + // then: Toss가 확정한 승인 시각이 완결 처리로 그대로 전달되어야 한다 + verify(paymentService).completePayment(payment, "pk1", tossApprovedAt); } @Test @@ -88,13 +89,13 @@ void reconcile_AmountMismatch_LeftForManualReview() { givenStalePayments(payment); when(paymentRepository.findById(1L)).thenReturn(Optional.of(payment)); when(tossPaymentClient.findPaymentByOrderId("order1")) - .thenReturn(Optional.of(new TossPaymentInfo("pk1", "DONE", 30_000))); + .thenReturn(Optional.of(new TossPaymentInfo("pk1", "DONE", 30_000, null))); // when reconciliationService.reconcilePendingPayments(); // then - verify(paymentService, never()).completePayment(any(), any()); + verify(paymentService, never()).completePayment(any(), any(), any()); assertThat(payment.getStatus()).isEqualTo(PaymentStatus.PENDING); } @@ -112,7 +113,7 @@ void reconcile_NotFoundAtToss_MarksFailed() { // then assertThat(payment.getStatus()).isEqualTo(PaymentStatus.FAILED); - verify(paymentService, never()).completePayment(any(), any()); + verify(paymentService, never()).completePayment(any(), any(), any()); } @Test @@ -123,7 +124,7 @@ void reconcile_TossCanceled_MarksCanceled() { givenStalePayments(payment); when(paymentRepository.findById(1L)).thenReturn(Optional.of(payment)); when(tossPaymentClient.findPaymentByOrderId("order1")) - .thenReturn(Optional.of(new TossPaymentInfo("pk1", "CANCELED", 5_000))); + .thenReturn(Optional.of(new TossPaymentInfo("pk1", "CANCELED", 5_000, null))); // when reconciliationService.reconcilePendingPayments(); @@ -143,13 +144,13 @@ void reconcile_AlreadyCompleted_Skipped() { .status(PaymentStatus.SUCCESS).build(); when(paymentRepository.findById(1L)).thenReturn(Optional.of(completed)); when(tossPaymentClient.findPaymentByOrderId("order1")) - .thenReturn(Optional.of(new TossPaymentInfo("pk1", "DONE", 5_000))); + .thenReturn(Optional.of(new TossPaymentInfo("pk1", "DONE", 5_000, null))); // when reconciliationService.reconcilePendingPayments(); // then - verify(paymentService, never()).completePayment(any(), any()); + verify(paymentService, never()).completePayment(any(), any(), any()); assertThat(completed.getStatus()).isEqualTo(PaymentStatus.SUCCESS); } @@ -163,13 +164,13 @@ void reconcile_OneFailure_DoesNotBlockOthers() { when(tossPaymentClient.findPaymentByOrderId("order1")) .thenThrow(new RuntimeException("Toss 조회 실패")); when(tossPaymentClient.findPaymentByOrderId("order2")) - .thenReturn(Optional.of(new TossPaymentInfo("pk2", "DONE", 5_000))); + .thenReturn(Optional.of(new TossPaymentInfo("pk2", "DONE", 5_000, null))); when(paymentRepository.findById(2L)).thenReturn(Optional.of(second)); // when reconciliationService.reconcilePendingPayments(); // then - verify(paymentService).completePayment(second, "pk2"); + verify(paymentService).completePayment(eq(second), eq("pk2"), any()); } } diff --git a/roome/src/test/java/com/roome/domain/payment/service/PaymentServiceTest.java b/roome/src/test/java/com/roome/domain/payment/service/PaymentServiceTest.java index dc2533a8..a0f6bf1f 100644 --- a/roome/src/test/java/com/roome/domain/payment/service/PaymentServiceTest.java +++ b/roome/src/test/java/com/roome/domain/payment/service/PaymentServiceTest.java @@ -321,16 +321,39 @@ void completePayment_Success() { .status(PaymentStatus.PENDING) .build(); + LocalDateTime tossApprovedAt = LocalDateTime.of(2026, 7, 15, 10, 0); + // when - paymentService.completePayment(payment, "pk123"); + paymentService.completePayment(payment, "pk123", tossApprovedAt); - // then + // then: 승인 시각은 Toss가 확정한 값으로 기록된다 assertThat(payment.getStatus()).isEqualTo(PaymentStatus.SUCCESS); assertThat(payment.getPaymentKey()).isEqualTo("pk123"); + assertThat(payment.getApprovedAt()).isEqualTo(tossApprovedAt); verify(pointService).earnPoints(testUser, PointReason.POINT_PURCHASE_550); verify(paymentLogRepository).save(any()); } + @Test + @DisplayName("Toss 승인 시각이 없으면 서버 시각으로 대체하여 완결한다.") + void completePayment_NullApprovedAt_FallsBackToServerTime() { + // given + Payment payment = Payment.builder() + .user(testUser) + .orderId("order123") + .amount(5_000) + .purchasedPoints(550) + .status(PaymentStatus.PENDING) + .build(); + + // when + paymentService.completePayment(payment, "pk123", null); + + // then + assertThat(payment.getStatus()).isEqualTo(PaymentStatus.SUCCESS); + assertThat(payment.getApprovedAt()).isNotNull(); + } + @Test @DisplayName("이미 완결된 결제를 다시 완결해도 포인트가 중복 지급되지 않아야 한다 (멱등성).") void completePayment_AlreadyCompleted_NoDoubleEarn() { @@ -338,7 +361,7 @@ void completePayment_AlreadyCompleted_NoDoubleEarn() { Payment payment = successPayment("pk123"); // when - paymentService.completePayment(payment, "pk123"); + paymentService.completePayment(payment, "pk123", LocalDateTime.now()); // then verify(pointService, never()).earnPoints(any(), any());