# 폰페이 Phase 3 — Widget API (eKYC + 폰페이 세션) 구현 지침서

> 대상: IntelliJ (Spring Boot)
> 선행 조건: Phase 1 (DDL + common) + Phase 2 (core + Webhook + partner-api) 완료
> 참조: `v2-docs/PHONEPAY_INTEGRATION_PLAN.md` (v5), `v2-docs/PHONEPAY_PHASE2_GUIDE.md`
> 패턴 참조: `AximController.java` (Widget API 패턴), `AximPayClient.java` (외부 API 호출)

---

## 범위

| 모듈 | 신규 파일 | 기존 수정 |
|------|----------|----------|
| **common** | `AximEkycStatusResponse.java`, `AximEkycInfoResponse.java` (DTO 2개) | `AximPayClient.java` (eKYC 메서드 2개 추가) |
| **open-api** | `PhonepayWidgetController.java`, `EkycStatusResponse.java`, `EkycInfoResponse.java`, `PhonepaySessionResponse.java`, `PhonepayCreateSessionRequest.java`, `PhonepayCancelResponse.java` | `AximController.java` (eKYC 엔드포인트 2개 추가) |

**총 8개 파일** (신규 7 + 수정 2)

---

## 1. common 모듈 — eKYC DTO 2개

### 1.1 `AximEkycStatusResponse.java`

경로: `common/src/main/java/com/cryptoments/common/client/dto/axim/AximEkycStatusResponse.java`

Axim eKYC 인증 여부 API 응답 DTO. `AximPayClient`에서 사용.

```java
package com.cryptoments.common.client.dto.axim;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

/**
 * Axim eKYC 인증 여부 응답.
 *
 * <p>Axim API: GET /api/v1/open/partners/members/kyc/{connectId}/status
 */
@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AximEkycStatusResponse {
    /** 인증 완료 여부 */
    private Boolean verified;
    /** 인증 상태: UNVERIFIED, VERIFYING, VERIFIED, REJECTED */
    private String status;
}
```

### 1.2 `AximEkycInfoResponse.java`

경로: `common/src/main/java/com/cryptoments/common/client/dto/axim/AximEkycInfoResponse.java`

Axim eKYC 인증 정보(송금인 정보) API 응답 DTO.

```java
package com.cryptoments.common.client.dto.axim;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

/**
 * Axim eKYC 인증 정보 응답.
 *
 * <p>Axim API: GET /api/v1/open/partners/members/kyc/{connectId}
 * <p>전체 필드를 담아두고, Widget에서는 필요한 필드만 선별하여 반환.
 */
@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AximEkycInfoResponse {
    /** 인증 상태: PENDING, IN_PROGRESS, APPROVED, REJECTED, EXPIRED */
    private String status;
    /** 인증된 회원 이름 */
    private String name;
    /** 생년월일 (yyyy-MM-dd) */
    private String birthday;
    /** 휴대폰 번호 */
    private String phoneNumber;
    /** 이메일 */
    private String email;
    /** 금융기관 코드 */
    private String financeCode;
    /** 금융기관명 */
    private String financeCompany;
    /** 계좌번호 */
    private String accountNumber;
    /** 예금주명 */
    private String accountHolder;
    /** 인증 완료 시각 (yyyy-MM-dd HH:mm:ss) */
    private String verifiedAt;
}
```

---

## 2. common 모듈 — `AximPayClient.java` 수정

경로: `common/src/main/java/com/cryptoments/common/client/AximPayClient.java`

기존 AximPayClient에 eKYC 메서드 2개를 추가한다.

### 2.1 import 추가

기존 import 블록(`import com.cryptoments.common.client.dto.axim.*;`)이 와일드카드이므로 추가 import 불필요.
새 DTO(`AximEkycStatusResponse`, `AximEkycInfoResponse`)는 같은 패키지에 있으므로 자동 포함.

### 2.2 메서드 2개 추가

클래스 마지막 메서드 뒤에 추가:

