# 폰페이 Phase 2 — core + partner-api + open-api(Webhook) 구현 지침서

> 대상: IntelliJ (Spring Boot)
> 선행 조건: Phase 1 완료 (DDL + common 모듈)
> 참조: `v2-docs/PHONEPAY_INTEGRATION_PLAN.md` (v5), `v2-docs/PHONEPAY_PHASE1_GUIDE.md`
> 패턴 참조: DepositService (입금 생성), SettlementService (Ledger CREDIT), AximWebhookController (Webhook 수신), PartnerIntegrationService (설정 CRUD + activate)

---

## 범위

| 모듈 | 신규 파일 | 기존 수정 |
|------|----------|----------|
| **core** | `PhonepayService.java` | — |
| **partner-api** | `PartnerPhonepayController.java`, `PartnerPhonepayService.java`, DTO 2개 | `PartnerIntegrationController.java` (폰페이 설정 4개 엔드포인트 추가), `PartnerIntegrationService.java` (폰페이 설정 메서드 추가) |
| **open-api** | `BanqPipeWebhookController.java` | — |

---

## 1. core 모듈 — `PhonepayService.java`

경로: `core/src/main/java/com/cryptoments/core/phonepay/PhonepayService.java`

이 서비스가 폰페이 비즈니스 로직의 중심이다. Widget(세션 생성/취소)과 Webhook(콜백 처리) 양쪽에서 호출한다.

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

import com.cryptoments.common.client.BanqPipeClient;
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.entity.Deposit;
import com.cryptoments.common.entity.PartnerPhonepaySettings;
import com.cryptoments.common.entity.PhonepaySession;
import com.cryptoments.common.entity.TransactionStatusHistory;
import com.cryptoments.common.enums.ChangeSource;
import com.cryptoments.common.enums.DepositMethod;
import com.cryptoments.common.enums.DepositStatus;
import com.cryptoments.common.enums.DepositType;
import com.cryptoments.common.enums.LedgerReferenceType;
import com.cryptoments.common.enums.PhonepaySessionStatus;
import com.cryptoments.common.enums.TransactionType;
import com.cryptoments.common.exception.ConflictException;
import com.cryptoments.common.exception.ErrorCodes;
import com.cryptoments.common.exception.NotFoundException;
import com.cryptoments.common.repository.DepositRepository;
import com.cryptoments.common.repository.PartnerPhonepaySettingsRepository;
import com.cryptoments.common.repository.PhonepaySessionRepository;
import com.cryptoments.common.repository.TransactionStatusHistoryRepository;
import com.cryptoments.core.notification.NotificationService;
import com.cryptoments.core.settlement.SettlementService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Map;
import java.util.UUID;

/**
 * 폰페이 (BanqPipe) 핵심 비즈니스 로직.
 *
 * <p>세션 생성/취소, BanqPipe 콜백 처리 (Deposit 생성 + Ledger CREDIT)를 담당한다.
 */
@Service
public class PhonepayService {

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

    /** KRW 통화 ID (seed data) */
    private static final Long KRW_CURRENCY_ID = 99L;
    /** FIAT 네트워크 ID (seed data) */
    private static final Long FIAT_NETWORK_ID = 99L;

    private final BanqPipeClient banqPipeClient;
    private final PartnerPhonepaySettingsRepository phonepaySettingsRepository;
    private final PhonepaySessionRepository phonepaySessionRepository;
    private final DepositRepository depositRepository;
    private final SettlementService settlementService;
    private final NotificationService notificationService;
    private final TransactionStatusHistoryRepository statusHistoryRepository;

    public PhonepayService(BanqPipeClient banqPipeClient,
                           PartnerPhonepaySettingsRepository phonepaySettingsRepository,
                           PhonepaySessionRepository phonepaySessionRepository,
                           DepositRepository depositRepository,
                           SettlementService settlementService,
                           NotificationService notificationService,
                           TransactionStatusHistoryRepository statusHistoryRepository) {
        this.banqPipeClient = banqPipeClient;
        this.phonepaySettingsRepository = phonepaySettingsRepository;
        this.phonepaySessionRepository = phonepaySessionRepository;
        this.depositRepository = depositRepository;
        this.settlementService = settlementService;
        this.notificationService = notificationService;
        this.statusHistoryRepository = statusHistoryRepository;
    }

    // ── 세션 생성 ──

