# P2P Phase 1~3 구현 Handoff

> **작성일**: 2026-06-05
> **범위**: Phase 1 잔여 (DDL 정합성) + Phase 2 (출금 P2P 전환) + Phase 3 (회원 + 스크래핑 + 출금자 페이지)
> **운영 DB**: 적용 완료 (13개 P2P 테이블 + 은행 시드 17개)

---

## 사전 상태

- 운영 DB에 전체 DDL 적용 완료 (기존 6개 DROP+재생성 + 신규 7개 + withdrawals.to_address nullable)
- 기존 Phase 1 코드 커밋 완료 (Core Services 5개 + Partner API + Mapper)
- push 대기 중 (배포 안 됨)

## 반드시 읽을 문서

- `v2-docs/P2P_MATCHING_ARCHITECTURE.md` (v0.6) — 전체 설계
- `v2-docs/P2P_IMPLEMENTATION_GUIDE.md` — 기존 구현 가이드
- `v2-docs/SCRAPING_DATA_MODEL.md` — 스크래핑 데이터 모델

---

## Phase 1 잔여: DDL 정합성 수정

기존 P2P 테이블이 재생성되어 Entity/Repository 수정 필요.

### 1-1. P2pWithdrawOrder Entity 수정

제거:
- `bankCode`, `bankName`, `accountNumber`, `accountHolder` (인라인 계좌 → bank_accounts 참조)
- `feeRate`, `feeAmount` (출금 수수료 없음)
- `expiresAt` (만료 없음, 파트너/사용자 직접 취소만)

추가:
```java
@XColumn("withdrawal_id")
private Long withdrawalId;           // withdrawals.id 참조

@XColumn("bank_account_id")
private Long bankAccountId;          // bank_accounts.id 참조

@XColumn("refunded_amount")
private Long refundedAmount;         // 잔여분 처리 금액

@XColumn("refund_type")
private String refundType;           // CANCEL / FORCE_SETTLE / USDT_WITHDRAW

@XColumn("refund_memo")
private String refundMemo;

@XColumn("refunded_at")
private LocalDateTime refundedAt;

@XColumn("usdt_convert_address")
private String usdtConvertAddress;   // 사용자 입력 USDT 주소

@XColumn("usdt_convert_requested_at")
private LocalDateTime usdtConvertRequestedAt;

@XColumn("usdt_convert_status")
private String usdtConvertStatus;    // REQUESTED / APPROVED / REJECTED
```

### 1-2. P2pMatch Entity 수정

추가:
```java
@XColumn("dispute_evidence_url")
private String disputeEvidenceUrl;

@XColumn("dispute_submitted_by")
private String disputeSubmittedBy;   // DEPOSITOR / WITHDRAWER

@XColumn("resolved_by")
private Long resolvedBy;             // Admin ID

@XColumn("resolve_memo")
private String resolveMemo;
```

### 1-3. P2pSettlement Entity 수정

추가:
```java
@XColumn("settlement_type")
private String settlementType;       // ONCHAIN / INNER (default ONCHAIN)
```

### 1-4. WithdrawalStatus Enum 추가

```java
P2P_PENDING  // 출금 → P2P 전환됨, 매칭 대기
```

### 1-5. 신규 Entity 7개

| Entity | 테이블 | 비고 |
|--------|--------|------|
| Bank | banks | PK: code(VARCHAR), 시드 17개 |
| BankMaintenanceWindow | bank_maintenance_windows | |
| ScrapingCredential | credentials | PK: id(CHAR 36, UUID) |
| ScrapingCallLog | call_logs | |
| BankAccount | bank_accounts | owner_type(MEMBER/PARTNER) |
| P2pMember | p2p_members | member_token UNIQUE |
| P2pDepositLink | p2p_deposit_links | link_code UNIQUE |

### 1-6. 신규 Enum

