# Open API — 회원 카테고리 설계서

> 작성일 2026-08-21 · **전면 검증 반영 2026-08-22** · 상태: **설계 (구현 전)** · 대상 모듈 `open-api`
> 범위: 파트너 서버가 서버-투-서버로 호출하는 `/api/v1/*` 신규 API

---

## 0. 이 문서가 정의하는 것

| # | API | 목적 | 자금 이동 |
|---|-----|------|-----------|
| A | P2P 회원 등록 | 파트너 사용자를 P2P 회원으로 발급 (회원 페이지 링크 획득) | 없음 |
| B | P2P 출금 (원장 충전) | 파트너 잔액을 차감해 P2P 판매 재고(KRW 원장)로 충전 | **있음** |
| C | 회원 정보 | 한 사용자에 대해 시스템이 아는 모든 것 조회 | 없음 |
| ~~D~~ | ~~결제 링크 생성~~ | **제외 (2026-08-22)** — 링크 생성은 파트너 콘솔에서 한다 | — |
| E | Axim Pay 결제 요청 | 연결된 Axim 지갑에 결제 푸시 | **있음(입금)** |
| F | 가용 잔액 조회 | 지금 출금 가능한 금액 (정본 산식) | 없음 |
| G | 출금 사전 검증 | 이 금액으로 출금 가능한지 미리 확인 | 없음 |

> F·G는 최초 요청에 없던 항목이나, 점검 결과 **v1에 출금 가능액을 알 방법이 아예 없고 기존 잔액 API 2개가
> 서로 다른 값을 "가용액"이라는 이름으로 내보내고 있어** 포함했다(§7).

### 확정된 설계 결정

| 항목 | 결정 |
|------|------|
| **B 호출 횟수** | **원스텝 1회** — 서버가 출금 생성 + P2P 전환 + 원금/수수료 선차감 + 원장 충전을 한 트랜잭션으로 처리 |
| **C 외부 조회** | **기본 DB만**, `?include=ekyc` 명시 시에만 Axim eKYC 실시간 호출 |
| **E 지갑 식별** | **`partnerUserId` 로 자동 해석**. 파트너는 내부 PK를 몰라도 된다 |
| **인증/멱등** | **현행 HMAC 유지** — body 서명·Idempotency-Key 헤더 도입 안 함. 단 §1.1·§1.4의 한계를 문서화하고 감수한다 |

### ⚠️ 이 문서는 초안을 두 차례 정정한 결과다

2026-08-22 전면 코드·운영DB 검증에서 **초안의 사실 오류 14건, 기존 코드 결함 17건**이 나왔다.
각 API 절에 정정 내역과 결함 항목을 명시했다. **§2의 선결 과제를 먼저 처리하지 않으면 구현에 착수하면 안 된다.**

---

## 1. 공통 규약

### 1.1 인증 — 기존 v1 방식 그대로

신규 API도 **현행 `/api/v1/*` 인증을 그대로 쓴다.** 파트너 연동 코드를 바꾸지 않는 것이 전제다.

```
X-API-KEY:      {partner.api_key}
X-TIMESTAMP:    {epoch seconds}
X-ACCESS-TOKEN: Base64(HMAC-SHA256("{timestamp}.{apiKey}", partner.api_secret_hash))
```

- 유효 창 ±300초. 파트너 상태 `ACTIVE` 필수.
- ⚠️ 컨트롤러 세션 파라미터는 반드시 **`OpenApiSessionData`** — `WidgetSessionData` 를 쓰면 캐스팅 실패로 401.
- 모든 조회/쓰기는 `session.getPartnerId()` 로 스코프. 타 파트너 자원은 **404**(403 아님 — 존재 누출 금지).

> **v1 인증 방식 자체는 이 문서의 논의 대상이 아니다.** 이미 연동 중인 파트너들 때문에 v1을 유지하고
> 있으며, v2 마이그레이션은 별도 준비가 필요한 사안이다. 신규 API는 기존 방식에 얹는다.

### 1.2 식별자 정책 — 내부 PK 노출 금지

| 노출 O | 노출 X |
|--------|--------|
| `chainType`, `currencyType` | `networkId`, `currencyId` |
| `partnerUserId`, `memberToken` | `p2p_members.id`, `partner_id` |
| `orderCode`(`pwo_*`), `paymentCode`(`pay_*`), `linkCode` | `external_wallet_id`, `withdrawal_id`, `payment_links.id` |
| `bankAccountId` ※ | |

※ `bankAccountId` 만 예외 — C에서 내려준 값을 B에서 되돌려 받는 용도. **단 B는 반드시 소유권을 검증해야 한다**(§4 B4).

#### ⚠️ 지원 `chainType` 은 3개뿐이다 — `ETH` 는 없다

운영 DB `blockchain_networks` 실측: **`BSC`(56), `POLYGON`(137), `TRON`(728126428)**, 그리고 `FIAT`(99, Bank Transfer KRW).

- 초안 예시에 쓴 `"ETH"` / `"ETHEREUM"` 은 **DB에 존재하지 않는다.** 문서 예시를 전부 `BSC`/`TRON`으로 교체했다.
- `ChainCurrencyResolver` javadoc이 `"ETHEREUM"` 을 예시로 드는 것도 **틀렸다**(§8 X4).

#### ☠️ `ChainCurrencyResolver` 는 방향에 따라 실패 처리가 다르다

| 메서드 | 실패 시 |
|---|---|
| `resolveNetworkId(chainType)` | **`NotFoundException("600")`** — "지원하지 않는 체인입니다" |
| `resolveCurrency(currencyType, chainType)` | **`NotFoundException("860")`** |
| `toChainType(networkId)` | **예외가 아니라 문자열 `"UNKNOWN"`** |
| `toCurrencyType(currencyId)` | 동일하게 `"UNKNOWN"` |

- 출력 방향의 `"UNKNOWN"` 은 로그도 알림도 없이 **정상 200 응답에 실려 파트너에게 나간다.** 기존 v1 컨트롤러 4곳이 그렇게 하고 있다.
- 게다가 `@Cacheable("chainTypeById")` 라 **`"UNKNOWN"` 이 5분간 캐시**된다. eviction API도 없다.
- **신규 API는 `"UNKNOWN"` 을 응답에 싣지 않는다** — 해당 항목을 제외하거나 명시적으로 에러를 낸다.
- `resolveCurrency` / `resolveCurrencyId` 에는 **캐시가 없다**(매 호출 DB 조회). 반복 호출 시 유의.

### 1.3 응답/에러 형태

- 성공: DTO를 **봉투 없이** 그대로 반환. `@RestControllerAdvice` 는 저장소 전체에 **0건**이며, spec-bundle 실측도 최상위가 배열/객체다.
- 실패: 프레임워크가 `ApiError { code, message, description, data, stackTrace }` 로 변환.
  ⚠️ `stackTrace` 가 **파트너 노출 스키마에 포함**돼 있다. `axim.rest.debug: false` 로 꺼져 있다고 가정하지만 계약상으로는 열려 있다.
- Jackson: `non_null` 제외, `yyyy-MM-dd HH:mm:ss` UTC, BigDecimal plain.
- ⚠️ **`@Valid` 실패 시 `code` 값이 무엇인지 저장소에서 확인할 수 없다.** Axim jar 내부에 있고, 검증 테스트가 0건이다. **파트너 문서에 "validation 실패 = code X" 라고 쓸 근거가 현재 없다** → §2 선결 과제 2.
- v1에는 **페이지네이션이 없다.** 21개 엔드포인트 전부 `List<T>` 또는 단일 DTO이며, `getUnconfirmedDeposits` 는 무제한 반환한다. 신규 목록 API를 만든다면 `XPage<T>`(위젯 경로에만 존재하는 패턴)를 도입할지 먼저 정해야 한다(§9 미결정 14).

### 1.4 중복 요청 방어 — 헤더 멱등키 없이 자연키로

| API | 자연키 | 재요청 시 |
|-----|--------|-----------|
| A | `p2p_members UNIQUE(partner_id, partner_user_id)` | 기존 회원 반환 — **단 §3 A1 참조, 현재 구조로는 동시성에서 500** |
| B | `withdrawals.idem_key` + `UNIQUE(partner_id, idem_key)` | **파사드 레벨 멱등 — 동일 요청 200 재사용, 내용 상이 409. §4.1** |
| E | `axim_payments.partner_reference` + 상태 in-flight | 동일 `orderId` 재요청 시 **기존 결제 200 반환, push 재발사 없음** (§6.1). ⚠️ 유니크 제약이 없어 동시 요청은 통과 가능 |

**B의 멱등 기반**: `withdrawals.idem_key` 는 GENERATED STORED 컬럼으로, 종결실패 상태
(`FAILED/CANCELLED/REJECTED/EXHAUSTED`)면 `NULL`, 그 외에는 `partner_reference` 와 같다.

> ⚠️ **`COMPLETED` 는 NULL이 되지 않는다.** P2P 출금은 접수 즉시 `COMPLETED` 이므로 그 `partnerReference` 는
> 해당 파트너에게 **영구히 소진**된다. P2P 주문을 취소해도 `withdrawals` 는 `COMPLETED` 로 남아 재사용 경로가 없다.
> 파트너 문서에 "P2P 출금의 `partnerReference` 는 재사용 불가"를 명시해야 한다.

⚠️ 종결실패 상태 목록을 애플리케이션에서 다시 열거하지 말 것 — DDL의 `CASE` 가 유일한 정의다.

### 1.5 신규 에러코드 대역 — **1100번대**

실측 결과 코드 공간이 이미 세 겹으로 겹쳐 있다.

**(a) `ErrorCodes` ↔ `OpenApiException` 중복 7건**

| code | `ErrorCodes` | `OpenApiException` |
|---|---|---|
| 1001 | CODEF_CREDENTIAL_INVALID | INVALID_API_KEY |
| 1002 | CODEF_BANK_MAINTENANCE | INVALID_API_SECRET |
| 1003 | CODEF_TRANSPORT_ERROR | PARTNER_NOT_ACTIVE |
| 1010 | P2P_DEPOSIT_LINK_NOT_FOUND | CHAIN_NOT_SUPPORTED |
| 1011 | P2P_DEPOSIT_LINK_EXPIRED | TOKEN_NOT_SUPPORTED |
| 1020 | KRW_NOT_ENABLED | INVALID_ADDRESS |
| 1030 | P2P_WITHDRAW_FEE_NOT_CHARGEABLE | INSUFFICIENT_BALANCE |

> **`OpenApiException` 은 죽은 코드다** — 저장소 전체에서 던지거나 참조하는 곳이 **0건**. 1001~1040은 런타임에 나가지 않는다.
> 그래서 지금은 사고가 없지만, **신규 API가 이 클래스를 쓰는 순간 파트너 분기가 깨진다.** 쓰지 않는다.

**(b) `ErrorCodes` 파일 내부 자기충돌 4건** — 이쪽이 더 위험하다(둘 다 살아 있는 코드다)

| code | 충돌 |
|---|---|
| 601 | `BLOCKCHAIN_NETWORK_INACTIVE` ↔ `COLLECTION_BATCH_NOT_FOUND` |
| 602 | `BLOCKCHAIN_NETWORK_SERVICE_UNAVAILABLE` ↔ `COLLECTION_BATCH_ALREADY_PROCESSED` |
| 701 | `TRANSACTION_STATUS_INVALID` ↔ `WEBHOOK_URL_NOT_SET` |
| 702 | `WITHDRAWAL_PROCESSING_FAILED` ↔ `TELEGRAM_NOT_CONFIGURED` |

→ 별도 정리 과제(§8 X5). 이 설계의 범위는 아니지만 **신규 코드를 그 근처에 두지 않는다.**

**(c) partner-api `PartnerAuthException` 도 1001~1009 를 쓴다.**

**결론**: 실제 코드는 `…1040` 다음 `4120` 으로 점프하므로 **1041 이상이 전부 미사용**이다.
신규는 **1100번대**를 쓴다(충돌 회피 + 여유).

| Code | 상수 | 메시지 | HTTP |
|------|------|--------|------|
| 1100 | `PARTNER_USER_NOT_FOUND` | 해당 사용자를 찾을 수 없습니다. | 404 |
| 1101 | `AXIM_WALLET_NOT_CONNECTED` | Axim 지갑이 연결되지 않았습니다. | 409 |
| 1102 | `AXIM_NOT_ENABLED` | Axim 기능이 비활성화된 파트너입니다. | 409 |
| 1104 | `PAYMENT_LINK_NOT_FOUND` | 결제 링크를 찾을 수 없습니다. | 404 |
| 1105 | `DUPLICATE_PARTNER_REFERENCE` | 이미 처리된 요청 식별자입니다. | 409 |
| 1106 | `EKYC_LOOKUP_FAILED` | eKYC 정보를 조회하지 못했습니다. | 502 |
| 1107 | `BANK_ACCOUNT_NOT_OWNED` | 해당 회원의 계좌가 아닙니다. | 403 |

