# P2P 수수료 체계 재설계 — 최종 지침서 (DDL v2.8)

작성일: 2026-08-11
대상: Cowork 서브에이전트 (구현)
범위: **수수료 체계 + UI + 파트너 생성 경로.** 매칭 룰(최상위 총판 기준) 변경은 **별건**으로 분리한다.

> **이 문서가 아래 두 문서를 대체한다. 두 문서는 폐기한다.**
> - `P2P_FEE_UNIFICATION_GUIDE.md` — 입금 트리 워터폴 방식 (설계 변경으로 폐기)
> - `P2P_WITHDRAW_MATCH_FEE_GUIDE.md` — gross-up 방식 (설계 변경으로 폐기)

---

## 개정 이력 — r2 (2026-08-12, 코드 리뷰 반영)

초판 구현 후 리뷰에서 나온 결함 3건을 반영해 아래를 변경했다. **초판과 다른 부분은 이 절이 우선한다.**

### R1. `p2p_matches` 금액 컬럼 → 요율 컬럼

금액 3종(`fee_amount_krw` / `rebate_amount_krw` / `withdraw_fee_usdt`)을 **저장하지 않고 유도**한다. 같은 행의 `krw_amount`·`usdt_amount`와 요율로 계산되므로 중복이고, 요율과 금액이 어긋난 행이 생길 수 있다. 실제 징수의 권위 기록은 `ledger_entries(FEE)`이며, 매칭의 요율은 그 금액을 **리베이트/체인/시스템으로 분해**하기 위해서만 필요하다.

```
입금자 수수료 KRW  = FLOOR(krw_amount × fee_rate / 100)
리베이트      KRW  = FLOOR(krw_amount × rebate_rate / 100)
출금자 수수료 USDT = usdt_amount × withdraw_fee_rate / 100
```

Java는 `divide(HUNDRED, 0, DOWN)`, SQL은 `FLOOR(...)` — 양수 구간에서 동일하다. Java 쪽은 헬퍼 하나로 모을 것.

### R2. 리베이트를 **요율**로, 주문 시점에 스냅샷

초판은 `fee_rate`를 주문 시점에 고정하면서 리베이트만 매칭 시점에 실시간 조회했다. 그 사이 요율이 바뀌면 `rebate > fee`가 되어 집계 클램프가 체인 재원을 0으로 만들고 **총판·시스템 몫이 통째로 사라진다.**

`p2p_deposit_orders.rebate_rate` 를 신설해 주문 생성 시 고정하고, 매칭이 그 값을 복사한다. `fee_rate` 와 같은 시점·같은 성격이 된다.

### R3. `usdtAmount` 단일 계산 — 잠금/해제 라운딩 대칭

현재 잠금은 `krw / exchangeRate` 를 **UP**(`P2pMatchingService` 273-274, 300, 386)으로, 저장은 **DOWN**(`createMatch`)으로 계산한다. 해제는 저장값 기준이라 매칭마다 차액이 영구 잠긴다.

**호출부에서 `usdtAmount` 를 한 번(DOWN) 계산해 잠금 산정과 `createMatch` 양쪽에 전달한다.**

```java
BigDecimal usdtAmount = BigDecimal.valueOf(krw)
        .divide(wo.getExchangeRate(), 18, RoundingMode.DOWN);
BigDecimal lockNeeded = usdtAmount.add(
        usdtAmount.multiply(wFeeRate).divide(HUNDRED, 18, RoundingMode.DOWN));
```

UP → DOWN 전환은 안전하다. 정산이 `settleFromLocked(usdt_amount) + unlockForMatch(fee)` 로 잠근 값을 정확히 소진하고, 온체인 전송액도 같은 DOWN 값이라 부족할 경로가 없다.

### R4. `p2pEnabled` 원복 (범위 이탈)

`P2pSettingsResponse` 와 `P2pFeeSettingsService.listPartnerFees` 에서 `Boolean.TRUE.equals(partner.getP2pEnabled())` → `partner.isP2pMatchingAllowed()` 로 바꾼 변경은 사양에 없다. KRW 마스터 토글이 꺼진 파트너의 화면 표시가 뒤바뀐다. **원복한다.**

### R5. 마이그레이션 보강

- `p2p.max_fee_rate` 는 운영 DB에 **키가 없다**(실측 확인). `INSERT` 가 없으면 상한 저장 EP가 404. §7 참조
- `settlement_daily_fees.redistribution_id` DDL 폴드인 완료

---

## 1. 확정 모델

### 1.1 두 재원

```
[재원 A] 입금자 수수료 — 구매자 부담, 주문 시점 확정
  수수료 = 입금파트너.p2p_rebate_rate          → 출금 파트너 (리베이트)
         + Σ(체인 p2p_parent_fee_rate)         → 상위 총판들 + 시스템

[재원 B] 출금자 수수료 — 출금 파트너 부담, 매칭 시점 확정
  수수료 = Σ(출금파트너 체인 p2p_withdraw_parent_fee_rate)
```

### 1.2 체인 규칙

`p2p_parent_fee_rate`는 **그 파트너의 상위가 받을 몫**이다. 각 값이 서로 겹치지 않으므로 그냥 더하면 총액이 된다.

| 노드 | 필드 값 | 받는 몫 |
|---|---|---|
| 매장(입금 파트너) | `0.6` | **0** — 아무도 지정해주지 않음 |
| 총판2 | `0.2` | 0.6 (매장이 지정) |
| 총판1(최상위) | — | 0.2 (총판2가 지정) |
| 시스템 | — | 총판1의 값 |