    /**
     * 폰페이 입금 세션을 생성한다.
     *
     * <p>eKYC 정보(senderName, senderPhone, senderBank)는 호출자(Widget Controller)에서 조회하여 전달.
     * Widget에서 직접 전송하지 않으므로 위변조 방지됨.
     *
     * @param partnerId  파트너 ID
     * @param amount     입금 금액 (원, KRW)
     * @param senderName 송금인 이름 (eKYC)
     * @param senderPhone 송금인 전화번호 (eKYC)
     * @param senderBank  송금인 은행명 (eKYC)
     * @return 생성된 PhonepaySession
     */
    public PhonepaySession createSession(Long partnerId, Long amount,
                                          String senderName, String senderPhone, String senderBank) {
        // 1. 설정 확인
        PartnerPhonepaySettings settings = phonepaySettingsRepository.findByPartnerId(partnerId);
        if (settings == null) {
            throw new NotFoundException(ErrorCodes.PHONEPAY_SETTINGS_NOT_FOUND);
        }
        if (!Boolean.TRUE.equals(settings.getIsEnabled())) {
            throw new ConflictException(ErrorCodes.PHONEPAY_NOT_CONFIGURED);
        }

        // 2. BanqPipe API 호출 — 세션 생성
        BanqPipeSessionRequest request = BanqPipeSessionRequest.builder()
                .amount(amount)
                .senderName(senderName)
                .senderPhone(senderPhone)
                .senderBank(senderBank)
                .build();

        BanqPipeSessionResponse bpResponse = banqPipeClient.createSession(settings.getApiKey(), request);

        // 3. PhonepaySession 엔티티 생성 + DB INSERT
        LocalDateTime expiresAt = null;
        if (bpResponse.getExpiresAt() != null) {
            try {
                expiresAt = LocalDateTime.parse(bpResponse.getExpiresAt(),
                        DateTimeFormatter.ISO_DATE_TIME);
            } catch (Exception e) {
                // 파싱 실패 시 1시간 기본값
                expiresAt = LocalDateTime.now().plusHours(1);
                log.warn("BanqPipe expiresAt 파싱 실패, 기본값 사용: {}", bpResponse.getExpiresAt());
            }
        } else {
            expiresAt = LocalDateTime.now().plusHours(1);
        }

        PhonepaySession session = PhonepaySession.builder()
                .partnerId(partnerId)
                .banqpipeSessionId(bpResponse.getSessionId())
                .status(PhonepaySessionStatus.WAITING)
                .amount(amount)
                .senderName(senderName)
                .senderPhone(senderPhone)
                .senderBank(senderBank)
                .recipientName(bpResponse.getRecipientName())
                .recipientPhone(bpResponse.getRecipientPhone())
                .expiresAt(expiresAt)
                .build();

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

        log.info("폰페이 세션 생성: sessionId={}, banqpipeSessionId={}, amount={}, sender={}",
                id, bpResponse.getSessionId(), amount, senderName);

        return session;
    }

    // ── 세션 조회 ──

    /**
     * 폰페이 세션을 조회한다.
     */
    public PhonepaySession getSession(Long partnerId, Long sessionId) {
        PhonepaySession session = phonepaySessionRepository.findOne(sessionId);
        if (session == null || !session.getPartnerId().equals(partnerId)) {
            throw new NotFoundException(ErrorCodes.PHONEPAY_SESSION_NOT_FOUND);
        }
        return session;
    }

    // ── 세션 취소 ──

    /**
     * 폰페이 세션을 취소한다. WAITING 상태에서만 가능 (BanqPipe 제약).
     */
    public PhonepaySession cancelSession(Long partnerId, Long sessionId) {
        PhonepaySession session = phonepaySessionRepository.findOne(sessionId);
        if (session == null || !session.getPartnerId().equals(partnerId)) {
            throw new NotFoundException(ErrorCodes.PHONEPAY_SESSION_NOT_FOUND);
        }

        if (session.getStatus() != PhonepaySessionStatus.WAITING) {
            throw new ConflictException(ErrorCodes.PHONEPAY_SESSION_NOT_CANCELLABLE);
        }

        // BanqPipe API 취소 호출
        PartnerPhonepaySettings settings = phonepaySettingsRepository.findByPartnerId(partnerId);
        banqPipeClient.cancelSession(settings.getApiKey(), session.getBanqpipeSessionId());

        // DB 상태 업데이트
        PhonepaySession cancelled = session.toBuilder()
                .status(PhonepaySessionStatus.CANCELLED)
                .cancelledAt(LocalDateTime.now())
                .build();
        phonepaySessionRepository.modify(cancelled);

        log.info("폰페이 세션 취소: sessionId={}, banqpipeSessionId={}", sessionId, session.getBanqpipeSessionId());
        return cancelled;
    }

