# 파트너 웹훅 재정의 지침서

작성일: 2026-08-13
대상: Cowork 서브에이전트 (구현)
선행: P2P 수수료 재설계 v2.8 (`67b4dc0` / `137d0b3` / admin `3cbbabc`)

> **호환 기간 없음.** 구 이벤트를 병행 발송하지 않고 즉시 전환한다. 배포 전 파트너 공지 필요.

---

## 1. 배경

출금 웹훅이 9종으로 늘어나면서 파트너가 상태 머신을 따라가기 어려워졌다. 그리고 실측으로 두 결함이 확인됐다.

**결함 1 — 자연 완료 경로에 종결 통지가 없다.** `WithdrawalStatus.COMPLETED` 전이가 코드에 두 곳인데, 예외 처리 경로(`WithdrawalService:1098`, 강제 정산·취소·USDT 전환)에만 알림이 있고 **전액 정상 정산 경로**(`P2pSettlementService:749`)에는 없다. 운영 실측으로 P2P 출금 주문에 연결된 `withdrawals` 34건이 전부 종결 통지 없이 끝났다 (`WITHDRAWAL_COMPLETED` 발송 **0건**).

**결함 2 — 입금 웹훅이 수수료를 알려주지 않는다.** 페이로드 키 21개에 `feeAmount`가 없고 `amount`는 gross다. 파트너는 실제 크레딧된 금액을 알 수 없다.

| 웹훅 `amount` | `deposits.fee_amount` | 실제 크레딧 |
|---|---|---|
| 70.921985 | 0.141844 | **70.780141** |
| 2127.659574 | 4.255319 | **2123.404255** |

지금은 수수료가 0.2%라 차이가 작지만 P2P 1%가 적용되면 1%씩 어긋난다.

---

## 2. 출금 웹훅 — 9종 → 3종

### 2.1 신규 이벤트

| 이벤트 | 시점 |
|---|---|
| `WITHDRAWAL_REQUESTED` | 출금 요청 생성 |
| `WITHDRAWAL_COMPLETED` | 종결(성공) — 온체인 전송 확정 **또는** P2P 출금 완료 |
| `WITHDRAWAL_FAILED` | 종결(실패) — 거부·취소·실패·소진 |

**폐기**: `WITHDRAWAL_APPROVED`, `WITHDRAWAL_P2P_PENDING`, `WITHDRAWAL_CONFIRMED`, `WITHDRAWAL_REJECTED`, `WITHDRAWAL_CANCELLED`, `WITHDRAWAL_EXHAUSTED`

파트너는 "요청했다 / 성공으로 끝났다 / 실패로 끝났다" 셋만 알면 된다. 중간에 P2P로 전환됐는지는 내부 사정이므로 통지하지 않는다.

### 2.2 발송 지점 매핑

| 현재 위치 | 현재 이벤트 | 조치 |
|---|---|---|
| `WithdrawalService:260` | `WITHDRAWAL_REQUESTED` | **유지** |
| `WithdrawalService:539` | `WITHDRAWAL_APPROVED` | **발송 제거** |
| `WithdrawalService:581` | `WITHDRAWAL_REJECTED` | → `WITHDRAWAL_FAILED` (`reason=REJECTED`) |
| `WithdrawalService:625` | `WITHDRAWAL_CANCELLED` (관리자) | → `WITHDRAWAL_FAILED` (`reason=CANCELLED_BY_ADMIN`) |
| `WithdrawalService:674` | `WITHDRAWAL_CANCELLED` (파트너) | → `WITHDRAWAL_FAILED` (`reason=CANCELLED_BY_PARTNER`) |
| `WithdrawalService:822` | `WITHDRAWAL_EXHAUSTED` | → `WITHDRAWAL_FAILED` (`reason=EXHAUSTED`) |
| `WithdrawalService:1032` | `WITHDRAWAL_P2P_PENDING` | **발송 제거** |
| `WithdrawalService:1109` | `WITHDRAWAL_COMPLETED` | 유지 + `settlementMethod=P2P` |
| `WebhookProcessingService:492` | `CONFIRMED`/`FAILED` (상태 유도) | → `COMPLETED`(`settlementMethod=ONCHAIN`) / `FAILED`(`reason=FAILED`) |
| **`P2pSettlementService:749`** | **없음** | → **`WITHDRAWAL_COMPLETED` 신설** (`settlementMethod=P2P`) ⚠️ 결함 1 |

