# Payment Session 구현 지침서 (IntelliJ 작업)

> **선행 완료**: DDL 운영 DB 적용 완료, common 모듈 Entity/Enum/Repository 생성 완료.
> **이 문서**: core + open-api 모듈에서 구현할 내용.

---

## 1. 파일 목록 (생성/수정)

### 신규 생성

| # | 파일 | 모듈 | 설명 |
|---|------|------|------|
| 1 | `core/src/.../session/PaymentSessionService.java` | core | 세션 생성/상태 갱신 핵심 서비스 |

### 수정

| # | 파일 | 모듈 | 변경 내용 |
|---|------|------|----------|
| 2 | `AximService.java` | core | `requestPayment()`, `handleCallback()` 에 세션 갱신 추가 |
| 3 | `TorqService.java` | core | `createTrade()`, `handleWebhook()` 에 세션 갱신 추가 |
| 4 | `PaymentLinkController.java` | open-api | `getPaymentLinkInfo()` 에 세션 조회/생성 + 응답 확장 |
| 5 | `PaymentLinkInfoResponse.java` | open-api | 세션 필드 추가 |

---

## 2. 신규: PaymentSessionService

### 위치
`core/src/main/java/com/cryptoments/core/session/PaymentSessionService.java`

### 전체 코드

```java
package com.cryptoments.core.session;

import com.cryptoments.common.entity.PaymentSession;
import com.cryptoments.common.enums.PaymentMethod;
import com.cryptoments.common.enums.PaymentSessionStatus;
import com.cryptoments.common.enums.PaymentSessionType;
import com.cryptoments.common.repository.PaymentSessionRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;

import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.UUID;

@Service
public class PaymentSessionService {

    private static final Logger log = LoggerFactory.getLogger(PaymentSessionService.class);
    private static final long DEFAULT_TTL_HOURS = 24;

    private final PaymentSessionRepository sessionRepository;

    public PaymentSessionService(PaymentSessionRepository sessionRepository) {
        this.sessionRepository = sessionRepository;
    }

    // ── 세션 생성/조회 ──

    /**
     * orderId로 세션 조회. 없으면 null.
     */
    public PaymentSession findByOrderId(Long partnerId, String orderId) {
        return sessionRepository.findByPartnerIdAndOrderId(partnerId, orderId);
    }

    /**
     * paymentLinkId로 활성 세션 조회. 없으면 null.
     * 결제 링크 재접속 시 사용.
     */
    public PaymentSession findActiveByPaymentLinkId(Long paymentLinkId) {
        List<PaymentSession> sessions = sessionRepository.findByPaymentLinkIdAndStatus(
                paymentLinkId, PaymentSessionStatus.ACTIVE.name());
        if (!sessions.isEmpty()) return sessions.get(0);

        sessions = sessionRepository.findByPaymentLinkIdAndStatus(
                paymentLinkId, PaymentSessionStatus.IN_PROGRESS.name());
        if (!sessions.isEmpty()) return sessions.get(0);

        return null;
    }

    /**
     * paymentLinkId의 가장 최근 세션 조회 (상태 무관).
     */
    public PaymentSession findLatestByPaymentLinkId(Long paymentLinkId) {
        PaymentSession session = sessionRepository.findByPaymentLinkId(paymentLinkId);
        return session;
    }

    /**
     * 결제 링크용 세션 생성.
     * orderId = linkCode + "_" + attempt (첫 시도는 linkCode 그대로).
     */
    public PaymentSession createLinkSession(Long partnerId, String linkCode, Long paymentLinkId,
                                             String partnerUserId, Long currencyId, Long networkId,
                                             java.math.BigDecimal amount, java.math.BigDecimal amountKrw,
                                             String depositMethod) {
        // 기존 활성 세션 확인
        PaymentSession existing = findActiveByPaymentLinkId(paymentLinkId);
        if (existing != null) {
            return existing;
        }

        // orderId 결정 (첫 시도: linkCode, 재시도: linkCode_2, linkCode_3 ...)
        String orderId = linkCode;
        if (sessionRepository.findByPartnerIdAndOrderId(partnerId, orderId) != null) {
            int attempt = 2;
            while (sessionRepository.findByPartnerIdAndOrderId(partnerId, linkCode + "_" + attempt) != null) {
                attempt++;
            }
            orderId = linkCode + "_" + attempt;
        }

        PaymentSession session = PaymentSession.builder()
                .sessionCode(generateSessionCode())
                .partnerId(partnerId)
                .sessionType(PaymentSessionType.LINK)
                .orderId(orderId)
                .paymentLinkId(paymentLinkId)
                .partnerUserId(partnerUserId)
                .currencyId(currencyId)
                .networkId(networkId)
                .amount(amount)
                .amountKrw(amountKrw)
                .depositMethod(depositMethod)
                .status(PaymentSessionStatus.ACTIVE)
                .expiresAt(LocalDateTime.now().plusHours(DEFAULT_TTL_HOURS))
                .build();

        Long id = sessionRepository.save(session);
        session.setId(id);

        log.info("결제 세션 생성 (LINK): sessionCode={}, orderId={}, linkId={}, partnerId={}",
                session.getSessionCode(), orderId, paymentLinkId, partnerId);
        return session;
    }

    /**
     * Widget SDK용 세션 생성.
     * orderId는 인테그레이터 제공 또는 자동 생성.
     */
    public PaymentSession createWidgetSession(Long partnerId, String orderId,
                                               String partnerUserId, Long currencyId, Long networkId,
                                               java.math.BigDecimal amount, java.math.BigDecimal amountKrw,
                                               String depositMethod, Long ttlHours) {
        // orderId 없으면 자동 생성
        if (orderId == null || orderId.isBlank()) {
            orderId = generateOrderId();
        }

        // 기존 세션 확인
        PaymentSession existing = sessionRepository.findByPartnerIdAndOrderId(partnerId, orderId);
        if (existing != null) {
            // 만료 체크
            if (existing.getExpiresAt() != null
                    && LocalDateTime.now().isAfter(existing.getExpiresAt())
                    && existing.getStatus() == PaymentSessionStatus.ACTIVE) {
                existing.setStatus(PaymentSessionStatus.EXPIRED);
                sessionRepository.modify(existing);
            }
            return existing;
        }

        PaymentSession session = PaymentSession.builder()
                .sessionCode(generateSessionCode())
                .partnerId(partnerId)
                .sessionType(PaymentSessionType.WIDGET)
                .orderId(orderId)
                .partnerUserId(partnerUserId)
                .currencyId(currencyId)
                .networkId(networkId)
                .amount(amount)
                .amountKrw(amountKrw)
                .depositMethod(depositMethod)
                .status(PaymentSessionStatus.ACTIVE)
                .expiresAt(LocalDateTime.now().plusHours(ttlHours != null ? ttlHours : DEFAULT_TTL_HOURS))
                .build();

        Long id = sessionRepository.save(session);
        session.setId(id);

        log.info("결제 세션 생성 (WIDGET): sessionCode={}, orderId={}, partnerId={}",
                session.getSessionCode(), orderId, partnerId);
        return session;
    }

    // ── 상태 갱신 ──

    /**
     * 자식 결제 시작 시 호출.
     * session: ACTIVE → IN_PROGRESS.
     */
    public void onPaymentStarted(Long sessionId, PaymentMethod paymentMethod,
                                  Long paymentRefId, String paymentStatus) {
        PaymentSession session = sessionRepository.findOne(sessionId);
        if (session == null) return;
        if (session.getStatus() != PaymentSessionStatus.ACTIVE) {
            log.warn("세션 상태 전이 불가 (ACTIVE가 아님): sessionId={}, status={}", sessionId, session.getStatus());
            return;
        }

        session.setStatus(PaymentSessionStatus.IN_PROGRESS);
        session.setPaymentMethod(paymentMethod);
        session.setPaymentRefId(paymentRefId);
        session.setPaymentStatus(paymentStatus);
        sessionRepository.modify(session);

        log.info("세션 IN_PROGRESS: sessionId={}, method={}, refId={}", sessionId, paymentMethod, paymentRefId);
    }

    /**
     * 자식 결제 상태 변경 시 호출 (Webhook/폴링).
     * 자식의 상태에 따라 세션을 터미널 상태로 전이.
     */
    public void onPaymentStatusChanged(Long sessionId, String newPaymentStatus, Long depositId) {
        PaymentSession session = sessionRepository.findOne(sessionId);
        if (session == null) return;
        if (session.getStatus().isTerminal()) return;

        session.setPaymentStatus(newPaymentStatus);

        switch (newPaymentStatus) {
            // 성공
            case "CONFIRMED":   // Axim
            case "COMPLETED":   // TORQ
                session.setStatus(PaymentSessionStatus.COMPLETED);
                session.setDepositId(depositId);
                session.setCompletedAt(LocalDateTime.now());
                break;

            // 취소
            case "CANCELED":
            case "CANCELLED":
                session.setStatus(PaymentSessionStatus.CANCELED);
                session.setCompletedAt(LocalDateTime.now());
                break;

            // 실패
            case "DENIED":
            case "FAILED":
                session.setStatus(PaymentSessionStatus.FAILED);
                session.setCompletedAt(LocalDateTime.now());
                break;

            // 만료
            case "EXPIRED":
                session.setStatus(PaymentSessionStatus.EXPIRED);
                session.setCompletedAt(LocalDateTime.now());
                break;

            // 분쟁 — 아직 진행 중이므로 세션 상태 유지
            case "DISPUTED":
                break;

            // RESOLVED — 판정 결과에 따라 분기
            case "RESOLVED":
                // 별도 처리 필요 (release → COMPLETED, refund → FAILED)
                break;

            // 중간 상태 (PENDING, ONGOING, ACCEPTED, TRANSFERRED 등) — 세션 유지
            default:
                break;
        }

        sessionRepository.modify(session);

        if (session.getStatus().isTerminal()) {
            log.info("세션 터미널: sessionId={}, status={}, paymentStatus={}",
                    sessionId, session.getStatus(), newPaymentStatus);
        }
    }

    // ── 유틸 ──

    /** 세션 코드 생성 — ses_{YYMM}_{random8} */
    private String generateSessionCode() {
        String yymm = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyMM"));
        String random = UUID.randomUUID().toString().replace("-", "").substring(0, 8);
        return "ses_" + yymm + "_" + random;
    }

    /** 레거시 orderId 자동 생성 — auto_{YYMM}_{random8} */
    public String generateOrderId() {
        String yymm = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyMM"));
        String random = UUID.randomUUID().toString().replace("-", "").substring(0, 8);
        return "auto_" + yymm + "_" + random;
    }
}
```