**수수료 = 리베이트 + Σ(체인)** 이므로 잔차가 발생하지 않는다. 이것이 기존 "1.6%가 어디에도 기록되지 않던" 문제의 근본 해소다 — 2%를 고정해 두었기 때문에 잔여를 흡수할 주체가 필요했던 것이다.

### 1.3 검산

**케이스 A — 최상위 직영 (파트너 36, 현 P2P 주 사용자)**

| 항목 | 값 |
|---|---|
| `36.p2p_parent_fee_rate` | 0.2 → 시스템 |
| `36.p2p_rebate_rate` | 0.2 → 출금 파트너 |
| 파트너 36 본인 몫 | **0** |
| **총 수수료** | **0.4%** |

**케이스 B — 매장 → 총판 → 시스템**

| 항목 | 값 |
|---|---|
| `매장.p2p_parent_fee_rate` | 0.6 → 총판 |
| `총판.p2p_parent_fee_rate` | 0.2 → 시스템 |
| `매장.p2p_rebate_rate` | 0.2 → 출금 파트너 |
| 매장 본인 몫 | **0** |
| **총 수수료** | **1.0%** |

체인이 깊을수록 수수료가 높아진다. 직영에 마진을 주려면 하위 매장을 두면 된다.

### 1.4 출금 수수료 — 1,000 USDT 출금 예시

| 단계 | 값 |
|---|---|
| 회원 출금 요청 | 1,000 USDT (**회원 수령액 불변**) |
| 출금 파트너 잠금 | **1,002** |
| 온체인 전송 | **1,000** |
| 출금 파트너 원장 | `DEBIT 1,000` + `FEE 2` |
| 입금 파트너 원장 | `CREDIT 1,000` + `FEE 10` |
| 입금 파트너 순증 | 990 |

**수수료를 즉시 계상하고 전송은 1,000만 한다.** 이렇게 하면 두 가지 이득이 있다.

- `usdt_amount = krw_amount / exchange_rate` **등식이 유지된다.** 분쟁·취소·재시도가 전부 1,000 기준
- 출금 수수료 코인이 **출금 파트너 MASTER에 잔류**하므로, 실현 잡의 `fromWalletId = source_partner MASTER` 규칙과 정합. 입금 수수료와 완전 대칭

⚠️ 매칭이 취소·실패하면 수수료도 걷지 않는다. 수수료 계상은 **정산 완료 시점**에만 하고, 잠금 1,002는 전액 해제한다.

### 1.5 요율 단위

**전부 퍼센트로 통일한다** (`1.0` = 1%). 현재 P2P 계열만 소수 비율(`0.02` = 2%)이라 100배 스케일이 다르다.

근거 — 일반 입금은 `/100`으로 나누고(`DepositService.java:187`) P2P는 나누지 않는다(`P2pDepositService.java:112`). 운영 데이터로도 확인됨: `deposit_fee_rate = 0.2` → 실효 0.2%, `p2p_deposit_orders.fee_rate = 0.02` → 실효 2.0%.

---

## 2. 필드

### 2.1 신설·유지

| 필드 | 소유 | 단위 | 의미 |
|---|---|---|---|
| `partners.p2p_parent_fee_rate` | 모든 파트너 | 퍼센트 | 입금 재원에서 **내 상위**가 받을 몫. 최상위 값 = 시스템 몫 |
| `partners.p2p_withdraw_parent_fee_rate` | 모든 파트너 | 퍼센트 | 출금 재원에서 **내 상위**가 받을 몫 |
| `partners.p2p_rebate_rate` | 입금 파트너 | 퍼센트 | 출금 파트너에게 줄 리베이트 |

### 2.2 폐기

| 대상 | 처리 |
|---|---|
| `partners.p2p_fee_rate` | 컬럼 유지 + deprecated. **수수료는 체인 합으로 파생** |
| `partners.p2p_share_rate` | 컬럼 유지 + deprecated |
| `partners.p2p_withdraw_bonus_rate` | `p2p_rebate_rate`로 **의미·소유 이전** (출금 파트너 → 입금 파트너) |
| `p2p_order_shares` | 테이블 유지, 신규 기록 중단 (**현재 행 0건** — 마이그레이션 부담 없음) |
| `p2p.fee_rate` / `p2p.system_rate` / `p2p.withdraw_bonus_rate` | 값 0, deprecated |
| `P2pRevenueShareService` / `P2pRevenueShareSettlementJob` / `P2pRevenueShareMapper` | **코드 삭제**. 테이블·기존 배치 1건은 이력 보존 |
| `P2pSettlementService.computeProportionalFeeKrw` | **메서드 삭제** — 매칭별 확정액 직독 |
| `SettlementService.aggregateDailyP2pFees` | **메서드 삭제** — 체인 배분으로 재작성 |
| `SettlementMapper` P2P 쉐어/보너스 쿼리 | **삭제** |
| `P2pFeeResolver.collectShareChain` / `resolveWithdrawBonusRate` / `resolveEffectiveFeeRate` | **삭제** — 체인 합산 메서드로 대체 |

---

## 3. DDL (v2.8)

### 3.1 헤더 개정

```
-- ║  v2.8 (2026-08-11): P2P 수수료 재설계 — 체인 합 모델.                ║
-- ║         p2p_parent_fee_rate / p2p_withdraw_parent_fee_rate /         ║
-- ║         p2p_rebate_rate 신설(퍼센트 단위). 수수료 = 체인 합 + 리베이트 ║
-- ║         (잔차 없음). p2p_fee_rate/p2p_share_rate/p2p_order_shares    ║
-- ║         deprecated. 매칭별 수수료 컬럼 신설. 테이블 수 변화 없음(54)  ║
```

### 3.2 ALTER

