# P2P 출금 원금도 요청 시점에 — 출금 완료 모델 완성 (2026-08-21)

repo `cryptoments` · **DDL 없음**
`WITHDRAW_FEE_UPFRONT_GUIDE.md` 의 **미완성분**을 마저 한다.

---

## 무엇이 빠져 있었나

오너는 2026-08-21 에 두 가지를 함께 말했다.

> "p2p 출금 행위는 **출금 완료**인 거고(대기가 아닌), 이 행위로 **수수료가 이미 발생**한다."

**수수료만 구현됐다.** 원금은 그대로 옛 모델이라 지금 이렇게 갈려 있다:

```
수수료   요청 시점 · 전액 · 파트너 원장 FEE          ✅ 배포됨
원금     정산 완료 시점 · 레그별 DEBIT               ❌ 옛 모델
상태     P2P_PENDING (대기)                          ❌ "완료"가 아니다
```

그 결과 **미매칭 원금이 어디에도 안 잡힌다** — `P2P_PENDING` 은 hold 에서 제외되고
(`WithdrawalMapper:53`) 잠금은 매칭 시점에야 걸린다(`P2pLockService:74`).
운영 실측 **130 USDT 가 파트너 가용잔액에 그대로 남아 재사용 가능**하다.

## ☠️ 이 작업 최대의 함정 — 원금만 빼면 매칭이 조용히 멈춘다

`lockForMatch` 의 가용성 게이트는 **같은 원장 파생 산식**을 쓴다
(`P2pLockService:65-67` → `SettlementService.getAvailableForWithdrawal:311-330`).

```
available = 원장잔액 − 일반출금hold − p2p_partner_locks.locked_balance
```

원금을 요청 시점에 빼면 **그 주문 자신을 매칭할 예산이 사라진다.**
실패는 예외가 아니라 `false` → 후보 skip 이고(`P2pMatchingService:530, 718-720`)
로그는 `debug` 한 줄뿐이다(`P2pLockService:88`).

동시에 매칭 후보 SQL 과 파트너 풀 KRW 표시는 `p2p_withdraw_entries` 기반이라 멀쩡하다 →
**"풀에는 있는데 매칭이 안 된다"** 가 된다. 과거 "거래 안돼요" 문의의 정확한 형태다.

> **그래서 원금 선차감은 잠금 폐기와 한 세트다.** 셋 중 하나만 하면 안 된다.

## 지금이 공짜인 이유 (실측 2026-08-21)

```
미종결 P2P 출금        3건 (130 USDT) — 전부 PENDING, matched_amount = 0
진행 중 매칭           0건
p2p_partner_locks      전부 더스트(1e-18) — 실질 0
```

**매칭이 배포를 가로지르는 건이 하나도 없다.** 잠금을 걷어도 깨질 예약이 없고,
정산 시점 DEBIT 을 제거해도 반쯤 정산된 건이 없다. 미루면 비싸진다.

---

## 오너 확정 (2026-08-21)

```
웹훅       요청 즉시 WITHDRAWAL_COMPLETED 발송
기존 3건   그대로 둔다 — 구 모델로 끝까지 흘려보낸다 (→ §5 과도기 분기)
```

---

## §1. 요청 시점 원금 차감 — `WithdrawalService.approveAsP2p`

수수료 계상(`P2pWithdrawFeeService.chargeUpfrontFee`, `:1219`) **바로 옆**에서,
**같은 트랜잭션**으로 원금을 뺀다.

```
settlementService.debit(partnerId, currencyId, networkId, withdrawal.getAmount(),
                        LedgerReferenceType.WITHDRAWAL, withdrawal.getId(), 설명)
```