---

## 3. 수정: AximService.java

### 3.1 생성자에 PaymentSessionService 주입

```java
// 기존 필드에 추가
private final PaymentSessionService sessionService;

// 생성자 파라미터에 추가
public AximService(AximPaymentRepository aximPaymentRepository,
                   ExternalWalletRepository externalWalletRepository,
                   PartnerAximSettingsRepository aximSettingsRepository,
                   PriceService priceService,
                   ObjectMapper objectMapper,
                   PaymentSessionService sessionService) {   // ← 추가
    // ...기존 할당...
    this.sessionService = sessionService;
}
```

### 3.2 requestPayment() — 세션 연동

`requestPayment()` 메서드 시그니처에 `Long sessionId` 파라미터 추가:

```java
public AximPayment requestPayment(Long partnerId, String partnerUserId,
                                  Long externalWalletId, BigDecimal amount,
                                  Long currencyId, Long networkId,
                                  String partnerReference, Long paymentLinkId,
                                  Long sessionId) {   // ← 추가
    // ...기존 로직...

    AximPayment payment = AximPayment.builder()
            // ...기존 필드...
            .sessionId(sessionId)   // ← 추가
            .build();

    Long id = aximPaymentRepository.save(payment);
    payment.setId(id);

    // ★ 세션 갱신
    if (sessionId != null) {
        sessionService.onPaymentStarted(sessionId, PaymentMethod.AXIM, id, "REQUESTED");
    }

    return payment;
}
```

