Skip to content

Commit a99c3ed

Browse files
authored
Fix: 결제 승인 시각을 Toss 승인 시각으로 기록하도록 수정 (#416)
1 parent c33ade8 commit a99c3ed

6 files changed

Lines changed: 67 additions & 18 deletions

File tree

roome/src/main/java/com/roome/domain/payment/dto/TossPaymentInfo.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.roome.domain.payment.dto;
22

3+
import java.time.LocalDateTime;
34
import lombok.AllArgsConstructor;
45
import lombok.Builder;
56
import lombok.Getter;
@@ -13,4 +14,5 @@ public class TossPaymentInfo {
1314
private final String paymentKey;
1415
private final String status; // DONE, CANCELED, READY, IN_PROGRESS, EXPIRED, ABORTED 등
1516
private final int totalAmount;
17+
private final LocalDateTime approvedAt; // Toss가 확정한 승인 시각 (미승인 상태면 null)
1618
}

roome/src/main/java/com/roome/domain/payment/service/PaymentReconciliationService.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ private void reconcile(Long paymentId, String orderId) {
8080
orderId, payment.getAmount(), info.getTotalAmount());
8181
return;
8282
}
83-
paymentService.completePayment(payment, info.getPaymentKey());
83+
paymentService.completePayment(payment, info.getPaymentKey(), info.getApprovedAt());
8484
log.warn("[대사 복구] 승인됐지만 미완결이던 결제를 완결: orderId={}, amount={}, points={}",
8585
orderId, payment.getAmount(), payment.getPurchasedPoints());
8686
}

roome/src/main/java/com/roome/domain/payment/service/PaymentService.java

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,7 @@ public PaymentResponseDto verifyPayment(Long userId, PaymentVerifyDto verifyDto)
140140
}
141141