- 순서는 **원금 → 수수료**. 잔액이 모자라면 원금에서 먼저 걸려야 한다
- `reference_id` = **출금 ID** — 수수료와 같다. `entry_type` 이 `DEBIT`/`FEE` 로 갈리므로 구분된다
- ⚠️ **`debit` 은 잔액 부족 시 `ConflictException(INSUFFICIENT_BALANCE)` 를 던진다**
  (`SettlementService:200-203`). 수수료(`recordFee`, 예외 없음)와 **실패 모드가 정반대**다.
  → **P2P 전환 요청 자체가 409 로 실패할 수 있다.** 이건 의도된 것이다 —
  없는 돈으로 P2P 출금을 접수하면 안 된다. 다만 에러 메시지에 "가용잔액 부족"이 드러나야 한다
- `WithdrawalType.SETTLEMENT_WITHDRAW` 는 **제외하라.** 정산 쉐어 출금은 `realized_balance`
  도메인에서 이미 차감된다 — 운영 원장에 또 DEBIT 하면 이중이다 (2026-07-08 실사고,
  `WithdrawalService:956-968` 주석). 기존 정산 시점 DEBIT 의 같은 분기를 그대로 옮겨라
- `P2pWithdrawService.createOrder` 경로는 `withdrawalId == null` 이라 대상이 없다.
  이미 요율>0 이면 409 로 막고 있다(`:134-139`) — **그대로 두라**

## §2. 상태 — 요청 시점에 `COMPLETED`

`:1162` 의 `P2P_PENDING` 전이를 **`COMPLETED`** 로 바꾼다. 상태 이력도 함께.

☠️ **`P2P_PENDING` enum 을 지우지 마라.** 기존 3건의 과도기 판별자다(§5).
신규 생성만 없어지는 것이다.

- hold 동작은 그대로다 — `COMPLETED` 도 `pendingWithdrawalHold` 제외 목록에 이미 있다
  (`WithdrawalMapper:53`). 원장에서 실제로 빠졌으니 hold 도 없는 게 맞다
- `cancel`/`reject`/`retry` 의 허용 상태 목록에 `COMPLETED` 는 없다 —
  관리자가 손댈 수 없는 것은 **기존과 동일**하다. 목록을 건드리지 마라

## §3. 잠금 폐기 — P2P 경로에서 `p2p_partner_locks` 쓰기 중단

원금이 이미 원장에서 나갔으므로 **예약할 대상이 없다.** 매칭 재고는
`p2p_withdraw_entries`(KRW 원장)가 이미 LOCK/UNLOCK/SETTLE 로 정확히 표현한다.

```
제거   lockForMatch      P2pMatchingService:490, 541, 718
제거   unlockForMatch    P2pMatchingService:1229, 1371, 2223, 2658, 2782, 2875
제거   settleFromLocked  P2pSettlementService:550
```

- ☠️ **`getAvailableForWithdrawal` 의 `locked` 항은 남겨 둬라.** 이 산식은 일반 출금·위젯·
  파트너 대시보드가 전부 쓴다. 쓰기를 중단하면 값이 더스트로 고정되어 사실상 0 이 된다 —
  **항을 지우는 것은 blast radius 가 크고 이득이 없다.** 테이블 정리는 별건이다
- 잠금이 하던 **가용성 게이트**는 §1 의 `debit` 예외가 대신한다. 요청 시점에 막히므로
  매칭 시점 재검사는 불필요하다
- 매칭 후보 선정 SQL 은 잠금을 **읽지 않는다**(`P2pMatchingMapper` 는 `p2p_withdraw_entries`
  합계만 본다) — 후보 로직은 무변경이다. 확인했다
- `p2p_lock_history` 도 함께 쓰지 않게 된다. 테이블·엔티티는 남겨라(이력)
- ⚠️ 잠금을 읽는 **표시** 지점이 여럿이다(어드민 P2P 대시보드 `P2pDashboardMapper:70`,
  어드민 출금 풀, 파트너 콘솔 P2P 풀). 값이 0 이 되어 화면에서 사라지는 것은 정상이다 —
  **화면을 고치지는 마라. 다만 어디가 0 이 되는지 보고하라**