기존 재사용: `KRW_NOT_ENABLED`(1020), `P2P_WITHDRAW_NOT_ENABLED`(1023), `P2P_AMOUNT_TOO_SMALL`(1000),
`P2P_MEMBER_NOT_FOUND`(991), `INSUFFICIENT_BALANCE`, `BANK_ACCOUNT_NOT_FOUND`(996),
`P2P_ORDER_NOT_CANCELLABLE`(987), `P2P_CONVERT_BLOCKED_BY_DISPUTE`(1026).

### 1.6 엔드포인트 총괄

| Method | Path | 설명 |
|--------|------|------|
| POST | `/api/v1/p2p/members` | A. P2P 회원 등록 |
| POST | `/api/v1/p2p/withdrawals` | B. P2P 출금 = 원장 충전 (원스텝) |
| GET | `/api/v1/p2p/withdrawals/{orderCode}` | B-보조. 주문 상태 조회 |
| GET | `/api/v1/p2p/withdrawals?partnerReference=…` | **B-보조. 응답 유실 복구 조회** (§4.1) |
| GET | `/api/v1/members?partnerUserId=…` | C. 회원 정보 통합 조회 (**path 아님** — §5) |
| POST | `/api/v1/axim/payments` | E. Axim Pay 결제 요청 |
| GET | `/api/v1/axim/payments?orderId=…` | **E-보조. 중복(409) 후 기존 결제 확인** (§6.1) |
| GET | `/api/v1/axim/payments/{paymentCode}` | E-보조. 결제 상태 조회 |
| POST | `/api/v1/axim/payments/{paymentCode}/cancel` | E-보조. 결제 취소 |
| GET | `/api/v1/partner/available-balance` | F. 가용 잔액(출금 가능액) |
| POST | `/api/v1/withdrawals/precheck` | G. 출금 사전 검증 |

> 자금이 움직이는 B·E는 파트너가 **상태를 되물을 수 없으면 정합성을 맞출 수 없다.** 특히 B는 응답 유실 시
> 복구 경로가 없으면 파트너 원장이 영구히 어긋난다(§4.1).
>
> ⚠️ **E에 결제 상태 웹훅은 만들지 않는다**(§6 E5). 파트너에게 중요한 것은 **입금 사실**이고 그건 기존
> DEPOSIT 웹훅이 전달한다.

---

## 2. 선결 과제

| # | 과제 | 이유 |
|---|------|------|
| **선결 1** | **`@Valid` 실패 응답 형태 실측** | 파트너 문서에 쓸 `code` 값을 모른다(Axim jar 내부). 실제 호출 1회로 확인 후 §1.3에 기록 |
| **선결 2** | **잔여 USDT 전환 흐름 완성** — API 범위 밖, 별도 문서 | **운영 실적 0건**이고 차단 결함 3건이 미해결이라 **지금은 회원이 잔여를 받을 방법이 없다.** B를 파트너에게 여는 **전제 조건** — 없으면 "출금은 되는데 잔여가 묶인다". → `v2-docs/P2P_REMAINDER_CONVERT_FINDINGS.md` |

---

## 3. A. P2P 회원 등록

### `POST /api/v1/p2p/members`

**Request**

| 필드 | 타입 | 필수 | 설명 |
|------|------|------|------|
| `partnerUserId` | String(100) | ✅ | 파트너 측 사용자 식별자 |
| `alias` | String(100) | | 회원 별칭 (파트너 내부 참조용) |
| `memo` | String | | 관리 노트 |

**Response** `201`

```json
{
  "partnerUserId": "user-001",
  "memberToken": "mbr_a1b2c3d4e5f6g7h8i9j0",
  "memberPageUrl": "https://p2p.cryptoments.cc/m/mbr_a1b2c3d4e5f6g7h8i9j0",
  "status": "ACTIVE",
  "tradingPaused": false,
  "pinSet": false,
  "depositMethod": "MANUAL",
  "bankAccountCount": 0,
  "alias": "홍길동",
  "createdAt": "2026-08-21 04:12:33"
}
```

### ✅ 확정: 회원 페이지 라우트는 `/m/{token}`

초안에서 미결정으로 남겼던 항목이 **코드 3곳에서 일치 확인**되어 확정됐다.

- SPA 라우터: `cryptoments-admin/p2p-ui/src/router/index.ts:30` → `path: '/m/:token'`
- 백엔드: `core/notification/P2pMemberNotifier.java:165,212,253` → `memberPageBaseUrl + "/m/" + memberToken`
- 파트너 콘솔: `partner-ui/src/api/services/p2p.service.ts:154-157` → `${base}/m/${token}`

⚠️ `cryptoments.p2p.member-page-base-url` 프로퍼티는 **open-api·scheduler에만 선언**돼 있고 partner-api엔 없다.
그리고 백엔드에 **재사용 가능한 URL 조립 헬퍼가 없다**(`P2pMemberNotifier` 의 private 필드뿐). A·B·C가 모두
`memberPageUrl` 을 내려주므로 **공용 컴포넌트(`P2pMemberPageUrlBuilder`)를 신설**한다.

### 초안 정정 및 결함

| # | 초안이 말한 것 | 실제 | 조치 |
|---|---|---|---|
| **A1** ☠️ | "`getOrCreate` 로 멱등, 신규 201 / 기존 200" | **멱등이 아니다.** read-then-insert에 잠금도 `DuplicateKeyException` catch도 없다. `UNIQUE(partner_id, partner_user_id)` 가 있으므로 **동시 호출 시 진 쪽은 1062가 그대로 올라와 500** | `WithdrawalService.resolveOrderKeyConflict` 와 같은 패턴으로 `catch (DuplicateKeyException) → 재조회` 수렴 추가 |
| **A2** | 응답에 `created` 플래그 | **얻을 수단이 없다.** `getOrCreate` 반환은 `P2pMember` 하나뿐이라 신규/기존 구분 불가. 현행 컨트롤러도 이 한계로 **항상 201** | `created` 필드 **삭제**. 항상 201로 통일하고 "이미 존재하면 기존 회원을 반환한다"를 문서에 명시 |
| **A3** ☠️ | `alias`/`memo` 를 optional로 받아 `updateProfile` 재사용 | **lost update.** `updateProfile` 은 `modify()` 가 아니라 `update()` 를 써서 **`alias` 만 보내면 기존 `memo` 가 null로 삭제**된다(의도된 동작). read~write 사이 커밋된 PIN·텔레그램·`trading_paused` 도 되돌린다 | **A는 `updateProfile` 을 호출하지 않는다.** `alias`/`memo` 는 신규 생성 시에만 반영하고, 기존 회원이면 무시한다. 수정은 기존 `PUT /members/{token}/profile` 로 |
| **A4** | "게이트는 `isKrwActive()` 만" | 현행 `createMember` 에는 **게이트가 아예 없다** → "만"이 아니라 "추가"다. 반대로 P2P 출금 진입점은 `isKrwActive()` → `isP2pWithdrawAllowed()` **2단** 고정 | 회원 등록만 `krw` 로 통과시키면 `p2p_withdraw_enabled=false` 파트너도 토큰·링크를 계속 발급받는다. **의도인지 결정 필요**(§9 미결정 15) |
| **A5** | — | `memberToken` = `"mbr_" + UUID hex 20자` = **총 24자**, 컬럼은 VARCHAR(50). **충돌 처리 없음**(재시도 루프도 catch도 없음, 80비트라 실무상 안전) | 문서에 기록만 |

**Partner 플래그 메서드 (실제 존재하는 것)**

| 메서드 | 컬럼 |
|---|---|
| `isKrwActive()` | `krw_enabled` |
| `isTorqAllowed()` | `krw_enabled && torq_enabled` |
| `isP2pMatchingAllowed()` | `krw_enabled && p2p_matching_enabled` |
| `isP2pWithdrawAllowed()` | `krw_enabled && p2p_withdraw_enabled` |
| `isCrossGroupMatchingAllowed()` | `p2p_cross_group_matching` |

`p2pEnabled`(`p2p_enabled`)는 **@deprecated**.

**에러**: 400 validation, 409 `1020 KRW_NOT_ENABLED`.

---

## 4. B. P2P 출금 — 원장 충전 (원스텝)

### `POST /api/v1/p2p/withdrawals`

한 번의 호출로 아래가 모두 일어난다. 내부 진입점은 `WithdrawalService.createAndConvertToP2p` — 파트너 콘솔
`POST /api/partner/withdrawals/p2p` 와 **동일 코드 경로**다. Open API는 얇은 어댑터일 뿐 새 자금 로직을 만들지 않는다.

```
① withdrawals 행 생성
② p2p_withdraw_orders 생성 (status=PENDING)
③ p2p_withdraw_entries CHARGE (+전액)   ← "충전"
④ 원금 DEBIT (요청 전액)                 ← 순서 고정
⑤ 수수료 확정 DEBIT (환급 없음)
⑥ withdrawals.status = COMPLETED        ← 접수 즉시 완료 모델
⑦ 파트너 웹훅 WITHDRAWAL_COMPLETED
⑧ (커밋 후) 회원 텔레그램 CHARGED
```

**Request**

| 필드 | 타입 | 필수 | 설명 |
|------|------|------|------|
| `partnerUserId` | String | ✅ | P2P 회원이 없으면 자동 생성 |
| `chainType` | String | ✅ | `BSC` / `POLYGON` / `TRON`. 정산 체인, 매칭은 같은 체인끼리만 |
| `currencyType` | String | | 기본 `USDT` |
| `requestCurrency` | Enum | | `USDT`(기본) \| `KRW` |
| `amount` | Decimal | ✅ | `> 0`, `requestCurrency` 단위 |
| `bankAccountId` | Long | | 미지정 시 회원의 첫 계좌 자동 선택. **지정 시 소유권 검증**(B4) |
| `partnerReference` | String(100) | ✅ | **멱등키. `@NotBlank`** — §4.1 |

**Response** `201`

```json
{
  "orderCode": "pwo_x1y2z3",
  "partnerUserId": "user-001",
  "memberToken": "mbr_a1b2",
  "memberPageUrl": "https://p2p.cryptoments.cc/m/mbr_a1b2",
  "chainType": "TRON",
  "currencyType": "USDT",
  "requestCurrency": "USDT",
  "usdtAmount": "1000.000000000000000000",
  "krwAmount": 1385000,
  "exchangeRate": "1385.0000",
  "feeAmount": "5.000000000000000000",
  "orderStatus": "PENDING",
  "withdrawalStatus": "COMPLETED",
  "bankAccountRegistered": true,
  "createdAt": "2026-08-21 04:20:11"
}
```

#### ★ 파트너 회계 원칙 — B 설계 전체의 기준선 (오너 확정 2026-08-22)

> **P2P 출금은 T0에 파트너 원장에서 나간다. 거기서 끝이다.**
> 이후 그 자금이 KRW로 팔리든(매칭) USDT로 회원에게 나가든(전환) **파트너 회계와 무관하다.**
> 혹시 회수하는 방법을 만들더라도 그건 **재입금**이지 출금의 되돌림이 아니다.
>
> **파트너 장부상 그것은 "P2P 회원에게 출금했다"로 종결된 건이다.**

이 원칙이 B의 API 표면을 결정한다.

| 항목 | 파트너 관심사인가 | Open API 노출 |
|---|---|---|
| 출금 접수 성공/실패 | ✅ | `POST` 응답 |
| 접수 사실 재확인 (응답 유실) | ✅ | `GET ?partnerReference=` |
| 회원 안내용 링크 | ✅ | `memberPageUrl` |
| 차감된 원금·수수료 | ✅ | `usdtAmount`, `feeAmount` |
| **매칭 진행률·잔여액** | ❌ 회원과 시스템의 문제 | — |
| **USDT 전환 상태** | ❌ 파트너 장부에 아무 일도 안 일어난다 | **노출하지 않는다** |
| **전환 성공/실패** | ❌ | — |

#### `withdrawalStatus: COMPLETED` 는 설계 의도다 — 이름을 바꾸지 않는다

위 원칙의 직접적 귀결이다. 파트너가 더 할 일이 없으므로 `COMPLETED` 가 맞다.

- **P2P 출금에는 취소가 없다**(§4.2). 취소가 없으니 `COMPLETED` 가 되돌아올 일도 없다.
- 파트너 문서에는 "**온체인 전송 완료가 아니라 출금 접수 완료**"라는 한 줄만 덧붙인다. 필드명 변경도, 삭제도 하지 않는다.
- `feeAmount` — 요청 시점 확정, 파트너 부담, **환급 없음**.
- `bankAccountRegistered=false` 면 파트너가 회원에게 `memberPageUrl` 로 계좌 등록을 안내해야 한다.
  실측: **주문은 있는데 계좌가 없는 회원이 2명** 존재한다. 실제로 일어나는 상태다.

### 4.1 멱등 설계 — 파사드 레벨에서 처리 (확정 2026-08-22)

#### 해결하려는 문제: B는 재시도가 불가능한 API였다

```
파트너 → POST /api/v1/p2p/withdrawals
서버   → 성공 (원금 차감 + 원장 충전 + 수수료 확정 완료)
응답   → 네트워크 타임아웃으로 유실
파트너 → 성공 여부를 알 방법이 없다
```