### 2.3 페이로드

**`WITHDRAWAL_COMPLETED`** — 완료 경로를 명시 필드로 구분한다.

```json
{
  "eventType": "WITHDRAWAL_COMPLETED",
  "settlementMethod": "ONCHAIN",
  "transactionHash": "0x...",
  "amount": "1000.000000000000000000",
  ...
}
```

- `settlementMethod`: `"ONCHAIN"` | `"P2P"`
- P2P 출금은 온체인 TX가 **파트너 간 정산**이라 회원 출금과 무관하다. 파트너에게 그 해시를 주면 혼란스러우므로 `transactionHash` 는 기존 정규화 규칙대로 `""` 로 둔다
- `transactionHash` 유무로 유추하게 하지 말 것 — 명시 필드로 판단하게 한다

**`WITHDRAWAL_FAILED`**

```json
{
  "eventType": "WITHDRAWAL_FAILED",
  "reason": "CANCELLED_BY_ADMIN",
  "reasonDetail": "잔액 부족",
  ...
}
```

- `reason`: `REJECTED` | `CANCELLED_BY_PARTNER` | `CANCELLED_BY_ADMIN` | `FAILED` | `EXHAUSTED`
- `reasonDetail`: 관리자 메모 등 자유 텍스트. 없으면 `null`
- 취소를 파트너·관리자로 나눈 이유 — 파트너가 직접 취소한 건 이미 알고 있고, 관리자 취소는 회원에게 안내해야 한다. 코드상 이미 `:625`/`:674`로 분리되어 있어 추가 비용이 없다

### 2.4 서명

서명식 `partnerId|txHash|amount|timestamp` 는 **변경하지 않는다.** `settlementMethod` / `reason` / `reasonDetail` 은 서명 대상이 아니다.

### 2.5 죽은 enum 정리

`TransactionType.P2P_WITHDRAW_EXPIRED` 는 코드 사용처 **0건**이다(`DEPOSIT` 15건, `WITHDRAWAL` 11건과 대조). 2026-06-11에 2건 발송된 DB 이력이 있으므로 **enum 값은 남기고 deprecated 주석만** 단다 — 과거 로그 조회 시 역직렬화가 필요하다.

---

## 3. 입금 웹훅 — 실 크레딧 기준으로

### 3.1 변경

`WebhookPayloadBuilder.buildDepositCallback` (`:153-198`)

| 필드 | 현재 | 변경 후 |
|---|---|---|
| `amount` | gross (`deposit.amount`) | **net (`deposit.amount − deposit.feeAmount`)** — 실 크레딧 |
| `feeAmount` | **없음** | **신설** — `deposit.feeAmount` |
| `grossAmount` | **없음** | **신설** — 온체인 전송액 (= 기존 `amount`) |

### 3.2 서명

서명은 **새 `amount`(net) 로 계산**한다. 파트너는 수신 필드로 `partnerId|transactionHash|amount|timestamp` 를 그대로 재현하므로 검증 로직이 깨지지 않는다.

> ⚠️ `generateSignature` 에 넘기는 `amount` 문자열과 페이로드의 `amount` 값이 **반드시 동일**해야 한다. 현재 코드(`:174`, `:197`)가 각각 계산하고 있으니 변수 하나로 묶어라.

### 3.3 `grossAmount` 를 함께 보내는 이유

`amount` 가 net 이 되면 온체인 전송액과 달라진다. 파트너가 `transactionHash` 로 체인을 조회해 대조하는 경우를 위해 gross 도 실어 보낸다.

### 3.4 적용 범위

P2P 정산의 입금측도 이 경로를 탄다(`createP2pDeposit` → `deposits` 행 → `buildDepositCallback`). 별도 P2P 이벤트가 필요 없는 이유다. TORQ 레그는 `feeAmount=0` 이므로 net == gross 가 된다.

---

## 4. 위젯 postMessage

`widget-ui/src/views/p2p.vue` 의 `P2P_TOPUP_COMPLETED` 가 gross 를 보낸다.

```js
sendMessageToParent('P2P_TOPUP_COMPLETED', {
  usdtAmount: totalUsdtAmount.value,   // ← gross
})
```

화면은 net 을 보여주는데 파트너에게는 gross 가 간다. **`usdtAmount` 를 net(실 크레딧)으로 바꾼다.** 서명이 없고 계약이 느슨하므로 필드 추가가 아니라 값 자체를 교체한다.