142142
log.info("✅ Step 5: Toss 결제 승인 요청 시작");
143+
LocalDateTime approvedAt;
143144
try {
144145
ResponseEntity<String> response = tossPaymentClient.requestConfirm(verifyDto);
145146
log.info("✅ Step 6: Toss API 응답 수신 - Status={}, Body={}",
@@ -161,7 +162,11 @@ public PaymentResponseDto verifyPayment(Long userId, PaymentVerifyDto verifyDto)
161162

162163
throw new BusinessException(ErrorCode.PAYMENT_VERIFICATION_FAILED);
163164
}
165+
// Toss가 확정한 승인 시각을 원장에 기록하기 위해 추출 (없으면 null로 두고 완결 시 서버 시각으로 대체)
166+
approvedAt = TossPaymentClient.parseTossDateTime(jsonResponse.path("approvedAt").asText(null));
164167
log.info("✅ Step 10: 결제 승인 성공 및 상태 확인 완료");
168+
} catch (BusinessException e) {
169+
throw e;
165170
} catch (Exception e) {
166171
log.error("❌ Step 11: 결제 승인 중 예외 발생: {}", e.getMessage());
167172
throw new BusinessException(ErrorCode.PAYMENT_VERIFICATION_FAILED);
@@ -184,7 +189,7 @@ public PaymentResponseDto verifyPayment(Long userId, PaymentVerifyDto verifyDto)
184189

185190

186191
// 결제 완결 처리 (상태 변경 + 포인트 지급 + 로그 저장)
187-
completePayment(payment, verifyDto.getPaymentKey());
192+
completePayment(payment, verifyDto.getPaymentKey(), approvedAt);
188193

189194
log.info("결제 성공 및 포인트 지급 완료: orderId={}, userId={}, pointsAdded={}",
190195
verifyDto.getOrderId(), userId, payment.getPurchasedPoints());
@@ -205,13 +210,14 @@ private PaymentResponseDto toResponse(Payment payment) {
205210
// 승인이 확인된 결제를 완결 처리한다 (상태 변경 + 포인트 지급 + 로그 저장)
206211
// verifyPayment(사용자 콜백)와 PaymentReconciliationService(대사 배치)가 공유하는 단일 완결 경로
207212
@Transactional
208-
public void completePayment(Payment payment, String paymentKey) {
213+
public void completePayment(Payment payment, String paymentKey, LocalDateTime approvedAt) {
209214
if (payment.getStatus() == PaymentStatus.SUCCESS) {
210215
log.warn("이미 완결된 결제 - 중복 완결 방지: orderId={}", payment.getOrderId());
211216
return;
212217
}
213218

214-
payment.markApproved(paymentKey, LocalDateTime.now());
219+
// 승인 시각은 Toss가 확정한 값을 원장에 기록하고, 값이 없으면 서버 시각으로 대체
220+
payment.markApproved(paymentKey, approvedAt != null ? approvedAt : LocalDateTime.now());
215221

216222
// Toss가 승인한 결제 금액(payment.amount)을 기준으로 카탈로그에서 지급 사유를 파생
217223
PointProduct product = PointProduct.findByPrice(payment.getAmount())

roome/src/main/java/com/roome/domain/payment/service/TossPaymentClient.java

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@
2121
import java.net.http.HttpClient;
2222
import java.net.http.HttpResponse;
2323
import java.nio.charset.StandardCharsets;
24+
import java.time.LocalDateTime;
25+
import java.time.OffsetDateTime;
26+
import java.time.format.DateTimeParseException;
2427
import java.util.Base64;
2528
import java.util.HashMap;
2629
import java.util.Map;
@@ -153,6 +156,7 @@ public Optional<TossPaymentInfo> findPaymentByOrderId(String orderId) {
153156
.status(json.path("status").asText(null))
154157
// 금액이 없으면 -1로 두어 어떤 결제 금액과도 일치하지 않게 함
155158
.totalAmount(json.path("totalAmount").asInt(-1))
159+
.approvedAt(parseTossDateTime(json.path("approvedAt").asText(null)))
156160
.build());
157161
} catch (HttpClientErrorException.NotFound e) {
158162
return Optional.empty();
@@ -196,6 +200,19 @@ public boolean cancelPayment(String paymentKey, String cancelReason, Integer can
196200
}
197201

198202

203+
// Toss의 ISO-8601 오프셋 시각(예: 2024-02-13T12:17:57+09:00)을 LocalDateTime으로 변환
204+
// 값이 없거나 형식이 잘못되면 null을 반환해 호출부가 서버 시각으로 대체하도록 함
205+
public static LocalDateTime parseTossDateTime(String isoDateTime) {
206+
if (isoDateTime == null || isoDateTime.isBlank()) {
207+
return null;
208+
}
209+
try {
210+
return OffsetDateTime.parse(isoDateTime).toLocalDateTime();
211+
} catch (DateTimeParseException e) {
212+
return null;
213+
}
214+
}
215+
199216
// Secret Key를 Base64 인코딩하여 반환
200217
private String encodeSecretKey() {
201218
log.info("현재 사용 중인 Toss Secret Key: {}", secretKey);

roome/src/test/java/com/roome/domain/payment/service/PaymentReconciliationServiceTest.java

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -70,14 +70,15 @@ void reconcile_TossDone_CompletesPayment() {
7070
Payment payment = pendingPayment(1L, "order1");
7171
givenStalePayments(payment);
7272
when(paymentRepository.findById(1L)).thenReturn(Optional.of(payment));
73+
LocalDateTime tossApprovedAt = LocalDateTime.of(2026, 7, 15, 10, 0);
7374
when(tossPaymentClient.findPaymentByOrderId("order1"))
74-
.thenReturn(Optional.of(new TossPaymentInfo("pk1", "DONE", 5_000)));
75+
.thenReturn(Optional.of(new TossPaymentInfo("pk1", "DONE", 5_000, tossApprovedAt)));
7576

7677
// when
7778
reconciliationService.reconcilePendingPayments();
7879

79-
// then
80-
verify(paymentService).completePayment(payment, "pk1");
80+
// then: Toss가 확정한 승인 시각이 완결 처리로 그대로 전달되어야 한다
81+
verify(paymentService).completePayment(payment, "pk1", tossApprovedAt);
8182
}
8283

8384
@Test
@@ -88,13 +89,13 @@ void reconcile_AmountMismatch_LeftForManualReview() {
8889
givenStalePayments(payment);
8990
when(paymentRepository.findById(1L)).thenReturn(Optional.of(payment));
9091
when(tossPaymentClient.findPaymentByOrderId("order1"))
91-
.thenReturn(Optional.of(new TossPaymentInfo("pk1", "DONE", 30_000)));
92+
.thenReturn(Optional.of(new TossPaymentInfo("pk1", "DONE", 30_000, null)));
9293

9394
// when
9495
reconciliationService.reconcilePendingPayments();
9596

9697
// then
97-
verify(paymentService, never()).completePayment(any(), any());
98+
verify(paymentService, never()).completePayment(any(), any(), any());
9899
assertThat(payment.getStatus()).isEqualTo(PaymentStatus.PENDING);
99100
}
100101

@@ -112,7 +113,7 @@ void reconcile_NotFoundAtToss_MarksFailed() {
112113

113114
// then
114115
assertThat(payment.getStatus()).isEqualTo(PaymentStatus.FAILED);
115-
verify(paymentService, never()).completePayment(any(), any());
116+
verify(paymentService, never()).completePayment(any(), any(), any());
116117
}
117118

118119
@Test
@@ -123,7 +124,7 @@ void reconcile_TossCanceled_MarksCanceled() {
123124
givenStalePayments(payment);
124125
when(paymentRepository.findById(1L)).thenReturn(Optional.of(payment));
125126
when(tossPaymentClient.findPaymentByOrderId("order1"))
126-
.thenReturn(Optional.of(new TossPaymentInfo("pk1", "CANCELED", 5_000)));
127+
.thenReturn(Optional.of(new TossPaymentInfo("pk1", "CANCELED", 5_000, null)));
127128

128129
// when
129130
reconciliationService.reconcilePendingPayments();
@@ -143,13 +144,13 @@ void reconcile_AlreadyCompleted_Skipped() {
143144
.status(PaymentStatus.SUCCESS).build();
144145
when(paymentRepository.findById(1L)).thenReturn(Optional.of(completed));
145146
when(tossPaymentClient.findPaymentByOrderId("order1"))
146-
.thenReturn(Optional.of(new TossPaymentInfo("pk1", "DONE", 5_000)));
147+
.thenReturn(Optional.of(new TossPaymentInfo("pk1", "DONE", 5_000, null)));
147148

148149
// when
149150
reconciliationService.reconcilePendingPayments();
150151

151152
// then
152-
verify(paymentService, never()).completePayment(any(), any());
153+
verify(paymentService, never()).completePayment(any(), any(), any());
153154
assertThat(completed.getStatus()).isEqualTo(PaymentStatus.SUCCESS);
154155
}
155156

@@ -163,13 +164,13 @@ void reconcile_OneFailure_DoesNotBlockOthers() {
163164
when(tossPaymentClient.findPaymentByOrderId("order1"))
164165
.thenThrow(new RuntimeException("Toss 조회 실패"));
165166
when(tossPaymentClient.findPaymentByOrderId("order2"))
166-
.thenReturn(Optional.of(new TossPaymentInfo("pk2", "DONE", 5_000)));
167+
.thenReturn(Optional.of(new TossPaymentInfo("pk2", "DONE", 5_000, null)));
167168
when(paymentRepository.findById(2L)).thenReturn(Optional.of(second));
168169

169170
// when
170171
reconciliationService.reconcilePendingPayments();
171172

172173
// then
173-
verify(paymentService).completePayment(second, "pk2");
174+
verify(paymentService).completePayment(eq(second), eq("pk2"), any());
174175
}
175176
}

roome/src/test/java/com/roome/domain/payment/service/PaymentServiceTest.java

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -321,24 +321,47 @@ void completePayment_Success() {
321321
.status(PaymentStatus.PENDING)
322322
.build();
323323

324+
LocalDateTime tossApprovedAt = LocalDateTime.of(2026, 7, 15, 10, 0);
325+
324326
// when
325-
paymentService.completePayment(payment, "pk123");
327+
paymentService.completePayment(payment, "pk123", tossApprovedAt);
326328

327-
// then
329+
// then: 승인 시각은 Toss가 확정한 값으로 기록된다
328330
assertThat(payment.getStatus()).isEqualTo(PaymentStatus.SUCCESS);
329331
assertThat(payment.getPaymentKey()).isEqualTo("pk123");
332+
assertThat(payment.getApprovedAt()).isEqualTo(tossApprovedAt);
330333
verify(pointService).earnPoints(testUser, PointReason.POINT_PURCHASE_550);
331334
verify(paymentLogRepository).save(any());
332335
}
333336

337+
@Test
338+
@DisplayName("Toss 승인 시각이 없으면 서버 시각으로 대체하여 완결한다.")
339+
void completePayment_NullApprovedAt_FallsBackToServerTime() {
340+
// given
341+
Payment payment = Payment.builder()
342+
.user(testUser)
343+
.orderId("order123")
344+
.amount(5_000)
345+
.purchasedPoints(550)
346+
.status(PaymentStatus.PENDING)
347+
.build();
348+
349+
// when
350+
paymentService.completePayment(payment, "pk123", null);
351+
352+
// then
353+
assertThat(payment.getStatus()).isEqualTo(PaymentStatus.SUCCESS);
354+
assertThat(payment.getApprovedAt()).isNotNull();
355+
}
356+
334357
@Test
335358
@DisplayName("이미 완결된 결제를 다시 완결해도 포인트가 중복 지급되지 않아야 한다 (멱등성).")
336359
void completePayment_AlreadyCompleted_NoDoubleEarn() {
337360
// given
338361
Payment payment = successPayment("pk123");
339362

340363
// when
341-
paymentService.completePayment(payment, "pk123");
364+
paymentService.completePayment(payment, "pk123", LocalDateTime.now());
342365

343366
// then
344367
verify(pointService, never()).earnPoints(any(), any());

0 commit comments

Comments
 (0)