```sql
-- ── ① partners: 체인 요율 3종 (전부 퍼센트 단위) ──────────────────────
ALTER TABLE partners
  ADD COLUMN p2p_parent_fee_rate DECIMAL(10,6) NOT NULL DEFAULT 0
    COMMENT 'P2P 입금 재원에서 이 파트너의 상위가 받을 몫 — 퍼센트 단위 (0.2 = 0.2%). 최상위 파트너의 값이 곧 시스템 몫. 수수료 = 체인 합 + 리베이트 (v2.8)'
    AFTER p2p_withdraw_bonus_rate,
  ADD COLUMN p2p_withdraw_parent_fee_rate DECIMAL(10,6) NOT NULL DEFAULT 0
    COMMENT 'P2P 출금 재원에서 이 파트너의 상위가 받을 몫 — 퍼센트 단위. 최상위 값 = 시스템 몫. 출금 수수료 = 이 체인의 합 (v2.8)'
    AFTER p2p_parent_fee_rate,
  ADD COLUMN p2p_rebate_rate DECIMAL(10,6) NOT NULL DEFAULT 0
    COMMENT 'P2P 리베이트율 — 퍼센트 단위. 이 파트너가 입금 파트너일 때 매칭 상대 출금 파트너에게 지급. 입금 재원에 포함 (v2.8)'
    AFTER p2p_withdraw_parent_fee_rate;

-- ── ② 구 필드 deprecated (DROP 하지 않음) ────────────────────────────
ALTER TABLE partners
  MODIFY COLUMN p2p_fee_rate DECIMAL(10,6) DEFAULT NULL
    COMMENT '(v2.8 deprecated — 수수료는 p2p_parent_fee_rate 체인 합 + p2p_rebate_rate 로 파생. 미사용) P2P 구매자 수수료율',
  MODIFY COLUMN p2p_share_rate DECIMAL(10,6) NOT NULL DEFAULT 0
    COMMENT '(v2.8 deprecated — 체인 모델로 대체. 미사용) P2P 쉐어율',
  MODIFY COLUMN p2p_withdraw_bonus_rate DECIMAL(10,6) DEFAULT NULL
    COMMENT '(v2.8 deprecated — p2p_rebate_rate 로 이전. 소유가 출금 파트너 → 입금 파트너로 변경됨) 출금자 매칭 보너스율',
  MODIFY COLUMN parent_fee_rate DECIMAL(10,6) NOT NULL DEFAULT 0
    COMMENT '상위가 이 파트너 트리에서 얻는 일반 입금 수수료율 — 퍼센트 단위 (0.2 = 0.2%). 최상위 값 = 시스템 계약 수익률 (v2.8 주석 정정: 기존 "0.002 = 0.2%" 표기는 실제 데이터와 불일치했음)';

-- ── ③ p2p_deposit_orders: 의미 변경 ──────────────────────────────────
UPDATE p2p_deposit_orders SET fee_rate = fee_rate * 100;   -- 소수 비율 → 퍼센트

ALTER TABLE p2p_deposit_orders
  MODIFY COLUMN fee_rate DECIMAL(10,6) NOT NULL DEFAULT 0
    COMMENT '주문 실효 수수료율 스냅샷 — 퍼센트 단위. 주문 생성 시 입금 파트너 체인 합 + 리베이트로 산출 (v2.8)',
  MODIFY COLUMN fee_amount BIGINT NOT NULL DEFAULT 0
    COMMENT '주문 총 수수료 (KRW) — 매칭 확정 후 레그별 fee_amount_krw 합으로 갱신 (v2.8)';

-- ── ④ p2p_matches: 매칭별 수수료 확정액 ──────────────────────────────
ALTER TABLE p2p_matches
  ADD COLUMN fee_rate DECIMAL(10,6) NOT NULL DEFAULT 0
    COMMENT '이 레그의 입금자 수수료율 스냅샷 — 퍼센트 단위. P2P=주문 스냅샷, TORQ/PARTNER=0 (v2.8)'
    AFTER exchange_rate,
  ADD COLUMN fee_amount_krw BIGINT NOT NULL DEFAULT 0
    COMMENT '이 레그의 입금자 수수료 확정액 (KRW) = krw_amount × fee_rate / 100 (floor) (v2.8)'
    AFTER fee_rate,
  ADD COLUMN rebate_amount_krw BIGINT NOT NULL DEFAULT 0
    COMMENT '이 레그의 리베이트 확정액 (KRW) — fee_amount_krw 에 포함된 내역. 출금 파트너 귀속 (v2.8)'
    AFTER fee_amount_krw,
  ADD COLUMN withdraw_fee_rate DECIMAL(10,6) NOT NULL DEFAULT 0
    COMMENT '이 레그의 출금자 수수료율 스냅샷 — 퍼센트 단위. 출금 파트너 체인 합. P2P 레그만, TORQ/PARTNER=0 (v2.8)'
    AFTER rebate_amount_krw,
  ADD COLUMN withdraw_fee_usdt DECIMAL(36,18) NOT NULL DEFAULT 0
    COMMENT '이 레그의 출금자 수수료 확정액 (USDT). 출금 파트너 부담, 출금 파트너 MASTER 잔류 (v2.8)'
    AFTER withdraw_fee_rate,
  MODIFY COLUMN withdraw_bonus_rate DECIMAL(10,6) NOT NULL DEFAULT 0
    COMMENT '(v2.8 deprecated — rebate_amount_krw 로 대체. 신규 매칭은 항상 0) 출금파트너 보너스율';
```

> ⚠️ `p2p_matches.usdt_amount` 는 **의미가 바뀌지 않는다.** 전송·분쟁·취소가 전부 `krw_amount / exchange_rate` 기준이다. 출금 수수료는 잠금에만 가산된다.