**⚠️ 호출부도 수정 필요**: `requestPayment()`를 호출하는 곳 (AximController, PartnerDepositService 등)에 `sessionId` 파라미터 추가. 세션이 없는 기존 호출은 `null` 전달.

### 3.3 handleCallback() — 세션 상태 전파

`handleCallback()` 끝에 세션 갱신 추가:

```java
public AximPayment handleCallback(String aximPaymentId, AximPaymentStatus status,
                                   String callbackData) {
    // ...기존 로직 (payment 조회, 멱등성 체크, 상태 업데이트)...

    AximPayment updated = builder.build();
    aximPaymentRepository.modify(updated);

    // ★ 세션 상태 전파 (추가)
    if (updated.getSessionId() != null) {
        Long depositId = (status == AximPaymentStatus.CONFIRMED) ? updated.getDepositId() : null;
        sessionService.onPaymentStatusChanged(updated.getSessionId(), status.name(), depositId);
    }

    log.info("Axim 콜백 처리: aximPaymentId={}, status={}", aximPaymentId, status);
    return updated;
}
```

### 3.4 cancelPayment() — 세션 상태 전파

```java
public AximPayment cancelPayment(Long paymentId) {
    // ...기존 로직...

    AximPayment updated = payment.toBuilder()
            .status(AximPaymentStatus.CANCELED)
            .build();
    aximPaymentRepository.modify(updated);

    // ★ 세션 상태 전파 (추가)
    if (updated.getSessionId() != null) {
        sessionService.onPaymentStatusChanged(updated.getSessionId(), "CANCELED", null);
    }

    return updated;
}
```