    // ── BanqPipe Callback 처리 ──

    /**
     * BanqPipe 콜백을 처리한다.
     *
     * <p>SESSION_COMPLETED → Deposit 생성 + Ledger CREDIT + 즉시 SETTLED.
     * SESSION_FAILED / SESSION_EXPIRED / SESSION_CANCELLED → 상태만 업데이트.
     *
     * <p>전체를 @Transactional로 감싸 Deposit INSERT + Ledger INSERT + Session UPDATE를 원자 처리.
     *
     * @param banqpipeSessionId BanqPipe 세션 ID
     * @param event             이벤트 타입 (SESSION_COMPLETED, SESSION_FAILED, SESSION_EXPIRED, SESSION_CANCELLED)
     * @param payload           콜백 전체 데이터
     */
    @Transactional
    public void handleCallback(String banqpipeSessionId, String event, Map<String, Object> payload) {
        log.info("폰페이 콜백 수신: banqpipeSessionId={}, event={}", banqpipeSessionId, event);

        // 1. 세션 조회
        PhonepaySession session = phonepaySessionRepository.findByBanqpipeSessionId(banqpipeSessionId);
        if (session == null) {
            log.warn("폰페이 콜백: 세션 없음, banqpipeSessionId={}", banqpipeSessionId);
            return;
        }

        // 2. 멱등성 체크 — 이미 종료 상태면 skip
        if (isTerminalStatus(session.getStatus())) {
            log.info("폰페이 콜백: 이미 종료 상태, skip. sessionId={}, status={}",
                    session.getId(), session.getStatus());
            return;
        }

        // 3. 이벤트별 분기
        switch (event) {
            case "SESSION_COMPLETED":
                handleSessionCompleted(session);
                break;
            case "SESSION_MATCHED":
                handleSessionMatched(session);
                break;
            case "SESSION_FAILED":
                handleSessionFailed(session);
                break;
            case "SESSION_EXPIRED":
                handleSessionExpired(session);
                break;
            case "SESSION_CANCELLED":
                handleSessionCancelled(session);
                break;
            default:
                log.warn("폰페이 콜백: 알 수 없는 이벤트 타입. event={}, sessionId={}", event, session.getId());
        }
    }

    // ── Private: SESSION_COMPLETED 처리 (핵심) ──

    private void handleSessionCompleted(PhonepaySession session) {
        LocalDateTime now = LocalDateTime.now();

        // 3a. PhonepaySession 상태 → COMPLETED
        PhonepaySession completed = session.toBuilder()
                .status(PhonepaySessionStatus.COMPLETED)
                .completedAt(now)
                .build();

        // 3b. Deposit 레코드 생성
        String depositCode = generateCode("dep");

        Deposit deposit = Deposit.builder()
                .depositCode(depositCode)
                .partnerId(session.getPartnerId())
                .partnerUserId(null)       // 폰페이는 partnerUserId 없음
                .networkId(FIAT_NETWORK_ID)
                .currencyId(KRW_CURRENCY_ID)
                .depositType(DepositType.USER_DEPOSIT)
                .depositMethod(DepositMethod.PHONEPAY)
                .amount(BigDecimal.valueOf(session.getAmount()))
                .feeAmount(BigDecimal.ZERO) // ★ 폰페이 수수료 없음
                .txHash(null)              // 블록체인 TX 없음
                .fromAddress(session.getSenderName())     // ★ 보내는 사람 이름
                .toAddress(session.getRecipientName())    // ★ 받는 사람 이름
                .blockNumber(null)
                .priceKrw(BigDecimal.ONE)   // KRW 자체 → 1.000000
                .priceUsd(null)             // KRW/USD 환율 — 선택 사항, null 허용
                .walletAddressId(null)
                .status(DepositStatus.CONFIRMED)
                .confirmedAt(now)
                .build();

        // 3c. Deposit 저장
        Long depositId = depositRepository.save(deposit);
        deposit.setId(depositId);

        // 상태 이력 — DETECTED 건너뛰고 바로 CONFIRMED
        recordStatusHistory(TransactionType.DEPOSIT, depositId,
                null, DepositStatus.CONFIRMED.name(), ChangeSource.SYSTEM, "폰페이 입금 확정");

        // 3d. PhonepaySession에 depositId 연결
        completed.setDepositId(depositId);
        phonepaySessionRepository.modify(completed);

        // 3e. 원장 기록 — CREDIT만 (FEE 없음)
        settlementService.credit(
                session.getPartnerId(),
                KRW_CURRENCY_ID,
                FIAT_NETWORK_ID,
                BigDecimal.valueOf(session.getAmount()),
                LedgerReferenceType.DEPOSIT,
                depositId,
                depositCode + " 폰페이 입금 확정"
        );

        // 3f. Deposit 즉시 SETTLED (블록체인 집금 불필요)
        Deposit settled = deposit.toBuilder()
                .status(DepositStatus.SETTLED)
                .settledAt(now)
                .build();
        depositRepository.modify(settled);

        recordStatusHistory(TransactionType.DEPOSIT, depositId,
                DepositStatus.CONFIRMED.name(), DepositStatus.SETTLED.name(),
                ChangeSource.SYSTEM, "폰페이 입금 즉시 정산");

        log.info("폰페이 입금 완료: sessionId={}, depositId={}, amount={}원, sender={}",
                session.getId(), depositId, session.getAmount(), session.getSenderName());

        // 3g. 파트너 알림 발행
        try {
            String webhookData = "{\"event\":\"PHONEPAY_DEPOSIT\",\"depositId\":" + depositId
                    + ",\"amount\":" + session.getAmount() + "}";
            String telegramMsg = "💰 폰페이 입금: " + session.getAmount() + "원"
                    + " (송금인: " + session.getSenderName() + ")";
            notificationService.send(TransactionType.DEPOSIT, session.getPartnerId(),
                    depositId, webhookData, telegramMsg);
        } catch (Exception e) {
            log.warn("폰페이 입금 알림 발송 실패: depositId={}", depositId, e);
        }
    }