## §4. 정산 시점 — 원금 DEBIT 제거

`P2pSettlementService:557-564` 의 `debit` 을 제거한다 — **단 §5 과도기 분기는 남긴다.**

☠️ **이 DEBIT 에는 멱등 키가 없다**(`SettlementService.debit:195-222`, `credit` 만 txHash
멱등). §1 을 넣고 여기를 안 지우면 **레그마다 이중 차감**이고, 자동으로 발견될 리컨실 잡도
없다(2026-07-08 · 2026-07-22 에 같은 유형으로 두 번 사고, 둘 다 사후 수동 보정).

- 입금측 CREDIT(상대 파트너)·온체인 전송은 **건드리지 마라**
- `p2p_withdraw_entries` 의 LOCK/UNLOCK/SETTLE 도 **건드리지 마라** — KRW 재고 장부다

## §5. 과도기 분기 — 기존 3건은 구 모델로

**판별자는 `withdrawals.status == P2P_PENDING`** 이다. 신규는 §2 로 `COMPLETED` 가 되므로
이 값을 가진 건은 배포 전 생성분뿐이다.

```java
// 과도기 (2026-08-21 배포 전 생성분) — 원금이 요청 시점에 차감되지 않은 건.
//   판별: withdrawals.status == P2P_PENDING (신규는 요청 시 COMPLETED)
//   해당 3건(출금 869·872·873)이 종결되면 이 분기는 죽는다. 그때 제거하라.
```

| 지점 | 구 건 (`P2P_PENDING`) | 신규 건 (`COMPLETED`) |
|---|---|---|
| 정산 시 원금 DEBIT | **한다** (기존 코드 그대로) | 하지 않는다 |
| `checkAndCompleteOrders` 의 withdrawals 전이 + 웹훅 | **한다** (`:909-922` 그대로) | 하지 않는다 (이미 요청 시 발송) |
| 종결 시 잔여 환급 CREDIT (§6) | **하지 않는다** — 뺀 적이 없다 | 한다 |
| 잠금 | 걸지 않는다 (§3, 구 건도 동일) | 걸지 않는다 |

- 구 건도 **잠금은 걸지 않는다.** 걸린 게 없으니 `settleFromLocked` 를 부르면 음수 클램프
  알람만 뜬다. 잠금은 §3 대로 전 경로에서 제거하라
- 분기는 **읽기 쉬운 한 곳**에 모아라. `P2P_PENDING` 을 여기저기서 검사하면 3건이 끝난 뒤
  지우기 어려워진다

## §6. 종결 시 잔여 원금 환급 — ☠️ 현재 환급 코드 0건

원금을 선차감하면 **주문이 전액 팔리지 않고 끝나는 경우 남은 원금을 파트너에게 돌려줘야 한다.**
지금 코드에는 `credit` 호출이 **한 곳도 없다**(grep 확인).

특히 `forceSettle` 은 javadoc 이 *"ledger DEBIT 없음 → USDT 복원"*(`P2pWithdrawService:343`)을
전제로 쓰여 있다. **선차감만 하고 이 경로를 그대로 두면 파트너가 원화도 주고 USDT 도 잃는다.**

환급 대상 3경로 — 전부 `applyRemainderResolution`(`P2pWithdrawService:641-648`)이
`RELEASE` 를 적재하는 **같은 자리**다. 거기서 함께 처리하면 한 곳에 모인다:

| 경로 | 잔여 원금 |
|---|---|
| `cancelOrder` | CREDIT 환급 |
| `forceSettle` | CREDIT 환급 |
| `convertToDirectWithdrawal` / `approveUsdtConvert` | CREDIT 환급 → 그 뒤 기존대로 신규 출금 생성 + `freeze`. 환급하지 않으면 신규 출금이 없는 돈을 freeze 한다 |

