# 폰페이 Phase 1 — DDL + common 모듈 구현 지침서

> 대상: IntelliJ (Spring Boot)
> 참조: `v2-docs/PHONEPAY_INTEGRATION_PLAN.md`, `v2-docs/BANQPIPE_INTEGRATION_GUIDE.md`
> 패턴 참조: Axim (PartnerAximSettings, AximPayClient)

---

## 1. DDL — 운영 DB 적용

### 1.1 `partner_phonepay_settings` 테이블

```sql
CREATE TABLE partner_phonepay_settings (
    id BIGINT AUTO_INCREMENT PRIMARY KEY
        COMMENT 'PK',
    partner_id BIGINT NOT NULL
        COMMENT 'partners.id — 파트너',
    is_enabled BOOLEAN DEFAULT FALSE
        COMMENT '폰페이 연동 활성화 여부',
    api_key VARCHAR(255)
        COMMENT 'BanqPipe API Key (bpk_xxx...)',
    callback_url VARCHAR(500)
        COMMENT 'BanqPipe에 등록할 Callback URL (참조용 기록)',
    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_partner (partner_id),
    KEY idx_enabled (is_enabled)
) COMMENT '파트너별 폰페이 (BanqPipe) 연동 설정';
```

### 1.2 `phonepay_sessions` 테이블

```sql
CREATE TABLE phonepay_sessions (
    id BIGINT AUTO_INCREMENT PRIMARY KEY
        COMMENT 'PK',
    partner_id BIGINT NOT NULL
        COMMENT 'partners.id',
    deposit_id BIGINT
        COMMENT 'deposits.id — SESSION_COMPLETED 시 생성된 입금 레코드',
    banqpipe_session_id VARCHAR(100) NOT NULL
        COMMENT 'BanqPipe 세션 ID (dp_xxx...)',
    status VARCHAR(20) NOT NULL DEFAULT 'WAITING'
        COMMENT 'WAITING / MATCHED / COMPLETED / FAILED / EXPIRED / CANCELLED',
    amount BIGINT NOT NULL
        COMMENT '입금 금액 (원, KRW)',
    sender_name VARCHAR(100) NOT NULL
        COMMENT '송금인 이름 (eKYC)',
    sender_phone VARCHAR(20)
        COMMENT '송금인 전화번호',
    sender_bank VARCHAR(50)
        COMMENT '송금인 은행명',
    recipient_name VARCHAR(100)
        COMMENT '배정된 수취인 이름',
    recipient_phone VARCHAR(20)
        COMMENT '배정된 수취인 전화번호',
    recipient_bank VARCHAR(50)
        COMMENT '배정된 수취인 은행명',
    expires_at DATETIME(6)
        COMMENT '세션 만료 시각 (1시간)',
    matched_at DATETIME(6)
        COMMENT 'SMS 매칭 시각',
    completed_at DATETIME(6)
        COMMENT '완료 시각',
    failed_at DATETIME(6)
        COMMENT '실패 시각',
    cancelled_at DATETIME(6)
        COMMENT '취소 시각',
    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_banqpipe_session (banqpipe_session_id),
    KEY idx_partner_status (partner_id, status),
    KEY idx_deposit (deposit_id)
) COMMENT '폰페이 입금 세션 — BanqPipe 세션 1:1 매핑';
```

### 1.3 Seed Data

```sql
-- KRW 통화 (fiat) — ID는 기존 데이터와 충돌하지 않는 값 사용
-- 먼저 기존 ID 확인: SELECT MAX(id) FROM currencies;
INSERT INTO currencies (id, symbol, name, decimals, token_type, is_active)
VALUES (99, 'KRW', 'Korean Won', 0, 'FIAT', TRUE);

-- FIAT 네트워크 — ID는 기존 데이터와 충돌하지 않는 값 사용
-- 먼저 기존 ID 확인: SELECT MAX(id) FROM blockchain_networks;
INSERT INTO blockchain_networks (id, chain_type, name, chain_id, is_active)
VALUES (99, 'FIAT', 'Bank Transfer (KRW)', 0, TRUE);
```

### 1.4 DepositMethod 컬럼 주석 갱신

```sql
-- deposits 테이블 deposit_method 컬럼 COMMENT 갱신 (DDL 문서 동기화용)
ALTER TABLE deposits MODIFY COLUMN deposit_method VARCHAR(20) NOT NULL
    COMMENT 'HD_WALLET / EXTERNAL_WALLET / DECIMAL_MATCH / DIRECT / MANUAL / PHONEPAY';
```

---

## 2. Entity — `PartnerPhonepaySettings.java`

경로: `common/src/main/java/com/cryptoments/common/entity/PartnerPhonepaySettings.java`