- 재시도 → `partnerReference` 있으면 409, 없으면 10초 후 **이중 차감**
- 조회 → `orderCode` 를 모른다(응답을 못 받았으므로). `GET /{orderCode}` 밖에 없어 **자기가 만든 주문을 찾을 수 없다**

자금이 이미 빠져나갔는데 파트너 원장은 그걸 모르는 상태로 영구히 남는다. **자금 이동 API에서 가장 먼저 막아야 할 구멍이다.**

#### ✅ 해법: `createAndConvertToP2p` 안이 아니라 그 위에서 멱등을 잡는다

"200 재사용이 불가능하다"는 것은 **core 안에서 불가능**하다는 뜻이다. `requestWithdrawal` 은 멱등 히트 시
기존 건을 반환하지만 `createAndConvertToP2p` 가 그걸 분기 없이 `approveAsP2p` 로 넘기고, `approveAsP2p` 는
`COMPLETED` 를 거부해 409를 낸다. **파사드가 그 분기를 대신 한다.**

복구 체인은 이미 리포지토리에 있다 — 신규 쿼리가 필요 없다.

```
findByPartnerIdAndIdemKey(partnerId, partnerReference)   ← WithdrawalRepository:22
  → findByWithdrawalId(withdrawal.id)                     ← P2pWithdrawOrderRepository:17
    → P2pWithdrawOrder
```

`idem_key` 는 종결실패(`FAILED/CANCELLED/REJECTED/EXHAUSTED`)면 NULL이므로, 이 조회는 **"진행/성공 중인
건만 잡힌다"** 는 의미까지 정확히 맞다. 실패한 건의 `partnerReference` 는 자연히 재사용 가능해진다.

```
OpenApiP2pFacade.createP2pWithdrawal():
  1. 게이트 선검사 (isKrwActive → isP2pWithdrawAllowed)          ← B9
  2. bankAccountId 소유권 검증                                    ← B4
  3. findByPartnerIdAndIdemKey(partnerId, partnerReference)
     ├─ 히트 → findByWithdrawalId → order 비교(§아래) → 200 or 409
     └─ 미스 ↓
  4. createAndConvertToP2p(... partnerReference ...)   ← 오버로드 추가(B8)
  5. catch (ConflictException | DuplicateKeyException)
     → idemKey 재조회 → order 있으면 200 수렴, 없으면 원래 예외 재전파
```

**core 코드를 건드리지 않는다.** 유일한 core 변경은 B8(오버로드로 `partnerReference` 전달)뿐이다.

#### 비교 기준은 `withdrawals` 가 아니라 `p2p_withdraw_orders` 다 — 이것이 KRW 문제도 푼다

기존 `isSameWithdrawalRequest` 가 KRW에서 깨지는 이유는 **`withdrawals.amount`(USDT)** 를 비교하는데
KRW 요청의 USDT 금액은 `amount / rate` 라 환율에 따라 흔들리기 때문이다.

그런데 `p2p_withdraw_orders` 에는 `request_currency` · `krw_amount` · `usdt_amount` 가 **모두 저장돼 있다.**

| 요청 | 비교 대상 | 안정성 |
|---|---|---|
| `requestCurrency=USDT` | `order.usdt_amount` | 입력값 그대로 — 안정 |
| `requestCurrency=KRW` | **`order.krw_amount`** | `krwOverride` 로 고정 저장됨 — **환율 무관, 안정** |

추가 비교 축: `partner_user_id`, `network_id`, `request_currency`.

→ **KRW 요청도 멱등이 성립한다.** 초안의 B2(§9 미결정 16)가 해소됐다.

#### 응답 규칙

| 상황 | 응답 |
|---|---|
| 신규 생성 | **201** |
| 동일 `partnerReference` + 동일 내용 | **200** — 기존 주문 그대로 반환 |
| 동일 `partnerReference` + **다른 내용** | **409 `1105 DUPLICATE_PARTNER_REFERENCE`**, `description` 에 어느 필드가 다른지 |
| 종결실패한 건의 `partnerReference` 재사용 | **201** — `idem_key` 가 NULL이라 자연히 신규 생성 |

⚠️ **`partnerReference` 는 성공한 P2P 출금에서 영구 소진된다.** P2P 출금은 접수 즉시 `COMPLETED` 이고
`idem_key` 는 `COMPLETED` 에서 NULL이 되지 않으므로, 주문을 취소해도 그 참조로 **새 출금을 만들 수 없다**.
이건 멱등키의 정상 동작이다 — 파트너 문서에 "요청마다 새 `partnerReference` 를 쓸 것"을 명시한다.

#### `GET /api/v1/p2p/withdrawals?partnerReference={ref}` — 응답 유실 복구

멱등 200과 **별개의 복구 경로**를 함께 연다. 재요청은 부작용이 없다고 해도, 파트너가 "확인만" 하고 싶을 때
POST를 던지게 만들 이유가 없다.

- 같은 조회 체인(`findByPartnerIdAndIdemKey` → `findByWithdrawalId`)을 쓰므로 구현 비용이 거의 없다.
- 없으면 **404** (실패했거나 애초에 접수되지 않은 것 — 파트너는 안전하게 재시도할 수 있다).
- 응답은 `GET /{orderCode}` 와 동일한 상세 형태.

#### ☠️ 구현 시 반드시 지킬 것

**1. 파사드에 `@Transactional` 을 걸지 않는다.**
core 트랜잭션이 롤백된 뒤 예외를 잡아 **재조회**해야 하는데, 파사드가 같은 트랜잭션 안에 있으면
rollback-only 상태라 조회 자체가 실패한다. 파사드는 트랜잭션 밖에서 (a) 선조회 (b) core 호출
(c) 예외 시 재조회 순으로 동작해야 한다.

**2. 예외를 무차별로 삼키지 않는다.**
`ConflictException` 을 통째로 잡아 200으로 바꾸면 `INSUFFICIENT_BALANCE` 같은 진짜 실패까지 성공으로
둔갑한다. **반드시 "재조회해서 주문이 실제로 존재할 때만" 200으로 수렴**하고, 없으면 원래 예외를 그대로
재전파한다.

**3. 잔여 경합 창 — 완전히 막을 수는 없다.**

| 시점 | T1 | T2 |
|---|---|---|
| 1 | 선조회 미스 | 선조회 미스 |
| 2 | core 실행 중 (**미커밋**) | — |
| 3 | — | core 진입 → INSERT → **UNIQUE(partner_id, idem_key) 위반** |
| 4 | — | 재조회 — T1이 아직 미커밋이면 **미스** |

T2가 이 창에 걸리면 재조회로도 못 찾는다. 이때 **500이 아니라 409 `1105`("동일 참조의 요청이 처리 중")
로 응답**하고, 파트너는 잠시 후 `GET ?partnerReference=` 로 확인하도록 문서화한다.
**어떤 경우에도 이 경로가 새 주문을 만들지 않는다는 점이 중요하다** — DB 유니크 제약이 최종 방어다.

**4. B4 소유권 검증은 파사드 3단계(멱등 조회) *이전*에 한다.**
멱등 히트로 200을 반환하는 경로에서도 검증을 건너뛰면, 잘못된 `bankAccountId` 를 보낸 재요청이
검증 없이 통과한다.

### 초안 정정 및 결함

| # | 초안이 말한 것 | 실제 | 조치 |
|---|---|---|---|
| ~~**B1**~~ ✅ | "동일 `partnerReference` 면 200 재사용", 응답 `duplicated` 필드 | core 안에서는 409가 난다 (`approveAsP2p` 가 `COMPLETED` 거부) | **§4.1 파사드 멱등으로 해결.** 단 응답 `duplicated` 필드는 삭제 — HTTP 201/200 으로 구분한다 |
| ~~**B2**~~ ✅ | `requestCurrency=KRW` 도 멱등 | `isSameWithdrawalRequest` 가 USDT를 비교해 환율 드리프트로 깨짐 | **§4.1 — order의 `krw_amount` 를 비교해 해결** |
| ~~**B3**~~ ✅ | `partnerReference` optional | 없으면 `guardRapidDuplicate` 10초 창이 **정당한 요청을 차단**하고, 10초 후엔 이중 차감 가능 | **`@NotBlank` 필수화로 해결** — `partnerReference` 가 있으면 연타 가드 경로 자체를 타지 않는다 |
| **B4** ☠️ **보안** | `bankAccountId` optional | **`approveAsP2p` 에 계좌 소유권 검증이 전혀 없다.** 미지정 시 `accounts.get(0)` 자동 선택뿐. 파트너가 임의의 `bankAccountId` 를 넣으면 **남의 계좌가 수취 계좌로 박히고 거래 확인증에 노출**된다. 참고로 기존 `CreateP2pWithdrawRequest.bankAccountId` 는 `@NotNull` 이라 설계안이 규칙을 완화하는 셈 | **파사드 3단계 전에 소유권 검증**(§4.1) — `bankAccount.ownerType==MEMBER && ownerId==member.id` 아니면 **403 `1107`**. 근본 수정은 `approveAsP2p` 내부에 넣는 것이 맞다(별도 과제) |
| **B5** | 응답 `principalDebited` | **정보량 0.** 판정식 `P2pPrincipalModel.usesOperatingLedgerPrincipal(w)` = `withdrawalType != SETTLEMENT_WITHDRAW` 인데 이 경로는 항상 `USER_PAYOUT` → **무조건 true**. 실패하면 트랜잭션 전체 롤백이라 false가 나올 수 없다 | 필드 **삭제** |
| **B6** | 응답 필드를 반환값에서 얻음 | `createAndConvertToP2p` 반환은 `Withdrawal` 하나. `orderCode`/`krwAmount`/`exchangeRate`/`orderStatus` 는 **`findByWithdrawalId` 추가 조회**, `memberToken` 은 **회원 추가 조회** 필요 (기존 `PartnerWithdrawalService:317-330` 이 정확히 그렇게 한다) | 추가 조회 2회를 설계에 명시 |
| **B7** | `feeAmount` 항상 존재 | `approveAsP2p` 가 **`upfrontFee > 0` 일 때만** 로컬 인스턴스에 세팅. 요율 0이면 **null** | 응답에서 `0` 으로 coalesce |
| **B8** | `partnerReference` 를 그냥 넘김 | `createAndConvertToP2p` 는 `Withdrawal` 을 **내부에서 직접 빌드**하고 `partnerReference` 파라미터가 없다 | **오버로드 추가 필요** — 시그니처 변경 없이는 넣을 방법이 없다 |
| **B9** | — | `createAndConvertToP2p` 는 **KRW/P2P 게이트를 `approveAsP2p` 안에서야** 검사한다. `krw_enabled=false` 파트너의 요청도 `requestWithdrawal`(잠금·freeze·알림 페이로드 생성)을 전부 통과한 뒤 롤백된다 | **파사드 1단계에서 선검사**(§4.1). 에러코드 순서는 기존과 동일하게 `1020` → `1023` |
| **B10** | — | `PartnerWithdrawalController:248` javadoc의 "P2P_PENDING으로 전환"은 **stale**. 실제로 `P2P_PENDING` 은 더 이상 생성되지 않는다 | 주석 정정 |

### `GET /api/v1/p2p/withdrawals/{orderCode}`

**용도는 "접수 사실 재확인"이다. 진행 상황 추적이 아니다.**
파트너 장부는 T0에 종결됐으므로(★ 파트너 회계 원칙) 매칭 진행률·잔여액·전환 상태를 내리지 않는다.

```json
{
  "orderCode": "pwo_...",
  "partnerUserId": "user-001",
  "memberToken": "mbr_...",
  "memberPageUrl": "https://p2p.cryptoments.cc/m/mbr_...",
  "chainType": "TRON",
  "currencyType": "USDT",
  "requestCurrency": "USDT",
  "usdtAmount": "1000.000000000000000000",
  "krwAmount": 1385000,
  "exchangeRate": "1385.0000",
  "feeAmount": "5.000000000000000000",
  "withdrawalStatus": "COMPLETED",
  "bankAccountRegistered": true,
  "partnerReference": "wd-20260821-0001",
  "createdAt": "2026-08-21 04:20:11"
}
```

**의도적으로 뺀 것과 그 이유**

| 뺀 필드 | 이유 |
|---|---|
| `remainingKrw` / `receivedKrw` / `incomingKrw` | 매칭 진행 상황 = **회원과 시스템의 문제**. 파트너 장부와 무관 |
| `matches[]` | 동일 |
| `orderStatus` (`PENDING`/`PARTIALLY_MATCHED`/…) | 동일. 파트너가 볼 상태는 `withdrawalStatus` 뿐이고 그것은 항상 `COMPLETED` |
| `usdtConvert` | 파트너 장부에 아무 일도 일어나지 않는다 |

> 파트너가 자체 화면에 판매 진행률을 보여주고 싶다면 그건 **회원 페이지 링크(`memberPageUrl`)로 보내는 것**이
> 정답이다. 진행 상황의 정본은 회원 페이지이고, 같은 값을 두 곳에서 계산하면 반드시 어긋난다.

⚠️ 이 응답은 `p2p_withdraw_entries` 를 **조회하지 않는다.** 원장 금액 3종(`P2pPageAmounts`)은
회원 페이지·콘솔 전용이다. 실수로 끌어오면 위 원칙이 무너지고 N+1도 따라온다.

