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
Expand Up @@ -15,4 +15,7 @@ public class TossPaymentInfo {
private final String status; // DONE, CANCELED, READY, IN_PROGRESS, EXPIRED, ABORTED 등
private final int totalAmount;
private final LocalDateTime approvedAt; // Toss가 확정한 승인 시각 (미승인 상태면 null)
private final String method; // 결제 수단
private final String receiptUrl; // 영수증 URL
private final String approveNo; // PG(카드사) 승인 번호
}
22 changes: 20 additions & 2 deletions roome/src/main/java/com/roome/domain/payment/entity/Payment.java
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,10 @@ public class Payment extends BaseTimeEntity {
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

// 회원 탈퇴 시에도 결제 원장은 법적 보존을 위해 남기고 user 참조만 끊으므로 nullable
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
private User user; // 결제한 사용자
@JoinColumn(name = "user_id")
private User user; // 결제한 사용자 (탈퇴 후 비식별화되면 null)

@Column(unique = true)
private String paymentKey; // 결제 성공 시 반환되는 키
Expand All @@ -43,6 +44,11 @@ public class Payment extends BaseTimeEntity {
private LocalDateTime approvedAt; // 결제 승인(완결) 시각 - 환불 기한 산정의 기준
private LocalDateTime canceledAt; // 결제 취소 시각

// PG 승인 메타데이터 (best-effort로 채워지므로 nullable)
private String method; // 결제 수단 (카드, 가상계좌 등)
private String receiptUrl; // 영수증 URL
private String approveNo; // PG(카드사) 승인 번호

@Version
private Long version; // 낙관적 락 - 동시 상태 변경(중복 완결/취소) 방지

Expand All @@ -53,6 +59,18 @@ public void markApproved(String paymentKey, LocalDateTime approvedAt) {
this.approvedAt = approvedAt;
}

// PG 승인 메타데이터 기록 (승인 완결과 함께 호출)
public void applyPgDetails(String method, String receiptUrl, String approveNo) {
this.method = method;
this.receiptUrl = receiptUrl;
this.approveNo = approveNo;
}

// 회원 탈퇴 시 개인 식별 참조만 끊어 결제 원장을 비식별 보존
public void detachUser() {
this.user = null;
}

// 결제 실패 처리 (PENDING -> FAILED)
public void markFailed() {
transitionTo(PaymentStatus.FAILED);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,10 @@ public class PaymentLog {
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

// 회원 탈퇴 시에도 결제 이력은 보존하고 user 참조만 끊으므로 nullable
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
private User user; // 결제한 사용자
@JoinColumn(name = "user_id")
private User user; // 결제한 사용자 (탈퇴 후 비식별화되면 null)

// 이 로그가 속한 결제 건 (로그를 Payment와 매칭하기 위한 연결)
// (기존 로그 호환을 위해 nullable, 신규 로그는 항상 설정)
Expand All @@ -47,4 +48,9 @@ public class PaymentLog {
protected void onCreate() {
this.createdAt = LocalDateTime.now();
}

// 회원 탈퇴 시 개인 식별 참조만 끊어 결제 이력을 비식별 보존
public void detachUser() {
this.user = null;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,4 @@ public interface PaymentLogRepository extends JpaRepository<PaymentLog, Long> {

// 특정 결제 키(paymentKey)로 결제 내역 조회
List<PaymentLog> findByPaymentKey(String paymentKey);

// 특정 사용자(userId)의 결제 로그 삭제
void deleteByUserId(Long userId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,4 @@ public interface PaymentRepository extends JpaRepository<Payment, Long> {

// 특정 사용자(userId)의 성공한 결제 내역 조회
List<Payment> findByUserIdAndStatus(Long userId, PaymentStatus status);

// 특정 사용자(userId)의 결제 데이터 삭제
void deleteByUserId(Long userId);
}
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(), info.getApprovedAt());
paymentService.completePayment(payment, info.getPaymentKey(), info);
log.warn("[대사 복구] 승인됐지만 미완결이던 결제를 완결: orderId={}, amount={}, points={}",
orderId, payment.getAmount(), payment.getPurchasedPoints());
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
package com.roome.domain.payment.service;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.roome.domain.payment.dto.PaymentLogResponseDto;
import com.roome.domain.payment.dto.PaymentRequestDto;
import com.roome.domain.payment.dto.PaymentResponseDto;
import com.roome.domain.payment.dto.PaymentVerifyDto;
import com.roome.domain.payment.dto.TossPaymentInfo;
import com.roome.domain.payment.entity.Payment;
import com.roome.domain.payment.entity.PaymentLog;
import com.roome.domain.payment.entity.PaymentStatus;
Expand Down Expand Up @@ -126,32 +126,26 @@ public PaymentResponseDto verifyPayment(Long userId, PaymentVerifyDto verifyDto)
throw new BusinessException(ErrorCode.PAYMENT_AMOUNT_MISMATCH);
}

LocalDateTime approvedAt;
TossPaymentInfo tossInfo;
try {
// 응답 전문에는 카드 및 구매자 정보가 포함될 수 있어 body를 로깅 x
ResponseEntity<String> response = tossPaymentClient.requestConfirm(verifyDto);
if (!response.getStatusCode().is2xxSuccessful()) {
throw new BusinessException(ErrorCode.PAYMENT_VERIFICATION_FAILED);
}

JsonNode jsonResponse = new ObjectMapper().readTree(response.getBody());
if (!"DONE".equals(jsonResponse.path("status").asText())) {
// confirm 응답을 파싱해 상태, 금액 검증 및 원장 메타데이터에 사용
tossInfo = TossPaymentClient.parsePaymentInfo(new ObjectMapper().readTree(response.getBody()));
if (!"DONE".equals(tossInfo.getStatus())) {
log.warn("결제 상태가 DONE이 아님: orderId={}, status={}",
verifyDto.getOrderId(), jsonResponse.path("status").asText());
verifyDto.getOrderId(), tossInfo.getStatus());
throw new BusinessException(ErrorCode.PAYMENT_VERIFICATION_FAILED);
}

// 승인 결과의 원본인 confirm 응답으로 금액 검증
// confirm 응답 자체가 승인 사실의 신뢰 원천이므로 재조회는 지연만 늘리고 불일치 위험을 키울 수 있음
int approvedAmount = jsonResponse.path("totalAmount").asInt(-1);
if (approvedAmount != payment.getAmount()) {
if (tossInfo.getTotalAmount() != payment.getAmount()) {
log.warn("승인 금액 불일치: orderId={}, 저장 금액={}, Toss 승인 금액={}",
verifyDto.getOrderId(), payment.getAmount(), approvedAmount);
verifyDto.getOrderId(), payment.getAmount(), tossInfo.getTotalAmount());
throw new BusinessException(ErrorCode.PAYMENT_AMOUNT_MISMATCH);
}

// Toss가 확정한 승인 시각을 원장에 기록하기 위해 추출 (없으면 null로 두고 완결 시 서버 시각으로 대체)
approvedAt = TossPaymentClient.parseTossDateTime(jsonResponse.path("approvedAt").asText(null));
} catch (BusinessException e) {
throw e;
} catch (Exception e) {
Expand All @@ -160,7 +154,7 @@ public PaymentResponseDto verifyPayment(Long userId, PaymentVerifyDto verifyDto)
}

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

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

// 승인 시각은 Toss가 확정한 값을 원장에 기록하고, 값이 없으면 서버 시각으로 대체
payment.markApproved(paymentKey, approvedAt != null ? approvedAt : LocalDateTime.now());
LocalDateTime approvedAt = tossInfo.getApprovedAt() != null
? tossInfo.getApprovedAt() : LocalDateTime.now();
payment.markApproved(paymentKey, approvedAt);
// PG 승인 메타데이터를 원장에 기록
payment.applyPgDetails(tossInfo.getMethod(), tossInfo.getReceiptUrl(), tossInfo.getApproveNo());

// Toss가 승인한 결제 금액(payment.amount)을 기준으로 카탈로그에서 지급 사유를 파생
PointProduct product = PointProduct.findByPrice(payment.getAmount())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -115,13 +115,7 @@ public Optional<TossPaymentInfo> findPaymentByOrderId(String orderId) {
);

JsonNode json = objectMapper.readTree(response.getBody());
return Optional.of(TossPaymentInfo.builder()
.paymentKey(json.path("paymentKey").asText(null))
.status(json.path("status").asText(null))
// 금액이 없으면 -1로 두어 어떤 결제 금액과도 일치하지 않게 함
.totalAmount(json.path("totalAmount").asInt(-1))
.approvedAt(parseTossDateTime(json.path("approvedAt").asText(null)))
.build());
return Optional.of(parsePaymentInfo(json));
} catch (HttpClientErrorException.NotFound e) {
return Optional.empty();
} catch (Exception e) {
Expand All @@ -130,6 +124,20 @@ public Optional<TossPaymentInfo> findPaymentByOrderId(String orderId) {
}
}

// Toss 결제 객체 JSON을 TossPaymentInfo로 파싱
public static TossPaymentInfo parsePaymentInfo(JsonNode json) {
return TossPaymentInfo.builder()
.paymentKey(json.path("paymentKey").asText(null))
.status(json.path("status").asText(null))
// 금액이 없으면 -1로 두어 어떤 결제 금액과도 일치하지 않게 함
.totalAmount(json.path("totalAmount").asInt(-1))
.approvedAt(parseTossDateTime(json.path("approvedAt").asText(null)))
.method(json.path("method").asText(null))
.receiptUrl(json.path("receipt").path("url").asText(null))
.approveNo(json.path("card").path("approveNo").asText(null))
.build();
}

// Toss 결제 취소 요청
public boolean cancelPayment(String paymentKey, String cancelReason, Integer cancelAmount) {
String requestUrl = TOSS_API_URL + "/" + paymentKey + "/cancel";
Expand Down
17 changes: 11 additions & 6 deletions roome/src/main/java/com/roome/domain/user/service/UserService.java
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
import com.roome.domain.mycd.entity.MyCd;
import com.roome.domain.mycd.repository.MyCdCountRepository;
import com.roome.domain.mycd.repository.MyCdRepository;
import com.roome.domain.payment.entity.Payment;
import com.roome.domain.payment.entity.PaymentLog;
import com.roome.domain.payment.repository.PaymentLogRepository;
import com.roome.domain.payment.repository.PaymentRepository;
import com.roome.domain.point.repository.PointHistoryRepository;
Expand Down Expand Up @@ -233,12 +235,15 @@ private void deletePointData(Long userId) {
}

private void deletePaymentData(Long userId) {
// 결제 로그 삭제
paymentLogRepository.deleteByUserId(userId);
log.debug("[회원탈퇴] 결제 로그 삭제 완료: userId={}", userId);
// 전자상거래법상 대금결제 기록은 보존 의무가 있으므로 물리 삭제 x
// 개인 식별 참조(user)만 끊어 비식별 상태로 보존
List<Payment> payments = paymentRepository.findByUserId(userId);
payments.forEach(Payment::detachUser);

// 결제 정보 삭제
paymentRepository.deleteByUserId(userId);
log.debug("[회원탈퇴] 결제 정보 삭제 완료: userId={}", userId);
List<PaymentLog> paymentLogs = paymentLogRepository.findByUserId(userId);
paymentLogs.forEach(PaymentLog::detachUser);

log.debug("[회원탈퇴] 결제 기록 비식별화 보존 완료: 결제 {}건, 로그 {}건, userId={}",
payments.size(), paymentLogs.size(), userId);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -70,15 +70,16 @@ 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, tossApprovedAt)));
TossPaymentInfo tossInfo = TossPaymentInfo.builder()
.paymentKey("pk1").status("DONE").totalAmount(5_000)
.approvedAt(LocalDateTime.of(2026, 7, 15, 10, 0)).build();
when(tossPaymentClient.findPaymentByOrderId("order1")).thenReturn(Optional.of(tossInfo));

// when
reconciliationService.reconcilePendingPayments();

// then: Toss가 확정한 승인 시각이 완결 처리로 그대로 전달되어야 한다
verify(paymentService).completePayment(payment, "pk1", tossApprovedAt);
// then: Toss 조회 정보가 완결 처리로 그대로 전달되어야 한다
verify(paymentService).completePayment(payment, "pk1", tossInfo);
}

@Test
Expand All @@ -89,7 +90,7 @@ 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, null)));
.thenReturn(Optional.of(new TossPaymentInfo("pk1", "DONE", 30_000, null, null, null, null)));