```java
BankAccountOwnerType        // MEMBER, PARTNER
P2pMemberStatus             // ACTIVE, SUSPENDED
P2pDepositLinkStatus        // ACTIVE, USED, EXPIRED, CANCELLED
P2pDepositLinkMatchMode     // AUTO, MANUAL
ScrapingCredentialStatus    // CREATED, ACTIVE, EXPIRED, DEACTIVATED, REVOKED
P2pRefundType               // CANCEL, FORCE_SETTLE, USDT_WITHDRAW
P2pSettlementType           // ONCHAIN, INNER
P2pUsdtConvertStatus        // REQUESTED, APPROVED, REJECTED
```

### 1-7. 신규 Repository 7개

각 Entity에 대응하는 `IXRepository` + 비즈니스 findBy 메서드.

```java
BankRepository              // findByCode(String code)
BankMaintenanceWindowRepository
ScrapingCredentialRepository // findByBankCodeAndAccountNumber(String, String)
ScrapingCallLogRepository
BankAccountRepository       // findByOwnerTypeAndOwnerId(String, Long), findByBankCodeAndAccountNumber(String, String)
P2pMemberRepository         // findByMemberToken(String), findByPartnerIdAndPartnerUserId(Long, String)
P2pDepositLinkRepository    // findByLinkCode(String)
```

### 1-8. P2pMatchingService 수정

Mapper 호출에 `networkId` 전달 (이미 Mapper는 수정됨, 서비스 호출부만):

```java
// 103행 수정
matchingMapper.findExactMatchForUpdate(remaining, depositOrder.getNetworkId());
// 119행 수정
matchingMapper.findMatchableWithdrawOrdersForUpdate(depositOrder.getNetworkId());
```

---

## Phase 2: 출금 P2P 전환

### 2-1. WithdrawalService에 P2P 전환 메서드 추가

```java
/**
 * 출금 승인 시 P2P 전환.
 * PENDING_APPROVAL → P2P_PENDING + p2p_withdraw_order 생성.
 * p2p_member 없으면 자동 생성 + 링크 발급.
 */
@Transactional
public Withdrawal approveAsP2p(Long withdrawalId, Long bankAccountId, String changedBy) {
    Withdrawal withdrawal = findById(withdrawalId);
    // 상태 검증: PENDING_APPROVAL 또는 REQUESTED만 가능
    
    withdrawal.setStatus(WithdrawalStatus.P2P_PENDING);
    withdrawalRepository.modify(withdrawal);
    
    // p2p_member 자동 생성 (없으면)
    P2pMember member = p2pMemberService.getOrCreate(
        withdrawal.getPartnerId(), withdrawal.getPartnerUserId());
    
    // p2p_withdraw_order 생성
    P2pWithdrawOrder p2pOrder = P2pWithdrawOrder.builder()
        .orderCode("pwo_" + generateId())
        .partnerId(withdrawal.getPartnerId())
        .partnerUserId(withdrawal.getPartnerUserId())
        .networkId(resolveNetworkId(withdrawal))
        .requestCurrency(P2pRequestCurrency.KRW) // or USDT
        .krwAmount(convertToKrw(withdrawal.getAmount()))
        .usdtAmount(withdrawal.getAmount())
        .exchangeRate(getExchangeRate())
        .withdrawalId(withdrawalId)
        .bankAccountId(bankAccountId)
        .status(P2pWithdrawStatus.PENDING)
        .build();
    p2pWithdrawOrderRepository.save(p2pOrder);
    
    // freeze는 이미 withdrawal 생성 시 완료됨 — 추가 freeze 불필요
    
    return withdrawal;
}
```

### 2-2. 파트너 콘솔에서 직접 생성 + P2P 동시 전환 (루트 B)

```java
/**
 * 출금 요청 생성 + 즉시 P2P 전환 (파트너 콘솔).
 * to_address 없이 생성 가능.
 */
public Withdrawal createAndConvertToP2p(Long partnerId, String partnerUserId,
                                         BigDecimal amount, Long bankAccountId, ...) {
    Withdrawal withdrawal = createWithdrawal(...); // to_address = null
    return approveAsP2p(withdrawal.getId(), bankAccountId, "CONSOLE");
}
```