### 4.2 잔여 처리 — **취소는 없다** (정책 확정 2026-08-22)

> P2P 출금은 **취소되지 않는다.** 매칭되지 않은 잔여는 USDT로 전환해 회원 주소로 출금하고 주문을 종결한다.

★ 파트너 회계 원칙에 따라 **전환 흐름 자체는 이 설계서의 관심사가 아니다.** 파트너 장부에서는 T0에 이미
나간 돈이고, 회수하더라도 그건 재입금이지 출금의 되돌림이 아니다.

**API 계약에 영향을 주는 것만 남긴다.**

| # | API 계약 |
|---|---|
| 1 | Open API에 **`/cancel` 엔드포인트를 만들지 않는다** |
| 2 | Open API에 **전환 신청 엔드포인트를 만들지 않는다** — 회원 페이지(`/p2p/page/convert-request`)·파트너 콘솔(`convert-direct`) surface다 |
| 3 | `GET /{orderCode}` 에 **전환 상태·매칭 진행률·잔여액을 노출하지 않는다** |
| 4 | 파트너 문서에 **"P2P 출금은 취소되지 않으며, 잔여는 회원이 USDT로 전환해 수령한다"** 를 명시한다 |
| 5 | 파트너 문서에 **"수수료는 매칭 성사 여부와 무관하게 요청 시점에 확정되며 환급되지 않는다"** 를 명시한다 (오너 확정) |

#### ⚠️ 단, B를 파트너에게 여는 전제 조건이다

전환 흐름이 동작하지 않으면 **회원이 잔여를 받을 방법이 없다.** 2026-08-22 실측 결과 이 경로는
**운영 실적 0건**이고 차단 결함 3건이 미해결이다. 상세는 별도 문서:

→ **`v2-docs/P2P_REMAINDER_CONVERT_FINDINGS.md`** (조사 결과 + 선행 과제 P1·P4·P7~P10)

§2 선결 2에 이 의존을 기록했다.

## 5. C. 회원 정보 통합 조회

### `GET /api/v1/members?partnerUserId={id}&include=ekyc,orders,balances`

### ⚠️ path variable 이 아니라 query parameter 다 — 운영 데이터가 강제한다

| 항목 | p2p_members (116건) | external_wallets (341건) |
|---|---|---|
| 공백/한글 등 특수문자 | **9건** | **29건** |
| 대문자 포함 | 31건 | — |
| 최대 길이 | 35자 | 36자 |

실제 값: `"boss2000 박근만"`, `"gkxm787허일범"`, `"아바테스트"`

공백과 한글이 그대로 들어 있어 path variable이면 매 호출 percent-encoding이 필요하고 nginx/tomcat 디코딩
설정에 따라 조용히 깨진다. 기존 `/api/v1/users/{userId}/transactions` 가 path를 쓰지만 **그건 같은 위험을
이미 안고 있는 것**이지 따라야 할 관례가 아니다.

### ⚠️ `partner_user_id` 매칭은 대소문자를 구분하지 않는다

`p2p_members`/`external_wallets`/`withdrawals`/`axim_payments` 전부 **`utf8mb4_unicode_ci`**.
`UNIQUE(partner_id, partner_user_id)` 도 마찬가지라 파트너가 `User1` 과 `user1` 을 다른 사용자로 취급해도
**시스템은 같은 회원으로 본다.** 실측 충돌 0건. 스키마 제약이므로 바꾸지 않고 **API 문서에 명시**한다.

**Query**

| 파라미터 | 필수 | 설명 |
|----------|------|------|
| `partnerUserId` | ✅ | 대소문자 구분 안 함 |
| `include` | | `ekyc`, `orders`, `balances` (콤마 다중) |

- `ekyc` — Axim 실시간 호출. 없으면 **외부 HTTP 0회**. 비용은 §5.2.
- `orders` — 최근 P2P 출금 주문 10건.
- `balances` — 해당 사용자 HOT 지갑 잔액(스냅샷).

**Response** `200`

```json
{
  "partnerUserId": "user-001",
  "p2p": {
    "registered": true,
    "memberToken": "mbr_...", "memberPageUrl": "https://p2p.cryptoments.cc/m/mbr_...",
    "status": "ACTIVE", "tradingPaused": false, "pinSet": true, "depositMethod": "AUTO",
    "telegram": { "connected": true, "chatIdMasked": "****1234", "notifyEnabled": true, "connectedAt": "..." },
    "bankAccounts": [
      { "bankAccountId": 12, "bankCode": "088", "bankName": "신한은행",
        "accountNumberMasked": "110-***-**5678", "accountHolder": "홍길동",
        "scrapingStatus": "ACTIVE", "scrapingExpiresAt": "2027-06-01 00:00:00" }
    ],
    "ledger": { "activeOrderCount": 2, "remainingKrw": 1250000, "activeUsdtAmount": "902.5" },
    "recentOrders": [ { "orderCode": "pwo_...", "krwAmount": 1385000, "remainingKrw": 500000, "status": "PARTIALLY_MATCHED", "createdAt": "..." } ],
    "alias": "홍길동", "createdAt": "..."
  },
  "axim": {
    "connected": true, "status": "CONNECTED", "siteId": "cm-001", "connectId": "6161...",
    "walletAddresses": { "BSC": "0xabc...", "TRON": "TX1..." },
    "connectedAt": "...",
    "connectUrl": null
  },
  "ekyc": { "verified": true, "name": "홍길동", "phone": "010-****-5678", "bankCode": "088", "bankName": "신한은행" },
  "ekycError": null,
  "wallets": [ { "chainType": "TRON", "address": "TX1...", "currencyType": "USDT", "balance": "12.5" } ]
}
```

**설계 원칙**

1. **미등록은 404가 아니다.** 실측이 뒷받침한다 — **P2P 회원 116명 중 Axim 연결까지 있는 사람은 31명뿐**. 한쪽에만 존재하는 것이 다수 케이스다.
2. **파트너 어디에도 흔적이 없는 사용자만 404**(`1100`). 판정 테이블: `p2p_members` / `external_wallets` / **`wallet_addresses`** / `deposits`.
   ⚠️ 초안의 `wallet_assignments` 에는 `partner_user_id` 컬럼이 **없다**.
3. **마스킹은 서버가 한다** → §5.3 C3.
4. **eKYC 노출 필드 제한** — `verified/name/phone/bankCode/bankName` 만. `birthday`/`email`/`accountNumber`/`accountHolder` 는 **절대 내보내지 않는다**.
5. **자격증명은 어떤 형태로도 나가지 않는다.** `scrapingStatus` 플래그까지만.
6. **빈 상태가 기본값이다.** **116명 중 68명(59%)이 PIN도 계좌도 없다** — 회원 페이지에 한 번도 접속하지 않은 상태다. `pinSet=false`, `bankAccounts=[]` 는 예외가 아니라 다수다. `null` 이 아닌 명시적 `false`/`[]` 로 내린다.
7. **★ raw 필드만 제공한다. 판단은 파트너 몫이다** (확정 2026-08-22).
   `nextAction` / `canSellP2p` / `onboardingStep` 같은 **파생 판정 필드를 만들지 않는다.** 파트너마다 온보딩 순서와 게이트 정책이 다르다.
   - **사실은 내려준다**: `connected`, `verified`, `pinSet`, `tradingPaused`, `scrapingStatus`, `bankAccounts[]` …
   - **예외는 "우리만 아는 사실"** — `memberPageUrl`, `connectUrl` 처럼 URL 포맷이 우리 지식이고 언제든 바뀔 수 있는 것은 조립해서 준다. 이건 판단이 아니라 사실이다.

   ⚠️ **감수하는 비용**: P2P 매칭 게이트 조건이 바뀌면(필수 조건 추가 등) 파트너의 자체 판정이 실제 매칭 결과와 어긋난다. raw 방식의 구조적 대가이며, **게이트 조건 변경 시 파트너 공지가 필요**하다.

**에러**: 404 `1100`, 400 validation. **eKYC 실패는 에러가 아니라 `ekyc:null` + `ekycError`, 본 응답 200.**

### 5.0 활용 시나리오 — 파트너가 raw 필드를 조합하는 방식

> **API는 아래 판정을 하지 않는다.** 파트너 문서에 예시로 싣고, **필드 목록이 이 시나리오들을 실제로
> 커버하는지 검증하는 용도**로 쓴다. 하나라도 못 그리면 필드가 빠진 것이다.

| # | 시나리오 | 파트너가 보는 필드 | 이어지는 API |
|---|---|---|---|
| 1 | **Axim 결제 푸시** | `axim.connected == true` | E `POST /axim/payments` |
| 2 | **Axim 연결 유도** | `axim.connected == false` → `axim.connectUrl` | (링크 전달, 완료는 웹훅) |
| 3 | **eKYC 인증 요구** | `ekyc.verified == false` | (파트너 자체 게이트 — 시스템은 막지 않는다) |
| 4 | **Axim 주소로 출금** | `axim.walletAddresses[chainType]` | `POST /users/withdrawal` 의 `toAddress` |
| 5 | **온보딩 다음 단계** | `p2p.registered` → `pinSet` → `bankAccounts[]` → `scrapingStatus` 순으로 비어 있는 첫 항목 | A / `memberPageUrl` 전달 |
| 6 | **결제 수단 라우팅** | `axim.connected` / `wallets[]` 유무 | E 또는 D 또는 입금 주소 |
| 7 | **P2P 판매 가능 여부** | `p2p.status==ACTIVE && !tradingPaused && bankAccounts[].length>0` (+ 파트너 자신의 P2P 활성 설정) | B |
| 8 | **알림 채널 선택** | `p2p.telegram.connected` | — |
| 9 | **휴면/미활성 판정** | `p2p.pinSet == false` = 회원 페이지 미접속 (실측 **59%**) | `memberPageUrl` 재발송 |

**시나리오가 드러내는 것 두 가지**

- **④ Axim 주소로 출금**: `withdrawal_address_whitelist` 는 `policy.addressWhitelistEnabled` 가 켜진 파트너에게만 강제된다. 켠 파트너라면 **Axim 주소를 자동 화이트리스트 등록할지**가 별도 결정이다 — 자동 등록은 화이트리스트의 의미를 없앤다(§9 미결정 20).
- **③ eKYC**: 시스템은 미인증을 **어디서도 자동으로 막지 않는다.** 출금 한도도 P2P 매칭도 eKYC를 보지 않는다. 게이트는 전적으로 파트너 정책이며, 이 사실을 파트너 문서에 명시해야 오해가 없다.

**의도적으로 지원하지 않는 시나리오**

| 시나리오 | 이유 |
|---|---|
| 계좌 중복 탐지 ("이 계좌 이미 쓰는 중") | `bank_accounts UNIQUE(bank_code, account_number)` 가 전역이라 판정은 가능하나, **다른 회원(다른 파트너일 수도)의 존재를 노출**한다. 등록 시점 에러로만 처리한다 |

### 5.1 조회 경로 — 기존 코드에 그대로 쓸 수 있는 것이 없다

| 필요한 것 | 현재 | 조치 |
|---|---|---|
| partnerId+partnerUserId **조회만** | `P2pMemberService` 에는 **`getOrCreate` 뿐** | ☠️ **GET이 회원을 생성한다.** `P2pMemberRepository.findByPartnerIdAndPartnerUserId`(nullable) 사용 |
| Axim CONNECTED 지갑 | 상태 필터 메서드 **없음**. 전건 조회 후 Java 스트림 필터가 **4곳 중복** | 리포지토리 메서드 추가 + 중복 제거 |
| 은행 계좌 | `findByOwnerTypeAndOwnerId(MEMBER, memberId)` 존재 | 그대로 |
| P2P 원장 | `amounts(orderIds)` 배치 1쿼리 | 그대로 |
| 응답 조립 | partner-api **private 컨트롤러 메서드**, DTO도 partner-api 소속 | **재사용 불가.** open-api에 `MemberInfoAssembler` 신설 (진입키도 다르다 — partner-api=memberToken, C=partnerUserId) |

**`primary` 계좌 개념은 코드에도 스키마에도 없다.** 기존 코드는 `accounts.get(0)` 을 "대표"라 부를 뿐이다.
다만 **MEMBER는 1계좌 강제**(2건째 `ACCOUNT_LIMIT_EXCEEDED`)라 실질 최대 1건 → `primary` 필드 삭제.

⚠️ `bank_accounts` 의 `UNIQUE (bank_code, account_number)` 는 **전역 유니크**다. 같은 계좌를 두 파트너의 두
회원이 각각 등록할 수 없다. 실측 MEMBER 계좌 33건 / 회원 116명.

### 5.2 Axim 블록 — 세 가지 함정

**(1) `wallet_addresses` JSON 키는 `networkId` 다.**

```sql
wallet_addresses JSON NOT NULL
    COMMENT '네트워크별 주소 — {"1": "0x...", "3": "T..."}  (key = networks.id)'
```