```sql
-- ── ⑤ settlement_daily_fees: fee_role 확장 ───────────────────────────
ALTER TABLE settlement_daily_fees
  MODIFY COLUMN fee_role VARCHAR(20) NOT NULL DEFAULT 'BUYER_SHARE'
    COMMENT 'BUYER_SHARE(입금 재원 체인 몫) / SYSTEM(입금 재원 시스템 몫) / REBATE(출금 파트너 리베이트) / WITHDRAW_SHARE(출금 재원 체인 몫) / WITHDRAW_SYSTEM(출금 재원 시스템 몫) / WITHDRAW_BONUS(v2.8 deprecated) (v2.8)',
  MODIFY COLUMN share_rate DECIMAL(10,6) NOT NULL DEFAULT 0
    COMMENT '이 참여자의 쉐어율 — 퍼센트 단위 (0.6 = 0.6%). v2.8부터 체인에서 나온 설정 요율. 사후 역산 금지';

-- ── ⑥ p2p_order_shares deprecated ────────────────────────────────────
ALTER TABLE p2p_order_shares
  COMMENT '(v2.8 deprecated — 체인 모델로 대체. 신규 기록 없음, 기존 행 0건) P2P 주문 쉐어 스냅샷';

-- ── ⑦ system_settings ────────────────────────────────────────────────
UPDATE system_settings SET setting_value = '0',
  description = '(v2.8 deprecated — 수수료는 p2p_parent_fee_rate 체인 합으로 파생) P2P 구매자 수수료율'
 WHERE setting_key = 'p2p.fee_rate';
UPDATE system_settings SET setting_value = '0',
  description = '(v2.8 deprecated — 시스템 몫은 최상위 p2p_parent_fee_rate) P2P 시스템 수익율'
 WHERE setting_key = 'p2p.system_rate';
UPDATE system_settings SET setting_value = '0',
  description = '(v2.8 deprecated — partners.p2p_rebate_rate 로 이전) P2P 출금자 매칭 보너스율'
 WHERE setting_key = 'p2p.withdraw_bonus_rate';
```

---

## 4. 백엔드 구현

### 4.1 `core/p2p/P2pFeeResolver.java` — 전면 재작성

기존 메서드(`resolveEffectiveFeeRate`, `collectShareChain`, `resolveWithdrawBonusRate`, `globalFeeRate`, `globalWithdrawBonusRate`)를 **전부 삭제**하고 아래로 대체한다. 모든 반환값은 **퍼센트 단위**.

```java
/** 체인 노드 — 수취 파트너와 그 몫 */
public record ChainEntry(Long partnerId, BigDecimal rate) {}

/**
 * 입금 재원 체인 — 이 파트너부터 루트까지 각 노드의 p2p_parent_fee_rate 를
 * "그 노드의 상위가 받을 몫"으로 수집한다.
 *
 * <p>최상위 노드의 값은 수취자 partnerId = null (시스템)로 기록한다.
 * 순환 방어를 위해 visited 집합을 사용한다 (기존 collectShareChain 패턴).
 */
public List<ChainEntry> collectDepositChain(Partner depositPartner) { ... }

/** 출금 재원 체인 — p2p_withdraw_parent_fee_rate 기준. 규칙 동일 */
public List<ChainEntry> collectWithdrawChain(Partner withdrawPartner) { ... }

/** 입금 파트너의 리베이트율 (퍼센트) */
public BigDecimal resolveRebateRate(Partner depositPartner) { ... }

/** 입금자 총 수수료율 = 체인 합 + 리베이트 (퍼센트) */
public BigDecimal resolveDepositFeeRate(Partner depositPartner) { ... }

/** 출금자 총 수수료율 = 출금 체인 합 (퍼센트) */
public BigDecimal resolveWithdrawFeeRate(Partner withdrawPartner) { ... }
```

- 트리 최대 깊이는 **실측 1단**(전체 53곳 중 36 루트 + 17 자식, 2단 이상 0). 비정규화 캐시는 **두지 않는다** — 일반 입금의 `min_fee_rate` 캐시가 정합성 불일치 시 정산을 통째로 건너뛰는 함정(`SettlementService.java:497-504`)을 복제할 이유가 없다
- 순환 방어는 필수. 깊이 하드리밋 10 (`PartnerSubMgmtService.java:526` 선례)

### 4.2 `core/p2p/P2pDepositService.java` — 순서 반전 ⚠️

현재 `:110-115`에서 수수료를 확정하고 `:164`에서 매칭한다. **뒤집는다.**

```java
// 1) 주문 생성 — feeRate = resolveDepositFeeRate(partner) 스냅샷, feeAmount = 0
// 2) matchingService.tryMatchDeposit(order)          ← 레그 확정
// 3) 레그별 fee 합산 → 주문 갱신
long totalFee = matchRepo.findByDepositOrderId(order.getId()).stream()
        .filter(m -> m.getStatus() != P2pMatchStatus.CANCELLED
                  && m.getStatus() != P2pMatchStatus.FAILED)
        .mapToLong(P2pMatch::getFeeAmountKrw).sum();
depositOrderRepo.modify(order.toBuilder().feeAmount(totalFee).build());
```

- `fee_rate`는 주문 시점 스냅샷(입금 파트너 기준이라 매칭 전에 확정 가능), `fee_amount`는 매칭 후 확정
- `p2p_order_shares` 기록 블록(`:118-159`) **전체 삭제**

### 4.3 `core/p2p/P2pMatchingService.java`

**레그별 수수료 산출** — `createMatch`(789~)