```java
    // ── eKYC API ──

    /**
     * eKYC 인증 여부를 조회한다.
     *
     * <p>Axim API: GET /api/v1/open/partners/members/kyc/{connectId}/status
     *
     * @param apiKey    파트너 API Key
     * @param secretKey 파트너 Secret Key
     * @param connectId aximPartnerId:partnerUserId hex 인코딩
     * @return 인증 여부 (verified, status)
     */
    public AximEkycStatusResponse getEkycStatus(String apiKey, String secretKey, String connectId) {
        String url = baseUrl + "/api/v1/open/partners/members/kyc/" + connectId + "/status";
        log.info("[AximPayClient] getEkycStatus 요청: connectId={}", connectId);
        try {
            AximEkycStatusResponse response = restClient.get()
                    .uri(url)
                    .headers(h -> addAuthHeaders(h, apiKey, secretKey))
                    .retrieve()
                    .body(AximEkycStatusResponse.class);
            log.info("[AximPayClient] getEkycStatus 성공: verified={}",
                    response != null ? response.getVerified() : "null");
            return response;
        } catch (Exception e) {
            log.error("[AximPayClient] getEkycStatus 실패: connectId={}, error={}",
                    connectId, e.getMessage(), e);
            throw new AximApiException("eKYC 인증 여부 조회 실패: " + e.getMessage(), e);
        }
    }

    /**
     * eKYC 인증 정보(송금인 정보)를 조회한다.
     *
     * <p>Axim API: GET /api/v1/open/partners/members/kyc/{connectId}
     * <p>전체 필드(name, birthday, phoneNumber, email, financeCode, financeCompany, accountNumber, accountHolder)를 반환.
     * Widget에서 필요한 필드만 선별하여 사용자에게 노출.
     *
     * @param apiKey    파트너 API Key
     * @param secretKey 파트너 Secret Key
     * @param connectId aximPartnerId:partnerUserId hex 인코딩
     * @return 인증된 송금인 정보
     */
    public AximEkycInfoResponse getEkycInfo(String apiKey, String secretKey, String connectId) {
        String url = baseUrl + "/api/v1/open/partners/members/kyc/" + connectId;
        log.info("[AximPayClient] getEkycInfo 요청: connectId={}", connectId);
        try {
            AximEkycInfoResponse response = restClient.get()
                    .uri(url)
                    .headers(h -> addAuthHeaders(h, apiKey, secretKey))
                    .retrieve()
                    .body(AximEkycInfoResponse.class);
            log.info("[AximPayClient] getEkycInfo 성공: name={}",
                    response != null ? response.getName() : "null");
            return response;
        } catch (Exception e) {
            log.error("[AximPayClient] getEkycInfo 실패: connectId={}, error={}",
                    connectId, e.getMessage(), e);
            throw new AximApiException("eKYC 인증 정보 조회 실패: " + e.getMessage(), e);
        }
    }
```

---

## 3. open-api 모듈 — Widget 응답 DTO 5개

### 3.1 `EkycStatusResponse.java`

경로: `open-api/src/main/java/com/cryptoments/openapi/dto/response/EkycStatusResponse.java`

W2 eKYC 인증 여부 Widget 응답. Axim 응답을 그대로 전달.

```java
package com.cryptoments.openapi.dto.response;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import one.axim.gradle.annotation.XSample;

/**
 * eKYC 인증 여부 응답 (Widget → 사용자).
 */
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class EkycStatusResponse {
    /** 인증 완료 여부 */
    @XSample("true")
    private Boolean verified;
    /** 인증 상태: UNVERIFIED, VERIFYING, VERIFIED, REJECTED, NOT_CONFIGURED */
    @XSample("VERIFIED")
    private String status;
}
```

### 3.2 `EkycInfoResponse.java`

경로: `open-api/src/main/java/com/cryptoments/openapi/dto/response/EkycInfoResponse.java`

W3 eKYC 인증 정보 Widget 응답. Axim 응답에서 필요한 4개 필드만 선별.

```java
package com.cryptoments.openapi.dto.response;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import one.axim.gradle.annotation.XSample;

/**
 * eKYC 인증 정보 응답 (Widget → 사용자).
 *
 * <p>Axim eKYC 전체 정보 중 폰페이에 필요한 4개 필드만 반환.
 * birthday, email, accountNumber, accountHolder는 Widget에 노출하지 않음.
 */
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class EkycInfoResponse {
    /** 인증된 실명 */
    @XSample("홍길동")
    private String name;
    /** 휴대폰 번호 */
    @XSample("01012345678")
    private String phone;
    /** 금융기관 코드 */
    @XSample("004")
    private String bankCode;
    /** 금융기관명 */
    @XSample("KB국민은행")
    private String bankName;
}
```