- 현재 위젯은 **변환 없이 raw JSON을 그대로** 내보낸다 → 위젯이 `{"1":"0x..."}` 를 받고 있다.
- C는 §1.2에 따라 **chainType 키로 변환**한다. `ChainCurrencyResolver.toChainType(networkId)` 사용, **`"UNKNOWN"` 이 나오면 해당 항목을 응답에서 제외**한다.
- 변환 코드는 아직 없다 — 신규 작성.
- ⚠️ `AximService.updateWalletAddresses` javadoc이 `{"BSC":"0x..."}` 라고 적혀 있는데 **틀렸다**(§8 X3).

**(2) CONNECTED 지갑이 여러 건일 수 있다.**

DB 유니크 제약이 없고 `supersedePreviousConnections` 라는 **애플리케이션 로직으로만** 1건을 보장한다.
기존 코드 4곳은 **정렬 없는 `findFirst()`** — 리포지토리 반환 순서 의존.

- 실측: 중복 CONNECTED **0건**. 아직 사고는 없다.
- C·E 모두 **`connected_at DESC` 정렬 후 첫 건**을 쓴다.
- REVOKED 이력(실측 **147건**)은 `connected=false` 로 내리되 `status: "REVOKED"` 를 함께 주어 **"해지함"과 "한 번도 연결 안 함"을 구분**할 수 있게 한다.

**(3) `connectUrl` — 미연결 사용자에게 보낼 Axim 연결 링크**

위젯이 쓰는 진입점은 URL 하나이고, **구성 요소가 전부 서버에 있다.**

```
https://www.axim.one/app/siteConnect?siteId={siteId}&connectId={connectId}
```

- `siteId` = `partner_axim_settings.site_id`, `connectId` = `hex(aximPartnerId + ":" + partnerUserId)`
- 연결 완료는 **`connection.succeeded` 웹훅** 으로 들어와 `external_wallets` 에 저장된다.
- `connected=false` 일 때만 채우고, **연결됐으면 `null`** 로 내린다.

☠️ **함정 3개 — 전부 코드에 사고 기록이 있다**

| # | 함정 |
|---|---|
| 1 | **`siteId`/`connectId` 가 비면 링크를 주지 말 것.** 값이 없는 링크를 열면 axim.one이 앱을 못 찾아 App Store로 폴백해 **앱이 깔려 있는데도 사용자가 갇힌다**(2026-08-21 사고). 문자열 `"undefined"`/`"null"` 오염도 걸러야 한다 — 값 유무만 보면 가드가 무력화된다 |
| 2 | **`redirectUrl` 을 붙이지 않는다.** 위젯이 그렇게 하는 이유가 있다 — URL에 세션 토큰이 실리면 axim.one 접근로그·히스토리에 남고, 임의 https로 되돌려 보낼 수 있으면 **오픈 리다이렉트**가 된다 |
| 3 | **`ua`(User-Agent) 파라미터는 붙이지 않는다.** 앱 열기 판별용인데 **파트너 서버는 사용자 UA를 모른다.** 파트너 프론트가 필요 시 덧붙이도록 문서화한다 |

⚠️ **확인 필요**: `connectId` 는 결정적 해시라 **비밀이 아니다.** 파트너의 Axim ID와 `partnerUserId` 를 알면
제3자가 같은 링크를 만들 수 있다. 제3자가 자기 지갑을 피해자의 `connectId` 에 연결하면 어떻게 되는지는
**Axim 쪽 동작이라 우리 코드로 판정 불가**다. ④ Axim 주소로 출금과 엮이면 위험할 수 있으므로 Axim에 확인해야 한다(§9 미결정 19).

**(4) eKYC 1건 조회에 외부 HTTP 2회 — 최악 20초.**

기존 `getEkycStatus`/`getEkycInfo` 는 `connectId` 를 만들려고 **먼저 `getPartnerInfo()` 를 호출**한다.
타임아웃은 connect 5s / response 10s.

- `connectId = hex(aximPartnerId + ":" + partnerUserId)` 는 **로컬 계산 가능**하다. `getInitInfo` 는 실패 시 `settings.getSiteId()` fallback을 쓰는데 **eKYC 경로에는 없다.**
- → C는 **`siteId` fallback으로 `getPartnerInfo` 를 생략**한다. 외부 1회(최악 10초).

### 5.3 함께 고칠 결함 (회원 도메인)

| # | 대상 | 문제 | 조치 |
|---|---|---|---|
| **C1** ☠️ | `AximController.getEkycInfo` | **네트워크 장애를 "미인증(404 `EKYC_NOT_VERIFIED`)"으로 뭉갠다.** 미인증인지 Axim 장애인지 구분 불가 | 장애는 `1106 EKYC_LOOKUP_FAILED` 로 분리. **위젯 동작도 함께 교정** — 현재는 사용자에게 "미인증"이라 거짓말한다 |
| **C2** | `ExternalWalletRepository` | CONNECTED 필터 스트림이 4곳 복붙, 정렬 없는 `findFirst()` | 리포지토리 메서드 추가 + `connected_at DESC` 통일 |
| **C3** | 마스킹 유틸 부재 | 공용 유틸 **없음**. `maskChatId` 가 두 모듈에 **중복 구현**, `maskAccount` 두 개는 **규칙이 서로 반대**(앞 4자리 vs 뒤 4자리)이고 둘 다 **로그 전용**. 응답 마스킹 **0건** — `P2pMemberResponse` 는 계좌번호를, `getEkycInfo` 는 전화번호를 **원본 노출** | `common` 에 `MaskingUtils` 신설 후 4개 흡수. **기존 파트너 응답의 원본 노출도 함께 정리** |
| **C4** | `AximService.updateWalletAddresses` javadoc | JSON 키를 chainType이라 설명하나 실제는 networkId | 주석 수정 |

> ⚠️ C3은 **기존 응답에서 값이 가려지는 변경**이다. partner-ui가 계좌번호 원본에 의존하는지 확인 필요.

---

## 6. E. Axim Pay 결제 요청

### `POST /api/v1/axim/payments`

**Request**

| 필드 | 필수 | 설명 |
|------|------|------|
| `partnerUserId` | ✅ | 이 사용자의 CONNECTED external_wallet 을 서버가 찾는다 |
| `amount` | ✅ | 코인 수량 |
| `chainType` | ✅ | `BSC`/`POLYGON`/`TRON` |
| `currencyType` | | 기본 `USDT` (기존 위젯은 USDT 하드코딩) |
| `priceKrw` | | **§6 E2 — 어느 값이 저장/전송되는지 주의** |
| `feeAmount` | | 파트너 표시용 수수료. **E3·E4 수정으로 DB에도 저장된다** |
| `orderId` | ✅ | **중복 방지 키.** `@NotBlank` → `axim_payments.partner_reference` (§6.1) |
| `linkCode` | | 결제를 링크에 연결. **링크 생성은 파트너 콘솔에서 한다** — Open API 범위 아님. 못 찾으면 404 `1104`(E12) |

**Response** `201`

```json
{
  "paymentCode": "pay_2608_ab12cd34",
  "aximPaymentId": "axp_...",
  "partnerUserId": "user-001",
  "status": "PENDING",
  "amount": "100.0", "currencyType": "USDT", "chainType": "TRON",
  "priceKrw": "1385.00",
  "feeAmount": "0.5",
  "walletAddress": "TX1...",
  "orderId": "order-12345", "linkCode": "a1b2c3d4e5f6g7h8",
  "createdAt": "2026-08-21 04:35:00"
}
```

- `feeAmount` / `walletAddress` 는 **E3·E4 수정 후** 채워진다. 수정 전에는 항상 null이므로 **수정이 선행돼야 한다.**
- `reused` 필드는 없다 — 재사용 개념을 두지 않는다(§6.1).

**동작**

1. `partner_axim_settings.is_enabled` 확인 → 아니면 `1102`.
2. CONNECTED external_wallet 을 **`connected_at DESC` 정렬 후 첫 건**으로 조회. 없으면 **409 `1101`** + `data`:
   ```json
   { "code": "1101", "data": { "connectId": "6161...", "siteId": "cm-001" } }
   ```
   ⚠️ `aximPartnerId` 를 얻으려고 `getPartnerInfo()` 를 호출하지 말고 **`settings.getSiteId()` fallback** 사용.
3. **`orderId` 중복 검사** — 진행 중 결제가 있으면 **409 `1105`**(§6.1). 재사용하지 않는다.
4. `AximService.requestPayment(...)` INSERT → `sendPaymentToAxim(...)` push.
   ☠️ **push의 유일한 경로는 `sendPaymentToAxim`** 이고, 웹훅 매칭 키는 `axim_payment_id` 뿐이다. INSERT만 하고 push를 빼먹으면 결제가 영원히 `REQUESTED` 로 남고 **웹훅이 영구 미매칭**된다.
5. push 실패 시 `FAILED` 로 종결하고 **예외 전파**(partner-api 방식). 위젯 방식(삼키고 200)을 따르지 않는다.

### 6.1 중복 처리 — **재사용을 두지 않는다** (확정 2026-08-22)

기존 위젯의 in-flight **재사용**은 위젯 사정에서 나온 장치다 — 사용자가 결제 화면에서 뒤로 갔다 오거나
새로고침하면 결제가 또 만들어지고 Axim에 푸시가 중복으로 간다. 그래서 같은 링크에 진행 중 결제가 있으면
그걸 돌려준다.

**Open API에는 이 개념을 넣지 않는다.** 파트너 서버는 뒤로 가기를 하지 않는다.
"조용히 다른 것을 돌려주는" 동작은 서버-투-서버에서 예측하기 어렵고, E1-b(스코프 미검증)의 원인이기도 했다.

| 상황 | 동작 |
|---|---|
| 신규 `orderId` | **201** — 결제 생성 + push |
| 진행 중(`REQUESTED`/`PENDING`/`ONGOING`) 결제가 있는 `orderId` 재요청 | **409 `1105`** — 조용히 재사용하지 않는다 |
| 종결된(`CONFIRMED`/`FAILED`/`EXPIRED`/`CANCELED`) `orderId` 재사용 | **201** — 새 결제 |

`orderId` 는 **필수**(`@NotBlank`)다. 이유가 셋이다.

1. **중복 결제·중복 push 방어** — 현재 `orderId` 만 보내는 호출은 방어가 **전혀 없다**.
2. **DEPOSIT 웹훅 `orderId` 오매칭 완화**(E5-b) — 같은 `orderId` 로 동시 in-flight 가 생기지 않는다.
3. **파트너 대사(對査)** — 실측상 `partner_reference` 가 **265건 중 13건**뿐이다. 나머지는 어느 주문의 결제인지 우리도 파트너도 모른다.

#### `GET /api/v1/axim/payments?orderId={orderId}` — 409 후 확인 경로

409를 받은 파트너가 **기존 결제의 `paymentCode` 를 알 방법**이 필요하다. B의 `?partnerReference=` 와 같은 역할이다.
없으면 파트너는 409만 받고 아무것도 못 한다.

⚠️ **잔여 위험**: `axim_payments` 의 `(partner_id, partner_reference)` 는 **인덱스만 있고 유니크가 아니다.**
선조회로 대부분 잡히지만 **동시 요청 두 건은 둘 다 통과**해 결제가 2건 생길 수 있다.
B는 `withdrawals.idem_key` 유니크가 최종 방어선인데 E에는 그게 없다 → §9 미결정 18.

⚠️ 위젯의 링크 기반 재사용은 **위젯 사정이므로 건드리지 않는다.** 다만 그 블록의 스코프 미검증(E1-b)은 별개로 고친다.

### 초안 정정 및 결함

