# 폰페이 링크 구현 가이드

> **목적**: 파트너가 고객에게 전달할 수 있는 폰페이(KRW 은행 이체) 전용 결제 링크 생성·관리 기능.
> 기존 `PaymentLink`(암호화폐 결제 링크)와 동일한 아키텍처 패턴, 별도 테이블·엔티티·UI.
>
> **구현 순서**: DDL → common (Entity/Repository/Enum) → partner-api (Controller/Service/DTO) → open-api (Widget Controller) → partner-ui (Vue)

---

## 결제 링크 vs 폰페이 링크 차이점

| 항목 | 결제 링크 (PaymentLink) | 폰페이 링크 (PhonepayLink) |
|------|------------------------|--------------------------|
| 통화 | USDT/USDC 등 (선택) | **KRW 고정** |
| 금액 타입 | `BigDecimal` (소수점) | `Long` (원 단위 정수) |
| 네트워크 | 선택 가능 | **없음** |
| 입금 방식 | HD_WALLET 등 선택 | **폰페이 전용** |
| 시세 스냅샷 | priceKrw, priceUsd 저장 | **불필요** (이미 KRW) |
| Widget 경로 | `/link/{linkCode}` | `/phonepay/{linkCode}` |
| Widget 흐름 | 지갑 연결 → 입금 | eKYC 확인 → BanqPipe 세션 → 이체 |

---

## 1. DDL — `phonepay_links` 테이블

> `v2-docs/CRYPTOMENTS_V2_DDL.sql`의 **Section 3** (입금 계층)에 추가.

```sql
-- ============================================================
-- 3-7. 폰페이 링크
-- ============================================================
CREATE TABLE phonepay_links (
    id              BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT 'PK',
    link_code       VARCHAR(20)    NOT NULL COMMENT '링크 고유 코드 (plnk_{16자리})',
    partner_id      BIGINT         NOT NULL COMMENT 'partners.id',
    amount          BIGINT                  COMMENT '입금 금액 (원, KRW). NULL이면 고객이 입력',
    partner_user_id VARCHAR(100)   NOT NULL COMMENT '파트너 측 사용자 식별자',
    partner_reference VARCHAR(200)          COMMENT '파트너 참조 코드 (주문번호 등)',
    status          VARCHAR(20)    NOT NULL DEFAULT 'ACTIVE' COMMENT 'ACTIVE / USED / EXPIRED / CANCELLED / DEACTIVATED',
    phonepay_session_id BIGINT              COMMENT 'phonepay_sessions.id — 결제 완료 시 연결',
    expires_at      DATETIME(6)             COMMENT '만료 시각. NULL이면 무기한',
    created_at      DATETIME(6)    DEFAULT CURRENT_TIMESTAMP(6) COMMENT '생성일시',
    updated_at      DATETIME(6)    DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6) COMMENT '수정일시',

    UNIQUE KEY uk_link_code (link_code),
    KEY idx_partner_status (partner_id, status),
    KEY idx_phonepay_session (phonepay_session_id)
) COMMENT '폰페이 결제 링크 — 파트너가 고객에게 전달하는 KRW 입금 링크';
```

### 운영 DB 적용

```sql
-- app-01 또는 bastion 경유 실행
CREATE TABLE phonepay_links ( ... );  -- 위 DDL 그대로
```

---

## 2. common 모듈

### 2-1. Enum — `PhonepayLinkStatus`

> **파일**: `common/src/main/java/com/cryptoments/common/enums/PhonepayLinkStatus.java`

```java
package com.cryptoments.common.enums;

/**
 * 폰페이 링크 상태.
 * PaymentLinkStatus와 동일한 상태 머신이지만 별도 enum으로 분리.
 */
public enum PhonepayLinkStatus {
    /** 활성 — 고객이 결제 가능 */
    ACTIVE,
    /** 사용 완료 — 폰페이 세션 COMPLETED */
    USED,
    /** 만료 — expiresAt 경과 */
    EXPIRED,
    /** 파트너가 수동 취소 */
    CANCELLED,
    /** 파트너가 비활성화 (재활성화 가능) */
    DEACTIVATED;
}
```

### 2-2. Entity — `PhonepayLink`

> **파일**: `common/src/main/java/com/cryptoments/common/entity/PhonepayLink.java`

```java
package com.cryptoments.common.entity;

import com.cryptoments.common.enums.PhonepayLinkStatus;
import lombok.*;
import one.axim.framework.mybatis.annotation.XColumn;
import one.axim.framework.mybatis.annotation.XEntity;

import java.time.LocalDateTime;

@XEntity("phonepay_links")
@Getter @Setter @Builder(toBuilder = true)
@NoArgsConstructor @AllArgsConstructor
public class PhonepayLink {

    /** PK */
    @XColumn(value = "id", isPrimaryKey = true, isAutoIncrement = true)
    private Long id;

    /** 링크 고유 코드 — plnk_{random16} */
    @XColumn("link_code")
    private String linkCode;

    /** partners.id */
    @XColumn("partner_id")
    private Long partnerId;

    /** 입금 금액 (원, KRW). NULL이면 고객 입력 */
    @XColumn("amount")
    private Long amount;

    /** 파트너 측 사용자 식별자 */
    @XColumn("partner_user_id")
    private String partnerUserId;

    /** 파트너 참조 코드 (주문번호 등) */
    @XColumn("partner_reference")
    private String partnerReference;

    /** 링크 상태 */
    @XColumn("status")
    private PhonepayLinkStatus status;

    /** phonepay_sessions.id — 결제 완료 시 연결 */
    @XColumn("phonepay_session_id")
    private Long phonepaySessionId;

    /** 만료 시각. NULL이면 무기한 */
    @XColumn("expires_at")
    private LocalDateTime expiresAt;

    /** 생성일시 */
    @XColumn(value = "created_at", insert = false, update = false)
    private LocalDateTime createdAt;

    /** 수정일시 */
    @XColumn(value = "updated_at", insert = false, update = false)
    private LocalDateTime updatedAt;
}
```