### 2-3. P2pSettlementService.completeSettlement() 수정

기존: 직접 debit + credit
변경: **원본 withdrawal 참조하여 부분 debit**

```java
// 정산 완료 시:
// 1. unfreeze(매칭 USDT) — withdrawal#1의 freeze에서 해제
// 2. debit(매칭 USDT) — withdrawal#1 참조로 원장 DEBIT
//    ledger: reference_type=WITHDRAWAL, reference_id=withdrawal#1.id
// 3. credit(매칭 USDT) — B파트너 원장 CREDIT
// 4. FEE 2건 (p2p_fee + deposit_fee)
```

### 2-4. 잔여분 처리 3옵션

```java
// A) 출금 전환: 잔여분을 새 withdrawal로
public Withdrawal convertToDirectWithdrawal(Long p2pOrderId, String toAddress) {
    // unfreeze(잔여) from 원본 withdrawal
    // 원본 withdrawal → COMPLETED
    // 새 withdrawal 생성 (잔여 금액, to_address, REQUESTED)
    // freeze(잔여) on 새 withdrawal
}

// B) 강제 정산: 파트너가 이미 지급, 기록만
public void forceSettle(Long p2pOrderId, String memo) {
    // unfreeze(잔여) from 원본 withdrawal
    // 원본 withdrawal → COMPLETED
    // p2p_withdraw_order: refund_type=FORCE_SETTLE, refund_memo 기록
    // ledger DEBIT 없음 (USDT 복원)
}

// C) 취소: 환불
public void cancelRemaining(Long p2pOrderId, String reason) {
    // unfreeze(잔여) from 원본 withdrawal
    // 원본 withdrawal → COMPLETED
    // p2p_withdraw_order: refund_type=CANCEL, 환불 기록
    // 파트너가 사용자 잔액 복원 필요
}
```

### 2-5. USDT 전환 요청

```java
// 사용자가 P2P 페이지에서 요청
public void requestUsdtConvert(Long p2pOrderId, String address) {
    order.setUsdtConvertAddress(address);
    order.setUsdtConvertRequestedAt(LocalDateTime.now());
    order.setUsdtConvertStatus("REQUESTED");
}

// 파트너가 승인
public Withdrawal approveUsdtConvert(Long p2pOrderId) {
    // convertToDirectWithdrawal(p2pOrderId, order.getUsdtConvertAddress())
    order.setUsdtConvertStatus("APPROVED");
}
```

### 2-6. Partner API 추가

```
POST /api/partner/withdrawals/{id}/p2p-convert         — P2P 전환 승인
POST /api/partner/p2p/withdraw-orders/{code}/force-settle    — 강제 정산
POST /api/partner/p2p/withdraw-orders/{code}/cancel-remaining — 잔여 취소
POST /api/partner/p2p/withdraw-orders/{code}/convert-direct   — 출금 전환
POST /api/partner/p2p/withdraw-orders/{code}/approve-usdt     — USDT 전환 승인
```

### 2-7. 파트너 콘솔 UI 수정

- 출금 상세: [P2P 전환] 버튼 (PENDING_APPROVAL 상태)
- 출금 생성 폼: "출금 방식" 선택 (직접 출금 / P2P 전환)
- P2P 출금 주문 상세: 잔여분 처리 [출금 전환] [강제 정산] [취소] 버튼
- USDT 전환 요청 알림 + [승인] [거부] 버튼

---

## Phase 3: 회원 + 스크래핑 + 출금자 페이지

### 3-1. Core Services