패턴: `PartnerAximSettings.java` 동일 구조.

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

import lombok.*;
import one.axim.framework.mybatis.annotation.XColumn;
import one.axim.framework.mybatis.annotation.XEntity;

import java.time.LocalDateTime;

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

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

    /** partners.id — 파트너 */
    private Long partnerId;

    /** 폰페이 연동 활성화 여부 */
    private Boolean isEnabled;

    /** BanqPipe API Key (bpk_xxx...) */
    private String apiKey;

    /** BanqPipe에 등록할 Callback URL */
    private String callbackUrl;

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

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

---

## 3. Entity — `PhonepaySession.java`

경로: `common/src/main/java/com/cryptoments/common/entity/PhonepaySession.java`

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

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

import java.time.LocalDateTime;

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

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

    /** partners.id */
    private Long partnerId;

    /** deposits.id — SESSION_COMPLETED 시 생성된 입금 레코드 */
    private Long depositId;

    /** BanqPipe 세션 ID (dp_xxx...) */
    private String banqpipeSessionId;

    /** 세션 상태 */
    private PhonepaySessionStatus status;

    /** 입금 금액 (원, KRW) */
    private Long amount;

    /** 송금인 이름 (eKYC) */
    private String senderName;

    /** 송금인 전화번호 */
    private String senderPhone;

    /** 송금인 은행명 */
    private String senderBank;

    /** 배정된 수취인 이름 */
    private String recipientName;

    /** 배정된 수취인 전화번호 */
    private String recipientPhone;

    /** 배정된 수취인 은행명 */
    private String recipientBank;

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

    /** SMS 매칭 시각 */
    private LocalDateTime matchedAt;

    /** 완료 시각 */
    private LocalDateTime completedAt;

    /** 실패 시각 */
    private LocalDateTime failedAt;

    /** 취소 시각 */
    private LocalDateTime cancelledAt;

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

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

---

## 4. Repository

### 4.1 `PartnerPhonepaySettingsRepository.java`

경로: `common/src/main/java/com/cryptoments/common/repository/PartnerPhonepaySettingsRepository.java`

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

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

@XRepository
public interface PartnerPhonepaySettingsRepository extends IXRepository<Long, PartnerPhonepaySettings> {
    PartnerPhonepaySettings findByPartnerId(Long partnerId);
}
```

### 4.2 `PhonepaySessionRepository.java`

경로: `common/src/main/java/com/cryptoments/common/repository/PhonepaySessionRepository.java`

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

import com.cryptoments.common.entity.PhonepaySession;
import com.cryptoments.common.enums.PhonepaySessionStatus;
import one.axim.framework.mybatis.repository.IXRepository;
import one.axim.framework.mybatis.annotation.XRepository;

import java.util.List;

@XRepository
public interface PhonepaySessionRepository extends IXRepository<Long, PhonepaySession> {
    PhonepaySession findByBanqpipeSessionId(String banqpipeSessionId);
    PhonepaySession findByDepositId(Long depositId);
    List<PhonepaySession> findByPartnerIdAndStatus(Long partnerId, PhonepaySessionStatus status);
}
```

---

## 5. Enum

### 5.1 `PhonepaySessionStatus.java` (신규)

경로: `common/src/main/java/com/cryptoments/common/enums/PhonepaySessionStatus.java`

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

public enum PhonepaySessionStatus {
    /** 입금 대기 중 */
    WAITING,
    /** SMS 매칭 완료, 처리 중 */
    MATCHED,
    /** 입금 처리 완료 */
    COMPLETED,
    /** 처리 실패 */
    FAILED,
    /** 세션 만료 */
    EXPIRED,
    /** 취소 (WAITING 상태에서만 가능) */
    CANCELLED;
}
```

### 5.2 `DepositMethod.java` 수정

경로: `common/src/main/java/com/cryptoments/common/enums/DepositMethod.java`

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

public enum DepositMethod {
    /** HD 지갑 방식 입금 */
    HD_WALLET,
    /** 외부 지갑 방식 입금 */
    EXTERNAL_WALLET,
    /** 소수점 매칭 방식 입금 */
    DECIMAL_MATCH,
    /** 직접 입금 (파트너 충전 등) */
    DIRECT,
    /** 수동 입금 (관리자 수동 처리) */
    MANUAL,
    /** 폰페이(BanqPipe) 원화 입금 */
    PHONEPAY;
}
```

---

## 6. BanqPipe Client

### 6.1 `BanqPipeClient.java`

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

패턴 참조: `AximPayClient.java` — 단, BanqPipe는 HMAC 인증 없이 `X-API-Key` 헤더만 사용하므로 훨씬 단순.

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