| # | 초안 | 실제 | 조치 |
|---|---|---|---|
| **E1** ☠️ | "`orderId` 또는 `linkCode` 로 in-flight 재사용" | **`linkCode` 전용이다. `orderId` 로직은 코드에 없다.** `orderId` 만 보내는 호출은 중복 방어가 **전혀 없다** | **✅ `orderId` 필수 + 중복 시 409**(§6.1). 재사용 개념은 넣지 않는다 |
| **E1-b** ☠️ **보안** | — | 위젯의 재사용 블록이 **partnerId를 검증하지 않는다.** 링크에 물린 in-flight 결제가 있으면 호출자가 누구든 `paymentCode`/`aximPaymentId` 를 받는다. `findFirst()` 도 정렬 없음 | Open API는 재사용이 없어 해당 없음. **위젯 블록은 별도로 스코프 검증을 추가한다** |
| **E2** ⚠️ | 응답 `priceKrw` | **두 값이 다르다.** DB의 `payment.priceKrw` 는 `priceService.getCurrentPrice()` 조회값, Axim에 보낸 값은 **클라이언트가 준 `request.getPriceKrw()`** 다 (partner-api는 반대로 DB값을 보낸다) | Open API는 **DB 저장값을 응답**하고, Axim 전송도 **DB값으로 통일**한다(partner-api 방식) |
| **E3·E4** ☠️ | 응답 `feeAmount` / `walletAddress` | **컬럼도 엔티티 필드도 있는데 INSERT 빌더에 없다.** 이후 `setFeeAmount`/`setWalletAddress` 호출도 **전 소스 0건** → 영원히 NULL. `feeAmount` 는 **Axim에는 전달되지만 우리 DB에는 안 남는다**.<br>**운영 실측 265건: `fee_amount` 0건, `wallet_address` 0건** (`price_krw` 는 265/265) | **✅ 고친다 — 응답에서 빼지 않는다.** ① `requestPayment` 에 `feeAmount` 파라미터 추가 → 엔티티에 세팅 ② `walletAddress` 는 같은 메서드가 이미 조회한 `ExternalWallet` 의 `wallet_addresses[networkId]` 에서 파생해 세팅. **호출부는 위젯·partner-api 2곳뿐**이라 시그니처 변경이 저렴하다 |
| ~~**E5**~~ | "파트너 웹훅으로 상태 전달" | **Axim 결제 상태 웹훅은 필요 없다**(오너 확정 2026-08-22). 파트너에게 중요한 것은 **입금 사실**이고, 그건 기존 **온체인 입금 확정 DEPOSIT 웹훅**이 이미 전달한다. 결제의 중간 상태(ongoing/failed/expired)는 파트너 관심사가 아니다 | 웹훅 신설하지 않음. `GET /{paymentCode}` 는 **보조**로 유지(푸시가 나갔는지 확인용) |
| **E5-b** ⚠️ | — | ☠️ **DEPOSIT 웹훅의 `orderId` 는 txHash 매칭이 아니라 휴리스틱이다.** `axim_payments` 에 tx_hash 가 없어서, `partnerId + partnerUserId` 로 `deposit_id` 미링크 + FAILED/EXPIRED 아닌 건 중 **`max(id)`(가장 최근)** 를 골라 연결한다 → **같은 사용자에게 in-flight 결제가 2건이면 오매칭**된다 | `orderId` 필수 + 중복 409(§6.1)로 **같은 주문의 동시 in-flight 는 막힌다.** 단 서로 다른 `orderId` 의 동시 결제는 여전히 오매칭 가능 — 근본 해결은 아니다 |
| **E6** | "실패 시 FAILED + 예외 전파" | 그건 **partner-api 동작**이다. **위젯은 예외를 삼키고 `REQUESTED` 로 200 응답**하면서 DB는 FAILED로 바꿔 **응답과 DB가 불일치**한다 | Open API는 **partner-api 방식** 채택 |
| **E7** ⚠️ | 트랜잭션 롤백 우려 | **`AximService` 에 `@Transactional` 이 0건**이다. 각 repository 호출이 독립 auto-commit이라 롤백으로 FAILED 기록이 날아갈 위험은 **현재 구조엔 없다**. 오히려 **`@Transactional` 을 새로 붙이면 FAILED 마킹과 INSERT가 함께 롤백되어** 우려가 현실이 된다 | ☠️ **이 경로를 `@Transactional` 로 감싸지 말 것** |
| **E8** ☠️ **보안 — IDOR** | `POST /{paymentCode}/cancel` | 위젯이 **`axim_payments.id`(auto-increment 정수)를 경로에 그대로 받는다.** `GET|DELETE /widgets/api/axim/payments/{paymentId}` → `findOne(paymentId)` 는 PK 조회일 뿐이고 **세션 파라미터조차 받지 않는다.** `1, 2, 3…` 순회로 **남의 결제를 보고 취소할 수 있다** | **✅ 고친다 — 소유권 검증을 넣는다.** ① `cancelPayment`/조회에 `partnerId` 를 받아 `payment.partnerId` 대조, 불일치 시 404 ② Open API 경로는 §1.2에 따라 `paymentCode` 사용(추측 가능한 순번 미노출) ③ **위젯 엔드포인트도 세션을 받아 같은 검증을 한다** — IDOR의 실제 위치는 거기다 |
| **E9** | — | `cancelPayment` 허용 상태는 **`REQUESTED`/`PENDING` 만**. **`ONGOING` 은 취소 불가**인데 in-flight 재사용 필터는 ONGOING을 포함한다 → **재사용된 결제를 취소하려 하면 409** | 문서에 명시 |
| **E10** ✅ | "`orderId` = `partnerReference`" | **맞다.** `requestPayment` 7번째 인자가 `partnerReference` 이고 역방향(`WebhookPayloadBuilder.resolveOrderId`)도 일관 | 유지. 단 위젯은 fallback이 없어 NULL 허용 — Open API는 **빈 값이면 `paymentCode` 로 대체**(partner-api 방식) |
| **E11** ✅ | "위젯 `linkId` 는 숫자일 것" | **틀렸던 건 초안이다.** `AximPaymentRequest.linkId` 는 **`String`** 이고 값이 linkCode다. 변환(`findByLinkCode`)이 이미 있다 | 파트너 콘솔에서 만든 `linkCode` 를 그대로 넘기면 된다 |
| **E12** ⚠️ | — | 링크를 못 찾아도 **에러 없이 `paymentLinkId=null` 로 진행**한다. 잘못된 linkCode는 조용히 무시되고 중복 방어도 사라진다 | Open API는 **404 `1104`** 로 거절 |
| **E13** | — | `aximPaymentId` 는 DB `String` 인데 위젯 응답 DTO는 `Long` 이고 `parseLongOrNull` 로 변환 → **비숫자 ID면 조용히 null** | Open API 응답은 **String** 으로 |
| **E14** | — | `currencyId` 를 못 찾으면(`USDT` 없는 네트워크) `requestPayment` 가 시세 조회를 건너뛰어 `priceKrw`/`priceUsd` 가 NULL | `currencyType` 해석 실패 시 **400** |

---

## 7. F·G. 가용 잔액 조회 / 출금 사전 검증

### 7.0 점검 결과 — 현재 상태는 결함이다

정본 가용액 산식은 `SettlementService.getAvailableForWithdrawal` 하나이고, **`/api/v1/*` 에는 없다.**
노출처는 partner-api 콘솔 / 대시보드 / 위젯 precheck 3곳뿐. 대신 "잔액"이라는 이름의 API 두 개가 **서로 다른 값**을 낸다.

| 엔드포인트 | 실제 값 | 문제 |
|---|---|---|
| `GET /api/v1/partner/balances`(MASTER) `/wallets`(HOT) | **`wallet_balances` 온체인 스냅샷** | 백업 싱크 6시간 주기 → **최대 6시간 지연**. hold·잠금 미반영. `WalletResponseV1` 에 가용액 필드 자체가 없음 |
| `GET /widgets/api/balance` | `ledger_entries` **원장 원잔액** | ⚠️ **`availableBalance` 가 이름만 가용액** — `balance` 와 같은 값이고 hold를 빼지 않는다. `lockedBalance` 는 상수 `"0"`. **실제보다 크게 나온다** |

결과: 파트너가 v1으로 잔액을 조회해 그 금액으로 출금을 던지면 **409로 튕긴다.**

### 7.0-b ☠️ 정본 산식의 세 번째 항은 이미 죽었다

```java
available = ledgerMapper.computeBalance(...)                 // 원장 실잔액 (fee-net)
          − withdrawalMapper.sumPendingGeneralWithdrawals()  // 진행 중 일반 출금 hold
          − p2p_partner_locks.locked_balance                 // ☠️ 죽은 항
```

| 확인 | 실측 |
|---|---|
| `lockForMatch`/`unlockForMatch` 호출부 | **0건** — "☠️ 잠금 게이트 제거 (2026-08-21)" 주석만 남음 |
| 살아 있는 사용처 | `P2pLockService.getAvailableForP2p`(**읽기 전용**, 파트너·어드민 풀 현황)뿐 |
| 운영 DB (2026-08-21) | 5행 중 3행이 >0, **합계 `0.000000000000000005`** (1e-18 먼지) |

P2P 출금 원금은 **요청 시점에 원장에서 전액 DEBIT** 되므로 이미 `ledgerBalance` 에서 빠져 있다. lock으로 또 빼면 **이중 차감**이다.

> **그래서 F 응답에 `p2pLockedBalance` 필드를 두면 안 된다.** 값이 항상 0/먼지라 구분되지 않고, 이름이 실제
> P2P 재고와 무관하다 — **이름이 거짓말하는 필드**다.
> ☠️ **잠금을 되살리는 방향의 수정은 금지** — `p2p_partner_locks` 쓰기를 부활시키면 매칭이 조용히 멈춘다. 제거만 허용.

### 7.1 F. `GET /api/v1/partner/available-balance`

**Query**: `chainType`(선택), `currencyType`(선택). 미지정 시 활성 전체.

**Response** `200` — `List<AvailableBalanceResponse>`

```json
[{
  "chainType": "TRON", "currencyType": "USDT",
  "ledgerBalance": "1000.000000",
  "pendingWithdrawalHold": "150.000000",
  "availableBalance": "850.000000",
  "onchainSnapshot": "1002.310000",
  "onchainSnapshotAt": "2026-08-21 00:12:00"
}]
```

- `availableBalance` = `getAvailableForWithdrawal(...)` **그대로**. 음수는 0 클램프.
- **항등식: `ledgerBalance − pendingWithdrawalHold = availableBalance`** — 두 항뿐이다.
  ⚠️ 현재 코드는 죽은 lock 항을 더 빼서 **1e-18 만큼 어긋난다.** F 구현 시 **F5(§7.3)를 함께 처리**해 항등식을 성립시킨다.
- `onchainSnapshot` 은 **참고용**. 필드명과 `onchainSnapshotAt` 으로 스냅샷임을 명시한다. `onchainSnapshot ≠ ledgerBalance` 는 정상이다.

**"P2P에 묶인 내 돈"은 별도 축이다.** 파트너가 알고 싶어 하는 값은 `p2p_withdraw_entries` 잔액이고,
**이미 DEBIT되어 가용액에서 빠져 있으므로 뺄셈 항이 아니다.** 넣는다면 별도 객체로만:

```json
"p2pInventory": { "remainingKrw": 1250000, "activeOrderCount": 2, "note": "이미 차감된 금액 — availableBalance 와 무관" }
```

### 7.2 G. `POST /api/v1/withdrawals/precheck`

**Request** `{ chainType, currencyType, amount, toAddress?, partnerUserId? }`

**Response** `200`

```json
{
  "allowed": false, "reasonCode": "EXCEEDS_AVAILABLE", "reason": "출금 가능 금액을 초과했습니다.",
  "availableBalance": "850.000000", "requestedAmount": "900.000000",
  "minWithdrawalAmount": "10.000000", "estimatedFee": "1.000000"
}
```

- 검증 항목은 위젯 `WithdrawalController.evaluate(...)` 와 동일: 최소 금액, 가용액, 온체인, 주소 유효성/화이트리스트.
- **위젯과 달리 금액 수치를 포함한다.** 위젯이 숨기는 건 "엔드유저 화면"이라는 이유(2026-08-13 결정)이고, **서버-투-서버 파트너는 자기 잔액을 볼 권리가 있다.**
- `allowed=true` 는 **보장이 아니다** — 사이 시점에 다른 출금이 들어오면 접수에서 409가 날 수 있다.
- ⚠️ **복붙 금지.** 위젯 `evaluate` 를 core(`WithdrawalPrecheckService`)로 끌어올려 공유한다. 두 벌이 되면 반드시 어긋난다.

### 7.3 함께 고칠 결함 (잔액 도메인)

| # | 대상 | 문제 | 조치 |
|---|---|---|---|
| **F1** | `widget/BalanceController` | `availableBalance` 가 원장 원잔액(hold 미차감), `lockedBalance` 상수 `"0"` | `getAvailableForWithdrawal` 로 교체. **위젯 표시 금액이 줄어드는 변경 — 프론트 동시 배포 + 파트너 공지 필요** |
| **F2** | `v1/PartnerInfoController` | 이름이 "balances"인데 온체인 스냅샷 | `onchainSnapshotAt` 추가 + "출금 가능액 아님, F를 쓰라" 명시. **경로 유지**(기존 연동 파괴 금지) |
| **F3** | `v1/UserController.createWithdrawal` javadoc | `@response` 에 **409가 없다** — 잔액 부족/중복이 전부 409인데 문서 누락 | `@response 409` 추가 후 spec 재생성 |
| **F4** | `SettlementService.recordFee` | 잔액 검증 없이 **음수 허용**(ERROR 로그만) | **의도된 설계 — 변경하지 않는다.** 대신 "원금 DEBIT → 수수료 FEE 순서 고정"을 체크리스트에 유지 |
| **F5** | `getAvailableForWithdrawal` | **폐기된 `p2p_partner_locks` 를 아직 차감** — 이중 차감이자 항등식을 깨는 원인 | ① 차감 로직 + repository 의존 제거 → `available = ledger − pending` ② 잔존 3행 정리(DML) ③ **`P2pLockService` 는 남긴다**(읽기 사용처 존재). 영향 방향은 가용액이 1e-18 늘어나는 것이라 자금 위험 없음 |

### 7.4 출금 잔액 검증 — 현재 동작 (정상, 변경 없음)

| 경로 | 검증 |
|------|------|
| `requestWithdrawal` (v1 `/users/withdrawal`) | ✅ 4단계 — ⓪온체인 게이트(fail-open) → ①가드행 `FOR UPDATE` → ②`freeze` 가용액 비교 → ④save 후 원자 재검증 |
| `approveAsP2p` | ✅ 조건부 — `usesOperatingLedgerPrincipal` 인 건에 한해 락 → 가용액 비교 → `debit` 2차 방어 |
| `createAndConvertToP2p` (**B**) | ✅ 위 둘에 위임. **온체인 게이트 OFF**(P2P는 온체인 전송이 없으므로 의도된 것) |