```
환급액 = RELEASE 로 적재하는 잔여 KRW 를 그 주문의 환율로 USDT 환산
```

- ☠️ **수수료는 환급하지 않는다** — 오너 확정. 원금만이다
- ☠️ **과도기 건(`P2P_PENDING`)은 환급하지 않는다** — 뺀 적이 없다. 하면 **유령 크레딧**이다
- `credit` 은 txHash 멱등이 있으나 여기선 txHash 가 없다.
  **재진입 방지는 `applyRemainderResolution` 의 기존 가드**(`remainder_resolution != null`,
  `refund_type != null`)에 의존하라. 새 멱등 장치를 만들지 마라
- 매칭 단위 취소/실패(6지점)는 **원금과 무관하다.** 원금은 주문 단위로 이미 나갔고,
  매칭 취소는 KRW 원장 UNLOCK 으로 재고를 되돌린다. 여기에 CREDIT 을 넣지 마라

## §7. 웹훅 — 요청 즉시 `WITHDRAWAL_COMPLETED`

오너 확정: **요청 즉시 발송**.

- `approveAsP2p` 에서 발송한다. 지금 있는 *"P2P 전환은 내부 사정이라 웹훅 없음"* 주석
  (`WithdrawalService:1226-1228`)은 **뒤집혔다** — 지우고 새 근거를 남겨라
- ☠️ **이중 발송을 막아라.** `completeP2pConvertedWithdrawal`(`:1308-1338`)이 종결 3경로에서
  또 `WITHDRAWAL_COMPLETED` 를 보낸다. 멱등 키가 없다.
  → 신규 건에서는 그 발송을 **스킵**하라 (과도기 건은 그대로 발송)
- `checkAndCompleteOrders`(`P2pSettlementService:909-922`)의 발송도 §5 대로 과도기 건만
- ⚠️ **페이로드의 `feeAmount` 가 하드코딩 `"0"` 이다**(`WebhookPayloadBuilder:589`).
  요청 즉시 완료 모델에서는 수수료가 확정돼 있으므로 **`withdrawals.fee_amount` 를 실어라**
- `transactionHash` 는 빈 값이다(`:553`). P2P 는 온체인 해시가 매칭별로 갈리므로 정상 —
  **억지로 채우지 마라**
- 파생 잔여 출금의 침묵 규칙(`parent_withdrawal_id`, `:542-547`)은 **그대로 두라**

---

## 절대 규칙

- **DDL·DML 금지. DB 접속 금지. 운영 서버 접속 금지**
- ☠️ **§1·§3·§4 는 반드시 한 커밋에.** 따로 나가면 이중 차감이거나 매칭 정지다
- ☠️ **4개 Spring 모듈은 함께 배포된다는 전제로 쓰라** — 부분 롤백은 이중 차감이다
- 입금측 CREDIT · 온체인 전송 · 수수료 경로를 건드리지 마라
- `p2p_withdraw_entries`(KRW 원장) 규약을 건드리지 마라 — 매칭 재고의 정본이다
- 매칭 후보 선정 SQL(`P2pMatchingMapper`)을 건드리지 마라
- `getAvailableForWithdrawal` 의 `locked` 항을 지우지 마라 (§3)
- `WithdrawalStatus.P2P_PENDING` enum 을 지우지 마라 (§5 판별자)
- `SETTLEMENT_WITHDRAW` 제외 분기를 빠뜨리지 마라 (§1)
- MyBatis `<script>` 안에 `<` `<=` `<>` 금지 — SAXParseException 으로 전 서비스 다운
- `IXRepository.modify()` 는 non-null 만 갱신 · `save()` 는 PK 반환
- 새 라이브러리 금지 · **git commit / push 하지 마라**

## 완료 기준