import com.cryptoments.common.client.dto.banqpipe.BanqPipeCancelResponse;
import com.cryptoments.common.client.dto.banqpipe.BanqPipeSessionRequest;
import com.cryptoments.common.client.dto.banqpipe.BanqPipeSessionResponse;
import com.cryptoments.common.exception.BanqPipeApiException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.RestClientException;

/**
 * BanqPipe 외부 API 클라이언트.
 * X-API-Key 헤더 인증 기반.
 */
@Component
public class BanqPipeClient {

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

    private final RestClient restClient;

    @Value("${external.banqpipe.url:https://api.banqpipe.com/api/v1}")
    private String baseUrl;

    public BanqPipeClient(RestClient.Builder restClientBuilder) {
        this.restClient = restClientBuilder.build();
    }

    /**
     * 입금 세션 생성.
     * POST /deposit/sessions
     */
    public BanqPipeSessionResponse createSession(String apiKey, BanqPipeSessionRequest request) {
        try {
            return restClient.post()
                    .uri(baseUrl + "/deposit/sessions")
                    .header("X-API-Key", apiKey)
                    .contentType(MediaType.APPLICATION_JSON)
                    .body(request)
                    .retrieve()
                    .body(BanqPipeSessionResponse.class);
        } catch (RestClientException e) {
            log.error("BanqPipe 세션 생성 실패: {}", e.getMessage());
            throw new BanqPipeApiException("세션 생성 실패: " + e.getMessage(), e);
        }
    }

    /**
     * 입금 세션 조회.
     * GET /deposit/sessions/:id
     */
    public BanqPipeSessionResponse getSession(String apiKey, String sessionId) {
        try {
            return restClient.get()
                    .uri(baseUrl + "/deposit/sessions/{id}", sessionId)
                    .header("X-API-Key", apiKey)
                    .retrieve()
                    .body(BanqPipeSessionResponse.class);
        } catch (RestClientException e) {
            log.error("BanqPipe 세션 조회 실패: sessionId={}, {}", sessionId, e.getMessage());
            throw new BanqPipeApiException("세션 조회 실패: " + e.getMessage(), e);
        }
    }

    /**
     * 입금 세션 취소 — WAITING 상태에서만 가능.
     * PUT /deposit/sessions/:id/cancel
     */
    public BanqPipeCancelResponse cancelSession(String apiKey, String sessionId) {
        try {
            return restClient.put()
                    .uri(baseUrl + "/deposit/sessions/{id}/cancel", sessionId)
                    .header("X-API-Key", apiKey)
                    .retrieve()
                    .body(BanqPipeCancelResponse.class);
        } catch (RestClientException e) {
            log.error("BanqPipe 세션 취소 실패: sessionId={}, {}", sessionId, e.getMessage());
            throw new BanqPipeApiException("세션 취소 실패: " + e.getMessage(), e);
        }
    }
}
```

### 6.2 DTO — `BanqPipeSessionRequest.java`

경로: `common/src/main/java/com/cryptoments/common/client/dto/banqpipe/BanqPipeSessionRequest.java`

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

import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.*;

/**
 * BanqPipe 입금 세션 생성 요청.
 */
@Getter @Setter @Builder
@NoArgsConstructor @AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public class BanqPipeSessionRequest {

    /** 입금 금액 (원) */
    private Long amount;

    /** 입금자 이름 */
    @JsonProperty("sender_name")
    private String senderName;

    /** 입금자 전화번호 */
    @JsonProperty("sender_phone")
    private String senderPhone;

    /** 입금자 은행 */
    @JsonProperty("sender_bank")
    private String senderBank;
}
```

### 6.3 DTO — `BanqPipeSessionResponse.java`

경로: `common/src/main/java/com/cryptoments/common/client/dto/banqpipe/BanqPipeSessionResponse.java`

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

import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.*;

/**
 * BanqPipe 입금 세션 응답.
 */
@Getter @Setter @Builder
@NoArgsConstructor @AllArgsConstructor
public class BanqPipeSessionResponse {

    /** 세션 ID (dp_xxx...) */
    @JsonProperty("session_id")
    private String sessionId;

    /** 세션 상태 */
    private String status;

    /** 배정된 수취인 이름 */
    @JsonProperty("recipient_name")
    private String recipientName;

    /** 배정된 디바이스 전화번호 */
    @JsonProperty("recipient_phone")
    private String recipientPhone;

    /** 입금 금액 */
    private Long amount;

    /** 세션 만료 시각 (UTC ISO-8601) */
    @JsonProperty("expires_at")
    private String expiresAt;

    /** 입금자 이름 (조회 응답에만 포함) */
    @JsonProperty("sender_name")
    private String senderName;