```java
BigDecimal legFeeRate = (legType == P2pLegType.P2P) ? dpo.getFeeRate() : BigDecimal.ZERO;
long legFeeKrw = BigDecimal.valueOf(krwAmount).multiply(legFeeRate)
        .divide(HUNDRED, 0, RoundingMode.DOWN).longValue();
long legRebateKrw = (legType == P2pLegType.P2P)
        ? BigDecimal.valueOf(krwAmount).multiply(feeResolver.resolveRebateRate(depositPartner))
              .divide(HUNDRED, 0, RoundingMode.DOWN).longValue()
        : 0L;

BigDecimal wFeeRate = (legType == P2pLegType.P2P)
        ? feeResolver.resolveWithdrawFeeRate(withdrawPartner) : BigDecimal.ZERO;
BigDecimal wFeeUsdt = usdtAmount.multiply(wFeeRate)
        .divide(HUNDRED, 18, RoundingMode.DOWN);
```

- `withdrawBonusRate`는 **항상 `BigDecimal.ZERO`**. L800-806 보너스 캡 블록 **삭제**
- `usdtAmount`는 기존 산식 그대로 (`krw / exchangeRate`)

**잠금 산정** — `usdtNeeded`가 나오는 **네 곳(273, 300, 386, 그리고 createMatch)** 을 헬퍼로 통일

```java
/** 출금 파트너가 실제로 묶어야 할 USDT = 기준액 + 출금 수수료 */
private BigDecimal lockAmount(BigDecimal baseUsdt, BigDecimal withdrawFeeRate) {
    return baseUsdt.add(baseUsdt.multiply(withdrawFeeRate).divide(HUNDRED, 18, RoundingMode.UP));
}
```

- **주문 등록 시 가용성 검증도 `× (1 + rate)`** 로 해야 한다. 잔액을 딱 맞춰 두면 마지막 부분 매칭에서 잠금이 실패해 매칭이 누락된다
- `unlockForMatch`도 같은 값을 써야 한다. 1,000만 풀면 2가 영구 잠김

**`checkRoute`(`:1059-1113`)** — `getStringSetting("p2p.fee_rate")`를 `feeResolver.resolveDepositFeeRate(partner)` 로 교체. 기존 버그(파트너 오버라이드 무시)를 동시에 수정한다.

### 4.4 `core/p2p/P2pSettlementService.java`

```java
// 입금측 — 기존 computeProportionalFeeKrw 대체
long feeKrwThis = match.getFeeAmountKrw();
BigDecimal feeUsdt = BigDecimal.valueOf(feeKrwThis)
        .divide(match.getExchangeRate(), 18, RoundingMode.DOWN);
settlementService.credit(dpo.getPartnerId(), ..., match.getUsdtAmount(), ...);
settlementService.recordFee(dpo.getPartnerId(), ..., feeUsdt, ...);

// 출금측 — 신규. 정산 완료 시점에만 계상
BigDecimal wFee = match.getWithdrawFeeUsdt();
if (wFee.signum() > 0) {
    settlementService.recordFee(wo.getPartnerId(), ..., wFee,
            LedgerReferenceType.P2P_SETTLEMENT, settlement.getId(), ...);
}
```

- `computeProportionalFeeKrw`(`:713-738`) **메서드 삭제**
- `legType == PARTNER → 0` 분기(`:446`)는 `fee_amount_krw = 0`으로 자연 처리 → 삭제
- `creditTorqLeg`(`:516-557`) **변경 없음** (FEE 0 유지)
- 매칭 취소·실패 시 `FEE`를 기록하지 않고 잠금 전액 해제

### 4.5 `core/settlement/SettlementService.java` — 집계 재작성

`aggregateDailyP2pFees`를 **삭제**하고 체인 배분으로 재작성한다.

```
[입금 재원]  대상 = 당일 SETTLED P2P 레그의 fee_amount_krw
  ├ REBATE        participant = 출금 파트너,  amount = rebate_amount_krw
  └ 나머지를 입금 파트너 체인에 배분
       BUYER_SHARE  participant = 각 상위 총판
       SYSTEM       participant = null (최상위 노드의 몫)

[출금 재원]  대상 = 당일 SETTLED P2P 레그의 withdraw_fee_usdt
  └ 출금 파트너 체인에 배분
       WITHDRAW_SHARE   participant = 각 상위 총판
       WITHDRAW_SYSTEM  participant = null
```

**`source_partner_id` 규칙 — 코인 소재지 기준**

| 재원 | `source_partner_id` | 근거 |
|---|---|---|
| 입금 재원 (REBATE / BUYER_SHARE / SYSTEM) | **입금 파트너** | 수수료 코인이 입금 파트너 MASTER에 잔류 |
| 출금 재원 (WITHDRAW_SHARE / WITHDRAW_SYSTEM) | **출금 파트너** | 출금 수수료 코인이 출금 파트너 MASTER에 잔류 |

> ⚠️ 실현 잡(`:854-864`)이 `fromWalletId = source_partner MASTER`로 스윕한다. 코인이 없는 지갑을 source로 쓰면 스윕이 실패한다.

일반 입금 집계(`aggregateDailyFees`)는 **변경하지 않는다.**

### 4.6 `admin-api/.../P2pFeeSettingsService.java`

- `FEE_MIN`/`FEE_MAX` 상수 및 밴드 검증 **삭제** — 수수료가 파생값이라 밴드 개념이 없다
- `findChainViolations` / `chainShareSum` / `collectSubtree` **삭제**
- 신규 검증: **각 노드의 `p2p_parent_fee_rate` ≥ 0**, 그리고 **리베이트 + 체인 합 ≤ 상한**(`p2p.max_fee_rate`, 신규 시스템 설정, 기본 3.0)
- 응답 DTO를 3필드 기준으로 재구성. 모든 멤버에 JavaDoc 필수
- `update()` 사용 유지 (`modify()`는 null 스킵)