### 3.5 confirmPayment() — 세션 상태 전파

```java
public AximPayment confirmPayment(String aximPaymentId) {
    // ...기존 로직...

    AximPayment updated = payment.toBuilder()
            .status(AximPaymentStatus.CONFIRMED)
            .confirmedAt(LocalDateTime.now())
            .build();
    aximPaymentRepository.modify(updated);

    // ★ 세션 상태 전파 (추가)
    if (updated.getSessionId() != null) {
        sessionService.onPaymentStatusChanged(updated.getSessionId(), "CONFIRMED", updated.getDepositId());
    }

    return updated;
}
```

---

## 4. 수정: TorqService.java

### 4.1 생성자에 PaymentSessionService 주입

```java
private final PaymentSessionService sessionService;
// 생성자 파라미터에 추가
```

### 4.2 createTrade() — 세션 연동

`createTrade()` 메서드 시그니처에 `Long sessionId` 파라미터 추가:

```java
public TorqTrade createTrade(Long partnerId, String partnerUserId,
                              long krwAmount, String receiveAddress,
                              String buyerName, String buyerPhone,
                              String buyerBank, String buyerAccountHolder,
                              String buyerAccountNumber, String kycUid,
                              Long quoteId,
                              Long sessionId) {   // ← 추가
    // ...기존 로직...

    TorqTrade trade = TorqTrade.builder()
            // ...기존 필드...
            .sessionId(sessionId)   // ← 추가
            .build();

    Long id = torqTradeRepository.save(trade);
    trade.setId(id);

    // ★ 세션 갱신
    if (sessionId != null) {
        sessionService.onPaymentStarted(sessionId, PaymentMethod.TORQ, id, "CREATED");
    }

    return trade;
}
```

**⚠️ 호출부 수정**: `TorqWidgetController.createTrade()` 에서 `sessionId` 전달.

### 4.3 handleWebhook() — 세션 상태 전파

```java
public void handleWebhook(Long escrowId, String event, Map<String, Object> payload) {
    // ...기존 로직 (trade 조회, 상태 업데이트)...

    // ★ 세션 상태 전파 (기존 로직 끝에 추가)
    if (trade.getSessionId() != null) {
        Long depositId = trade.getDepositId();  // COMPLETED 시 연결된 deposit
        sessionService.onPaymentStatusChanged(trade.getSessionId(), trade.getStatus().name(), depositId);
    }
}
```