### 3.3 `PhonepayCreateSessionRequest.java`

경로: `open-api/src/main/java/com/cryptoments/openapi/dto/request/PhonepayCreateSessionRequest.java`

W4 세션 생성 요청. 사용자는 금액만 입력. 송금인 정보는 서버에서 eKYC로 채움.

```java
package com.cryptoments.openapi.dto.request;

import lombok.Getter;
import lombok.Setter;
import one.axim.gradle.annotation.XSample;

/**
 * 폰페이 세션 생성 요청 (Widget → 서버).
 *
 * <p>송금인 정보(이름, 전화번호, 은행)는 서버에서 eKYC API로 조회하여 채움.
 * Widget 클라이언트가 직접 전송하지 않음 → 위변조 방지.
 */
@Getter
@Setter
public class PhonepayCreateSessionRequest {
    /** 입금 금액 (원, KRW) */
    @XSample("50000")
    private Long amount;
}
```

### 3.4 `PhonepaySessionResponse.java`

경로: `open-api/src/main/java/com/cryptoments/openapi/dto/response/PhonepaySessionResponse.java`

W4/W5 공통 세션 응답.

```java
package com.cryptoments.openapi.dto.response;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import one.axim.gradle.annotation.XSample;

/**
 * 폰페이 세션 응답 (W4 생성 + W5 조회 공용).
 */
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class PhonepaySessionResponse {
    /** 세션 ID (DB PK) */
    @XSample("123")
    private Long sessionId;
    /** BanqPipe 세션 ID */
    @XSample("dp_a1b2c3d4")
    private String banqpipeSessionId;
    /** 세션 상태: WAITING, MATCHED, COMPLETED, FAILED, EXPIRED, CANCELLED */
    @XSample("WAITING")
    private String status;
    /** 입금 금액 (원) */
    @XSample("50000")
    private Long amount;
    /** 송금인 이름 (eKYC) */
    @XSample("홍길동")
    private String senderName;
    /** 배정된 수취인 이름 */
    @XSample("김철수")
    private String recipientName;
    /** 배정된 수취인 전화번호 */
    @XSample("01098765432")
    private String recipientPhone;
    /** 배정된 수취인 은행명 */
    @XSample("국민은행")
    private String recipientBank;
    /** 세션 만료 시각 (ISO 8601) */
    @XSample("2026-04-28T16:30:00")
    private String expiresAt;
    /** 남은 시간 (초) — W5 응답에서만 의미 있음 */
    @XSample("1800")
    private Long remainingSeconds;
}
```

### 3.5 `PhonepayCancelResponse.java`

경로: `open-api/src/main/java/com/cryptoments/openapi/dto/response/PhonepayCancelResponse.java`

W6 세션 취소 응답.

```java
package com.cryptoments.openapi.dto.response;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import one.axim.gradle.annotation.XSample;

/**
 * 폰페이 세션 취소 응답 (W6).
 */
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class PhonepayCancelResponse {
    /** 세션 ID */
    @XSample("123")
    private Long sessionId;
    /** 취소 후 상태 */
    @XSample("CANCELLED")
    private String status;
    /** 취소 시각 (ISO 8601) */
    @XSample("2026-04-28T15:05:00")
    private String cancelledAt;
}
```

---

## 4. open-api 모듈 — `AximController.java` 수정 (W2 + W3)

경로: `open-api/src/main/java/com/cryptoments/openapi/controller/widget/AximController.java`

기존 AximController 클래스에 eKYC 엔드포인트 2개를 추가한다.

### 4.1 import 추가

기존 import 블록에 추가:

```java
import com.cryptoments.openapi.dto.response.EkycStatusResponse;
import com.cryptoments.openapi.dto.response.EkycInfoResponse;
import com.cryptoments.common.client.dto.axim.AximEkycStatusResponse;
import com.cryptoments.common.client.dto.axim.AximEkycInfoResponse;
```