- 잔액 부족은 **요청 시점 즉시 409 `INSUFFICIENT_BALANCE`**(접수 후 FAILED 아님).
- 예외: 온체인 게이트가 fail-open이라 노드 장애 시 통과 후 릴레이어 단계에서 `EXHAUSTED`/`FAILED` 가 될 수 있다.
- ☠️ **`SettlementService.debit` 은 원장 원잔액만 본다** — hold를 빼지 않는다. `debit` 단독으로는 가용액 검증이 아니다. **신규 코드가 `debit` 만 호출하고 검증을 생략하면 안 된다.**
- `settlement_balances` 에는 `available_balance`/`frozen_balance` 컬럼이 **없다**. `frozen_amount` 는 2026-07-02 DROP됨. **가용액은 저장되지 않고 매번 파생된다** — 신규 API도 컬럼을 만들지 않는다.

---

## 8. 구현 배치

```
open-api/src/main/java/com/cryptoments/openapi/
├── controller/v1/
│   ├── P2pMemberV1Controller.java        A
│   ├── P2pWithdrawalV1Controller.java    B
│   ├── MemberInfoV1Controller.java       C
│   ├── AximPaymentV1Controller.java      E
│   └── BalanceV1Controller.java          F·G
├── service/
│   ├── MemberInfoAssembler.java          C — 여러 도메인 조회를 한 응답으로 조립
│   ├── OpenApiP2pFacade.java             A·B — chainType 해석 + 게이트 선검사 + 소유권 검증
│   └── OpenApiAximFacade.java            E — 지갑 해석 + orderId 중복검사 + 스코프 검증
└── dto/  request/ · response/

common/src/main/java/com/cryptoments/common/util/
└── MaskingUtils.java                     C3 — account/phone/chatId 공용 (기존 4개 흡수)

core/src/main/java/com/cryptoments/core/
├── withdrawal/WithdrawalPrecheckService.java   G — 위젯 evaluate() 승격, 위젯도 이걸 호출
└── p2p/P2pMemberPageUrlBuilder.java            A·B·C — memberPageUrl 조립 (현재 헬퍼 없음)

```

> 전환 흐름(`P2pUsdtConvertExecuteJob` 등)은 **이 설계서의 범위가 아니다** —
> surface가 회원 페이지·콘솔·core·scheduler다. → `v2-docs/P2P_REMAINDER_CONVERT_FINDINGS.md`

**규칙 (CLAUDE.md 준수)**

- 세션 파라미터는 전부 `OpenApiSessionData`.
- DTO 모든 필드에 JavaDoc 주석 필수. `@Data` 금지. 요청 `@Getter @Setter`, 응답 `@Getter @Builder`.
- **core에 새 자금 로직을 만들지 않는다.** B는 `createAndConvertToP2p`(오버로드만 추가), E는 `requestPayment`+`sendPaymentToAxim`.
- `@Valid @RequestBody` 필수.

### 설정 추가 (2건)

| 모듈 | 키 | 이유 |
|---|---|---|
| (확인) | `cryptoments.p2p.member-page-base-url` | open-api엔 이미 있음. **partner-api엔 없음** — 필요 시 추가 |

### DDL 변경

| 항목 | 필요 여부 |
|---|---|
| B 멱등 (`withdrawals.idem_key`) | **불필요** — 이미 존재 |
| E 중복 (`axim_payments.partner_reference`) | **불필요** — 컬럼·인덱스 존재 (유니크는 없음) |

### ★ restdoc generator 작성 규칙 (필수 준수)

파트너에게 나가는 API 문서는 `gradle-restdoc-generator 2.1.7` 이 **소스 주석에서 자동 생성**한다.
주석이 규칙을 어기면 문서가 조용히 비어 나간다 — 컴파일은 통과하므로 **리뷰에서만 잡힌다.**

#### 1) 컨트롤러 매핑 — `name` 속성 필수

```java
@PostMapping(value = "/p2p/withdrawals", name = "P2P 출금 요청")
```

`name` 이 문서에 표시되는 API 명이다. **현재 58개 API 전부 채워져 있다** — 빠뜨리면 그 API만 이름 없이 나간다.

#### 2) ☠️ `@header` 는 **메서드 javadoc**에 쓴다 — 클래스 레벨은 무시된다

실측으로 확인된 사실이다.

| 위치 | spec-bundle `headers` |
|---|---|
| `TransactionController` **클래스**에 HMAC 3헤더 기재 | **`[]`** — 반영 안 됨 |
| `UserController.createDepositWallet` **메서드**에 `@header Access-Token` | `[{name: "Access-Token", …}]` ✅ |

현재 **세션이 필요한 API 44개가 `headers: []`** 다 — 파트너 문서에 인증 헤더가 한 줄도 없다.
`restMetaGenerator.auth` 블록도 `bearer/Authorization` 고정이라 v1 HMAC 3헤더를 자동으로 채워주지 않는다.

→ **신규 API는 메서드마다 아래 3줄을 반드시 넣는다.**

```java
 * @header X-API-KEY 파트너 API 키
 * @header X-TIMESTAMP 요청 타임스탬프 (epoch seconds)
 * @header X-ACCESS-TOKEN HMAC-SHA256 서명 (Base64)
```

#### 3) `@auth` 태그가 `needsSession` 을 결정한다 — 세션 파라미터가 아니다

`/api/v1/currency-prices/*` 9개는 **세션 파라미터를 선언하지 않은 공개 엔드포인트**인데
javadoc에 `@auth true` 가 있어서 문서상 `needsSession=true` 로 나간다. **문서와 코드가 정반대다.**

→ 신규 API는 `@auth` 값을 **실제 세션 파라미터 유무와 일치**시킨다.

#### 4) 메서드 javadoc 템플릿 (이대로 복사해 채운다)

```java
/**
 * P2P 출금을 요청한다. 파트너 잔액을 차감해 P2P 판매 재고(KRW 원장)로 충전한다.
 *
 * @param request P2P 출금 요청 (사용자 ID, 체인, 금액, 멱등키)
 * @param session 인증된 파트너 세션
 * @return 생성된 P2P 출금 주문
 * @response 201 생성 성공
 * @response 200 동일 partnerReference 재요청 — 기존 주문 반환
 * @response 400 요청 파라미터 오류
 * @response 403 해당 회원의 계좌가 아님
 * @response 404 체인/통화를 찾을 수 없음
 * @response 409 잔액 부족 / 기능 비활성 / 참조 중복
 * @group P2P
 * @auth true
 * @header X-API-KEY 파트너 API 키
 * @header X-TIMESTAMP 요청 타임스탬프 (epoch seconds)
 * @header X-ACCESS-TOKEN HMAC-SHA256 서명 (Base64)
 */
```

⚠️ **`@response 409` 를 빠뜨리지 말 것.** 기존 `UserController.createWithdrawal` 이 그 사례다 —
잔액 부족·중복이 전부 409인데 javadoc에 없어서 **파트너 문서에 409가 존재하지 않는다**(§7.3 F3).

#### 5) 클래스 레벨에는 `@group` / `@auth` 만

```java
/**
 * P2P 출금 API.
 *
 * @group P2P
 * @auth true
 */
@RestController
@RequestMapping("/api/v1/p2p")
```

#### 6) DTO — 필드마다 JavaDoc + `@XSample`

```java
/** 출금 금액 (USDT) */
@XSample("1000.000000")
@NotNull @DecimalMin("0.000001")
private BigDecimal amount;
```

`one.axim.gradle.annotation.XSample` 이 문서의 요청/응답 예시를 만든다. 없으면 예시가 비거나 타입 기본값이 나간다.

#### 7) 문서 제외는 `@XApiIgnore`

`one.axim.gradle.annotation.XApiIgnore` — 웹훅 컨트롤러·내부 엔드포인트에 붙인다.
세션 DTO는 `build.gradle` 의 `excludeClasses` 로 이미 제외돼 있다(`OpenApiSessionData`, `WidgetSessionData`, `SessionData`).

#### 8) 생성·배포 절차

```bash
./gradlew :open-api:generateSpecBundle
# → build/docs/spec-bundle.json 생성 후 src/main/resources/static/docs/ 로 복사
```

☠️ **결과물을 반드시 커밋한다.** `processResources` 는 `build/docs/spec-bundle.json` 이 없으면
**조용히 스킵**하고 이미 커밋된 옛 파일을 그대로 배포한다. 빌드 디렉터리는 CI에서 청소되므로
**커밋하지 않으면 신규 API가 문서에 영원히 안 나온다.**

서빙 경로: `GET /docs/spec-bundle.json` (static, 인증 없이 공개). 뷰어는 `v2-docs/api.html`.

#### 9) 함께 갱신할 산출물

| 파일 | 현재 상태 |
|---|---|
| `open-api/.../static/docs/spec-bundle.json` | 서빙본. 마지막 커밋 **2026-04-29 → 약 4개월 stale** |
| `v2-docs/spec-bundle.json` | 별도 사본, 크기가 다름 — **두 벌이 어긋나 있다** |
| `v2-docs/openapi.json` | **파손** — `securitySchemes: null`, 세션 필드가 query parameter로 노출 |
| `v2-docs/api.html` | 뷰어 — 새 `@group` 이 렌더되는지 확인 |
| `OPEN_API_V1_COMPAT_GUIDE_80.md`, `PARTNER_API_INTEGRATION_ONBOARDING.md`, `partner-guide.html` | 파트너 가이드 |

### 별도 정리 과제 (이 설계 범위 밖, 기록만)

| # | 항목 |
|---|---|
| X1 | `ErrorCodes` 자기충돌 4건(601/602/701/702) — 둘 다 살아 있는 코드 |
| X2 | `OpenApiException` 죽은 코드 정리(참조 0건) |
| X3 | `AximService.updateWalletAddresses` javadoc 오류 |
| X4 | `ChainCurrencyResolver` javadoc의 `"ETHEREUM"` 예시 오류 (DB에 없음) |
| X5 | `toChainType`/`toCurrencyType` 이 `"UNKNOWN"` 을 200 응답에 실어 보냄 + 5분 캐시 |
| X6 | `v2-docs/openapi.json` 파손, spec-bundle 두 벌 불일치 |

---

## 9. 미결정 사항

| # | 항목 | 선택지 | 비고 |
|---|------|--------|------|
| ~~1~~ | ~~회원 페이지 라우트~~ | **✅ 확정: `/m/{token}`** | 코드 3곳 일치 확인 |
| 2 | A 게이트 범위 | `krwActive` 만 vs `p2pWithdrawAllowed` 까지 | A4 — 현행은 게이트 자체가 없음 |
| 3 | E `chainType` 필수 여부 | **필수 제안** | 자동 선택은 예상 못한 체인으로 결제될 위험 |
| 4 | C `balances` include | 넣을지 | 스냅샷 지연 명시 필요 |
| ~~5~~ | ~~B 취소 API 노출~~ | **✅ 해소: 취소 없음. 잔여는 USDT 전환만** | §4.2 |
| 6 | P2P 웹훅 이벤트 추가 | `P2P_MATCHED`/`P2P_SETTLED` | 폴링 부담 완화 |
| 7 | F1 위젯 잔액 수정 시점 | 신규 API와 함께 vs 별도 | 표시 금액이 줄어드는 변경 |
| 8 | G precheck 리팩토링 범위 | 위젯도 core 전환 vs v1만 신규 | 두 벌이 되면 어긋난다 |
| 9 | F `p2pInventory` 포함 | 포함 vs 제외 | 뺄셈 항으로 오해 위험 |
| 10 | F5 잔존 행 정리 | UPDATE vs 방치 | 1e-18 먼지 3행 |
| 11 | C3 마스킹 적용 범위 | C만 vs 기존 파트너 응답까지 | partner-ui 의존 확인 필요 |
| 12 | C1 위젯 동반 교정 | 함께 vs 별도 | 위젯이 장애를 "미인증"이라 거짓말 중 |
| 13 | C 조립 로직 위치 | core 승격 vs open-api 전용 | 승격 시 DTO 이동 → partner-api 회귀 위험 |
| **14** | **v1 목록 API 페이지네이션** | `XPage` 도입 vs `List` 유지 | v1엔 페이지네이션이 아예 없다. `getUnconfirmedDeposits` 는 무제한 반환 |
| ~~15~~ | ~~B `requestCurrency=KRW` 멱등~~ | **✅ 해소: order의 `krw_amount` 비교** | §4.1 |
| **18** | **`axim_payments (partner_id, partner_reference)` 유니크 추가 여부** | 추가 vs 현행(인덱스만) | §6.1 — 없으면 동시 요청 시 결제 2건. 추가하면 DDL 변경 1건이 생긴다 |
| ~~19~~ | ~~`connectId` 가 비밀이 아닌 것의 파장~~ | **✅ 해소 (2026-08-29) — 자금 위험 없음** | 아래 §9.1 참조 |
| **20** | **Axim 주소 자동 화이트리스트 등록** | 자동 vs 수동 | 화이트리스트를 켠 파트너 대상. 자동 등록은 화이트리스트의 의미를 없앤다 |