---

## 5. 수정: PaymentLinkController.java

### 5.1 생성자에 PaymentSessionService 주입

```java
private final PaymentSessionService sessionService;
// 생성자 파라미터에 추가
```

### 5.2 getPaymentLinkInfo() — 세션 조회/생성 추가

기존 `in-flight 결제 노출` 블록 (184~200행)을 **세션 기반으로 교체**:

```java
// ── 세션 조회/생성 (기존 in-flight 블록 교체) ──
try {
    // 1. 활성 세션 조회
    PaymentSession session = sessionService.findActiveByPaymentLinkId(pl.getId());

    // 2. 없으면 (첫 진입 또는 이전 세션 종료) → 새 세션 생성
    if (session == null) {
        session = sessionService.createLinkSession(
                pl.getPartnerId(), pl.getLinkCode(), pl.getId(),
                pl.getPartnerUserId(), pl.getCurrencyId(), pl.getNetworkId(),
                pl.getAmount(),
                pl.getAmount() != null && pl.getPriceKrw() != null
                        ? pl.getAmount().multiply(pl.getPriceKrw()) : null,
                pl.getDepositMethod() != null ? pl.getDepositMethod().name() : null);
    }

    // 3. 세션 정보를 응답에 포함
    response.setSessionCode(session.getSessionCode());
    response.setSessionStatus(session.getStatus().name());
    response.setOrderId(session.getOrderId());

    if (session.getPaymentMethod() != null) {
        response.setPaymentMethod(session.getPaymentMethod().name());
        response.setPaymentStatus(session.getPaymentStatus());
        response.setPaymentRefId(session.getPaymentRefId());

        // Axim 진행 중이면 기존 inFlight 필드도 채움 (하위 호환)
        if (session.getPaymentMethod() == PaymentMethod.AXIM
                && !session.getStatus().isTerminal()) {
            AximPayment inFlight = aximPaymentRepository.findOne(session.getPaymentRefId());
            if (inFlight != null) {
                response.setInFlightPaymentId(inFlight.getId());
                response.setInFlightPaymentStatus(inFlight.getStatus().name());
                response.setInFlightAximPaymentId(inFlight.getAximPaymentId());
            }
        }

        // TORQ 진행 중이면 escrowId 포함
        if (session.getPaymentMethod() == PaymentMethod.TORQ
                && !session.getStatus().isTerminal()) {
            response.setTorqPendingEscrowId(session.getPaymentRefId());
        }
    }
} catch (Exception e) {
    log.warn("세션 조회 실패: linkId={}, error={}", linkId, e.getMessage());
}
```

### 5.3 completePayment() — 세션도 COMPLETED

```java
@PostMapping(name = "결제 완료 처리", value = "/{linkId}/complete")
public ResponseEntity<PaymentLinkInfoResponse> completePayment(@PathVariable String linkId) {
    PaymentLink pl = findLink(linkId);
    if (pl.getStatus() == PaymentLinkStatus.ACTIVE) {
        paymentLinkRepository.modify(pl.toBuilder().status(PaymentLinkStatus.USED).build());
        pl.setStatus(PaymentLinkStatus.USED);
    }

    // ★ 세션도 COMPLETED 처리 (추가)
    PaymentSession session = sessionService.findActiveByPaymentLinkId(pl.getId());
    if (session != null && !session.getStatus().isTerminal()) {
        sessionService.onPaymentStatusChanged(session.getId(), "COMPLETED", null);
    }

    log.info("Payment link completed: linkId={}", linkId);
    return ResponseEntity.ok(toResponse(pl));
}
```

---

## 6. 수정: PaymentLinkInfoResponse.java

기존 필드에 추가:

```java
/** 세션 코드 */
private String sessionCode;

/** 세션 상태 (ACTIVE / IN_PROGRESS / COMPLETED / FAILED / CANCELED / EXPIRED) */
private String sessionStatus;

/** orderId (인테그레이터 매칭용) */
private String orderId;

/** 결제 방식 (AXIM / TORQ / DIRECT) — 세션에 결제가 시작된 경우 */
private String paymentMethod;

/** 자식 결제 상태 */
private String paymentStatus;

/** 자식 결제 ID */
private Long paymentRefId;

/** TORQ 진행 중인 경우 에스크로 ID (= paymentRefId이지만 명시적 필드) */
private Long torqPendingEscrowId;
```