### 4.2 W2 — eKYC 인증 여부 조회

`getPartnerInfo()` 메서드 아래(클래스 마지막)에 추가:

```java
    // ── eKYC API (폰페이 연동) ──

    /**
     * eKYC 인증 여부 조회.
     * Axim eKYC API를 통해 사용자의 본인인증 완료 여부를 확인한다.
     * 폰페이 결제 화면 진입 시 최초 호출.
     *
     * @param session 세션 데이터 (partnerUserId 포함)
     * @return 인증 여부 (verified, status)
     * @response 200 조회 성공
     * @auth true
     */
    @GetMapping(name = "eKYC 인증 여부 조회", value = "/ekyc/status")
    public EkycStatusResponse getEkycStatus(WidgetSessionData session) {

        PartnerAximSettings settings = aximSettingsRepository.findByPartnerId(session.getPartnerId());
        if (settings == null || !Boolean.TRUE.equals(settings.getIsEnabled())) {
            return EkycStatusResponse.builder()
                    .verified(false)
                    .status("NOT_CONFIGURED")
                    .build();
        }

        // connectId 생성: aximPartnerId 조회 필요 → getPartnerInfo로 파트너 ID 취득
        try {
            var partnerInfo = aximPayClient.getPartnerInfo(
                    settings.getApiKey(), settings.getApiSecretEnc());
            String connectId = generateConnectId(partnerInfo.getPartnerId(), session.getPartnerUserId());

            if (connectId == null) {
                return EkycStatusResponse.builder()
                        .verified(false)
                        .status("NO_USER_ID")
                        .build();
            }

            AximEkycStatusResponse axim = aximPayClient.getEkycStatus(
                    settings.getApiKey(), settings.getApiSecretEnc(), connectId);

            return EkycStatusResponse.builder()
                    .verified(axim.getVerified())
                    .status(axim.getStatus())
                    .build();

        } catch (Exception e) {
            log.error("eKYC 인증 여부 조회 실패: partnerId={}, error={}",
                    session.getPartnerId(), e.getMessage());
            return EkycStatusResponse.builder()
                    .verified(false)
                    .status("API_ERROR")
                    .build();
        }
    }

    /**
     * eKYC 인증 정보 조회 (송금인 정보).
     * Axim eKYC API를 통해 인증된 사용자의 이름, 전화번호, 은행 정보를 조회한다.
     * 폰페이 결제 화면에 송금인 정보를 표시하고, 세션 생성 시 자동 채움에 사용.
     *
     * <p>보안: birthday, email, accountNumber, accountHolder는 Widget에 노출하지 않음.
     *
     * @param session 세션 데이터 (partnerUserId 포함)
     * @return 송금인 정보 (name, phone, bankCode, bankName)
     * @response 200 조회 성공
     * @response 404 Axim 설정 없음
     * @auth true
     */
    @GetMapping(name = "eKYC 인증 정보 조회", value = "/ekyc/info")
    public EkycInfoResponse getEkycInfo(WidgetSessionData session) {

        PartnerAximSettings settings = aximSettingsRepository.findByPartnerId(session.getPartnerId());
        if (settings == null || !Boolean.TRUE.equals(settings.getIsEnabled())) {
            throw new NotFoundException(ErrorCodes.AXIM_SETTINGS_NOT_FOUND);
        }

        try {
            var partnerInfo = aximPayClient.getPartnerInfo(
                    settings.getApiKey(), settings.getApiSecretEnc());
            String connectId = generateConnectId(partnerInfo.getPartnerId(), session.getPartnerUserId());

            if (connectId == null) {
                throw new NotFoundException(ErrorCodes.EKYC_NOT_VERIFIED);
            }

            AximEkycInfoResponse axim = aximPayClient.getEkycInfo(
                    settings.getApiKey(), settings.getApiSecretEnc(), connectId);

            return EkycInfoResponse.builder()
                    .name(axim.getName())
                    .phone(axim.getPhoneNumber())
                    .bankCode(axim.getFinanceCode())
                    .bankName(axim.getFinanceCompany())
                    .build();

        } catch (NotFoundException e) {
            throw e;
        } catch (Exception e) {
            log.error("eKYC 인증 정보 조회 실패: partnerId={}, error={}",
                    session.getPartnerId(), e.getMessage());
            throw new NotFoundException(ErrorCodes.EKYC_NOT_VERIFIED);
        }
    }
```