```java
// P2pMemberService
- getOrCreate(partnerId, partnerUserId) → member_token 자동 생성
- findByToken(memberToken)
- setPin(memberToken, pinCode) → BCrypt 해시
- verifyPin(memberToken, pinCode) → boolean
- resetPin(memberToken) → pin_hash = null
- suspend(memberToken) / activate(memberToken)

// BankAccountService
- create(ownerType, ownerId, bankCode, accountNumber, accountHolder) → UNIQUE 검증
- findByOwner(ownerType, ownerId)
- delete(id) → 진행 중 출금 없을 때만

// P2pScrapingService
- storeCredential(bankCode, accountNumber, accountHolder, credentialJson)
    → 패턴 검증 → AES-256-GCM 암호화 → credentials INSERT
- activateConsent(credentialId, days)
    → consent_activated_at/expires_at 설정 → ACTIVE
- verify(credentialId, fromDate, toDate)
    → 벤더 호출 → 거래 내역 반환 → call_logs 기록
- linkToAccount(credentialId, bankAccountId)
    → bank_accounts.scraping_credential_id 연결
```

### 3-2. Partner API

```
POST   /api/partner/p2p/members              — 회원 등록 (→ 링크)
GET    /api/partner/p2p/members              — 목록
GET    /api/partner/p2p/members/{token}      — 상세
POST   /api/partner/p2p/members/{token}/reset-pin
POST   /api/partner/p2p/members/{token}/suspend
POST   /api/partner/p2p/members/{token}/activate

POST   /api/partner/p2p/service-account      — 파트너 서비스 계좌 등록
PUT    /api/partner/p2p/service-account      — 수정
DELETE /api/partner/p2p/service-account      — 삭제
```

### 3-3. 출금자 P2P 페이지 API (open-api)

별도 Controller: `P2pWithdrawPageController`
세션: `P2pPageSessionData` (memberToken 기반, PIN 인증 후 발급)
경로: `/p2p/page/*`

```
POST /p2p/page/auth                — 핀코드 인증 → 세션 토큰 발급
POST /p2p/page/pin/set             — 핀코드 설정 (최초)

GET  /p2p/page/dashboard           — 메인 (계좌 상태, 대기 잔액, 최근 거래)
GET  /p2p/page/banks               — 은행 목록 + 패턴 조회
POST /p2p/page/account             — 계좌 등록 + 스크래핑 인증
DELETE /p2p/page/account           — 계좌 삭제
POST /p2p/page/account/re-auth    — 스크래핑 재인증 (만료 시)

GET  /p2p/page/orders              — 출금 거래 내역
GET  /p2p/page/orders/{code}       — 거래 상세 (매칭 포함)
GET  /p2p/page/orders/{code}/receipt — 거래 확인증

POST /p2p/page/orders/{code}/confirm-deposit/{matchId} — 출금자 입금 확인 (분쟁 해소)
GET  /p2p/page/orders/{code}/disputes                  — 분쟁 목록

POST /p2p/page/convert-request     — USDT 전환 요청 (주소 입력)
```

### 3-4. 파트너 콘솔 UI

P2P 관리 → 회원 관리:
- 목록 (token, 계좌 상태, 스크래핑 상태, 활성 출금 건수)
- 등록 폼 (partner_user_id 입력 → 링크 자동 생성)
- 상세 (링크 복사, 핀코드 리셋, 접근 차단/해제)

비즈니스 설정 → P2P:
- P2P 서비스 ON/OFF
- P2P 수수료율 (p2p_fee_rate)
- 파트너 서비스 계좌 등록/수정/삭제

---

## 핵심 규칙 (코드 작성 시 주의)

1. **모든 금액에 KRW + USDT 병기** — 입력 기준 통화가 정확값, 나머지 환산
2. **출금 주문 만료 없음** — expires_at 필드 없음, 파트너/사용자 직접 취소만
3. **매칭 이체 기한 20분** — p2p_matches.expires_at
4. **분쟁 시 전체 펜딩** — 1건 분쟁 → 모든 매칭 정산 보류
5. **P2P 전환 즉시** — 계좌/스크래핑 미등록이어도 전환 OK
6. **freeze는 withdrawal에서** — p2p_withdraw_order가 아닌 원본 withdrawal의 freeze 사용
7. **부분 debit** — 정산 건별로 원본 withdrawal 참조하여 debit
8. **bank_accounts UNIQUE** — 동일 계좌 중복 등록 불가
9. **save() vs modify()** — Axim에서 save()=INSERT(PK 반환), 업데이트는 modify()

## 빌드 확인

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