---

## 7. 수정: TorqWidgetController.java

### 7.1 createTrade() 에 세션 연동

`createTrade()` 메서드에서 `torqService.createTrade()` 호출 시 `sessionId` 전달:

```java
// 현재:
TorqTrade trade = torqService.createTrade(
        partnerId, partnerUserId,
        request.getKrwAmount(), receiveAddress,
        buyerName, buyerPhone, buyerBank, buyerAccountHolder,
        buyerAccountNumber, kycUid, request.getQuoteId());

// 변경: sessionId 추가 (request에서 받거나, 없으면 null)
Long sessionId = null;  // Phase 3에서 Widget SDK가 전달하게 됨
TorqTrade trade = torqService.createTrade(
        partnerId, partnerUserId,
        request.getKrwAmount(), receiveAddress,
        buyerName, buyerPhone, buyerBank, buyerAccountHolder,
        buyerAccountNumber, kycUid, request.getQuoteId(),
        sessionId);
```

> **Note**: Phase 2에서는 결제 링크 경유 TORQ 거래에만 sessionId가 전달됨.
> 결제 링크에서 TORQ를 선택하는 경우, `request.getLinkCode()`로 PaymentSession을 조회하여 sessionId를 얻어야 함.
> 이 부분은 결제 링크→TORQ 전환 흐름이 구현될 때 연결.

---

## 8. import 주의사항

각 수정 파일에 필요한 import:

```java
// AximService, TorqService
import com.cryptoments.core.session.PaymentSessionService;
import com.cryptoments.common.enums.PaymentMethod;

// PaymentLinkController
import com.cryptoments.core.session.PaymentSessionService;
import com.cryptoments.common.entity.PaymentSession;
import com.cryptoments.common.enums.PaymentMethod;
```

---

## 9. 빌드 및 배포

```bash
# 1. common 빌드
./gradlew :common:compileJava

# 2. core 빌드 (PaymentSessionService 포함)
./gradlew :core:compileJava

# 3. open-api 빌드
./gradlew :open-api:compileJava

# 4. open-api JAR 생성 + 배포
./gradlew :open-api:bootJar
# → build/libs/open-api-*.jar → app-01 배포
```

---

## 10. 검증 포인트

### 10.1 결제 링크 진입

```
GET /widgets/payment/links/{linkCode}
→ 응답에 sessionCode, sessionStatus, orderId 포함 확인
→ 첫 진입: sessionStatus = "ACTIVE"
→ Axim 진행 중: sessionStatus = "IN_PROGRESS", paymentMethod = "AXIM"
```

### 10.2 Axim 결제 생성 → 세션 갱신

```
axim_payments 레코드에 session_id가 채워지는지 확인
payment_sessions.status = IN_PROGRESS, payment_method = AXIM
```

### 10.3 Axim Webhook (CANCELED) → 세션 터미널

```
Axim CANCELED 콜백 수신 후:
payment_sessions.status = CANCELED
payment_sessions.payment_status = CANCELED
```

### 10.4 링크 재진입 (이전 세션 실패)

```
기존 세션 CANCELED → 링크가 ACTIVE →
GET /widgets/payment/links/{linkCode}
→ 새 세션 생성 (orderId = linkCode_2)
→ sessionStatus = "ACTIVE" (새 세션)
```

### 10.5 DB 확인 쿼리

```sql
-- 세션 조회
SELECT * FROM payment_sessions ORDER BY id DESC LIMIT 10;

-- 세션-결제 연결 확인
SELECT s.session_code, s.status, s.payment_method, s.payment_status,
       a.payment_code, a.status as axim_status
FROM payment_sessions s
LEFT JOIN axim_payments a ON a.session_id = s.id
ORDER BY s.id DESC LIMIT 10;
```