### 2-3. Repository — `PhonepayLinkRepository`

> **파일**: `common/src/main/java/com/cryptoments/common/repository/PhonepayLinkRepository.java`

```java
package com.cryptoments.common.repository;

import com.cryptoments.common.entity.PhonepayLink;
import one.axim.framework.mybatis.annotation.XRepository;
import one.axim.framework.mybatis.repository.IXRepository;

import java.util.List;

@XRepository
public interface PhonepayLinkRepository extends IXRepository<Long, PhonepayLink> {

    /** 링크 코드로 조회 (Widget에서 사용) */
    PhonepayLink findByLinkCode(String linkCode);

    /** 파트너별 목록 (상태 필터 선택) */
    List<PhonepayLink> findByPartnerId(Long partnerId);

    /** 파트너 + 상태 필터 */
    List<PhonepayLink> findByPartnerIdAndStatus(Long partnerId, String status);
}
```

### 2-4. ErrorCodes 추가

> **파일**: `common/src/main/java/com/cryptoments/common/exception/ErrorCodes.java`에 추가

```java
// ── 폰페이 링크 ──
public static final ErrorCode PHONEPAY_LINK_NOT_FOUND = new ErrorCode("960", "폰페이 링크를 찾을 수 없습니다.");
public static final ErrorCode PHONEPAY_LINK_NOT_USABLE = new ErrorCode("961", "사용할 수 없는 폰페이 링크입니다.");
```

---

## 3. partner-api 모듈

### 3-1. DTO — `CreatePhonepayLinkRequest`

> **파일**: `partner-api/src/main/java/com/cryptoments/partnerapi/dto/request/CreatePhonepayLinkRequest.java`

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

import jakarta.validation.constraints.NotBlank;
import lombok.Getter;
import lombok.Setter;

/**
 * 폰페이 링크 생성 요청.
 */
@Getter @Setter
public class CreatePhonepayLinkRequest {

    /** 입금 금액 (원, KRW). null이면 고객이 직접 입력 */
    private Long amount;

    /** 파트너 사용자 ID (필수) */
    @NotBlank
    private String partnerUserId;

    /** 파트너 내부 참조 코드 (주문번호 등) */
    private String partnerReference;

    /** 만료 시간(분). null이면 만료 없음 */
    private Integer expiresInMinutes;
}
```

### 3-2. DTO — `PhonepayLinkResponse`

> **파일**: `partner-api/src/main/java/com/cryptoments/partnerapi/dto/response/PhonepayLinkResponse.java`

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

import com.cryptoments.common.entity.PhonepayLink;
import lombok.*;

import java.time.LocalDateTime;

/**
 * 폰페이 링크 응답 (생성 + 목록 겸용).
 */
@Getter @Setter @Builder
@NoArgsConstructor @AllArgsConstructor
public class PhonepayLinkResponse {

    /** PK */
    private Long id;

    /** 링크 코드 */
    private String linkCode;

    /** 금액 (원). null이면 고객 입력 */
    private Long amount;

    /** 파트너 사용자 ID */
    private String partnerUserId;

    /** 참조 코드 */
    private String partnerReference;

    /** 상태 */
    private String status;

    /** 폰페이 세션 ID (사용 완료 시) */
    private Long phonepaySessionId;

    /** 만료 시각 */
    private LocalDateTime expiresAt;

    /** 생성 시각 */
    private LocalDateTime createdAt;

    /** 완성된 링크 URL */
    private String linkUrl;

    public static PhonepayLinkResponse from(PhonepayLink link, String widgetBaseUrl) {
        return PhonepayLinkResponse.builder()
                .id(link.getId())
                .linkCode(link.getLinkCode())
                .amount(link.getAmount())
                .partnerUserId(link.getPartnerUserId())
                .partnerReference(link.getPartnerReference())
                .status(link.getStatus() != null ? link.getStatus().name() : null)
                .phonepaySessionId(link.getPhonepaySessionId())
                .expiresAt(link.getExpiresAt())
                .createdAt(link.getCreatedAt())
                .linkUrl(widgetBaseUrl + "/phonepay/" + link.getLinkCode())
                .build();
    }
}
```

### 3-3. Controller — `PartnerPhonepayController` 확장

> **파일**: `partner-api/src/main/java/com/cryptoments/partnerapi/controller/PartnerPhonepayController.java`
>
> 기존 `getSessions` / `getSession` 메서드 아래에 폰페이 링크 엔드포인트를 추가한다.

