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
@@ -1,5 +1,6 @@
package com.roome.domain.payment.dto;

import java.time.LocalDateTime;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
Expand All @@ -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)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ public PaymentResponseDto verifyPayment(Long userId, PaymentVerifyDto verifyDto)
}

log.info("✅ Step 5: Toss 결제 승인 요청 시작");
LocalDateTime approvedAt;
try {
ResponseEntity<String> response = tossPaymentClient.requestConfirm(verifyDto);
log.info("✅ Step 6: Toss API 응답 수신 - Status={}, Body={}",
Expand All @@ -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);
Expand All @@ -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());
Expand All @@ -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())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -153,6 +156,7 @@ public Optional<TossPaymentInfo> 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();
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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);
}

Expand All @@ -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
Expand All @@ -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();
Expand All @@ -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);
}

Expand All @@ -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());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -321,24 +321,47 @@ 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() {
// given
Payment payment = successPayment("pk123");

// when
paymentService.completePayment(payment, "pk123");
paymentService.completePayment(payment, "pk123", LocalDateTime.now());

// then
verify(pointService, never()).earnPoints(any(), any());
Expand Down
Loading