    // ── Private: 기타 상태 업데이트 ──

    private void handleSessionMatched(PhonepaySession session) {
        PhonepaySession matched = session.toBuilder()
                .status(PhonepaySessionStatus.MATCHED)
                .matchedAt(LocalDateTime.now())
                .build();
        phonepaySessionRepository.modify(matched);
        log.info("폰페이 세션 매칭: sessionId={}", session.getId());
    }

    private void handleSessionFailed(PhonepaySession session) {
        PhonepaySession failed = session.toBuilder()
                .status(PhonepaySessionStatus.FAILED)
                .failedAt(LocalDateTime.now())
                .build();
        phonepaySessionRepository.modify(failed);
        log.info("폰페이 세션 실패: sessionId={}", session.getId());
    }

    private void handleSessionExpired(PhonepaySession session) {
        PhonepaySession expired = session.toBuilder()
                .status(PhonepaySessionStatus.EXPIRED)
                .build();
        phonepaySessionRepository.modify(expired);
        log.info("폰페이 세션 만료: sessionId={}", session.getId());
    }

    private void handleSessionCancelled(PhonepaySession session) {
        PhonepaySession cancelled = session.toBuilder()
                .status(PhonepaySessionStatus.CANCELLED)
                .cancelledAt(LocalDateTime.now())
                .build();
        phonepaySessionRepository.modify(cancelled);
        log.info("폰페이 세션 취소(콜백): sessionId={}", session.getId());
    }

    // ── Private helpers ──

    private boolean isTerminalStatus(PhonepaySessionStatus status) {
        return status == PhonepaySessionStatus.COMPLETED
                || status == PhonepaySessionStatus.FAILED
                || status == PhonepaySessionStatus.EXPIRED
                || status == PhonepaySessionStatus.CANCELLED;
    }

    private String generateCode(String prefix) {
        return prefix + "_"
                + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyMM"))
                + "_" + UUID.randomUUID().toString().replace("-", "").substring(0, 8);
    }