```java
package com.cryptoments.partnerapi.controller;

import com.cryptoments.common.entity.PhonepayLink;
import com.cryptoments.common.entity.PhonepaySession;
import com.cryptoments.common.enums.PhonepayLinkStatus;
import com.cryptoments.common.exception.ConflictException;
import com.cryptoments.common.exception.ErrorCodes;
import com.cryptoments.common.exception.NotFoundException;
import com.cryptoments.common.repository.PhonepayLinkRepository;
import com.cryptoments.common.repository.PhonepaySessionRepository;
import com.cryptoments.partnerapi.dto.request.CreatePhonepayLinkRequest;
import com.cryptoments.partnerapi.dto.response.PhonepayLinkResponse;
import com.cryptoments.partnerapi.session.PartnerSessionData;
import jakarta.validation.Valid;
import one.axim.framework.core.annotation.XPaginationDefault;
import one.axim.framework.core.data.XPage;
import one.axim.framework.core.data.XPagination;
import one.axim.framework.rest.controller.XSessionController;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;

import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;

/**
 * 파트너 콘솔 — 폰페이 세션 + 폰페이 링크 관리.
 *
 * @group 폰페이
 */
@RestController
@RequestMapping("/api/partner/phonepay")
public class PartnerPhonepayController extends XSessionController<PartnerSessionData> {

    private final PhonepaySessionRepository phonepaySessionRepository;
    private final PhonepayLinkRepository phonepayLinkRepository;

    @Value("${cryptoments.widget.base-url:https://widget.cryptoments.cc}")
    private String widgetBaseUrl;

    public PartnerPhonepayController(PhonepaySessionRepository phonepaySessionRepository,
                                     PhonepayLinkRepository phonepayLinkRepository) {
        this.phonepaySessionRepository = phonepaySessionRepository;
        this.phonepayLinkRepository = phonepayLinkRepository;
    }

    // ═══════════════════════════════════════════
    // 폰페이 세션 (기존)
    // ═══════════════════════════════════════════

    /**
     * 폰페이 세션 목록 조회 (파트너 소유분만, 상태 필터 선택).
     */
    @GetMapping(name = "폰페이 세션 목록", value = "/sessions")
    public XPage<PhonepaySession> getSessions(
            @XPaginationDefault(column = "id") XPagination pagination,
            @RequestParam(required = false) String status) {
        Long partnerId = getSession().getPartnerId();
        Map<String, Object> conditions = new HashMap<>();
        conditions.put("partnerId", partnerId);
        if (status != null && !status.isBlank()) {
            conditions.put("status", status);
        }
        return phonepaySessionRepository.findWhere(pagination, conditions);
    }

    /**
     * 폰페이 세션 상세 조회.
     */
    @GetMapping(name = "폰페이 세션 상세", value = "/sessions/{id}")
    public PhonepaySession getSessionDetail(@PathVariable Long id) {
        Long partnerId = getSession().getPartnerId();
        PhonepaySession session = phonepaySessionRepository.findOne(id);
        if (session == null || !session.getPartnerId().equals(partnerId)) {
            throw new NotFoundException(ErrorCodes.PHONEPAY_SESSION_NOT_FOUND);
        }
        return session;
    }

    // ═══════════════════════════════════════════
    // 폰페이 링크
    // ═══════════════════════════════════════════

    /**
     * 폰페이 링크 목록 조회.
     *
     * @param pagination 페이지네이션
     * @param status     상태 필터 (선택)
     * @return 폰페이 링크 페이지
     * @group 폰페이
     * @auth true
     */
    @GetMapping(name = "폰페이 링크 목록", value = "/links")
    public XPage<PhonepayLink> getLinks(
            @XPaginationDefault(column = "id") XPagination pagination,
            @RequestParam(required = false) String status) {
        Long partnerId = getSession().getPartnerId();
        Map<String, Object> conditions = new HashMap<>();
        conditions.put("partnerId", partnerId);
        if (status != null && !status.isBlank()) {
            conditions.put("status", status);
        }
        return phonepayLinkRepository.findWhere(pagination, conditions);
    }

    /**
     * 폰페이 링크 생성.
     *
     * @param request 생성 요청 (amount, partnerUserId, partnerReference, expiresInMinutes)
     * @return 생성된 링크 정보 + URL
     * @group 폰페이
     * @auth true
     */
    @PostMapping(name = "폰페이 링크 생성", value = "/links")
    @ResponseStatus(HttpStatus.CREATED)
    public PhonepayLinkResponse createLink(@Valid @RequestBody CreatePhonepayLinkRequest request) {
        Long partnerId = getSession().getPartnerId();

        // 링크 코드 생성: plnk_ + 16자리 UUID
        String linkCode = "plnk_" + UUID.randomUUID().toString().replace("-", "").substring(0, 16);

        PhonepayLink link = PhonepayLink.builder()
                .partnerId(partnerId)
                .linkCode(linkCode)
                .amount(request.getAmount())
                .partnerUserId(request.getPartnerUserId())
                .partnerReference(request.getPartnerReference())
                .status(PhonepayLinkStatus.ACTIVE)
                .expiresAt(request.getExpiresInMinutes() != null
                        ? LocalDateTime.now().plusMinutes(request.getExpiresInMinutes())
                        : null)
                .build();

        Long id = phonepayLinkRepository.save(link);
        link.setId(id);
        return PhonepayLinkResponse.from(link, widgetBaseUrl);
    }

    /**
     * 폰페이 링크 취소 (ACTIVE → CANCELLED).
     */
    @PostMapping(name = "폰페이 링크 취소", value = "/links/{linkId}/cancel")
    public PhonepayLink cancelLink(@PathVariable Long linkId) {
        Long partnerId = getSession().getPartnerId();
        PhonepayLink link = findPartnerLink(partnerId, linkId);
        if (link.getStatus() != PhonepayLinkStatus.ACTIVE) {
            throw new ConflictException(ErrorCodes.INVALID_STATUS_TRANSITION);
        }
        link.setStatus(PhonepayLinkStatus.CANCELLED);
        phonepayLinkRepository.modify(link);
        return link;
    }

    /**
     * 폰페이 링크 비활성화 (ACTIVE → DEACTIVATED).
     */
    @PostMapping(name = "폰페이 링크 비활성화", value = "/links/{linkId}/deactivate")
    public PhonepayLink deactivateLink(@PathVariable Long linkId) {
        Long partnerId = getSession().getPartnerId();
        PhonepayLink link = findPartnerLink(partnerId, linkId);
        if (link.getStatus() != PhonepayLinkStatus.ACTIVE) {
            throw new ConflictException(ErrorCodes.INVALID_STATUS_TRANSITION);
        }
        link.setStatus(PhonepayLinkStatus.DEACTIVATED);
        phonepayLinkRepository.modify(link);
        return link;
    }

    /**
     * 폰페이 링크 재활성화 (DEACTIVATED → ACTIVE).
     */
    @PostMapping(name = "폰페이 링크 활성화", value = "/links/{linkId}/activate")
    public PhonepayLink activateLink(@PathVariable Long linkId) {
        Long partnerId = getSession().getPartnerId();
        PhonepayLink link = findPartnerLink(partnerId, linkId);
        if (link.getStatus() != PhonepayLinkStatus.DEACTIVATED) {
            throw new ConflictException(ErrorCodes.INVALID_STATUS_TRANSITION);
        }
        link.setStatus(PhonepayLinkStatus.ACTIVE);
        phonepayLinkRepository.modify(link);
        return link;
    }

    /**
     * 폰페이 링크 삭제 (CANCELLED / EXPIRED / DEACTIVATED 상태만).
     */
    @DeleteMapping(name = "폰페이 링크 삭제", value = "/links/{linkId}")
    @ResponseStatus(HttpStatus.NO_CONTENT)
    public void deleteLink(@PathVariable Long linkId) {
        Long partnerId = getSession().getPartnerId();
        PhonepayLink link = findPartnerLink(partnerId, linkId);
        if (link.getStatus() == PhonepayLinkStatus.ACTIVE
                || link.getStatus() == PhonepayLinkStatus.USED) {
            throw new ConflictException(ErrorCodes.INVALID_STATUS_TRANSITION);
        }
        phonepayLinkRepository.remove(linkId);
    }

    // ── 내부 유틸 ──

    private PhonepayLink findPartnerLink(Long partnerId, Long linkId) {
        PhonepayLink link = phonepayLinkRepository.findOne(linkId);
        if (link == null || !partnerId.equals(link.getPartnerId())) {
            throw new NotFoundException(ErrorCodes.PHONEPAY_LINK_NOT_FOUND);
        }
        return link;
    }
}
```