// when
reconciliationService.reconcilePendingPayments();
Expand Down Expand Up @@ -124,7 +125,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, null)));
.thenReturn(Optional.of(new TossPaymentInfo("pk1", "CANCELED", 5_000, null, null, null, null)));

// when
reconciliationService.reconcilePendingPayments();
Expand All @@ -144,7 +145,7 @@ 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, null)));
.thenReturn(Optional.of(new TossPaymentInfo("pk1", "DONE", 5_000, null, null, null, null)));

// when
reconciliationService.reconcilePendingPayments();
Expand All @@ -164,7 +165,7 @@ 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, null)));
.thenReturn(Optional.of(new TossPaymentInfo("pk2", "DONE", 5_000, null, null, null, null)));
when(paymentRepository.findById(2L)).thenReturn(Optional.of(second));

// when
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import com.roome.domain.payment.dto.PaymentRequestDto;
import com.roome.domain.payment.dto.PaymentResponseDto;
import com.roome.domain.payment.dto.PaymentVerifyDto;
import com.roome.domain.payment.dto.TossPaymentInfo;
import com.roome.domain.payment.entity.Payment;
import com.roome.domain.payment.entity.PaymentStatus;
import com.roome.domain.payment.repository.PaymentLogRepository;
Expand Down Expand Up @@ -369,14 +370,21 @@ void completePayment_Success() {
.build();

LocalDateTime tossApprovedAt = LocalDateTime.of(2026, 7, 15, 10, 0);
TossPaymentInfo info = TossPaymentInfo.builder()
.paymentKey("pk123").status("DONE").totalAmount(5_000).approvedAt(tossApprovedAt)
.method("카드").receiptUrl("https://receipt/pk123").approveNo("00012345")
.build();

// when
paymentService.completePayment(payment, "pk123", tossApprovedAt);
paymentService.completePayment(payment, "pk123", info);

// then: 승인 시각은 Toss가 확정한 값으로 기록된다
// then: 승인 시각·PG 메타데이터가 원장에 기록된다
assertThat(payment.getStatus()).isEqualTo(PaymentStatus.SUCCESS);
assertThat(payment.getPaymentKey()).isEqualTo("pk123");
assertThat(payment.getApprovedAt()).isEqualTo(tossApprovedAt);
assertThat(payment.getMethod()).isEqualTo("카드");
assertThat(payment.getReceiptUrl()).isEqualTo("https://receipt/pk123");
assertThat(payment.getApproveNo()).isEqualTo("00012345");
verify(pointService).earnPoints(testUser, PointReason.POINT_PURCHASE_550);
verify(paymentLogRepository).save(any());
}
Expand All @@ -393,8 +401,12 @@ void completePayment_NullApprovedAt_FallsBackToServerTime() {
.status(PaymentStatus.PENDING)
.build();

// Toss가 승인 시각을 주지 않은 경우 (approvedAt = null)
TossPaymentInfo info = TossPaymentInfo.builder()
.paymentKey("pk123").status("DONE").totalAmount(5_000).approvedAt(null).build();

// when
paymentService.completePayment(payment, "pk123", null);
paymentService.completePayment(payment, "pk123", info);

// then
assertThat(payment.getStatus()).isEqualTo(PaymentStatus.SUCCESS);
Expand All @@ -408,7 +420,8 @@ void completePayment_AlreadyCompleted_NoDoubleEarn() {
Payment payment = successPayment("pk123");

// when
paymentService.completePayment(payment, "pk123", LocalDateTime.now());
paymentService.completePayment(payment, "pk123",
TossPaymentInfo.builder().paymentKey("pk123").status("DONE").totalAmount(5_000).build());

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