---

## 5. 원장 — P2P 입금/출금 수수료 구분

### 5.1 문제

P2P 정산이 FEE 원장 두 건을 남기는데 `(entry_type, reference_type, reference_id)` 가 **완전히 동일**하고 구분이 `description` 자유 텍스트에만 있다.

```java
// P2pSettlementService:471  입금자 수수료
recordFee(dpo.getPartnerId(), ..., LedgerReferenceType.P2P_SETTLEMENT, settlement.getId(),
          "P2P 구매자 수수료 — " + match.getMatchCode());

// P2pSettlementService:481  출금자 수수료
recordFee(wo.getPartnerId(), ..., LedgerReferenceType.P2P_SETTLEMENT, settlement.getId(),
          "P2P 출금자 수수료 — " + match.getMatchCode());
```

**INNER 정산에서는 `partner_id` 까지 같다.** 실측 P2P SETTLED 매칭의 **99.91%(34/35)** 가 동일 파트너다. 즉 거의 모든 정산에서 구별 불가능한 FEE 행 두 개가 생긴다.

### 5.2 조치

`LedgerReferenceType` 에 `P2P_WITHDRAW_FEE` 를 추가하고, 출금자 수수료 `recordFee` 가 이 값을 쓰게 한다.

```java
public enum LedgerReferenceType {
    DEPOSIT, WITHDRAWAL, COLLECTION, MIGRATION,
    P2P_SETTLEMENT,
    /** P2P 출금자 수수료 — 출금 파트너 부담. P2P_SETTLEMENT(입금 재원)와 구분 (v2.8) */
    P2P_WITHDRAW_FEE,
    SETTLEMENT_INFLOW;
}
```

DDL `ledger_entries.reference_type` COMMENT 도 갱신한다.

### 5.3 ⚠️ 필수 동반 수정 — 일반 입금 집계 필터

`SettlementMapper:38` 이 P2P 원장을 제외한다.

```sql
AND (le.reference_type IS NULL OR le.reference_type != 'P2P_SETTLEMENT')
```

**새 타입을 추가하면 이 필터를 통과해 출금자 수수료가 일반 입금 집계에 섞인다.** 반드시 함께 고쳐라.

```sql
AND (le.reference_type IS NULL
     OR le.reference_type NOT IN ('P2P_SETTLEMENT', 'P2P_WITHDRAW_FEE'))
```

> `<script>` 내부이므로 `<`·`<=`·`<>` 사용 금지. `NOT IN` 은 안전하다.

### 5.4 부수 효과 — 기존 결함 해소

레거시 주간 쉐어 매퍼들이 `reference_type = 'P2P_SETTLEMENT' AND entry_type = 'FEE'` 로 조회한다
(`AdminSettlementRebateMapper:168,194,222`, `PartnerSettlementRebateMapper:91`).
출금자 수수료가 새 타입으로 빠지면 이 쿼리들이 자동으로 입금 재원만 잡게 되어, INNER 정산에서 FEE 행이 2건 잡혀 `trade_count` 가 중복되던 문제가 해소된다.

---

## 6. 검증 및 완료 기준

### 6.1 발송 지점 전수 대조

`setStatus(WithdrawalStatus.*)` 13곳과 웹훅 발송을 대조해 **의도적 무발송**과 **누락**을 구분해 보고하라. 현재 알림 없는 전이는 넷이다.

| 위치 | 전이 | 현재 |
|---|---|---|
| `WithdrawalService:495` | `→ APPROVED` | 없음 — **확인 필요** |
| `WithdrawalService:730` | `→ CONFIRMED` | 없음 (온체인 경로가 별도 발송 — 의도적으로 보임) |
| `WithdrawalService:898` | `→ CANCELLED` | 없음 — **확인 필요** |
| `P2pSettlementService:749` | `→ COMPLETED` | 없음 — **결함 1, 반드시 추가** |

`:495` 와 `:898` 이 어떤 경로인지 확인하고, 신규 3종 체계에서 발송이 필요한지 판정해 보고하라.

### 6.2 검산