    private void recordStatusHistory(TransactionType txType, Long txId,
                                      String fromStatus, String toStatus,
                                      ChangeSource changeSource, String description) {
        try {
            TransactionStatusHistory history = TransactionStatusHistory.builder()
                    .transactionType(txType)
                    .transactionId(txId)
                    .fromStatus(fromStatus)
                    .toStatus(toStatus)
                    .changeSource(changeSource)
                    .description(description)
                    .build();
            statusHistoryRepository.save(history);
        } catch (Exception e) {
            log.warn("상태 이력 기록 실패: txType={}, txId={}", txType, txId, e);
        }
    }
}
```

### 1.1 core/build.gradle 의존성 확인

`core/build.gradle`에 `common` 의존성이 이미 있으므로 추가 불필요. `@Transactional`은 Spring Boot Starter에 포함.

---

## 2. open-api 모듈 — `BanqPipeWebhookController.java`

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

패턴 참조: `AximWebhookController.java` — 동일 패키지, 동일 구조.

**핵심 차이**: BanqPipe는 HMAC 서명 검증 없음. 단순히 콜백을 수신하고 처리한다.

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

import com.cryptoments.core.phonepay.PhonepayService;
import one.axim.framework.rest.annotation.XApiIgnore;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.bind.annotation.*;

import java.util.Map;

/**
 * BanqPipe 콜백 수신 컨트롤러.
 *
 * <p>BanqPipe에서 입금 세션 상태 변경 시 콜백을 POST로 전송한다.
 * 콜백 URL: https://api.cryptoments.cc/webhooks/banqpipe/{partnerId}
 *
 * <p>HMAC 서명 검증 없음 — BanqPipe는 단순 X-API-Key 모델.
 */
@XApiIgnore
@RestController
@RequestMapping("/webhooks/banqpipe")
public class BanqPipeWebhookController {

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

    private final PhonepayService phonepayService;

    public BanqPipeWebhookController(PhonepayService phonepayService) {
        this.phonepayService = phonepayService;
    }

    /**
     * BanqPipe 콜백 수신.
     *
     * <p>이벤트 종류:
     * <ul>
     *   <li>SESSION_MATCHED — SMS 매칭 완료</li>
     *   <li>SESSION_COMPLETED — 입금 완료</li>
     *   <li>SESSION_FAILED — 입금 실패</li>
     *   <li>SESSION_EXPIRED — 세션 만료</li>
     *   <li>SESSION_CANCELLED — 세션 취소</li>
     * </ul>
     *
     * @param partnerId 파트너 ID (URL 경로)
     * @param payload   콜백 데이터 (event, session_id, ...)
     * @return 수신 확인
     */
    @PostMapping("/{partnerId}")
    public Map<String, Boolean> handleCallback(
            @PathVariable Long partnerId,
            @RequestBody Map<String, Object> payload) {

        String event = (String) payload.get("event");
        String sessionId = (String) payload.get("session_id");

        log.info("BanqPipe 콜백 수신: partnerId={}, event={}, sessionId={}", partnerId, event, sessionId);

        try {
            phonepayService.handleCallback(sessionId, event, payload);
        } catch (Exception e) {
            log.error("BanqPipe 콜백 처리 실패: partnerId={}, event={}, sessionId={}, error={}",
                    partnerId, event, sessionId, e.getMessage(), e);
            // 콜백 실패해도 200 반환 (BanqPipe 재시도 방지)
        }

        return Map.of("received", true);
    }
}
```

### 2.1 패키지 경로 확인

`AximWebhookController.java`가 있는 패키지와 동일한 위치에 생성한다. 만약 `AximWebhookController.java`의 실제 패키지가 다르면 그에 맞춘다.

> **실제 패키지 확인**: `open-api/src/main/java/com/cryptoments/openapi/webhook/controller/` 또는 `open-api/src/main/java/com/cryptoments/webhook/controller/` — Phase 1에서 AximWebhookController 위치를 확인하고 동일 위치에 생성할 것.

---

## 3. partner-api 모듈 — 폰페이 설정 API

### 3.1 `PartnerIntegrationController.java` 수정

기존 파일에 폰페이 설정 4개 엔드포인트를 **추가**한다. Axim 패턴과 동일.

```java
// ── 아래 메서드 4개를 PartnerIntegrationController.java 끝부분에 추가 ──

    /**
     * 폰페이 연동 설정 조회.
     *
     * @return 폰페이 설정
     * @response 200 조회 성공
     * @group 연동 설정
     * @auth true
     */
    @GetMapping(name = "폰페이 연동 설정 조회", value = "/phonepay")
    public PartnerPhonepaySettings getPhonepaySettings() {
        Long partnerId = getSession().getPartnerId();
        return partnerIntegrationService.getPhonepaySettings(partnerId);
    }

    /**
     * 폰페이 연동 설정 수정.
     *
     * @param request 설정 수정 요청 (apiKey)
     * @return 수정된 설정
     * @group 연동 설정
     * @auth true
     */
    @PutMapping(name = "폰페이 연동 설정 수정", value = "/phonepay")
    public PartnerPhonepaySettings updatePhonepaySettings(
            @Valid @RequestBody com.cryptoments.partnerapi.dto.request.UpdatePhonepaySettingsRequest request) {
        Long partnerId = getSession().getPartnerId();
        return partnerIntegrationService.updatePhonepaySettings(partnerId, request);
    }

    /**
     * 폰페이 활성화.
     * API Key 확인 → Callback URL 생성 + DB 저장 → isEnabled=true.
     *
     * @return 활성화된 폰페이 설정
     * @response 200 활성화 성공
     * @group 연동 설정
     * @auth true
     */
    @PostMapping(name = "폰페이 활성화", value = "/phonepay/activate")
    public PartnerPhonepaySettings activatePhonepay() {
        return partnerIntegrationService.activatePhonepay(getSession().getPartnerId());
    }

    /**
     * 폰페이 비활성화.
     *
     * @return 비활성화된 폰페이 설정
     * @response 200 비활성화 성공
     * @group 연동 설정
     * @auth true
     */
    @PostMapping(name = "폰페이 비활성화", value = "/phonepay/deactivate")
    public PartnerPhonepaySettings deactivatePhonepay() {
        return partnerIntegrationService.deactivatePhonepay(getSession().getPartnerId());
    }
```