### 4.3 주의사항

- `generateConnectId()` 메서드는 이미 AximController에 private으로 존재 (line 125-132). 재사용.
- `getPartnerInfo()`로 `aximPartnerId`를 조회하는 부분은 기존 `getInitInfo()` 메서드와 동일 패턴.
- W2(`getEkycStatus`)는 조회 실패 시 에러를 던지지 않고 `verified=false` 반환 → Widget에서 eKYC 화면으로 안내.
- W3(`getEkycInfo`)는 인증 정보가 없으면 `NotFoundException` → Widget에서 eKYC 미인증 안내.

---

## 5. open-api 모듈 — `PhonepayWidgetController.java` (신규)

경로: `open-api/src/main/java/com/cryptoments/openapi/controller/widget/PhonepayWidgetController.java`

`AximController`와 동일한 위치 (`controller/widget/` 패키지). 폰페이 전용 Widget API.

```java
package com.cryptoments.openapi.controller.widget;

import com.cryptoments.common.client.AximPayClient;
import com.cryptoments.common.client.dto.axim.AximEkycInfoResponse;
import com.cryptoments.common.client.dto.axim.AximEkycStatusResponse;
import com.cryptoments.common.client.dto.axim.AximPartnerInfoResponse;
import com.cryptoments.common.entity.PartnerAximSettings;
import com.cryptoments.common.entity.PhonepaySession;
import com.cryptoments.common.exception.ErrorCodes;
import com.cryptoments.common.exception.NotFoundException;
import com.cryptoments.common.repository.PartnerAximSettingsRepository;
import com.cryptoments.core.phonepay.PhonepayService;
import com.cryptoments.openapi.dto.request.PhonepayCreateSessionRequest;
import com.cryptoments.openapi.dto.response.PhonepayCancelResponse;
import com.cryptoments.openapi.dto.response.PhonepaySessionResponse;
import com.cryptoments.openapi.session.WidgetSessionData;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.bind.annotation.*;

import java.time.Duration;
import java.time.LocalDateTime;

/**
 * 폰페이 Widget API 컨트롤러.
 *
 * <p>Widget에서 폰페이 입금 세션을 생성/조회/취소한다.
 * 모든 API는 WidgetSessionData 기반 인증.
 *
 * <p>송금인 정보(이름, 전화번호, 은행)는 서버에서 Axim eKYC API로 조회하여 채움.
 * Widget 클라이언트가 직접 전송하지 않으므로 위변조 방지.
 *
 * @group PhonePay
 * @auth true
 */
@RestController
@RequestMapping("/widgets/api/phonepay")
public class PhonepayWidgetController {

    private static final Logger log = LoggerFactory.getLogger(PhonepayWidgetController.class);

    private final PhonepayService phonepayService;
    private final AximPayClient aximPayClient;
    private final PartnerAximSettingsRepository aximSettingsRepository;

    public PhonepayWidgetController(PhonepayService phonepayService,
                                     AximPayClient aximPayClient,
                                     PartnerAximSettingsRepository aximSettingsRepository) {
        this.phonepayService = phonepayService;
        this.aximPayClient = aximPayClient;
        this.aximSettingsRepository = aximSettingsRepository;
    }

    /**
     * 폰페이 입금 세션 생성 (W4).
     *
     * <p>eKYC 인증 정보로 송금인을 자동 채우고, 사용자는 입금 금액만 입력.
     * 서버에서 Axim eKYC API를 호출하여 송금인 정보를 획득한 후
     * BanqPipe API로 세션을 생성한다.
     *
     * <p>흐름:
     * <ol>
     *   <li>WidgetSessionData에서 partnerId 추출</li>
     *   <li>Axim eKYC API → 인증 여부 확인 (미인증 시 EKYC_NOT_VERIFIED 예외)</li>
     *   <li>Axim eKYC API → 송금인 정보 획득 (name, phone, bankName)</li>
     *   <li>PhonepayService.createSession() → BanqPipe 세션 생성 + DB 저장</li>
     *   <li>수취인 정보 반환 (Widget에서 연락처 이체 안내 표시)</li>
     * </ol>
     *
     * @param request 세션 생성 요청 (amount만 포함)
     * @param session 세션 데이터
     * @return 생성된 폰페이 세션 정보 (수취인 이름/전화번호/은행 포함)
     * @response 200 세션 생성 성공
     * @response 400 eKYC 미인증
     * @auth true
     */
    @PostMapping(name = "폰페이 세션 생성", value = "/sessions")
    public PhonepaySessionResponse createSession(@RequestBody PhonepayCreateSessionRequest request,
                                                  WidgetSessionData session) {

        Long partnerId = session.getPartnerId();
        String partnerUserId = session.getPartnerUserId();

        // 1. Axim 설정 확인
        PartnerAximSettings aximSettings = aximSettingsRepository.findByPartnerId(partnerId);
        if (aximSettings == null || !Boolean.TRUE.equals(aximSettings.getIsEnabled())) {
            throw new NotFoundException(ErrorCodes.AXIM_SETTINGS_NOT_FOUND);
        }

        // 2. aximPartnerId 취득 → connectId 생성
        AximPartnerInfoResponse partnerInfo = aximPayClient.getPartnerInfo(
                aximSettings.getApiKey(), aximSettings.getApiSecretEnc());
        String connectId = generateConnectId(partnerInfo.getPartnerId(), partnerUserId);

        if (connectId == null) {
            throw new NotFoundException(ErrorCodes.EKYC_NOT_VERIFIED);
        }

        // 3. eKYC 인증 여부 확인
        AximEkycStatusResponse ekycStatus = aximPayClient.getEkycStatus(
                aximSettings.getApiKey(), aximSettings.getApiSecretEnc(), connectId);

        if (ekycStatus == null || !Boolean.TRUE.equals(ekycStatus.getVerified())) {
            throw new NotFoundException(ErrorCodes.EKYC_NOT_VERIFIED);
        }

        // 4. eKYC 인증 정보 → 송금인 정보 획득
        AximEkycInfoResponse ekycInfo = aximPayClient.getEkycInfo(
                aximSettings.getApiKey(), aximSettings.getApiSecretEnc(), connectId);

        String senderName = ekycInfo.getName();
        String senderPhone = ekycInfo.getPhoneNumber();
        String senderBank = ekycInfo.getFinanceCompany();

        log.info("폰페이 세션 생성: partnerId={}, sender={}, amount={}",
                partnerId, senderName, request.getAmount());

        // 5. PhonepayService로 세션 생성 (BanqPipe API 호출 + DB INSERT)
        PhonepaySession phonepaySession = phonepayService.createSession(
                partnerId, request.getAmount(), senderName, senderPhone, senderBank);

        // 6. 응답 변환
        return toSessionResponse(phonepaySession);
    }

    /**
     * 폰페이 세션 상태 조회 (W5).
     *
     * <p>Widget에서 3~5초 간격으로 폴링하여 세션 상태를 확인한다.
     * BanqPipe Callback이 서버 간 통신 역할이고, 이 API는 Widget UI 갱신용.
     *
     * @param id 세션 ID (DB PK)
     * @param session 세션 데이터
     * @return 세션 상태 (remainingSeconds 포함)
     * @response 200 조회 성공
     * @response 404 세션 없음
     * @auth true
     */
    @GetMapping(name = "폰페이 세션 조회", value = "/sessions/{id}")
    public PhonepaySessionResponse getSession(@PathVariable Long id,
                                               WidgetSessionData session) {

        PhonepaySession phonepaySession = phonepayService.getSession(session.getPartnerId(), id);
        return toSessionResponse(phonepaySession);
    }

    /**
     * 폰페이 세션 취소 (W6).
     *
     * <p>사용자가 대기 중 취소할 수 있다.
     * WAITING 상태에서만 취소 가능 (MATCHED 이후 불가 — BanqPipe 제약).
     *
     * @param id 세션 ID (DB PK)
     * @param session 세션 데이터
     * @return 취소 결과
     * @response 200 취소 성공
     * @response 404 세션 없음
     * @response 409 취소 불가 상태
     * @auth true
     */
    @PostMapping(name = "폰페이 세션 취소", value = "/sessions/{id}/cancel")
    public PhonepayCancelResponse cancelSession(@PathVariable Long id,
                                                 WidgetSessionData session) {

        PhonepaySession cancelled = phonepayService.cancelSession(session.getPartnerId(), id);

        return PhonepayCancelResponse.builder()
                .sessionId(cancelled.getId())
                .status(cancelled.getStatus().name())
                .cancelledAt(cancelled.getCancelledAt() != null
                        ? cancelled.getCancelledAt().toString() : null)
                .build();
    }

    // ── Private helpers ──

    /**
     * PhonepaySession 엔티티 → PhonepaySessionResponse 변환.
     */
    private PhonepaySessionResponse toSessionResponse(PhonepaySession session) {
        Long remainingSeconds = null;
        if (session.getExpiresAt() != null) {
            long remaining = Duration.between(LocalDateTime.now(), session.getExpiresAt()).getSeconds();
            remainingSeconds = Math.max(remaining, 0);
        }

        return PhonepaySessionResponse.builder()
                .sessionId(session.getId())
                .banqpipeSessionId(session.getBanqpipeSessionId())
                .status(session.getStatus().name())
                .amount(session.getAmount())
                .senderName(session.getSenderName())
                .recipientName(session.getRecipientName())
                .recipientPhone(session.getRecipientPhone())
                .recipientBank(session.getRecipientBank())
                .expiresAt(session.getExpiresAt() != null ? session.getExpiresAt().toString() : null)
                .remainingSeconds(remainingSeconds)
                .build();
    }

    /**
     * connectId 생성 — aximPartnerId + partnerUserId를 hex 인코딩.
     *
     * <p>AximController.generateConnectId()와 동일 로직.
     * AximController가 private이므로 여기서 중복 정의. 추후 유틸리티 클래스로 추출 가능.
     */
    private String generateConnectId(Long aximPartnerId, String partnerUserId) {
        if (partnerUserId == null) return null;
        String input = aximPartnerId + ":" + partnerUserId;
        byte[] bytes = input.getBytes(java.nio.charset.StandardCharsets.UTF_8);
        StringBuilder hex = new StringBuilder();
        for (byte b : bytes) hex.append(String.format("%02x", b));
        return hex.toString();
    }
}
```