**⚠️ 기존 `getSession()` 메서드명 충돌 주의**

기존 코드에 `getSession(@PathVariable Long id)` 메서드가 있는데, 이것은 `XSessionController.getSession()`과 이름이 충돌합니다. 위 코드에서 세션 상세 조회 메서드명을 `getSessionDetail`로 변경했습니다.

---

## 4. open-api 모듈 — Widget Controller

> **파일**: `open-api/src/main/java/com/cryptoments/openapi/controller/widget/PhonepayLinkWidgetController.java`
>
> Widget에서 폰페이 링크 페이지를 열 때 호출하는 공개 API.
> PaymentLinkController 패턴 참고.

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

import com.cryptoments.common.entity.Partner;
import com.cryptoments.common.entity.PhonepayLink;
import com.cryptoments.common.entity.PartnerAximSettings;
import com.cryptoments.common.entity.PartnerPhonepaySettings;
import com.cryptoments.common.enums.PhonepayLinkStatus;
import com.cryptoments.common.exception.ErrorCodes;
import com.cryptoments.common.exception.NotFoundException;
import com.cryptoments.common.repository.PartnerAximSettingsRepository;
import com.cryptoments.common.repository.PartnerPhonepaySettingsRepository;
import com.cryptoments.common.repository.PartnerRepository;
import com.cryptoments.common.repository.PhonepayLinkRepository;
import com.cryptoments.openapi.session.WidgetSessionData;
import one.axim.framework.rest.controller.XSessionController;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.*;

import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

/**
 * 폰페이 링크 Widget API.
 *
 * 고객이 폰페이 링크 URL을 열면 Widget이 이 API를 호출하여:
 * 1. 링크 정보 조회 (금액, 파트너 정보)
 * 2. Widget 세션 토큰 발급 (eKYC + 폰페이 세션 생성에 사용)
 * 3. 링크 상태 확인 (만료/취소 체크)
 *
 * @group 폰페이 링크
 */
@RestController
@RequestMapping("/widgets/phonepay/links")
public class PhonepayLinkWidgetController extends XSessionController<WidgetSessionData> {

    private final PhonepayLinkRepository phonepayLinkRepository;
    private final PartnerRepository partnerRepository;
    private final PartnerAximSettingsRepository aximSettingsRepository;
    private final PartnerPhonepaySettingsRepository phonepaySettingsRepository;

    @Value("${axim.rest.session.token-expire-days:1}")
    private int tokenExpireDays;

    public PhonepayLinkWidgetController(
            PhonepayLinkRepository phonepayLinkRepository,
            PartnerRepository partnerRepository,
            PartnerAximSettingsRepository aximSettingsRepository,
            PartnerPhonepaySettingsRepository phonepaySettingsRepository) {
        this.phonepayLinkRepository = phonepayLinkRepository;
        this.partnerRepository = partnerRepository;
        this.aximSettingsRepository = aximSettingsRepository;
        this.phonepaySettingsRepository = phonepaySettingsRepository;
    }