| 케이스 | 기대 |
|---|---|
| 일반 온체인 출금 완료 | `WITHDRAWAL_COMPLETED`, `settlementMethod=ONCHAIN`, `transactionHash` 채워짐 |
| P2P 전환 출금 전액 정산 | `WITHDRAWAL_COMPLETED`, `settlementMethod=P2P`, `transactionHash=""` |
| 관리자 취소 | `WITHDRAWAL_FAILED`, `reason=CANCELLED_BY_ADMIN` |
| 파트너 취소 | `WITHDRAWAL_FAILED`, `reason=CANCELLED_BY_PARTNER` |
| 입금 (수수료 0.2%, gross 70.921985) | `amount=70.780141`, `feeAmount=0.141844`, `grossAmount=70.921985` |
| TORQ 레그 입금 | `feeAmount=0`, `amount == grossAmount` |

### 6.3 회귀

- 일반 입금 집계(`fee_source=DEPOSIT`) 결과가 변경 전과 동일 — §5.3 필터 수정이 정확해야 한다
- P2P 집계는 `p2p_matches` 요율에서 산출하므로 원장 타입 변경에 영향 없음을 확인
- 서명 검증: 새 `amount` 로 재현 가능한지

### 6.4 코딩 규칙

MyBatis `<script>` 내 `<`·`<=`·`<>` 금지 (2026-06-11 기동 실패 장애). `ORDER BY`/`LIMIT`/`COUNT` 직접 작성 금지. DTO 멤버 JavaDoc 필수.

### 6.5 빌드

```bash
./gradlew :common:compileJava :core:compileJava :admin-api:compileJava \
          :partner-api:compileJava :open-api:compileJava :scheduler:compileJava
cd widget-ui && npm run build:prod
```

---

## 7. 함정

| # | 함정 | 대응 |
|---|---|---|
| 1 | 새 `reference_type` 이 일반 입금 집계에 섞임 | §5.3 필터 동반 수정 |
| 2 | 서명 `amount` 와 페이로드 `amount` 불일치 | 변수 하나로 묶어라 (§3.2) |
| 3 | `settlementMethod` 를 `transactionHash` 유무로 유추 | 명시 필드로 전달 |
| 4 | P2P 정산 TX 해시를 회원 출금 해시로 오해 | P2P 는 `transactionHash=""` |
| 5 | 이벤트 제거 시 발송 코드만 지우고 Telegram 알림까지 삭제 | Telegram 은 별도 채널 — 유지 여부 판단 |
| 6 | `deposits.fee_amount` 가 null 인 과거 행 | null-safe 처리, net = gross |

---

## 8. 배포

1. 코드 구현 → 로컬 빌드 → 커밋 → `git push origin main`
2. **파트너 공지 선행** — 최근 30일 실수신 파트너 9곳(39·45·34·1·15·44·50·43·31). 호환 기간 없이 즉시 전환
3. `spring:deploy-production` ▶ + `widget-ui:deploy` ▶ 동시
4. 배포 직후 §6.2 검산을 실거래로 확인

> 파트너 API 안내서(`v2-docs/CRYPTOMENTS_PARTNER_API_FRONTEND_GUIDE.md`)의 웹훅 절도 함께 갱신할 것.

---

## 9. 오너 결정 4건 (2026-08-13) — 구현 반영 완료

1차 구현에서 "판단 필요"로 남겼던 항목에 대한 결정과 반영 내용.

### D1 — 입금 웹훅 `krwAmount`/`usdAmount` 도 net 기준

`WebhookPayloadBuilder.buildDepositCallback` 의 `appendPriceInfo` 에 gross 대신 **net** 을 넘긴다.
`amount` 만 net 으로 바꾸면 원화·달러 환산이 gross 기준이라 서로 어긋나고, 파트너가 `krwAmount` 로
회원에게 원화를 표시하면 **수수료만큼 과다 안내**된다.
출금 웹훅의 `appendPriceInfo` 는 수수료 개념이 없으므로 그대로 둔다.

### D2 — 24시간 자동 만료 프로세스 폐기

`WithdrawalService.expireStaleWithdrawals()` / `ExpireStaleWithdrawalsJob` /
`SchedulerController` 의 `/expire-stale-withdrawals/trigger` 를 **모두 삭제**했다.
관리자가 판단해야 할 건을 스케줄러가 대신 종결하는 구조라 폐기한다(제거 시점 대상 건수 0).