---

## 6. 빌드 확인

```bash
./gradlew :common:compileJava :open-api:compileJava
```

common + open-api 모두 BUILD SUCCESSFUL이면 Phase 3 완료.

---

## 7. 체크리스트

| # | 항목 | 모듈 | 파일 |
|---|------|------|------|
| 1 | `AximEkycStatusResponse.java` 생성 | common | client/dto/axim/ |
| 2 | `AximEkycInfoResponse.java` 생성 | common | client/dto/axim/ |
| 3 | `AximPayClient.java`에 `getEkycStatus()` 추가 | common | client/ |
| 4 | `AximPayClient.java`에 `getEkycInfo()` 추가 | common | client/ |
| 5 | `EkycStatusResponse.java` 생성 | open-api | dto/response/ |
| 6 | `EkycInfoResponse.java` 생성 | open-api | dto/response/ |
| 7 | `PhonepayCreateSessionRequest.java` 생성 | open-api | dto/request/ |
| 8 | `PhonepaySessionResponse.java` 생성 | open-api | dto/response/ |
| 9 | `PhonepayCancelResponse.java` 생성 | open-api | dto/response/ |
| 10 | `AximController.java`에 W2 `getEkycStatus()` 추가 | open-api | controller/widget/ |
| 11 | `AximController.java`에 W3 `getEkycInfo()` 추가 | open-api | controller/widget/ |
| 12 | `PhonepayWidgetController.java` 생성 (W4/W5/W6) | open-api | controller/widget/ |
| 13 | 빌드 확인: `./gradlew :common:compileJava :open-api:compileJava` | — | — |