**import 추가**:
```java
import com.cryptoments.common.entity.PartnerPhonepaySettings;
```

### 3.2 `PartnerIntegrationService.java` 수정

기존 파일에 폰페이 설정 메서드 4개를 **추가**한다.

**필드 추가** (생성자 DI):
```java
// 기존 필드들 아래에 추가
private final PartnerPhonepaySettingsRepository phonepaySettingsRepository;
```

**생성자 파라미터에 추가**:
```java
// 기존 생성자 파라미터 마지막에 추가
com.cryptoments.common.repository.PartnerPhonepaySettingsRepository phonepaySettingsRepository
```

**생성자 본문에 추가**:
```java
this.phonepaySettingsRepository = phonepaySettingsRepository;
```

**메서드 4개 추가**:

```java
    // ── 폰페이 (BanqPipe) 설정 ──

    /**
     * 폰페이 설정 조회.
     */
    public PartnerPhonepaySettings getPhonepaySettings(Long partnerId) {
        PartnerPhonepaySettings settings = phonepaySettingsRepository.findByPartnerId(partnerId);
        if (settings == null) {
            throw new NotFoundException(ErrorCodes.PHONEPAY_SETTINGS_NOT_FOUND);
        }
        return settings;
    }

    /**
     * 폰페이 설정 수정 (Upsert).
     */
    public PartnerPhonepaySettings updatePhonepaySettings(Long partnerId,
                                                            com.cryptoments.partnerapi.dto.request.UpdatePhonepaySettingsRequest request) {
        PartnerPhonepaySettings settings = phonepaySettingsRepository.findByPartnerId(partnerId);

        if (settings == null) {
            // INSERT (신규)
            settings = PartnerPhonepaySettings.builder()
                    .partnerId(partnerId)
                    .apiKey(request.getApiKey())
                    .isEnabled(false)  // 신규는 항상 false (활성화는 별도)
                    .build();
            Long id = phonepaySettingsRepository.save(settings);
            settings.setId(id);

            // 신규 + isEnabled=true 요청 → 즉시 활성화
            if (Boolean.TRUE.equals(request.getIsEnabled())) {
                return activatePhonepay(partnerId);
            }
            return settings;
        }

        // UPDATE (기존)
        if (request.getApiKey() != null) settings.setApiKey(request.getApiKey());
        phonepaySettingsRepository.modify(settings);

        // isEnabled 전환 감지
        if (request.getIsEnabled() != null) {
            boolean wasEnabled = Boolean.TRUE.equals(settings.getIsEnabled());
            boolean wantEnabled = request.getIsEnabled();

            if (!wasEnabled && wantEnabled) {
                return activatePhonepay(partnerId);
            } else if (wasEnabled && !wantEnabled) {
                return deactivatePhonepay(partnerId);
            }
        }

        return settings;
    }

    /**
     * 폰페이 활성화.
     *
     * <ol>
     *   <li>API Key 존재 확인</li>
     *   <li>Callback URL 생성 + DB 저장</li>
     *   <li>isEnabled = true</li>
     * </ol>
     *
     * <p>BanqPipe는 Axim과 달리 Webhook 자동 등록 API가 없으므로,
     * 파트너가 BanqPipe 콘솔에서 Callback URL을 직접 등록해야 한다.
     * 여기서는 URL을 생성하고 DB에 기록만 한다.
     */
    public PartnerPhonepaySettings activatePhonepay(Long partnerId) {
        log.info("[폰페이 활성화] 시작: partnerId={}", partnerId);

        PartnerPhonepaySettings settings = phonepaySettingsRepository.findByPartnerId(partnerId);
        if (settings == null) {
            throw new NotFoundException(ErrorCodes.PHONEPAY_SETTINGS_NOT_FOUND);
        }

        if (settings.getApiKey() == null || settings.getApiKey().isBlank()) {
            throw new ConflictException(ErrorCodes.PHONEPAY_NOT_CONFIGURED);
        }

        // Callback URL 생성
        String callbackUrl = webhookBaseUrl + "/webhooks/banqpipe/" + partnerId;

        settings.setCallbackUrl(callbackUrl);
        settings.setIsEnabled(true);
        phonepaySettingsRepository.modify(settings);

        log.info("[폰페이 활성화] 완료: partnerId={}, callbackUrl={}", partnerId, callbackUrl);
        return settings;
    }

    /**
     * 폰페이 비활성화.
     */
    public PartnerPhonepaySettings deactivatePhonepay(Long partnerId) {
        PartnerPhonepaySettings settings = phonepaySettingsRepository.findByPartnerId(partnerId);
        if (settings == null) {
            throw new NotFoundException(ErrorCodes.PHONEPAY_SETTINGS_NOT_FOUND);
        }
        settings.setIsEnabled(false);
        phonepaySettingsRepository.modify(settings);
        log.info("[폰페이 비활성화] partnerId={}", partnerId);
        return settings;
    }
```