⚠️ 방치된 요청은 계속 `REQUESTED` 로 남는다. 어드민 **출금 목록 검색**(`status=REQUESTED` +
`startDate`/`endDate`)이 조회 수단이다. 단, `/withdrawals/pending-approval` 전용 화면은
`WHERE w.status = 'PENDING_APPROVAL'` 이라 **`REQUESTED` 를 포함하지 않는다** — 장기 대기 `REQUESTED`
건은 전용 화면에 뜨지 않으므로 목록 검색으로 봐야 한다. (별도 화면 신설은 이번 범위 밖)

### D3 — 정산 쉐어 출금은 출금 웹훅 전면 제외

`WithdrawalType.SETTLEMENT_WITHDRAW` 는 파트너가 **자기 정산 수익을 인출**하는 건이라
(`requestSettlementWithdrawal` → `settlement_balances.realized_balance` 차감, 항상 APPROVED 생성)
회원 거래가 아니고 파트너가 직접 실행한 행위다. 웹훅 3종을 **모두** 발송하지 않는다.

**게이트는 한 곳** — `WebhookPayloadBuilder` 의 private 공통 빌더
`buildWithdrawalCallback(withdrawal, eventType, legacyKeys, settlementMethod, reason, reasonDetail)`
진입부. 공개 메서드 3개(`buildWithdrawalCompleted` / `buildWithdrawalFailed` /
`buildWithdrawalCallback(3-arg)`)가 전부 여기로 위임하므로 **모든 발송 경로가 이 한 지점을 통과**한다.
제외 대상이면 `null` 을 돌려주고, `NotificationService.sendWebhook` 이 null 페이로드를 건너뛴다
(빈 payload 로 delivery log 를 만들면 배달 잡이 null 본문을 전송하므로 반드시 필요한 가드).

Telegram 은 **운영자 가시성 채널이라 유지** — 호출부가 텔레그램 메시지를 별도 인자로 넘기고
게이트는 웹훅 페이로드 생성 단계에만 걸려 있어 영향받지 않는다.

### D4 — 관리자 취소 사유

`WithdrawalService.cancel(withdrawalId, changedBy, changeSource, **reason**)` 으로 시그니처를 바꾸고,
사유를 **상태 이력 note** 와 웹훅 `reasonDetail` 양쪽에 기록한다. 미입력 시 이력에는
`"관리자 취소 (사유 미입력)"` 이 남고 `reasonDetail` 은 null 이다(빈 문자열은 null 로 정규화).

**사유는 API 레벨에서 선택(필수 아님).** 근거 — 컨트롤러가 `@RequestBody(required = false)` 라
어드민 콘솔이 body 없이 호출할 수 있다. 필수화하면 UI 배포 전에 백엔드가 먼저 나가는 순간
취소 기능이 전부 400 으로 죽는다(UI 수정은 이번 범위 밖). 대신 **UI 폼에서 입력을 강제**하면
API 파괴 없이 같은 결과를 얻는다. 미입력 사실은 이력에 남아 사후 감사도 가능하다.

웹훅은 D3 게이트를 그대로 따른다 — 회원 출금이면
`WITHDRAWAL_FAILED`(`reason=CANCELLED_BY_ADMIN`, `reasonDetail=입력 사유`), `SETTLEMENT_WITHDRAW` 면 미발송.

### 어드민 UI(`cryptoments-admin`) 후속 필요 목록 — 백엔드 범위 밖

| # | 화면/지점 | 필요 작업 |
|---|---|---|
| 1 | 출금 취소 다이얼로그 | 사유 입력 필드 신설 + **필수 검증**(백엔드는 선택이므로 UI 가 강제). `POST /withdrawals/{id}/cancel` body `{ "reason": "..." }`, 500자 제한 |
| 2 | 출금 목록 | 장기 대기(`REQUESTED` 24h+) 인지 수단 — 기본 필터 프리셋 또는 경과시간 뱃지. 자동 만료가 없어졌으므로 사람이 봐야 한다 |
| 3 | 승인 대기 화면 | `pending-approval` 이 `PENDING_APPROVAL` 만 조회 → `REQUESTED` 건이 누락됨을 UI 문구로 안내하거나 목록 검색으로 유도 |
| 4 | 출금 상태 이력 뷰 | 취소 사유(note)가 표시되는지 확인 |
| 5 | 스케줄러 수동 실행 화면(있다면) | `expire-stale-withdrawals` 트리거 버튼 제거 (엔드포인트 삭제됨 → 404) |