---

## 8. Phase 2 보정 사항 반영 확인

Phase 2에서 확인된 보정 사항이 Phase 3에도 적용되었는지 체크:

| Phase 2 보정 | Phase 3 적용 |
|-------------|-------------|
| Webhook 패키지: `com.cryptoments.webhook.controller` | — (Phase 3에 Webhook 변경 없음) |
| `@XApiIgnore` import: `one.axim.gradle.annotation.XApiIgnore` | — (Phase 3에 @XApiIgnore 사용 없음) |
| 응답 타입: `ResponseEntity<String>("OK")` | — (Phase 3에 Webhook 변경 없음) |
| `TransactionStatusHistory` 빌더 필드명: `txType/txId/note` | — (Phase 3에 상태 이력 기록 없음) |
| 파트너 스코프 보장 | ✅ `PhonepayWidgetController`에서 `session.getPartnerId()` 전달 |
| `recipientBank` 매핑 | ✅ `PhonepaySessionResponse`에 `recipientBank` 포함 |

---

## 9. 아키텍처 주의사항

### 9.1 eKYC API 호출 최적화

현재 구현은 `createSession()` 시 Axim API를 3회 호출한다:
1. `getPartnerInfo()` → aximPartnerId 취득
2. `getEkycStatus()` → 인증 여부 확인
3. `getEkycInfo()` → 송금인 정보 획득