### 9.1 미결정 19 조사 결과 — `connectId` (2026-08-29)

**결론: 자금 탈취 경로가 없다.** 초안이 "④ Axim 주소 출금과 엮이면 위험"이라 적은 것은 **가정문**이었고,
그 연결 고리가 구현에 존재하지 않는다.

**확인된 사실 (코드)**

| # | 사실 |
|---|---|
| 1 | `connectId` 는 해시가 아니라 **단순 hex 인코딩**이다 — `AximConnectIds.generate` 가 `hex(aximPartnerId + ":" + partnerUserId)` |
| 2 | 브라우저에 **평문으로 내려간다** — `AximInitInfoResponse.connectId`, `*LinkInfoResponse.aximConnectId` → `connect.vue` 가 siteConnect 링크를 조립. **설계상 불가피**(사용자가 앱을 열어야 함) |
| 3 | 형식은 **Axim 규약이 아니라 우리 규약**이다 — 웹훅은 `POST /webhooks/axim/{partnerId}` 로 파트너를 이미 알고, `connectId` 를 해석하는 코드는 우리 `extractPartnerUserId` 하나뿐. v1(`7_a123`)→v2(hex) 전환을 Axim 통보 없이 한 이력이 그 증거 |
| 4 | `supersedePreviousConnections` 가 **신규 연결로 기존 연결을 REVOKED 교체**한다 |
| 5 | 우리 API 중 `connectId` 를 **입력으로 받는 곳이 0건**이다 (요청 DTO·파라미터 모두 없음) |

**피해 경로 추적 — 여기가 핵심이다**

| 경로 | 실제 |
|---|---|
| **출금** | ❌ **불가.** 출금은 연결된 Axim 주소를 자동 사용하지 않고, 화이트리스트 자동 등록도 없다. `toAddress` 는 파트너가 HMAC 인증으로 명시하는 파라미터다. 위젯도 출금 주소를 Axim 에서 자동 채우지 않는다 |
| Axim Pay 결제 푸시 | 공격자 지갑으로 **결제 요청**이 감 → 공격자가 돈을 내야 한다. **탈취가 아니라 방해** |
| eKYC 정보 | 공격자 신원이 표시됨. 다만 시스템은 eKYC 로 아무것도 게이팅하지 않는다(§5.0 ③) |
| 미연결 회원 선점 | 피해자가 자기 Axim 을 연결하지 못함 — **서비스 방해(DoS)** |

**남는 위험은 "미연결 회원의 연결 선점에 의한 방해"** 수준이고 자금 위험이 아니다. 우선순위 낮음.

**설계 모델 (오너 확인 2026-08-29)**
> 웹↔앱 연결을 유도하고, 연결된 Axim 을 그 사용자의 것으로 인정한다(사용자의 행위이므로).
> 이후 결제 푸시·주소 전달은 연결된 Axim 에 한해 이뤄지고, 계정을 바꾸려면 연결을 끊는다.
> `connectId` 는 그 안에서 세션 식별자 역할이며 **노출을 전제로 설계됐다.**

⚠️ **이 판정을 뒤집으려면** 위 "피해 경로" 표의 ❌ 가 ✅ 로 바뀌어야 한다. 즉 **출금이 연결된 Axim
주소를 자동으로 쓰게 되는 변경**(설계서 §5.0 시나리오 ④ 자동화, 미결정 20 자동 화이트리스트 등록)이
들어가면 재평가가 필요하다. 그때는 `connectId` 를 랜덤 토큰 + DB 매핑으로 바꾸는 것을 함께 검토할 것
— 형식이 우리 규약이므로 Axim 규약 변경 없이 가능하다(위 사실 3).

---

## 10. 리뷰 체크리스트

**공통**
- [ ] 신규 컨트롤러가 전부 `OpenApiSessionData` 를 쓴다 (`WidgetSessionData` 0건)
- [ ] 응답 어디에도 `currencyId`/`networkId`/`externalWalletId`/`partnerId`/`payment_links.id` 가 없다
- [ ] `chainType` 예시·검증에 `ETH` 가 없다 (`BSC`/`POLYGON`/`TRON` 만)
- [ ] `toChainType` 이 `"UNKNOWN"` 을 반환한 항목이 응답에 실리지 않는다
- [ ] 신규 에러코드가 **1100번대**이고 `OpenApiException`(죽은 코드)을 쓰지 않는다
- [ ] 타 파트너 자원 조회 시 404 (403 아님)

**A**
- [ ] `getOrCreate` 에 `DuplicateKeyException` 수렴 처리가 들어갔다 (동시 요청 500 0건)
- [ ] 응답에 `created` 필드가 없고 항상 201이다
- [ ] **A가 `updateProfile` 을 호출하지 않는다** (재요청이 `memo` 를 지우지 않는지 테스트)

**B — 자금 이동. 아래 멱등 항목은 전부 테스트로 증명한다**
- [ ] `createAndConvertToP2p` 를 호출하고 자체 원금 DEBIT·수수료 로직을 새로 짜지 않았다 (core 변경은 B8 오버로드뿐)
- [ ] 실행 순서가 **원금 DEBIT → 수수료 FEE** 로 유지된다
- [ ] `partnerReference` 가 `@NotBlank` 다
- [ ] **동일 `partnerReference` + 동일 내용 재요청 → 200**, 원금이 **한 번만** 차감된다 (원장 실측)
- [ ] **`requestCurrency=KRW` 재요청이 환율 변동 후에도 200** 이다 (시세 조작 후 테스트 — B2 회귀 방지)
- [ ] **동일 `partnerReference` + 다른 금액 → 409 `1105`**, 새 주문이 생기지 않는다
- [ ] 종결실패 건의 `partnerReference` 재사용 → **201** (신규 생성)
- [ ] **동시 요청 2건이 원금을 두 번 차감하지 않는다** (경합 시 하나는 200 수렴)
- [ ] `GET ?partnerReference=` 가 존재하고, 미접수 건에 **404** 를 반환한다
- [ ] **`bankAccountId` 소유권을 검증한다** (남의 계좌 지정 시 403 `1107`)
- [ ] 응답에 `duplicated`/`principalDebited` 필드가 **없다**
- [ ] `feeAmount` 가 null이면 0으로 나간다
- [ ] KRW/P2P 게이트를 **파사드 진입부에서 선검사**한다 (`1020` → `1023` 순서)
- [ ] `remainingKrw` 가 `p2p_withdraw_entries` 합계 기반이다

**B-잔여 (§4.2) — Open API 범위**
- [ ] Open API에 **`/cancel` 엔드포인트가 없다**
- [ ] Open API에 **전환 신청 엔드포인트가 없다** (회원 페이지·콘솔 surface)
- [ ] `GET /{orderCode}` 응답에 **`usdtConvert` 블록이 없다** — 파트너 장부는 T0에 종결됐다
- [ ] 응답 어디에도 **매칭 진행률·잔여액이 없다** (파트너 관심사 아님)
- [ ] 파트너 문서에 **"수수료는 매칭 여부와 무관하게 확정·환급 없음"** 이 명시됐다
- [ ] 파트너 문서에 **"P2P 출금은 취소되지 않으며, 잔여는 회원이 USDT로 전환해 수령한다"** 가 명시됐다

> **선행 의존**(전환 흐름 P1·P4·P6~P10)의 체크리스트는 `v2-docs/P2P_REMAINDER_CONVERT_FINDINGS.md` 에 있다.
> API 범위가 아니므로 여기서 다루지 않되, **B 오픈 전 완료돼야 한다**(§2 선결 2).

**C**
- [ ] **`getOrCreate` 를 호출하지 않는다** — GET이 회원을 생성하지 않는다
- [ ] `partnerUserId` 를 **query parameter** 로 받고 `"boss2000 박근만"` 으로 호출 테스트가 통과한다
- [ ] `axim.walletAddresses` 키가 **chainType** 이다
- [ ] CONNECTED 지갑을 **`connected_at DESC` 정렬 후** 선택한다
- [ ] 응답에 `primary` 필드가 없다
- [ ] 404 판정에 **`wallet_addresses`** 를 쓴다
- [ ] 계좌번호·전화번호가 **`MaskingUtils` 경유**로 마스킹된다
- [ ] `include=ekyc` 없이 호출 시 외부 HTTP **0회**
- [ ] ★ 응답에 **`nextAction`/`canSellP2p`/`onboardingStep` 같은 파생 판정 필드가 없다** (raw만)
- [ ] `axim.connectUrl` 이 **미연결일 때만** 채워지고, `siteId`/`connectId` 중 하나라도 없으면 **`null`** 이다
- [ ] `connectUrl` 에 **`redirectUrl`·`ua` 파라미터가 없다**
- [ ] `connectUrl` 문자열에 `"undefined"`/`"null"` 이 박히지 않는다
- [ ] §5.0 시나리오 9개를 **응답 필드만으로 전부 그릴 수 있다** (못 그리면 필드 누락)
- [ ] eKYC 실패가 전체 응답을 실패시키지 않는다 (`ekycError` 로 분리)
- [ ] 응답에 `birthday`/`email`/`accountNumber` 원문·자격증명이 없다

**E**
- [ ] `sendPaymentToAxim` 을 반드시 호출한다 (INSERT만 하고 끝나는 경로 0건)
- [ ] `orderId` 가 **`@NotBlank`** 다
- [ ] 진행 중 결제가 있는 `orderId` 재요청이 **409** 다 (조용한 재사용 0건, `reused` 필드 없음)
- [ ] 종결된 `orderId` 재사용은 **201** 이다
- [ ] `GET ?orderId=` 로 409 후 기존 `paymentCode` 를 찾을 수 있다
- [ ] 중복 검사가 **파트너 스코프 안에서만** 이뤄진다
- [ ] cancel·조회 경로가 **`paymentCode`** 다 (auto-increment `id` 노출 0건)
- [ ] cancel·조회에 **파트너 스코프 검증**이 있다 (IDOR 테스트: 다른 파트너의 `paymentCode` → 404)
- [ ] **위젯 엔드포인트도** 세션을 받아 소유권을 검증한다 (E8 — IDOR의 실제 위치)
- [ ] 위젯 in-flight 재사용 블록에 **파트너 스코프 검증**이 추가됐다 (E1-b)
- [ ] ☠️ **`feeAmount`·`walletAddress` 가 DB에 저장된다** (E3·E4 수정 — INSERT 후 재조회로 null 아님 확인)
- [ ] `walletAddress` 가 해당 `chainType` 의 Axim 주소와 일치한다
- [ ] `priceKrw` 가 DB 저장값이고 Axim 전송값과 **같다**
- [ ] `aximPaymentId` 가 **String** 이다
- [ ] ☠️ 이 경로에 **`@Transactional` 을 붙이지 않았다**
- [ ] push 실패 시 FAILED 기록 + 예외 전파 (위젯식 "삼키고 200" 아님)
- [ ] 잘못된 `linkCode` 가 **404** 로 거절된다 (조용한 무시 아님)
- [ ] **결제 상태 웹훅을 신설하지 않았다** (입금은 기존 DEPOSIT 웹훅이 전달)

**F·G**
- [ ] `availableBalance` 가 `getAvailableForWithdrawal` 반환값 **그대로**다
- [ ] **`ledgerBalance − pendingWithdrawalHold = availableBalance`** 가 정확히 성립한다 (F5 선행)
- [ ] 응답에 `p2pLockedBalance` 필드가 **없다**
- [ ] `getAvailableForWithdrawal` 에서 `p2p_partner_locks` 차감이 제거됐고 **쓰기를 되살린 코드가 0건**이다
- [ ] `P2pLockService.getAvailableForP2p` 는 살아 있다
- [ ] G가 위젯 `evaluate` 와 **같은 코드**를 호출한다 (복붙 0건)
- [ ] 신규 코드에 `debit` 만 호출하고 가용액 비교를 생략한 경로가 없다
- [ ] 가용액을 저장하는 새 컬럼을 만들지 않았다
- [ ] `UserController.createWithdrawal` javadoc에 `@response 409` 가 추가됐다

**마무리 — restdoc generator 규칙 (§8)**
- [ ] `./gradlew :open-api:compileJava` 통과
- [ ] 모든 신규 매핑에 **`name = "한글 API 명"`** 이 있다
- [ ] ☠️ HMAC 3헤더가 **메서드 javadoc**에 있다 (클래스 레벨은 **무시됨** — 실측 확인)
- [ ] `@auth` 값이 **실제 세션 파라미터 유무와 일치**한다
- [ ] `@response` 에 **409가 빠지지 않았다** (잔액 부족·중복·기능 비활성)
- [ ] DTO 필드마다 JavaDoc + **`@XSample`** 이 있다
- [ ] `generateSpecBundle` 실행 후 **`static/docs/spec-bundle.json` 을 커밋**했다 (미커밋 시 옛 파일이 배포됨)
- [ ] 생성된 spec-bundle에서 신규 API의 `headers` 가 **`[]` 가 아니다**
- [ ] `v2-docs/spec-bundle.json` 동기화, `api.html` 에서 새 `@group` 렌더 확인