    /**
     * 폰페이 링크 정보 조회 + Widget 세션 토큰 발급.
     *
     * Widget이 `/phonepay/{linkCode}` 페이지 진입 시 최초 호출.
     * 인증 불필요 (공개 API).
     *
     * @param linkCode 링크 코드 (plnk_xxx)
     * @return 링크 정보 + accessToken (WidgetSessionData 기반)
     */
    @GetMapping(name = "폰페이 링크 조회", value = "/{linkCode}")
    public Map<String, Object> getLinkInfo(@PathVariable String linkCode) {
        PhonepayLink link = phonepayLinkRepository.findByLinkCode(linkCode);
        if (link == null) {
            throw new NotFoundException(ErrorCodes.PHONEPAY_LINK_NOT_FOUND);
        }

        // 만료 자동 처리
        if (link.getStatus() == PhonepayLinkStatus.ACTIVE
                && link.getExpiresAt() != null
                && link.getExpiresAt().isBefore(LocalDateTime.now())) {
            link.setStatus(PhonepayLinkStatus.EXPIRED);
            phonepayLinkRepository.modify(link);
        }

        boolean isUsable = (link.getStatus() == PhonepayLinkStatus.ACTIVE);

        // 파트너 정보
        Partner partner = partnerRepository.findOne(link.getPartnerId());

        Map<String, Object> result = new HashMap<>();
        result.put("linkCode", link.getLinkCode());
        result.put("partnerName", partner != null ? partner.getPartnerName() : null);
        result.put("amount", link.getAmount());
        result.put("partnerUserId", link.getPartnerUserId());
        result.put("partnerReference", link.getPartnerReference());
        result.put("status", link.getStatus().name());
        result.put("isUsable", isUsable);
        result.put("expiresAt", link.getExpiresAt());
        result.put("createdAt", link.getCreatedAt());

        // 사용 가능한 경우에만 Widget 세션 토큰 발급
        if (isUsable && partner != null) {
            // 폰페이 설정 확인
            PartnerPhonepaySettings phonepaySettings =
                    phonepaySettingsRepository.findByPartnerId(link.getPartnerId());
            if (phonepaySettings != null && Boolean.TRUE.equals(phonepaySettings.getIsEnabled())) {
                result.put("phonepayEnabled", true);
            } else {
                result.put("phonepayEnabled", false);
            }

            // Widget 세션 토큰 생성
            WidgetSessionData sessionData = new WidgetSessionData();
            sessionData.setPartnerId(partner.getId());
            sessionData.setPartnerCode(partner.getPartnerCode());
            sessionData.setPartnerName(partner.getPartnerName());
            sessionData.setPartnerUserId(link.getPartnerUserId());
            sessionData.setPermissions(List.of("PHONEPAY"));
            sessionData.setExpireAfterDays(tokenExpireDays);

            String accessToken = generateSessionToken(sessionData);
            result.put("accessToken", accessToken);
        }

        return result;
    }

    /**
     * 폰페이 링크 상태 조회 (폴링용).
     *
     * Widget이 폰페이 세션 완료 후 링크 상태 확인에 사용.
     *
     * @param linkCode 링크 코드
     * @return { status, phonepaySessionId }
     */
    @GetMapping(name = "폰페이 링크 상태", value = "/{linkCode}/status")
    public Map<String, Object> getLinkStatus(@PathVariable String linkCode) {
        PhonepayLink link = phonepayLinkRepository.findByLinkCode(linkCode);
        if (link == null) {
            throw new NotFoundException(ErrorCodes.PHONEPAY_LINK_NOT_FOUND);
        }

        // 만료 자동 처리
        if (link.getStatus() == PhonepayLinkStatus.ACTIVE
                && link.getExpiresAt() != null
                && link.getExpiresAt().isBefore(LocalDateTime.now())) {
            link.setStatus(PhonepayLinkStatus.EXPIRED);
            phonepayLinkRepository.modify(link);
        }

        Map<String, Object> result = new HashMap<>();
        result.put("status", link.getStatus().name());
        result.put("phonepaySessionId", link.getPhonepaySessionId());
        result.put("isUsable", link.getStatus() == PhonepayLinkStatus.ACTIVE);
        return result;
    }

    /**
     * 폰페이 링크 사용 완료 처리 (내부 호출).
     *
     * PhonepayWidgetController에서 세션 COMPLETED 시 호출.
     * → link.status = USED, link.phonepaySessionId = sessionId
     *
     * @param linkCode 링크 코드
     * @param sessionId 완료된 폰페이 세션 ID
     */
    public void markAsUsed(String linkCode, Long sessionId) {
        PhonepayLink link = phonepayLinkRepository.findByLinkCode(linkCode);
        if (link != null && link.getStatus() == PhonepayLinkStatus.ACTIVE) {
            link.setStatus(PhonepayLinkStatus.USED);
            link.setPhonepaySessionId(sessionId);
            phonepayLinkRepository.modify(link);
        }
    }
}
```

### Widget 흐름

```
고객이 링크 열기
    https://widget.cryptoments.cc/phonepay/plnk_a1b2c3d4e5f6g7h8
        │
        ▼
GET /widgets/phonepay/links/{linkCode}
    → 링크 정보 + accessToken 수신
    → phonepayEnabled 확인
        │
        ▼
    [accessToken으로 기존 Widget API 호출]
        │
GET /widgets/api/axim/ekyc/status  (W2)
    → verified: true → 진행
    → verified: false → eKYC 안내
        │
        ▼
GET /widgets/api/axim/ekyc/info  (W3)
    → 송금인 정보 표시
        │
        ▼
POST /widgets/api/phonepay/sessions  (W4)
    → { amount: link.amount }  (링크에 금액 있으면 자동 채움)
    → 세션 생성 → 수취인 정보 표시
        │
        ▼
GET /widgets/api/phonepay/sessions/{id}  (W5 폴링)
    → COMPLETED → 완료 화면
    → 링크 상태 USED로 자동 업데이트