### 4.7 `open-api` 위젯 DTO — 레그별 계산 ⚠️ 기존 버그 동시 수정

`P2pWidgetMatchResponse.computeExpectedUsdt`(`:120-130`)가 `gross × (1 − feeRate)`를 **모든 레그에 적용**한다. TORQ 레그의 `usdtAmount`는 이미 net(프리미엄이 `effectiveFxRate`에 내장)이라 **이중 차감 표시**되고 있다. TORQ가 거래액의 93.9%이므로 거의 모든 구매에서 오차가 난다.

```java
BigDecimal net = matches.stream()
        .filter(활성)
        .map(m -> m.getUsdtAmount().subtract(
                BigDecimal.valueOf(m.getFeeAmountKrw())
                        .divide(m.getExchangeRate(), 18, RoundingMode.DOWN)))
        .reduce(BigDecimal.ZERO, BigDecimal::add);
```

레그 상세 DTO(`P2pWidgetMatchDetailResponse`)에 `legType` / `feeRate` / `feeAmountKrw` 추가.

### 4.8 `partner-api`

- `P2pSettingsResponse`에 `parentFeeRate` / `withdrawParentFeeRate` / `rebateRate` / `effectiveFeeRate` 추가 (현재 `p2pEnabled` + `p2pFeeRate` 2개뿐이라 총판이 자기 쉐어를 볼 수 없다)
- 출금 풀 현황(`PartnerP2pPoolService`)의 "매칭 가능 잔여"를 **`÷ (1 + 출금수수료율/100)`** 으로 보정
  > 잠금이 `기준액 × (1 + rate)` 이므로, 가용 예산 `B`로 실제 매칭 가능한 거래 규모는 `B / (1 + rate)` 다.
  > 곱하면 매칭 가능액을 과대 표기한다 (2026-08-11 구현 중 정정).

---

## 5. 파트너 생성 시 수수료 필수 입력 ⚠️ 신규 요구

현재 **어드민·총판 어느 생성 경로에도 P2P 수수료 필드가 없다.** 생성 직후 값이 없어 수수료가 0이 된다.

### 5.1 어드민 경로

**`admin-api/.../dto/request/PartnerCreateRequest.java`** — 3필드 추가, 전부 `@NotNull`

```java
/** P2P 입금 재원에서 이 파트너의 상위가 받을 몫 (퍼센트, 0.2 = 0.2%) */
@NotNull @DecimalMin("0") @DecimalMax("100")
private BigDecimal p2pParentFeeRate;

/** P2P 출금 재원에서 이 파트너의 상위가 받을 몫 (퍼센트) */
@NotNull @DecimalMin("0") @DecimalMax("100")
private BigDecimal p2pWithdrawParentFeeRate;

/** 이 파트너가 입금 파트너일 때 출금 파트너에게 줄 리베이트 (퍼센트) */
@NotNull @DecimalMin("0") @DecimalMax("100")
private BigDecimal p2pRebateRate;
```

**`PartnerManagementService.createPartnerInternal`** — 빌더(`:220-239`)에 3필드 추가. `validateFees`(`:1003`)에 P2P 검증 추가.

### 5.2 총판 경로

**`partner-api/.../dto/request/CreateSubPartnerRequest.java`** — `FeeSettings`(`:49-59`)에 동일 3필드 추가.

> ⚠️ 현재 이 DTO의 수수료 필드에는 **검증 애너테이션이 하나도 없다.** `parentFeeRate`가 null이면 `BigDecimal.ZERO`로 조용히 폴백된다(`PartnerSubMgmtService.java:159`). 신규 필드는 반드시 `@NotNull` + 범위를 명시할 것.

**`PartnerSubMgmtService.createSubPartnerInternal`** — 빌더(`:201-219`)에 3필드 추가, `validateFees`(`:560`)에 검증 추가.

### 5.3 공통 검증

트리 유틸이 `P2pFeeSettingsService`의 `private` 메서드로만 존재한다. 생성 경로에서 재사용하려면 **core 모듈로 승격**해야 한다.

---

## 6. UI 변경

### 6.1 `widget-ui` — 최우선

| 파일:라인 | 현재 | 조치 |
|---|---|---|
| `locales/ko.js:189`, `en.js:189` | **`feeLabel: '수수료 (2%)'` 하드코딩** | `'수수료 ({rate}%)'` 로 파라미터화. 요율 변경 시 즉시 거짓 표시되는 상태 |
| `torq.vue:338` | 하드코딩 키 사용 | 실효 요율 전달 |
| `p2p.vue:871-888` 확인 화면 | 단일 요율로 프론트 계산 | 매칭 전이라 레그 미상 — **보수적으로 최대 요율 표시**, 매칭 후 확정값으로 갱신. 오차가 항상 사용자에게 유리한 방향이라 분쟁 없음 |
| `p2p.vue:991-996` 성공 화면 | `gross × (1 − rate)` 프론트 재계산 | 서버 `expectedUsdt` 를 그대로 사용 |
| `p2p.vue:529-532` verifying 화면 | **gross 표시** (성공 화면과 정의 불일치) | net으로 통일 |
| `p2p.vue:388-425` 레그 카드 | `legType` 미표시 | 배지 추가 |
| `p2p.vue:1237-1266`, `torq.vue:2000-2010` | 목업 `0.02` 하드코딩 | 퍼센트 단위로 정리 |

### 6.2 `admin-ui`