**import 추가**:
```java
import com.cryptoments.common.entity.PartnerPhonepaySettings;
import com.cryptoments.common.repository.PartnerPhonepaySettingsRepository;
```

### 3.3 DTO — `UpdatePhonepaySettingsRequest.java`

경로: `partner-api/src/main/java/com/cryptoments/partnerapi/dto/request/UpdatePhonepaySettingsRequest.java`

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

import lombok.*;

/**
 * 폰페이 설정 수정 요청.
 */
@Getter @Setter @Builder
@NoArgsConstructor @AllArgsConstructor
public class UpdatePhonepaySettingsRequest {

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

    /** 활성화 여부 (null이면 변경 안 함) */
    private Boolean isEnabled;
}
```

### 3.4 파트너 콘솔용 세션 조회 (선택)

파트너 콘솔에서 폰페이 세션 목록/상세를 조회할 수 있도록 별도 컨트롤러를 추가한다.

경로: `partner-api/src/main/java/com/cryptoments/partnerapi/controller/PartnerPhonepayController.java`

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

import com.cryptoments.common.entity.PhonepaySession;
import com.cryptoments.common.exception.ErrorCodes;
import com.cryptoments.common.exception.NotFoundException;
import com.cryptoments.common.repository.PhonepaySessionRepository;
import com.cryptoments.partnerapi.session.PartnerSessionData;
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.web.bind.annotation.*;

/**
 * 파트너 콘솔 — 폰페이 세션 조회.
 *
 * @group 폰페이
 * @auth true
 */
@RestController
@RequestMapping("/api/partner/phonepay")
public class PartnerPhonepayController extends XSessionController<PartnerSessionData> {

    private final PhonepaySessionRepository phonepaySessionRepository;

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

    /**
     * 폰페이 세션 목록 조회.
     *
     * @param pagination 페이지네이션
     * @param status     상태 필터 (선택)
     * @return 폰페이 세션 목록
     * @group 폰페이
     * @auth true
     */
    @GetMapping(name = "폰페이 세션 목록", value = "/sessions")
    public XPage<PhonepaySession> getSessions(
            @XPaginationDefault(column = "id") XPagination pagination,
            @RequestParam(required = false) String status) {
        Long partnerId = getSession().getPartnerId();
        // 전체 조회 (partnerId 필터는 Mapper 필요 — 아래 참고)
        return phonepaySessionRepository.findAll(pagination);
    }

    /**
     * 폰페이 세션 상세 조회.
     *
     * @param id 세션 ID
     * @return 폰페이 세션 상세
     * @group 폰페이
     * @auth true
     */
    @GetMapping(name = "폰페이 세션 상세", value = "/sessions/{id}")
    public PhonepaySession getSession(@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;
    }
}
```

> **TODO**: 세션 목록에서 partnerId 필터 + status 필터 + 검색이 필요하면 `PhonepaySessionMapper` (커스텀 @Mapper + @Select)를 추가해야 한다. 초기에는 findAll로 시작하고, 필요 시 Mapper를 Phase 4에서 추가.

---

## 4. 주의사항 및 보정 참고

### 4.1 Phase 1 보정 사항 적용 확인