```

**⚠️ 링크 ↔ 세션 연결 시점**

폰페이 세션이 COMPLETED 되는 시점은 BanqPipe Callback (Phase 2)에서 처리됩니다. 이 콜백에서 `phonepay_session.status = COMPLETED`로 업데이트할 때, 연결된 링크도 함께 USED로 변경해야 합니다.

이를 위해 **두 가지 방법** 중 택 1:

**방법 A (권장)**: `phonepay_sessions` 테이블에 `phonepay_link_id` 컬럼 추가
```sql
ALTER TABLE phonepay_sessions ADD COLUMN phonepay_link_id BIGINT COMMENT 'phonepay_links.id — 링크에서 생성된 세션';
ALTER TABLE phonepay_sessions ADD KEY idx_link (phonepay_link_id);
```
→ 세션 생성 시(W4) `linkCode`를 추가 파라미터로 받아 저장
→ BanqPipe Callback에서 세션 COMPLETED 시 `link_id`로 링크도 USED 처리

**방법 B**: Widget이 세션 COMPLETED 감지 후 별도 API 호출
→ Widget이 W5 폴링에서 COMPLETED 확인 → POST /widgets/phonepay/links/{linkCode}/complete 호출
→ 클라이언트 의존적이라 비권장

---

## 5. partner-ui (Vue 3)

### 5-1. API Service 추가

> **파일**: `partner-ui/src/api/services/deposit.service.ts`에 추가

```typescript
  // ── 폰페이 링크 ──

  getPhonepayLinks: (params: Record<string, unknown>) =>
    api.get<XPage<Record<string, unknown>>>('/api/partner/phonepay/links', params),

  createPhonepayLink: (data: { amount?: number; partnerUserId: string; partnerReference?: string; expiresInMinutes?: number }) =>
    api.post<Record<string, unknown>>('/api/partner/phonepay/links', data),

  cancelPhonepayLink: (linkId: number) =>
    api.post(`/api/partner/phonepay/links/${linkId}/cancel`),

  deactivatePhonepayLink: (linkId: number) =>
    api.post(`/api/partner/phonepay/links/${linkId}/deactivate`),

  activatePhonepayLink: (linkId: number) =>
    api.post(`/api/partner/phonepay/links/${linkId}/activate`),

  deletePhonepayLink: (linkId: number) =>
    api.delete(`/api/partner/phonepay/links/${linkId}`),
```

### 5-2. Vue 컴포넌트 — `PhonepayLinksView.vue`

> **파일**: `partner-ui/src/views/partner/deposits/PhonepayLinksView.vue`
>
> PaymentLinksView.vue 패턴을 따르되, 폰페이 전용으로 단순화.

```vue
<script setup lang="ts">
import { onMounted, onUnmounted, ref, reactive } from 'vue'
import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { depositService } from '@/api/services/deposit.service'
import type { XPage } from '@/api/types/common'
import PageHeader from '@/components/common/PageHeader.vue'
import DataTable from '@/components/common/DataTable.vue'
import type { Column } from '@/components/common/DataTable.vue'
import StatusBadge from '@/components/common/StatusBadge.vue'
import DateDisplay from '@/components/common/DateDisplay.vue'

const WIDGET_BASE_URL = import.meta.env.VITE_WIDGET_BASE_URL || 'https://widget.cryptoments.cc'

const loading = ref(false)
const data = ref<XPage<Record<string, unknown>> | null>(null)
const filters = reactive({ page: 1, size: 20, status: undefined as string | undefined })

// ── 액션 메뉴 ──
const openActionId = ref<number | null>(null)
const menuPos = ref({ x: 0, y: 0 })
function toggleAction(id: number, event: MouseEvent) {
  if (openActionId.value === id) { openActionId.value = null; return }
  const btn = event.currentTarget as HTMLElement
  const rect = btn.getBoundingClientRect()
  menuPos.value = { x: rect.right, y: rect.bottom }
  openActionId.value = id
}
function closeActionMenu() { openActionId.value = null }
onMounted(() => document.addEventListener('click', closeActionMenu))
onUnmounted(() => document.removeEventListener('click', closeActionMenu))

const columns: Column<Record<string, unknown>>[] = [
  { key: 'linkCode', label: '링크코드', class: 'font-mono text-xs' },
  { key: 'partnerUserId', label: '고객 ID' },
  { key: 'amount', label: '금액 (원)', class: 'text-right' },
  { key: 'partnerReference', label: '참조코드' },
  { key: 'linkUrl', label: '링크 URL' },
  { key: 'status', label: '상태' },
  { key: 'createdAt', label: '생성일' },
  { key: 'actions', label: '', class: 'w-10' },
]

// ── CRUD ──
async function fetchData() {
  loading.value = true
  try { data.value = await depositService.getPhonepayLinks(filters) } finally { loading.value = false }
}
async function cancelLink(id: number) {
  try { await depositService.cancelPhonepayLink(id); fetchData() } catch { /**/ }
}
async function deactivateLink(id: number) {
  try { await depositService.deactivatePhonepayLink(id); fetchData() } catch { /**/ }
}
async function activateLink(id: number) {
  try { await depositService.activatePhonepayLink(id); fetchData() } catch { /**/ }
}
async function deleteLink(id: number) {
  if (!confirm('삭제된 링크는 복구할 수 없습니다. 삭제하시겠습니까?')) return
  try { await depositService.deletePhonepayLink(id); fetchData() } catch { /**/ }
}
function copyUrl(linkCode: string) {
  navigator.clipboard.writeText(`${WIDGET_BASE_URL}/phonepay/${linkCode}`)
}

// ── 생성 폼 ──
const showCreate = ref(false)
const creating = ref(false)
const createError = ref('')
const form = reactive({
  amount: '',
  partnerUserId: '',
  partnerReference: '',
  expiresInMinutes: null as number | null,
})

async function handleCreate() {
  if (!form.partnerUserId) { createError.value = '고객 ID를 입력하세요.'; return }
  createError.value = ''
  creating.value = true
  try {
    await depositService.createPhonepayLink({
      amount: form.amount ? Number(form.amount) : undefined,
      partnerUserId: form.partnerUserId,
      partnerReference: form.partnerReference || undefined,
      expiresInMinutes: form.expiresInMinutes ?? undefined,
    })
    showCreate.value = false
    form.amount = ''
    form.partnerUserId = ''
    form.partnerReference = ''
    form.expiresInMinutes = null
    fetchData()
  } catch (e) { createError.value = (e as Error).message } finally { creating.value = false }
}

/** 금액 포맷 (천 단위 콤마) */
function formatKrw(amount: unknown): string {
  if (amount == null) return '고객 입력'
  return `₩${Number(amount).toLocaleString()}`
}