**향후 최적화**: Widget에서 W2/W3를 이미 호출한 상태이므로, createSession 시 eKYC 정보를 캐싱하거나
Widget이 송금인 정보를 전달하는 방식으로 변경할 수 있다. 단, 현재는 **보안 우선** (서버에서 직접 조회)으로 구현.

### 9.2 generateConnectId 중복

`AximController`와 `PhonepayWidgetController` 양쪽에 `generateConnectId()` private 메서드가 중복된다.
향후 유틸리티 클래스(`AximConnectIdUtils`)로 추출 가능하나, Phase 3 범위에서는 중복을 허용한다.

### 9.3 WidgetSessionData 인증

`/widgets/api/*` 경로는 **반드시 `WidgetSessionData`** 타입으로 세션을 수신해야 한다.
`OpenApiSessionData`로 받으면 **401 Unauthorized** 발생 (토큰 역직렬화 타입 불일치).
`PhonepayWidgetController`는 `/widgets/api/phonepay` 경로이므로 `WidgetSessionData` 사용이 정확하다.

---

## 10. 배포 후 테스트 시나리오

### 테스트 전제

- Axim Pay 연동이 활성화된 파트너 (aximSettings.isEnabled = true)
- 해당 파트너에 eKYC 인증 완료된 사용자 존재
- 폰페이(phonepaySettings.isEnabled = true)도 활성화된 상태

### 테스트 케이스

| # | 시나리오 | API | 기대 결과 |
|---|---------|-----|----------|
| T1 | eKYC 인증 여부 조회 (인증됨) | `GET /widgets/api/axim/ekyc/status` | `{"verified":true,"status":"VERIFIED"}` |
| T2 | eKYC 인증 여부 조회 (미인증) | `GET /widgets/api/axim/ekyc/status` | `{"verified":false,"status":"UNVERIFIED"}` |
| T3 | eKYC 인증 정보 조회 | `GET /widgets/api/axim/ekyc/info` | `{"name":"홍길동","phone":"010...","bankCode":"004","bankName":"KB국민은행"}` |
| T4 | 폰페이 세션 생성 | `POST /widgets/api/phonepay/sessions` `{"amount":50000}` | 200 + 수취인 정보 포함 응답 |
| T5 | 폰페이 세션 조회 (폴링) | `GET /widgets/api/phonepay/sessions/{id}` | 200 + remainingSeconds 포함 |
| T6 | 폰페이 세션 취소 (WAITING) | `POST /widgets/api/phonepay/sessions/{id}/cancel` | 200 + `status: CANCELLED` |
| T7 | 폰페이 세션 취소 (MATCHED 이후) | `POST /widgets/api/phonepay/sessions/{id}/cancel` | 409 Conflict |
| T8 | eKYC 미인증 상태에서 세션 생성 | `POST /widgets/api/phonepay/sessions` | 404 EKYC_NOT_VERIFIED |

> **주의**: Widget API는 Bearer 토큰(WidgetSessionData)이 필요하므로, 테스트 시 유효한 Widget 토큰을 먼저 발급받아야 한다.