Phase 1에서 사용자가 적용한 보정이 Phase 2에도 영향을 미치므로 확인:

| Phase 1 보정 | Phase 2 영향 |
|---|---|
| `BanqPipeApiException extends XRestException` (not RuntimeException) | PhonepayService에서 BanqPipeApiException을 throw하면 XExceptionHandler가 자동 처리. try-catch로 감쌀 필요 없음 (handleCallback의 try-catch는 콜백 안정성용이므로 유지) |
| `PartnerPhonepaySettings.apiKey`에 `@JsonIgnore` | PartnerIntegrationController가 PartnerPhonepaySettings를 직접 반환할 때 apiKey가 응답에 포함되지 않음. 설정 조회 시 apiKey가 보이지 않는 것이 정상 |
| Seed Data 컬럼명 보정 (`currency_type`, `chain_symbol`) | PhonepayService에서 `KRW_CURRENCY_ID=99`, `FIAT_NETWORK_ID=99` 상수 사용 — seed data ID와 일치하는지 운영 DB에서 확인 |

### 4.2 `@XApiIgnore` 어노테이션

BanqPipeWebhookController에 반드시 `@XApiIgnore`를 붙인다. Axim REST Doc Generator가 Webhook 엔드포인트를 API 문서에 포함하지 않도록 한다.

### 4.3 Callback URL 안내

`activatePhonepay`는 Callback URL을 **DB에 기록만** 한다. BanqPipe는 Axim과 달리 Webhook 자동 등록 API가 없으므로, 파트너가 BanqPipe 콘솔에서 이 URL을 직접 등록해야 한다. 파트너 콘솔 UI에서 Callback URL을 표시하여 복사할 수 있게 해야 한다.

### 4.4 BigDecimal 변환

PhonepaySession.amount는 `Long` (원 단위 정수), Deposit.amount는 `BigDecimal`. 변환: `BigDecimal.valueOf(session.getAmount())`.

### 4.5 open-api 세션 타입 규칙

BanqPipeWebhookController는 세션 인증이 **없다** (외부 콜백이므로). `XSessionController`를 상속하지 않고, 일반 `@RestController`로 구현한다. `@XApiIgnore` 필수.

---

## 5. 체크리스트

| # | 항목 | 파일 | 확인 |
|---|------|------|------|
| 1 | `PhonepayService.java` 생성 | `core/src/main/java/com/cryptoments/core/phonepay/PhonepayService.java` | ☐ |
| 2 | `BanqPipeWebhookController.java` 생성 | `open-api/src/main/java/com/cryptoments/openapi/webhook/controller/BanqPipeWebhookController.java` | ☐ |
| 3 | `PartnerIntegrationController.java`에 폰페이 4개 엔드포인트 추가 | partner-api 컨트롤러 | ☐ |
| 4 | `PartnerIntegrationService.java`에 폰페이 4개 메서드 추가 | partner-api 서비스 | ☐ |
| 5 | `UpdatePhonepaySettingsRequest.java` 생성 | partner-api DTO | ☐ |
| 6 | `PartnerPhonepayController.java` 생성 (세션 조회) | partner-api 컨트롤러 | ☐ |
| 7 | `./gradlew :core:compileJava` 성공 확인 | 빌드 | ☐ |
| 8 | `./gradlew :open-api:compileJava` 성공 확인 | 빌드 | ☐ |
| 9 | `./gradlew :partner-api:compileJava` 성공 확인 | 빌드 | ☐ |
| 10 | BanqPipeWebhookController 패키지 경로가 AximWebhookController와 동일한지 확인 | 패키지 | ☐ |
| 11 | PartnerPhonepaySettings.apiKey에 @JsonIgnore가 Phase 1에서 적용되었는지 확인 | 응답 검증 | ☐ |

---

## 6. Phase 3 예고

Phase 2 완료 후 Phase 3에서 구현할 내용:

| 모듈 | 내용 |
|------|------|
| **open-api** (Widget) | `PhonepayWidgetController.java` — W4(세션 생성), W5(상태 조회), W6(취소) |
| **open-api** (Widget) | `AximController.java` 확장 — W2(eKYC 인증 여부), W3(eKYC 인증 정보) |
| **common** | `AximEkycStatusResponse.java`, `AximEkycInfoResponse.java` DTO |
| **common** | `AximPayClient.java`에 `getEkycStatus()`, `getEkycInfo()` 메서드 추가 |
| **open-api** | Widget 응답 DTO — `EkycStatusResponse.java`, `EkycInfoResponse.java`, `PhonepaySessionResponse.java` |