function onPageChange(page: number) { filters.page = page; fetchData() }
onMounted(fetchData)
</script>

<template>
  <div class="space-y-4">
    <PageHeader title="폰페이 링크" description="KRW 은행 이체 결제 링크 관리">
      <template #actions>
        <Button @click="showCreate = !showCreate">{{ showCreate ? '닫기' : '폰페이 링크 생성' }}</Button>
      </template>
    </PageHeader>

    <!-- 생성 폼 -->
    <Card v-if="showCreate">
      <CardContent class="pt-6 space-y-4">
        <div>
          <label class="mb-1 block text-sm font-medium">결제 금액 (원, 선택)</label>
          <Input v-model="form.amount" type="number" placeholder="미입력 시 고객이 직접 입력" />
          <p class="mt-1 text-xs text-muted-foreground">미입력 시 결제 페이지에서 고객이 직접 금액을 입력합니다.</p>
        </div>
        <div class="grid gap-4 sm:grid-cols-2">
          <div>
            <label class="mb-1 block text-sm font-medium">참조 코드</label>
            <Input v-model="form.partnerReference" placeholder="주문번호 등" />
          </div>
          <div>
            <label class="mb-1 block text-sm font-medium">고객 ID *</label>
            <Input v-model="form.partnerUserId" placeholder="파트너 사용자 ID" />
          </div>
        </div>
        <div>
          <label class="mb-1 block text-sm font-medium">만료 시간</label>
          <select v-model="form.expiresInMinutes" class="h-9 w-full max-w-xs rounded-md border border-input bg-background px-3 text-sm">
            <option :value="null">만료 없음</option>
            <option :value="30">30분</option>
            <option :value="60">1시간</option>
            <option :value="1440">24시간</option>
          </select>
        </div>
        <p v-if="createError" class="text-sm text-red-600">{{ createError }}</p>
        <div class="flex gap-2">
          <Button :disabled="creating || !form.partnerUserId" @click="handleCreate">{{ creating ? '생성 중...' : '생성' }}</Button>
          <Button variant="outline" @click="showCreate = false">취소</Button>
        </div>
      </CardContent>
    </Card>

    <!-- 필터 + 목록 -->
    <Card>
      <CardContent class="pt-6 space-y-4">
        <div class="flex flex-wrap items-end gap-3">
          <div>
            <label class="mb-1 block text-xs font-medium text-muted-foreground">상태</label>
            <select v-model="filters.status" class="h-9 w-36 rounded-md border border-input bg-background px-3 text-sm">
              <option :value="undefined">전체</option>
              <option value="ACTIVE">활성</option>
              <option value="USED">사용완료</option>
              <option value="EXPIRED">만료</option>
              <option value="CANCELLED">취소</option>
              <option value="DEACTIVATED">비활성</option>
            </select>
          </div>
          <div class="flex gap-2">
            <Button size="sm" @click="filters.page = 1; fetchData()">조회</Button>
            <Button size="sm" variant="outline" @click="filters.status = undefined; filters.page = 1; fetchData()">초기화</Button>
          </div>
        </div>

        <DataTable :columns="columns" :data="data" :loading="loading" @page-change="onPageChange">
          <template #partnerUserId="{ row }">
            <router-link v-if="row.partnerUserId" :to="`/partner/users/${row.partnerUserId}`" class="text-primary hover:underline" @click.stop>{{ row.partnerUserId }}</router-link>
            <span v-else>-</span>
          </template>
          <template #amount="{ value }">
            <span class="text-sm font-medium">{{ formatKrw(value) }}</span>
          </template>
          <template #linkUrl="{ row }">
            <div v-if="row.linkCode" class="flex items-center gap-1">
              <a :href="`${WIDGET_BASE_URL}/phonepay/${row.linkCode}`" target="_blank" rel="noopener" class="text-xs text-primary hover:underline truncate max-w-[200px]" @click.stop>
                {{ WIDGET_BASE_URL }}/phonepay/{{ row.linkCode }}
              </a>
            </div>
          </template>
          <template #status="{ value }"><StatusBadge :status="value as string" /></template>
          <template #createdAt="{ value }"><DateDisplay :date="value as string" /></template>
          <template #actions="{ row }">
            <div @click.stop>
              <Button variant="ghost" size="sm" class="px-2" @click="toggleAction(row.id as number, $event)">⋯</Button>
            </div>
          </template>
        </DataTable>
      </CardContent>
    </Card>

    <!-- 액션 메뉴 -->
    <Teleport to="body">
      <div v-if="openActionId != null" class="fixed inset-0 z-40" @click="closeActionMenu" />
      <div v-if="openActionId != null"
        class="fixed z-50 min-w-[120px] rounded-md border bg-background p-1 shadow-md"
        :style="{ top: menuPos.y + 'px', left: (menuPos.x - 120) + 'px' }"
      >
        <template v-if="data?.pageRows?.find(r => r.id === openActionId)?.status === 'ACTIVE'">
          <button class="w-full rounded px-3 py-1.5 text-left text-sm hover:bg-muted" @click="copyUrl(data!.pageRows!.find(r => r.id === openActionId)!.linkCode as string); openActionId = null">URL 복사</button>
          <button class="w-full rounded px-3 py-1.5 text-left text-sm text-amber-600 hover:bg-muted" @click="deactivateLink(openActionId); openActionId = null">비활성화</button>
          <button class="w-full rounded px-3 py-1.5 text-left text-sm text-red-600 hover:bg-muted" @click="cancelLink(openActionId); openActionId = null">취소</button>
        </template>
        <template v-else-if="data?.pageRows?.find(r => r.id === openActionId)?.status === 'DEACTIVATED'">
          <button class="w-full rounded px-3 py-1.5 text-left text-sm text-green-600 hover:bg-muted" @click="activateLink(openActionId); openActionId = null">활성화</button>
          <button class="w-full rounded px-3 py-1.5 text-left text-sm text-red-600 hover:bg-muted" @click="deleteLink(openActionId); openActionId = null">삭제</button>
        </template>
        <template v-else-if="['CANCELLED', 'EXPIRED'].includes(data?.pageRows?.find(r => r.id === openActionId)?.status as string)">
          <button class="w-full rounded px-3 py-1.5 text-left text-sm text-red-600 hover:bg-muted" @click="deleteLink(openActionId); openActionId = null">삭제</button>
        </template>
        <template v-else>
          <span class="block px-3 py-1.5 text-xs text-muted-foreground">사용 가능한 액션 없음</span>
        </template>
      </div>
    </Teleport>
  </div>