```
1  ★ 요청 시점에 원금 DEBIT + 수수료 FEE 가 같은 트랜잭션 (reference_id = 출금 ID)
2  ★ 정산 시점 원금 DEBIT 이 신규 건에서 사라졌다 — 이중 차감 없음을 코드 경로로 증명
3  ★ 잠금 쓰기 3종(lock/unlock/settleFromLocked)이 P2P 경로에서 전부 사라졌다 — grep 증명
4  ★ 종결 3경로에 잔여 원금 CREDIT 환급이 있다. 수수료는 환급하지 않는다
5  ★ 과도기 분기: P2P_PENDING 건은 정산 DEBIT 함 · 환급 안 함 · 웹훅 기존 타이밍
      → 신규 건과 섞이지 않음을 코드 경로로 증명
6  ★ 웹훅 WITHDRAWAL_COMPLETED 가 요청 시 1회만 — 이중 발송 없음을 코드 경로로 증명
7  웹훅 페이로드에 fee_amount 가 실린다
8  SETTLEMENT_WITHDRAW 는 요청 시 DEBIT 대상이 아니다
9  getAvailableForWithdrawal 의 locked 항 · 매칭 후보 SQL · KRW 원장 무변경 (diff 증명)
10 ./gradlew 전 모듈 컴파일 통과
11 git commit·push 하지 않았다
```

## 보고

- 수정 파일:라인 + 한 줄
- **1~6 을 코드 경로로 증명**하라
- **10,000원 P2P 출금(요율 0.2%)의 전 생애**를 단계별로: 요청 → 부분 매칭 → 부분 정산 →
  잔여 취소. 각 시점에 파트너 원장·KRW 원장·상태·웹훅이 각각 어떻게 되는지 금액과 함께
- 잠금 폐기로 **0 이 되는 화면 항목** 목록 (고치지는 말고 보고만)
- 지침이 실제 코드와 어긋난 지점 — 고치지 말고 먼저 보고
- 빌드 결과

## 착수 전 필수 확인

```
core/.../withdrawal/WithdrawalService.java      :1140-1244 approveAsP2p · :956-968 SETTLEMENT_WITHDRAW 근거
                                                :1226-1228 웹훅 없음 주석(뒤집힘) · :1308-1338 completeP2pConverted
core/.../p2p/P2pWithdrawFeeService.java         선행 수수료 구현 — 같은 자리에 붙인다
core/.../settlement/SettlementService.java      :195-222 debit(잔액부족 예외) · :227-248 recordFee · :311-330 가용액
core/.../p2p/P2pSettlementService.java          :545-564 잠금해제+원금DEBIT · :892-927 checkAndCompleteOrders
core/.../p2p/P2pLockService.java                :65-99 게이트 · :105-160 해제/정산
core/.../p2p/P2pMatchingService.java            잠금 호출 9지점
core/.../p2p/P2pWithdrawService.java            :277-336 cancel · :343-380 forceSettle · :399-466 convert
                                                :641-648 applyRemainderResolution (환급을 붙일 자리)
common/.../mapper/WithdrawalMapper.java         :34-56 hold 제외 목록
core/.../notification/WebhookPayloadBuilder.java :542-547 파생 침묵 · :553 txHash · :589 feeAmount 하드코딩
v2-docs/WITHDRAW_FEE_UPFRONT_GUIDE.md           선행 변경 (수수료)
```

지침과 다르면 **멈추고 보고하라.**

---

## 이번 범위 밖

- `p2p_partner_locks` 테이블·`getAvailableForWithdrawal` 의 `locked` 항 정리 —
  쓰기 중단 후 값이 더스트로 고정되면 별건으로
- 잠금이 겸하던 **회계 검증 장치**(음수 클램프 알람 · `settled_total` 이력)의 대체물.
  원금 선차감 모델에서는 요청 시점 `debit` 예외가 1차 방어선이지만, 사후 리컨실은 여전히 없다
- 파트너 연동 문서 갱신 — 웹훅 타이밍이 바뀌었다 (요청 즉시 완료)