| 파일 | 조치 |
|---|---|
| `views/partners/PartnerCreateView.vue` | zod(`:62-110`)에 P2P 3필드 `required_error` 추가, 수수료 카드(`:346-400`)에 입력 블록 추가, payload(`:168-183`)에 필드 추가. **백엔드 미지원 `withdrawalFeeFixed`(`:179`) 제거** |
| `views/partners/tabs/PartnerP2pFeeCard.vue` | 3필드 기준으로 재작성. 단위 변환(`toPct`/`pctToFraction`, `:35,41`) **삭제** — 이제 저장·표시 모두 퍼센트 |
| `views/p2p/P2pFeeSettingsView.vue` | 글로벌 탭의 수수료율·보너스율 입력 **제거**(파생값). 파트너 탭을 체인 요율 조회로 재구성. `min="1" max="3"`(`:218`) 하드코딩 제거 |
| `views/partners/tabs/PartnerFeeTab.vue:37-39` | **버그 수정** — `minFeeRate`를 `parentFeeRate`로 계산하고 있어 2단 이상 트리에서 하한이 과소 표시됨. `partner.minFeeRate` 사용 |
| `api/types/partner.ts:113-116` | 유령 필드 `p2pFeeRate`/`withdrawalFeeFixed` 정리 |

### 6.3 `partner-ui`

| 파일 | 조치 |
|---|---|
| `views/partner/subpartners/SubPartnerNewView.vue` | **`parentFeeRate`를 아예 전송하지 않는 결함**(`:35`). P2P 3필드 추가하거나, 아래 모달로 통합하고 이 화면 폐기 |
| `views/partner/subpartners/SubPartnersView.vue:284-329` | 등록 모달에 P2P 3필드 추가 + 클라이언트 검증(현재 검증 0줄) |
| `views/partner/settings/P2pSettingsSection.vue` | 체인 요율·리베이트 표시 추가. `:33,36`의 stale 주석·에러 문구 정리 (API는 이미 구현돼 있음) |

> 하위 파트너 생성 폼이 **두 곳에 중복** 존재한다. 통합을 권한다.

---

## 7. 마이그레이션 — 전 파트너 재적용

수수료가 체인 합이라 **값을 세팅하지 않으면 0**이 된다. 전 파트너에 명시적으로 값을 넣는다.

```sql
-- 백업
CREATE TABLE bak_partners_p2p_v28_20260811 AS
  SELECT id, p2p_fee_rate, p2p_share_rate, p2p_withdraw_bonus_rate FROM partners;

-- 예시 정책: 시스템 0.2% / 리베이트 0.2% / 출금 재원 0.2%
--   ① 최상위 파트너 → 상위가 시스템이므로 p2p_parent_fee_rate = 시스템 몫
UPDATE partners SET p2p_parent_fee_rate = 0.2, p2p_withdraw_parent_fee_rate = 0.2
 WHERE parent_partner_id IS NULL;

--   ② 하위 파트너 → 직속 총판이 받을 몫 (총판별 협의값. 아래는 예시)
UPDATE partners SET p2p_parent_fee_rate = 0.6, p2p_withdraw_parent_fee_rate = 0
 WHERE parent_partner_id IS NOT NULL;

--   ③ 리베이트 — 입금 파트너 기준
UPDATE partners SET p2p_rebate_rate = 0.2 WHERE status = 'ACTIVE';
```

적용 후 실효 요율 확인:

```sql
SELECT p.id, p.partner_code, p.partner_type,
       p.p2p_rebate_rate
         + p.p2p_parent_fee_rate
         + COALESCE(pp.p2p_parent_fee_rate, 0) AS effective_fee_pct
  FROM partners p LEFT JOIN partners pp ON pp.id = p.parent_partner_id
 WHERE p.status = 'ACTIVE' ORDER BY effective_fee_pct;
```

> 트리 깊이가 실측 1단이라 위 쿼리로 전수 확인이 가능하다. 2단 이상이 생기면 재귀 CTE로 바꿀 것.

**미재분배 잔액** — 현재 `fee_source='P2P' AND fee_role='SYSTEM' AND redistribution_id IS NULL` 2건(0.6135 USDT)은 10 USD 실현 임계 미만이라 어차피 실현되지 않는다. **그대로 SYSTEM 몫으로 확정**하고 주간 잡을 다시 돌리지 않는다.

---

## 8. 검증 및 완료 기준

### 8.1 단위 전수 확인 ⚠️ 최우선

퍼센트 값을 금액에 곱할 때 **반드시 `/100`** 이 있어야 한다. 없으면 100배 과다.

```bash
grep -rn "p2p.fee_rate\|getP2pFeeRate\|getFeeRate()\|ParentFeeRate\|RebateRate" \
  --include=*.java core/ open-api/ admin-api/ partner-api/ scheduler/ common/
grep -rn "feeRate\|rebate" --include=*.vue --include=*.js --include=*.ts \
  ../cryptoments-admin/ widget-ui/
```

### 8.2 검산 (필수 재현)

**케이스 A — 파트너 36 (최상위 직영), 1,000 USDT 거래**

| 항목 | 기대값 |
|---|---|
| `p2p_deposit_orders.fee_rate` | `0.400000` |
| `p2p_matches.fee_amount_krw` | `krw × 0.4 / 100` (floor) |
| `p2p_matches.rebate_amount_krw` | `krw × 0.2 / 100` |
| `daily_fees` REBATE (출금 파트너) | 2 USDT 상당 |
| `daily_fees` SYSTEM | 2 USDT 상당 |
| 파트너 36 몫 | **0** |

**케이스 B — 매장(0.6) → 총판(0.2) → 시스템, 리베이트 0.2**

| 항목 | 기대값 |
|---|---|
| 주문 `fee_rate` | `1.000000` |
| REBATE | 0.2% |
| BUYER_SHARE (총판) | 0.6% |
| SYSTEM | 0.2% |
| 합 | **1.0% ✓** |