</template>
```

### 5-3. 라우터 추가

> **파일**: `partner-ui/src/router/index.ts` — children 배열에 추가

```typescript
{ path: 'phonepay-links', name: 'partner-phonepay-links', component: () => import('@/views/partner/deposits/PhonepayLinksView.vue'), meta: { title: '폰페이 링크' } },
```

### 5-4. 메뉴 추가

> **파일**: `partner-ui/src/utils/constants.ts` — MENU_ITEMS의 '입금 관리' children에 추가

```typescript
{ label: '폰페이 링크', to: '/partner/phonepay-links', icon: 'Link' },
```

> `폰페이 세션` 메뉴 바로 아래에 배치.

### 5-5. StatusBadge 호환

`PhonepayLinkStatus`의 상태값(`ACTIVE`, `USED`, `EXPIRED`, `CANCELLED`, `DEACTIVATED`)은 기존 `PaymentLinkStatus`와 동일하므로 `StatusBadge` 컴포넌트에서 이미 지원됩니다. 추가 작업 불필요.

---

## 6. 구현 체크리스트

### Backend (IntelliJ)

| # | 모듈 | 작업 | 파일 |
|---|------|------|------|
| 1 | DDL | `phonepay_links` 테이블 생성 | `CRYPTOMENTS_V2_DDL.sql` + 운영 DB |
| 2 | DDL | `phonepay_sessions`에 `phonepay_link_id` 컬럼 추가 (방법 A) | DDL + 운영 DB |
| 3 | common | `PhonepayLinkStatus` enum 생성 | `common/.../enums/` |
| 4 | common | `PhonepayLink` entity 생성 | `common/.../entity/` |
| 5 | common | `PhonepayLinkRepository` 생성 | `common/.../repository/` |
| 6 | common | `PhonepaySession` entity에 `phonepayLinkId` 필드 추가 | `common/.../entity/PhonepaySession.java` |
| 7 | common | ErrorCodes에 `PHONEPAY_LINK_NOT_FOUND`, `PHONEPAY_LINK_NOT_USABLE` 추가 | `common/.../exception/ErrorCodes.java` |
| 8 | partner-api | `CreatePhonepayLinkRequest` DTO 생성 | `partner-api/.../dto/request/` |
| 9 | partner-api | `PhonepayLinkResponse` DTO 생성 | `partner-api/.../dto/response/` |
| 10 | partner-api | `PartnerPhonepayController` 확장 (링크 CRUD 6개 엔드포인트) | `partner-api/.../controller/` |
| 11 | open-api | `PhonepayLinkWidgetController` 생성 | `open-api/.../controller/widget/` |
| 12 | open-api | BanqPipe Callback에서 링크 USED 처리 연동 | 기존 callback handler |
| 13 | — | `./gradlew :common:compileJava :partner-api:compileJava :open-api:compileJava` | 빌드 확인 |

### Frontend (VS Code)

| # | 작업 | 파일 |
|---|------|------|
| 14 | `deposit.service.ts`에 폰페이 링크 API 6개 추가 | `src/api/services/deposit.service.ts` |
| 15 | `PhonepayLinksView.vue` 생성 | `src/views/partner/deposits/` |
| 16 | 라우터에 `phonepay-links` 경로 추가 | `src/router/index.ts` |
| 17 | 메뉴에 '폰페이 링크' 항목 추가 | `src/utils/constants.ts` |

---

## 7. API 엔드포인트 요약

### Partner API (partner-api, 8082)

| 메서드 | 경로 | 역할 |
|--------|------|------|
| `GET` | `/api/partner/phonepay/links` | 폰페이 링크 목록 (상태 필터) |
| `POST` | `/api/partner/phonepay/links` | 폰페이 링크 생성 |
| `POST` | `/api/partner/phonepay/links/{id}/cancel` | 취소 (ACTIVE→CANCELLED) |
| `POST` | `/api/partner/phonepay/links/{id}/deactivate` | 비활성화 (ACTIVE→DEACTIVATED) |
| `POST` | `/api/partner/phonepay/links/{id}/activate` | 재활성화 (DEACTIVATED→ACTIVE) |
| `DELETE` | `/api/partner/phonepay/links/{id}` | 삭제 (종료 상태만) |

### Widget API (open-api, 8081)

| 메서드 | 경로 | 역할 |
|--------|------|------|
| `GET` | `/widgets/phonepay/links/{linkCode}` | 링크 정보 + 토큰 발급 (인증 불필요) |
| `GET` | `/widgets/phonepay/links/{linkCode}/status` | 링크 상태 조회 (폴링용) |

### 상태 전이

```
ACTIVE ──┬──→ USED        (세션 COMPLETED 시 자동)
         ├──→ EXPIRED     (expiresAt 경과 시 자동)
         ├──→ CANCELLED   (파트너 수동)
         └──→ DEACTIVATED (파트너 수동) ──→ ACTIVE (재활성화)

삭제 가능: CANCELLED, EXPIRED, DEACTIVATED
삭제 불가: ACTIVE, USED
```
