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
31 changes: 19 additions & 12 deletions roome/src/main/java/com/roome/domain/payment/entity/Payment.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

import com.roome.domain.user.entity.User;
import com.roome.global.entity.BaseTimeEntity;
import com.roome.global.exception.BusinessException;
import com.roome.global.exception.ErrorCode;
import jakarta.persistence.*;
import java.time.LocalDateTime;
import lombok.*;
Expand Down Expand Up @@ -44,24 +46,29 @@ public class Payment extends BaseTimeEntity {
@Version
private Long version; // 낙관적 락 - 동시 상태 변경(중복 완결/취소) 방지

public void updateStatus(PaymentStatus status) {
this.status = status;
}

public void updatePaymentKey(String paymentKey) {
this.paymentKey = paymentKey;
}

// 결제 완결(승인 확인) 처리 (승인 시각 기록)
// 결제 완결(승인 확인) 처리 (PENDING -> SUCCESS) (승인 시각 기록)
public void markApproved(String paymentKey, LocalDateTime approvedAt) {
this.status = PaymentStatus.SUCCESS;
transitionTo(PaymentStatus.SUCCESS);
this.paymentKey = paymentKey;
this.approvedAt = approvedAt;
}

// 결제 취소 처리(취소 시각 기록)
// 결제 실패 처리 (PENDING -> FAILED)
public void markFailed() {
transitionTo(PaymentStatus.FAILED);
}

// 결제 취소 처리 - PENDING or SUCCESS -> CANCELED (취소 시각 기록)
public void markCanceled(LocalDateTime canceledAt) {
this.status = PaymentStatus.CANCELED;
transitionTo(PaymentStatus.CANCELED);
this.canceledAt = canceledAt;
}

// 상태 머신: 허용되지 않은 전이는 거부 (원장 오염 방지)
private void transitionTo(PaymentStatus target) {
if (!this.status.canTransitionTo(target)) {
throw new BusinessException(ErrorCode.INVALID_PAYMENT_STATUS_TRANSITION);
}
this.status = target;
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
package com.roome.domain.payment.entity;

public enum PaymentStatus {
PENDING, SUCCESS, FAILED, CANCELED
PENDING, SUCCESS, FAILED, CANCELED;

// 허용된 상태 전이 규칙 (결제 원장의 상태 머신)
// PENDING -> SUCCESS (승인 완결) / FAILED(실패 or 미완료) / CANCELED (승인 전 PG 취소)
// SUCCESS -> CANCELED (환불)
// FAILED, CANCELED = 종료 상태 (전이 불가)
public boolean canTransitionTo(PaymentStatus target) {
return switch (this) {
case PENDING -> target == SUCCESS || target == FAILED || target == CANCELED;
case SUCCESS -> target == CANCELED;
case FAILED, CANCELED -> false;
};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ private void reconcile(Long paymentId, String orderId) {

// Toss에 기록 자체가 없음 = 사용자가 결제창까지 도달하지 못함 → 실패 처리
if (tossInfo.isEmpty()) {
payment.updateStatus(PaymentStatus.FAILED);
payment.markFailed();
log.info("[대사] Toss 기록 없음, 실패 처리: orderId={}", orderId);
return;
}
Expand All @@ -85,7 +85,7 @@ private void reconcile(Long paymentId, String orderId) {
orderId, payment.getAmount(), payment.getPurchasedPoints());
}
case "CANCELED", "PARTIAL_CANCELED" -> {
payment.updateStatus(PaymentStatus.CANCELED);
payment.markCanceled(LocalDateTime.now());
log.info("[대사] Toss에서 취소 확인, 취소 처리: orderId={}", orderId);
}
case "WAITING_FOR_DEPOSIT" -> {
Expand All @@ -94,7 +94,7 @@ private void reconcile(Long paymentId, String orderId) {
}
default -> {
// READY, IN_PROGRESS, EXPIRED, ABORTED 등: 임계 시간이 지나도록 승인에 도달하지 못함
payment.updateStatus(PaymentStatus.FAILED);
payment.markFailed();
log.info("[대사] 미완료 상태({}), 실패 처리: orderId={}", info.getStatus(), orderId);
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -250,7 +250,7 @@ public void failPayment(Long userId, String orderId) {
throw new BusinessException(ErrorCode.PAYMENT_ALREADY_PROCESSED);
}

payment.updateStatus(PaymentStatus.FAILED);
payment.markFailed();

log.warn("결제 실패: orderId={}", orderId);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ public enum ErrorCode {
PAYMENT_ALREADY_PROCESSED(HttpStatus.BAD_REQUEST, "이미 처리된 결제입니다."),
ORDER_ID_ALREADY_EXISTS(HttpStatus.CONFLICT, "이미 사용 중인 주문 ID입니다."),
PARTIAL_CANCEL_NOT_SUPPORTED(HttpStatus.BAD_REQUEST, "부분 취소는 지원하지 않습니다. 전액 취소만 가능합니다."),
INVALID_PAYMENT_STATUS_TRANSITION(HttpStatus.CONFLICT, "허용되지 않은 결제 상태 전이입니다."),
POINT_PURCHASE_NOT_FOUND(HttpStatus.NOT_FOUND, "포인트 결제 정보를 찾을 수 없습니다."),
// 서평 관련 예외
MY_BOOK_REVIEW_NOT_FOUND(HttpStatus.NOT_FOUND, "서평을 찾을 수 없습니다."),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
package com.roome.domain.payment.entity;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

import com.roome.domain.user.entity.User;
import com.roome.global.exception.BusinessException;
import com.roome.global.exception.ErrorCode;
import java.time.LocalDateTime;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;

class PaymentStateMachineTest {

@Nested
@DisplayName("PaymentStatus.canTransitionTo")
class TransitionRules {

@Test
@DisplayName("PENDING은 SUCCESS/FAILED/CANCELED로 전이할 수 있다.")
void pendingTransitions() {
assertThat(PaymentStatus.PENDING.canTransitionTo(PaymentStatus.SUCCESS)).isTrue();
assertThat(PaymentStatus.PENDING.canTransitionTo(PaymentStatus.FAILED)).isTrue();
assertThat(PaymentStatus.PENDING.canTransitionTo(PaymentStatus.CANCELED)).isTrue();
}

@Test
@DisplayName("SUCCESS는 CANCELED로만 전이할 수 있다.")
void successTransitions() {
assertThat(PaymentStatus.SUCCESS.canTransitionTo(PaymentStatus.CANCELED)).isTrue();
assertThat(PaymentStatus.SUCCESS.canTransitionTo(PaymentStatus.FAILED)).isFalse();
assertThat(PaymentStatus.SUCCESS.canTransitionTo(PaymentStatus.PENDING)).isFalse();
}

@Test
@DisplayName("FAILED와 CANCELED는 종료 상태로 어떤 전이도 불가능하다.")
void terminalStates() {
for (PaymentStatus target : PaymentStatus.values()) {
assertThat(PaymentStatus.FAILED.canTransitionTo(target)).isFalse();
assertThat(PaymentStatus.CANCELED.canTransitionTo(target)).isFalse();
}
}
}

@Nested
@DisplayName("Payment 상태 전이 메서드")
class EntityTransitions {

@Test
@DisplayName("PENDING 결제는 승인 완결할 수 있다.")
void markApproved_FromPending() {
Payment payment = pending();
payment.markApproved("pk1", LocalDateTime.now());
assertThat(payment.getStatus()).isEqualTo(PaymentStatus.SUCCESS);
assertThat(payment.getPaymentKey()).isEqualTo("pk1");
}

@Test
@DisplayName("SUCCESS 결제를 다시 승인하면 예외가 발생한다.")
void markApproved_FromSuccess_Rejected() {
Payment payment = withStatus(PaymentStatus.SUCCESS);
assertThatThrownBy(() -> payment.markApproved("pk1", LocalDateTime.now()))
.isInstanceOf(BusinessException.class)
.hasMessageContaining(ErrorCode.INVALID_PAYMENT_STATUS_TRANSITION.getMessage());
}

@Test
@DisplayName("SUCCESS 결제를 실패로 덮어쓸 수 없다.")
void markFailed_FromSuccess_Rejected() {
Payment payment = withStatus(PaymentStatus.SUCCESS);
assertThatThrownBy(payment::markFailed)
.isInstanceOf(BusinessException.class)
.hasMessageContaining(ErrorCode.INVALID_PAYMENT_STATUS_TRANSITION.getMessage());
}

@Test
@DisplayName("SUCCESS 결제는 취소(환불)할 수 있다.")
void markCanceled_FromSuccess() {
Payment payment = withStatus(PaymentStatus.SUCCESS);
payment.markCanceled(LocalDateTime.now());
assertThat(payment.getStatus()).isEqualTo(PaymentStatus.CANCELED);
assertThat(payment.getCanceledAt()).isNotNull();
}

@Test
@DisplayName("이미 취소된 결제는 다시 취소할 수 없다.")
void markCanceled_FromCanceled_Rejected() {
Payment payment = withStatus(PaymentStatus.CANCELED);
assertThatThrownBy(() -> payment.markCanceled(LocalDateTime.now()))
.isInstanceOf(BusinessException.class)
.hasMessageContaining(ErrorCode.INVALID_PAYMENT_STATUS_TRANSITION.getMessage());
}

private Payment pending() {
return withStatus(PaymentStatus.PENDING);
}

private Payment withStatus(PaymentStatus status) {
return Payment.builder()
.user(User.builder().id(1L).build())
.orderId("order1")
.amount(5_000)
.purchasedPoints(550)
.status(status)
.build();
}
}
}
Loading