99import com .backend .domain .payment .constant .PaymentStatus ;
1010import com .backend .domain .payment .dto .PaymentRequest ;
1111import com .backend .domain .payment .dto .PaymentResponse ;
12+ import com .backend .domain .payment .dto .PgChargeResultResponse ;
1213import com .backend .domain .payment .entity .Payment ;
1314import com .backend .domain .payment .entity .PaymentMethod ;
1415import com .backend .domain .payment .repository .PaymentMethodRepository ;
1516import com .backend .domain .payment .repository .PaymentRepository ;
1617import lombok .RequiredArgsConstructor ;
18+ import lombok .extern .slf4j .Slf4j ;
19+ import org .springframework .http .HttpStatus ;
1720import org .springframework .stereotype .Service ;
1821import org .springframework .transaction .annotation .Transactional ;
22+ import org .springframework .web .server .ResponseStatusException ;
1923
2024import java .time .LocalDateTime ;
21- import java .time .OffsetDateTime ;
2225
26+ @ Slf4j
2327@ Service
2428@ RequiredArgsConstructor
2529public class PaymentService {
@@ -28,19 +32,22 @@ public class PaymentService {
2832 private final PaymentMethodRepository paymentMethodRepository ; // 수단..
2933 private final CashRepository cashRepository ; // 지갑..
3034 private final CashTransactionRepository cashTransactionRepository ; // 원장..
31-
35+ private final TossBillingClient tossBillingClient ;
36+ private static final long MIN_AMOUNT = 100L ; // 최소 100원
3237 private static final long MAX_AMOUNT_PER_TX = 1_000_000L ; // 1회 한도(예시)..
3338
3439 @ Transactional // 원자성 보장..
3540 public PaymentResponse charge (Member actor , PaymentRequest req ) {
3641
3742 // 입력 검증..
38- if (req .getAmount () == null || req .getAmount () <= 0 ) // 금액 필수/양수..
39- throw new IllegalArgumentException ("금액은 1원 이상이어야 합니다." );
40- if (req .getAmount () > MAX_AMOUNT_PER_TX ) // 한도 체크..
41- throw new IllegalArgumentException ("1회 최대 충전 한도를 초과했습니다." );
42- if (req .getIdempotencyKey () == null || req .getIdempotencyKey ().isBlank ()) // 멱등키 필수..
43- throw new IllegalArgumentException ("idempotencyKey가 필요합니다." );
43+ if (req .getAmount () == null || req .getAmount () < MIN_AMOUNT )
44+ throw new ResponseStatusException (HttpStatus .BAD_REQUEST , "최소 결제 금액은 100원입니다." );
45+
46+ if (req .getAmount () > MAX_AMOUNT_PER_TX )
47+ throw new ResponseStatusException (HttpStatus .BAD_REQUEST ,"1회 최대 충전 한도를 초과했습니다." );
48+
49+ if (req .getIdempotencyKey () == null || req .getIdempotencyKey ().isBlank ())
50+ throw new ResponseStatusException (HttpStatus .BAD_REQUEST , "idempotencyKey가 필요합니다." );
4451
4552 // 멱등 선조회(같은 회원+키면 기존 결과 그대로 반환)..
4653 var existingOpt = paymentRepository .findByMemberAndIdempotencyKey (actor , req .getIdempotencyKey ());
@@ -53,9 +60,13 @@ public PaymentResponse charge(Member actor, PaymentRequest req) {
5360 // 결제수단 검증(본인 소유 + 삭제X + active)..
5461 PaymentMethod pm = paymentMethodRepository
5562 .findByIdAndMemberAndDeletedFalse (req .getPaymentMethodId (), actor ) // 삭제 안 된 본인 수단..
56- .orElseThrow (() -> new IllegalArgumentException ("결제수단을 찾을 수 없습니다." ));
57- if (Boolean .FALSE .equals (pm .getActive ())) // 비활성 차단..
58- throw new IllegalArgumentException ("비활성화된 결제수단입니다." );
63+ .orElseThrow (() -> new ResponseStatusException (HttpStatus .NOT_FOUND , "결제수단을 찾을 수 없습니다." ));
64+
65+ if (Boolean .FALSE .equals (pm .getActive ()) || Boolean .TRUE .equals (pm .getDeleted ()))
66+ throw new ResponseStatusException (HttpStatus .BAD_REQUEST , "비활성/삭제된 결제수단입니다." );
67+
68+ if (pm .getToken () == null || pm .getToken ().isBlank ())
69+ throw new ResponseStatusException (HttpStatus .BAD_REQUEST , "이 결제수단에는 billingKey가 없습니다. 카드 등록(빌링키 발급) 후 사용하세요." );
5970
6071 // payments: PENDING 생성..
6172 Payment payment = Payment .builder ()
@@ -77,9 +88,39 @@ public PaymentResponse charge(Member actor, PaymentRequest req) {
7788 .build ();
7889 payment = paymentRepository .save (payment );
7990
80- // (PG 모의) 승인 성공 가정 → 트랜잭션ID 발급..
81- String txId = "pg_" + payment .getId () + "_" + System .currentTimeMillis (); // 간단 모의..
82- payment .setTransactionId (txId );
91+ log .info ("[PM] id={}, token(billingKey)={}, brand={}, last4={}" ,
92+ pm .getId (), pm .getToken (), pm .getBrand (), pm .getLast4 ());
93+
94+ // (PG 모의) → (실제 토스 빌링 결제)로 교체..
95+ String customerKey = "user-" + actor .getId ();
96+ PgChargeResultResponse res ;
97+ try {
98+ res = tossBillingClient .charge (
99+ pm .getToken (), // billingKey
100+ req .getAmount (), // 금액
101+ req .getIdempotencyKey (), // 멱등키
102+ customerKey
103+ );
104+ } catch (ResponseStatusException ex ) {
105+ // 실패 상태로 마킹(선택)
106+ payment .setStatus (PaymentStatus .FAILED );
107+ paymentRepository .save (payment );
108+
109+ // 4xx/5xx 그대로 클라이언트에 전달
110+ throw ex ;
111+ }
112+
113+ // (tossBillingClient가 onStatus 에러를 던지도록 바꿨다면 아래는 안전망 정도로 유지)
114+ if (!res .isSuccess ()) {
115+ payment .setStatus (PaymentStatus .FAILED );
116+ payment .setTransactionId (res .getTransactionId ()); // 있을 수도/없을 수도
117+ paymentRepository .save (payment );
118+ throw new ResponseStatusException (HttpStatus .BAD_REQUEST ,
119+ "PG 승인 실패: " + res .getFailureMsg ());
120+ }
121+
122+ // 성공: PG에서 받은 키를 트랜잭션 ID로 저장...
123+ payment .setTransactionId (res .getTransactionId ());
83124
84125 // 지갑 잠금 후 잔액 증가 + 원장 DEPOSIT 기록..
85126 Cash cash = cashRepository .findWithLockByMember (actor ) // 비관적 잠금..
0 commit comments