    /** 입금자 전화번호 (조회 응답에만 포함) */
    @JsonProperty("sender_phone")
    private String senderPhone;

    /** 생성 시각 (조회 응답에만 포함) */
    @JsonProperty("created_at")
    private String createdAt;

    /** SMS 매칭 시각 (조회 응답에만 포함) */
    @JsonProperty("matched_at")
    private String matchedAt;
}
```

### 6.4 DTO — `BanqPipeCancelResponse.java`

경로: `common/src/main/java/com/cryptoments/common/client/dto/banqpipe/BanqPipeCancelResponse.java`

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

import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.*;

/**
 * BanqPipe 세션 취소 응답.
 */
@Getter @Setter @Builder
@NoArgsConstructor @AllArgsConstructor
public class BanqPipeCancelResponse {

    /** 세션 ID */
    @JsonProperty("session_id")
    private String sessionId;

    /** 취소 후 상태 (CANCELLED) */
    private String status;
}
```

---

## 7. Exception — `BanqPipeApiException.java`

경로: `common/src/main/java/com/cryptoments/common/exception/BanqPipeApiException.java`

패턴 참조: `AximApiException.java`

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

/**
 * BanqPipe API 호출 실패 시 발생하는 예외.
 */
public class BanqPipeApiException extends RuntimeException {

    public BanqPipeApiException(String message) {
        super(message);
    }

    public BanqPipeApiException(String message, Throwable cause) {
        super(message, cause);
    }
}
```

---

## 8. ErrorCodes 추가

`common/src/main/java/com/cryptoments/common/exception/ErrorCodes.java`에 추가:

```java
// ========================================
// 폰페이 (930번대)
// ========================================

public static final ErrorCode PHONEPAY_SETTINGS_NOT_FOUND = new ErrorCode("930", "폰페이 설정을 찾을 수 없습니다.");
public static final ErrorCode PHONEPAY_NOT_CONFIGURED = new ErrorCode("931", "폰페이 API Key를 먼저 설정해주세요.");
public static final ErrorCode PHONEPAY_SESSION_NOT_FOUND = new ErrorCode("932", "폰페이 세션을 찾을 수 없습니다.");
public static final ErrorCode PHONEPAY_API_FAILED = new ErrorCode("933", "BanqPipe API 호출에 실패했습니다.");
public static final ErrorCode PHONEPAY_SESSION_NOT_CANCELLABLE = new ErrorCode("934", "취소할 수 없는 상태의 세션입니다.");
public static final ErrorCode EKYC_NOT_VERIFIED = new ErrorCode("935", "eKYC 인증이 완료되지 않았습니다.");
```

---

## 9. application.yml 설정 추가

각 서비스 모듈의 `application.yml`에 BanqPipe 베이스 URL 설정:

```yaml
external:
  banqpipe:
    url: https://api.banqpipe.com/api/v1
```

> 기본값이 코드에 `@Value` 기본값으로 설정되어 있으므로 생략 가능하나, 명시적으로 기록 권장.

---

## 10. 체크리스트

| # | 항목 | 파일 | 확인 |
|---|------|------|------|
| 1 | DDL 운영 DB 적용 | SQL 직접 실행 | ☐ |
| 2 | DDL → `v2-docs/CRYPTOMENTS_V2_DDL.sql`에 추가 | DDL 문서 | ☐ |
| 3 | Seed Data (KRW, FIAT) 운영 DB 적용 | SQL 직접 실행 | ☐ |
| 4 | `PartnerPhonepaySettings.java` | common/entity/ | ☐ |
| 5 | `PhonepaySession.java` | common/entity/ | ☐ |
| 6 | `PartnerPhonepaySettingsRepository.java` | common/repository/ | ☐ |
| 7 | `PhonepaySessionRepository.java` | common/repository/ | ☐ |
| 8 | `PhonepaySessionStatus.java` | common/enums/ | ☐ |
| 9 | `DepositMethod.java`에 `PHONEPAY` 추가 | common/enums/ | ☐ |
| 10 | `BanqPipeClient.java` | common/client/ | ☐ |
| 11 | `BanqPipeSessionRequest.java` | common/client/dto/banqpipe/ | ☐ |
| 12 | `BanqPipeSessionResponse.java` | common/client/dto/banqpipe/ | ☐ |
| 13 | `BanqPipeCancelResponse.java` | common/client/dto/banqpipe/ | ☐ |
| 14 | `BanqPipeApiException.java` | common/exception/ | ☐ |
| 15 | `ErrorCodes.java`에 930번대 추가 | common/exception/ | ☐ |
| 16 | `./gradlew :common:compileJava` 성공 확인 | 빌드 | ☐ |
