# P2P PARTNER 레그 리팩토링 — 가짜 standing 주문 제거

> 작성 2026-06-30. 목표: PARTNER 레그(파트너 잔여 유동성 공급)가 **가짜 출금 주문(PARTNER_STANDING)을
> 만들지 않도록** 리팩토링한다. TORQ 레그처럼 `withdraw_order_id=NULL`로 두고, 필요한 정보(파트너·서비스계좌·
> 환율·금액)는 매칭/파트너에서 직접 해석한다.

## 0. 왜 (문제)

현재 `fillRemainderWithPartner`는 입금 잔여를 파트너 MASTER 유동성으로 메울 때, 기존 매칭/정산 레일을
재사용하려고 **파트너 본인을 출금자로 한 출금 주문(p2p_withdraw_orders, partner_user_id="PARTNER_STANDING")**을
즉석 생성한다. 이 가짜 주문이:

1. **데이터 오염** — 회원 출금주문과 같은 테이블에 비회원 행이 섞이고, NOT NULL인 `partner_user_id`에 센티넬을 욱여넣음.
2. **매칭 풀 누수** — 가짜 주문이 `findMatchableWithdrawOrders`(status PENDING/PARTIALLY) 풀에 들어갈 수 있어,
   레그 실패→`failMatch` 복원 시 다음 입금에 재매칭됨 (**2026-06-29 #26 1원 누수 사고**의 직접 원인).
3. **라이프사이클 꼬임** — 회원 주문용 상태머신을 1회용 내부 주문에도 적용해 종결/복원 규칙이 어긋남.

대조: **TORQ 레그는 가짜 주문을 안 만든다** (`withdraw_order_id=NULL` + escrow로 별도 추적). PARTNER도 동일하게 간다.

## 1. 핵심 사실 (PARTNER 레그 본질)

- PARTNER 레그의 유동성 공급자 = **입금 주문의 파트너 자신**(`fillRemainderWithPartner`가 `order.getPartnerId()`로 생성). 즉 출금측 파트너 = 입금측 파트너 = **동일** → 정산은 항상 **INNER**(`isInnerSettlement` true).
- 구매자는 **파트너 서비스 계좌**(bank_accounts owner_type=PARTNER, owner_id=partnerId)로 KRW를 입금. (현재는 standing 주문의 bank_account_id에 이 계좌가 박힘.)
- 은행 확인 = 스크래핑 잡이 그 서비스 계좌를 CODEF 조회해 입금 매칭.

→ 따라서 PARTNER 레그에 **별도 출금 주문이 필요 없다.** 파트너ID·서비스계좌·환율·금액은 (입금 파트너, 매칭)에서 직접 얻는다.

## 2. Touch Point (전수) + 변경

### 2-1. `core/p2p/P2pMatchingService.fillRemainderWithPartner` (생성)
- **현재**: 파트너 MASTER 유동성 lock → standing `P2pWithdrawOrder` 생성(save) → `createMatch(wo, ...)`.
- **변경**: standing 주문 생성 제거. 매칭을 **직접 빌드**(withdraw_order_id=NULL, leg_type=PARTNER, exchangeRate=liveRate, krw/usdt, status=CREATED, expiresAt). lock은 그대로(입금 파트너 기준). `createMatch` 우회(별도 `createPartnerLeg(order, need, networkId, rate)` 헬퍼 신설).
- partner 서비스계좌 검증은 유지(없으면 false)하되, bank_account_id를 매칭에 들고 다닐 필요는 없음(스크래핑이 파트너ID로 해석 — 2-6).

### 2-2. `P2pMatchingService.createMatch` (공용 생성)
- 현재 `wo`(출금주문) 의존: exchangeRate·networkId·partnerId·bonusRate·matchedAmount·status 업데이트. **PARTNER 경로는 여기를 타지 않게** 분리(2-1의 `createPartnerLeg`). P2P/(레거시)PARTNER 분기 제거.
- ⚠️ withdrawBonusRate: PARTNER 레그는 현재도 0 (FEE는 P2P 레그만) — 유지.

### 2-3. `P2pMatchingService.confirmBankTransfer` (은행확인 정산)
- 현재 `withdrawRepo.findOne(match.getWithdrawOrderId())`로 wo confirmed_amount 누적. **PARTNER(withdraw_order_id=NULL)는 wo 없음** → TORQ와 동일하게 wo 업데이트 skip(이미 `if (wo != null)` 가드 존재 → 자연 통과). 그 후 `startSettlementForMatch(match)` 호출은 동일.
- 가드 상태(BANK_PENDING/CREATED/DISPUTED)는 그대로 적용.

### 2-4. `P2pSettlementService.startSettlementForMatch` (정산 실행)
- 현재: TORQ → `creditTorqLeg`; 그 외(P2P/PARTNER) → `wo = findOne(withdrawOrderId)`로 from_partner/from_wallet 구성.
- **PARTNER(withdraw_order_id=NULL)** 는 wo가 NULL이라 현재 분기에서 `wo==null → return`(정산 누락!). → **PARTNER 전용 분기 추가**:
  - from_partner = **입금 주문의 partnerId**(= 동일 파트너), from_wallet = 그 파트너 MASTER, to_partner = 동일, to_wallet = 동일 → INNER 정산.
  - 사실상 `creditTorqLeg`와 유사한 "INNER 1단계 정산"(구매자=파트너 USDT 크레딧, FEE는 P2P 규칙대로). 또는 기존 P2pSettlement 빌드 로직을 partner 기준으로 재사용(from=to=partnerId → INNER 자동).
  - 권장: 기존 settlement 빌드(164~181)를 재사용하되 `wo` 대신 **partner 직접 해석**(networkId=match 또는 deposit, fromWalletId=toWalletId=partner MASTER). INNER라 온체인 TX 없음.

### 2-5. `P2pMatchingService.failMatch` / `forceCancelDisputedLeg` (실패/취소)
- 현재 `match.getLegType() != TORQ && withdrawOrderId != null` 일 때만 wo 복원. **PARTNER가 withdraw_order_id=NULL이 되면 이 블록을 자연히 안 탐**(TORQ처럼). → standing 주문 CANCELLED 처리 코드(2026-06-29 더스트 수정분) **불필요해짐 → 제거**.
- lock 해제는? 현재 PARTNER 레그도 lock 보유(2-1). → **PARTNER 레그 실패 시 lock 해제 필요** → TORQ는 lock 없지만 PARTNER는 lock 있음. 따라서 "withdraw_order_id != null" 조건 대신 **"leg_type != TORQ"** 기준으로 unlock하도록 조정(PARTNER도 unlock 타게). wo 복원만 NULL 가드.

### 2-6. `scheduler/job/P2pScrapingVerifyJob` (은행확인 스크래핑)
- 현재 `buildSameAccountGroup`/`verifyGroup`이 `wo.getBankAccountId()`로 계좌 해석. PARTNER(withdraw_order_id=NULL)는 TORQ처럼 단독 취급되고 verifyGroup이 wo NULL→SKIPPED → **PARTNER 레그 은행확인 불가(치명)**.
- **변경**: 레그의 수취계좌 해석을 일반화 — `resolveBankAccountId(match)`:
  - withdraw_order_id 있으면 → wo.bankAccountId (P2P 회원 레그)
  - PARTNER 레그(withdraw_order_id=NULL, leg_type=PARTNER) → **파트너 서비스계좌**(bankAccountRepo.findByOwnerTypeAndOwnerId(PARTNER, depositOrder.partnerId) 첫 계좌)
  - TORQ → 해당 없음(스크래핑 대상 아님)
- `buildSameAccountGroup`/`verifyGroup`이 이 헬퍼를 쓰도록 수정. PARTNER 레그도 같은 서비스계좌로 묶어 합산 확인.

### 2-7. 어드민/DTO/조회
- PARTNER 레그 매칭은 withdraw_order_id=NULL → 어드민 매칭/거래 상세에서 "출금측 = 파트너 자신(서비스계좌)"로 표기. (P2pTransactionLegResponse / P2pWithdrawOrderLeg 등 — withdraw 정보 NULL 허용 + leg_type=PARTNER 라벨 "파트너 유동성".)
- 출금 주문 목록/풀에서 PARTNER_STANDING 행이 더는 안 생기므로 자연 정리. (PARTNER_STANDING 센티넬 상수/표기 제거 가능.)

### 2-8. 기존 데이터 마이그레이션
- 기존 PARTNER_STANDING 출금주문(현재 6건, partner 36: #18·#19·#20 COMPLETED, #26 CANCELLED, #31·#32 COMPLETED)은 **이미 종결**되어 풀에 없음 → 즉시 위험 없음.
- 단, 혹시 PENDING/PARTIALLY로 남은 PARTNER_STANDING이 있으면 CANCELLED 종결(풀 제거). (일회성 SQL.)
- 과거 PARTNER 레그 매칭의 withdraw_order_id는 그대로 둔다(이력 보존). 신규부터 NULL.

## 3. 리스크 / 테스트

- **정산 경로(2-4)가 가장 위험** — PARTNER 레그가 정산 누락되거나 이중 크레딧되지 않도록. INNER 단일 크레딧 보장. `creditTorqLeg`의 멱등·이중방지 패턴 참고.
- **은행확인(2-6)** — 파트너 서비스계좌 해석이 정확해야 PARTNER 레그가 확인·정산됨.
- 테스트: (a) 입금 > P2P 유동성 → 잔여가 PARTNER로, standing 주문 **생성 안 됨** 확인, (b) 그 PARTNER 레그가 서비스계좌 입금으로 은행확인→INNER 정산 COMPLETED, (c) PARTNER 레그 실패 시 lock 해제 + 주문 풀 미복원, (d) 회귀: P2P/TORQ 레그 영향 없음.
- 컴파일: `:core :scheduler :admin-api :open-api :partner-api`.

## 4. 단계 (권장)

| Phase | 범위 |
|-------|------|
| R1 | 2-1·2-2 (생성: standing 미생성, createPartnerLeg) + 2-5 (실패/취소 NULL 가드·unlock) |
| R2 | 2-4 (정산 PARTNER 분기) + 2-3 (confirm 통과) |
| R3 | 2-6 (스크래핑 계좌 해석 일반화) |
| R4 | 2-7 어드민 표기 + 2-8 마이그레이션 + PARTNER_STANDING 상수 제거 |

각 Phase 후 컴파일 + 핵심 시나리오 검증. R1~R3까지 한 묶음으로 가야 PARTNER 레그가 end-to-end 동작(생성→확인→정산→실패).

## 5. 대안 (최소안, 비권장)

가짜 주문을 계속 만들되 (a) 매칭 풀 쿼리에서 partner_user_id='PARTNER_STANDING' 제외, (b) 1회용이라 실패 시 즉시 CANCELLED. → 누수(#26)는 막지만 "가짜 주문 자체를 없앤다"는 목표 미달 + 데이터 오염 잔존. 사용자 요구(가짜 주문 미생성)와 불일치하므로 **본 문서는 정공법(2장) 채택.**