**케이스 C — 출금 1,000 USDT, 출금 수수료 0.2%**

| 항목 | 기대값 |
|---|---|
| 잠금 | 1,002 |
| 온체인 전송 / `usdt_amount` | **1,000** |
| 출금측 원장 | `DEBIT 1,000` + `FEE 2` |
| `withdraw_fee_usdt` | 2 |
| 매칭 취소 시 | 잠금 1,002 전액 해제, `FEE` 미기록 |

### 8.3 회귀 불변

- 일반 입금 수수료(`fee_source = DEPOSIT`) 집계 결과가 **변경 전과 완전히 동일**
- TORQ 레그 크레딧 금액 불변 (FEE 0 유지)
- PARTNER 레그 FIAT 정산 경로 불변

### 8.4 양방향 DDL 정합성

DDL에 있는데 Entity에 없는 컬럼 / Entity에 있는데 DDL에 없는 컬럼 — 두 방향 모두 확인 보고.

### 8.5 빌드

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

> 샌드박스는 Java 11이므로 **Desktop Commander로 로컬 Mac에서** 실행할 것.

### 8.6 MyBatis `<script>` 주의 ⚠️

`<script>` 내부에서 `<`, `<=`, `<>` 직접 사용 금지. 기동 시점 `SAXParseException`으로 전 서비스가 내려간다 (2026-06-11 운영 장애). `&lt;` / `!=` / `<![CDATA[ ]]>` 사용. `ORDER BY` / `LIMIT` / `COUNT` 직접 작성 금지 (`XResultInterceptor` 처리).

### 8.7 완료 기준

1. §8.1 단위 전수 확인 보고
2. §8.2 세 케이스 재현 (단위 테스트 또는 로컬 DB)
3. §8.3 회귀 불변 확인
4. §8.4 양방향 정합성 이상 없음
5. §8.5 6개 모듈 컴파일 성공
6. UI 3개 레포 빌드 성공

---

## 9. 함정

| # | 함정 | 대응 |
|---|---|---|
| 1 | 단위 100배 오차 | §8.1 전수 확인. 퍼센트는 반드시 `/100` |
| 2 | 실현 잡이 `source_partner MASTER`에서 스윕 | §4.5 코인 소재지 기준 규칙 |
| 3 | 잠금·해제 금액 불일치 (1,000 vs 1,002) | 두 값 모두 헬퍼 하나로 산출 |
| 4 | 매칭 취소 시 수수료 계상 | 계상은 정산 완료 시점에만 |
| 5 | 위젯 TORQ 레그 이중 차감 표시 | §4.7 레그별 계산 |
| 6 | 파트너 생성 시 값 미설정 → 수수료 0 | §5 `@NotNull` 강제 |
| 7 | 총판 경로 DTO에 검증 애너테이션 부재 | `CreateSubPartnerRequest.FeeSettings` 명시 |
| 8 | `modify()`로는 null 기록 불가 | 오버라이드 해제가 필요하면 `update()` |
| 9 | 리베이트가 매칭 상대에 따라 달라짐 | 입금 파트너 기준으로 확정. 매칭 룰 변경(별건)으로 추가 완화 예정 |
| 10 | 하위 생성 폼 2곳 중복 | 통합 권장 |

---

## 10. 배포

1. DDL v2.8 적용 — **사람이 실행** (로컬 → 운영 순)
2. §7 마이그레이션 UPDATE 실행 + 실효 요율 전수 확인
3. 코드 구현 → 로컬 빌드 검증 → 커밋 → `git push origin main`
4. GitLab Pipelines에서 `spring:deploy-production` ▶ + `widget-ui:deploy` ▶ + admin/partner UI 배포 잡 ▶ 수동 실행
5. 배포 직후 §8.2 케이스 A를 운영 실거래 1건으로 재확인

> ⚠️ **백엔드와 widget-ui는 반드시 같은 타이밍에 올린다.** `fee_rate` 가 소수 비율 → 퍼센트로 바뀌므로,
> 구 위젯이 신 백엔드 응답을 받으면 수수료가 **100배**로 표시된다. 배포 순서가 어긋나면 사용자에게 즉시 노출된다.

> `git push`는 Cowork 샌드박스에 SSH 키가 없으므로 Desktop Commander로 로컬 Mac에서 실행한다.

---

## 11. 후속 (별건)

- **최상위 총판 기준 매칭 룰** — 실측상 크로스 파트너 매칭이 전 기간 1건(14,090원, 0.09%)이라 손실이 거의 없다. `p2p.tree_scoped_matching_enabled` 플래그로 점진 적용. ⚠️ `P2pMatchingMapper`의 `LIMIT 10`이 Java 필터보다 먼저 적용되므로 **반드시 SQL JOIN/WHERE**에 넣어야 한다
- **10 USD 실현 임계** — 참여자별 그룹이라 소액 몫이 정체한다. `source_partner` 단위로 스윕하고 참여자 몫은 그 안에서 나누는 방식으로 완화 가능
- **`source_type` 오태깅** — 일별 실현 잡이 `fee_source`를 보지 않고 일괄 `FEE`로 기록한다. 수익 명목별 집계가 왜곡된다
- **레거시 주간 수익쉐어 화면 2종 폐기** — `AdminSettlementRebateService` / `PartnerSettlementRebateService` 가
  `p2p.system_rate`(v2.8에서 `0`)와 `p2p_matches.withdraw_bonus_rate`(신규 매칭은 항상 0)를 읽는다.
  배포 후 시스템 몫·보너스가 0으로 표시되므로 **UI 작업 시 메뉴에서 제거**한다. 백엔드 엔드포인트는
  과거 이력 조회용으로 남긴다
