# P2P 수수료 구조 재설계 — 구현 지침서 (v3.0 · 체인 쉐어 모델)

> **작성일**: 2026-06-11
> **v3.0**: **체인 쉐어 모델** — 쉐어는 "받는 파트너 자신의 행"에 설정. 매장 + 모든 상위 총판이 각자 수취, 잔여=시스템.
> v2.x의 "매장에 총판/매장 분배율 설정" 모델 폐기. **이 문서는 v2.3 백엔드 구현이 완료된 상태에서의 변경 지침**이다.
> **DDL v2.4 운영 적용 완료** (2026-06-11) — DDL 작업 불필요.

---

## 1. 최종 모델

### 1.1 비즈니스 규칙 (v2.3에서 유지되는 것)

- 구매자(입금측) 회원만 부담. 온체인 전액 이동 + 원장 CREDIT 전액 / FEE 차감 (실수령 = 입금 − 수수료) — **무변경**
- 수수료율: 글로벌 `p2p.fee_rate` (1~3%, 시작 2%) + 파트너별 오버라이드 (NULL=글로벌, 0=면제, 1~3%) — **무변경**
- 정산 파이프라인 합류 (`settlement_daily_fees` fee_source='P2P' → balances → 실현 → 인보이스) — **무변경**
- 주문 생성 시점 스냅샷 원칙 — **무변경**

### 1.2 쉐어 모델 (v3.0 변경 핵심)

**각 파트너의 행에 "그 파트너가 받을 쉐어율" 하나만 설정한다** (`partners.p2p_share_rate`, 기본 0).

```
A 총판 (share 1%) > B 총판 (share 0.5%) > 매장 M (share 0.5%)
매장 M 회원이 100만원 구매 (수수료 2% = 2만원 상당 USDT):

  매장 M   0.5% → 5,000원 상당
  B 총판   0.5% → 5,000원 상당
  A 총판   1.0% → 10,000원 상당
  시스템   잔여 0% → 0원
  ─────────────────────────
  합계 = 수수료 2%
```

- 쉐어율은 **거래액 기준 절대율** (수수료율의 비율이 아님)
- 매장 자신 + 매장의 **모든 상위 총판**이 각자 자신의 쉐어율만큼 수취
- **시스템 = 잔여** (수수료율 − 체인 합)
- **불변식**: 모든 매장에 대해 `체인 쉐어 합 ≤ 그 매장의 effective 수수료율`
- 면제 매장(fee=0): 수수료가 없으므로 분배도 없음 — 체인 검증에서 제외
- Admin 설정 위치: **파트너 관리 페이지에서만** (글로벌 화면에는 수수료율만 남음)

### 1.3 데이터 모델 (DDL v2.4 — 운영 적용 완료)

| 위치 | 변경 |
|------|------|
| `partners.p2p_share_rate` | **신규** DECIMAL(10,6) NOT NULL DEFAULT 0 — 이 파트너가 받을 쉐어율 |
| `partners.p2p_fee_rate` | 유지 (NULL=글로벌, 매장에만 의미) |
| ~~`partners.p2p_distributor_share_rate` / `p2p_merchant_share_rate`~~ | **제거됨** |
| ~~`p2p_deposit_orders.distributor_share_rate` / `merchant_share_rate` / `parent_partner_id`~~ | **제거됨** |
| `p2p_order_shares` | **신규 테이블** — 주문별 수취 체인 스냅샷 `(deposit_order_id, partner_id, share_rate)`, uk(order,partner). 쉐어율 0인 파트너는 미기록 |
| `system_settings` | `p2p.fee_rate`만 유지 (~~distributor/merchant 키~~ 삭제됨) |
| `settlement_daily_fees.fee_source` | 유지 (변경 없음) |

---

## 2. 백엔드 변경 지침 (v2.3 구현 기준 diff)

### 2.1 common

| 파일 | 변경 |
|------|------|
| `entity/Partner.java` | `p2pDistributorShareRate`/`p2pMerchantShareRate` **제거** → `p2pShareRate` (BigDecimal) 추가 |
| `entity/P2pDepositOrder.java` | 스냅샷 3필드 **제거** (`distributorShareRate`/`merchantShareRate`/`parentPartnerId`) |
| `entity/P2pOrderShare.java` | **신설** — `@XEntity("p2p_order_shares")`: id, depositOrderId, partnerId, shareRate, createdAt |
| `repository/P2pOrderShareRepository.java` | **신설** — `List<P2pOrderShare> findByDepositOrderId(Long)` |
| `mapper/PartnerMapper.java` | `findIdsWithP2pOverride()` → 용도 변경: 글로벌 fee 변경 검증 대상. v3에서는 체인 검증이 필요하므로 §2.4 참고 |
| `mapper/SettlementMapper.java` | P2P 집계 쿼리 개편 (§2.3) |

### 2.2 core — P2pFeeResolver / P2pDepositService

**P2pFeeResolver** (개편):

```java
// 유지: globalFeeRate(), resolveEffectiveFeeRate(partner) = p2pFeeRate ?? global
// 제거: globalDistributorShareRate/globalMerchantShareRate, EffectiveP2pFee의 분배 필드

/** 매장→루트 체인의 쉐어 수취자 수집 (shareRate > 0인 파트너만). 순서 무관. */
public List<ShareEntry> collectShareChain(Partner merchant) {
    List<ShareEntry> chain = new ArrayList<>();
    Partner cur = merchant;
    Set<Long> visited = new HashSet<>();          // 순환 방어
    while (cur != null && visited.add(cur.getId())) {
        if (cur.getP2pShareRate() != null && cur.getP2pShareRate().signum() > 0) {
            chain.add(new ShareEntry(cur.getId(), cur.getP2pShareRate()));
        }
        cur = cur.getParentPartnerId() != null ? partnerRepository.findOne(cur.getParentPartnerId()) : null;
    }
    return chain;
}
// ShareEntry: record(Long partnerId, BigDecimal shareRate)
```

**P2pDepositService.createAndMatch** (개편):

```java
BigDecimal feeRate = feeResolver.resolveEffectiveFeeRate(partner);   // 오버라이드 ?? 글로벌
long feeAmount = ...;                                                // 기존 동일

List<ShareEntry> chain = (feeRate.signum() > 0) ? feeResolver.collectShareChain(partner) : List.of();
BigDecimal chainSum = chain.stream().map(ShareEntry::shareRate).reduce(ZERO, BigDecimal::add);

// 방어: 체인 합 > 수수료율 → 쉐어 전체 미기록 (전액 시스템) + 경고 (Admin 검증 우회 데이터 대비)
if (chainSum.compareTo(feeRate) > 0) {
    log.warn("P2P 쉐어 체인 합이 수수료율 초과 — 쉐어 미기록: merchant={}, chainSum={}, fee={}", ...);
    chain = List.of();
}

// 주문 저장 (fee_rate/fee_amount만) 후 체인 스냅샷 기록
Long orderId = ...save...;
for (ShareEntry e : chain) {
    p2pOrderShareRepository.save(P2pOrderShare.builder()
            .depositOrderId(orderId).partnerId(e.partnerId()).shareRate(e.shareRate()).build());
}
```

### 2.3 집계 — SettlementMapper / SettlementService

쿼리 2개로 분리한다 (수취자별 + 매장별 총계).

```java
/** ① 매장별 P2P 총계 (SYSTEM 잔여 계산용) — v2.3 쿼리에서 share 컬럼 제거 */
@Select("... SELECT le.partner_id AS depositPartnerId, le.currency_id, le.network_id, "
    + " SUM(CASE WHEN le.entry_type='CREDIT' THEN le.amount ELSE 0 END) AS totalCredit, "
    + " SUM(CASE WHEN le.entry_type='FEE' THEN le.amount ELSE 0 END) AS totalFee "
    + " FROM ledger_entries le "
    + " WHERE DATE(le.created_at)=#{date} AND le.reference_type='P2P_SETTLEMENT' "
    + "   AND le.entry_type IN ('CREDIT','FEE') "
    + " GROUP BY le.partner_id, le.currency_id, le.network_id HAVING totalFee > 0 ...")
List<Map<String, Object>> aggregateDailyP2pTotalsByDate(@Param("date") String date);

/** ② 매장×수취자별 쉐어 합 — FEE 금액을 주문 스냅샷 비율로 비례 분할 */
@Select("... SELECT le.partner_id AS depositPartnerId, pos.partner_id AS recipientPartnerId, "
    + " le.currency_id, le.network_id, "
    + " SUM(CASE WHEN le.entry_type='FEE' AND dpo.fee_rate > 0 "
    + "     THEN le.amount * pos.share_rate / dpo.fee_rate ELSE 0 END) AS shareAmount "
    + " FROM ledger_entries le "
    + " JOIN p2p_settlements ps ON ps.id = le.reference_id "
    + " JOIN p2p_matches pm ON pm.id = ps.match_id "
    + " JOIN p2p_deposit_orders dpo ON dpo.id = pm.deposit_order_id "
    + " JOIN p2p_order_shares pos ON pos.deposit_order_id = dpo.id "
    + " WHERE DATE(le.created_at)=#{date} AND le.reference_type='P2P_SETTLEMENT' "
    + "   AND le.entry_type = 'FEE' "
    + " GROUP BY le.partner_id, pos.partner_id, le.currency_id, le.network_id "
    + " HAVING shareAmount > 0 ...")
List<Map<String, Object>> aggregateDailyP2pSharesByDate(@Param("date") String date);
```

**`SettlementService.aggregateDailyP2pFees`** (개편):

1. ②의 결과로 (매장, 수취자, 통화, 네트워크)별 `PARTICIPANT=PARTNER` 행 저장 — `saveP2pDailyFee` 재사용 (source=매장, participant=수취자)
2. ①의 결과로 매장 그룹별 `SYSTEM 잔여 = totalFee − Σ(그 매장의 ② 합)` 계산 → SYSTEM 행 저장
3. `feeSource=P2P`, savedFees → `accumulateUnrealizedBalance` 누적 — 기존 패턴 유지
4. `shareRate` 컬럼(정보성)은 `shareAmount/totalCredit×100` 역산 — 기존 헬퍼 유지

### 2.4 admin-api — EP 계약 v3 (⚠️ admin-ui가 이 계약으로 선반영됨)

```
GET /api/admin/p2p/fee-settings
  → { feeRate }                                     ← 분배율 필드 제거

PUT /api/admin/p2p/fee-settings
  { feeRate, force? }                               ← 분배율 필드 제거

GET /api/admin/partners/{id}/p2p-fee
  → { feeRate,                                      ← 오버라이드 원본 (null=글로벌)
      shareRate,                                    ← 이 파트너가 받을 쉐어율 (0=없음)
      effectiveFeeRate,                             ← feeRate ?? global
      globalFeeRate }                               ← 참고용

PUT /api/admin/partners/{id}/p2p-fee
  { feeRate: 0.015 | 0 | null, shareRate: 0.01 }    ← shareRate는 값 (null 허용 시 0 취급)
```

비율 값은 `@JsonFormat(shape=STRING)` 직렬화 유지. 기존 `Global` 중첩 객체와 effective 분배 필드는 제거.

**검증 규칙**:

| EP | 규칙 |
|----|------|
| 글로벌 PUT | ① `0.01 ≤ feeRate ≤ 0.03` ② **모든 매장 전수**: `체인 합 ≤ effective fee` (effective fee = 오버라이드 ?? 새 글로벌, **fee=0 면제 매장은 제외**) → 위반 매장 있으면 409 + `force=true` 강제 저장 (기존 패턴 유지) |
| 파트너 PUT | ① `feeRate` 설정 시 0 또는 0.01~0.03 ② `shareRate ≥ 0` ③ **영향 매장 재검증**: 이 파트너 서브트리의 모든 파트너 각각을 매장으로 보고 `체인 합 ≤ effective fee` (fee=0 제외) — 위반 시 400 + 위반 파트너 코드 목록 |

서브트리 순회: `parent_partner_id` 기반 BFS (37개 규모 — 전수 OK). `PartnerMapper.findIdsWithP2pOverride()`는 폐기하고 전체 파트너 로드 후 메모리 순회로 단순화해도 무방.

**검증 시점에 잡는 것이 원칙** — 주문 생성의 "쉐어 미기록 강등"은 마지막 방어선일 뿐.

### 2.5 partner-api

변경 없음 (`P2pSettingsResponse.p2pFeeRate` = effective fee 유지). `P2pFeeResolver` 메서드명 변경 시 컴파일 추적.

---

## 3. admin-ui (Cowork 구현 — ✅ v3 반영 완료)

| 화면 | 내용 |
|------|------|
| P2P 관리 > **P2P 설정** | **구매자 수수료율만** (1~3% 검증 + 409→force 재시도). 수익 분배 카드 제거, "쉐어는 파트너 관리 페이지에서 설정" 안내 |
| 파트너 상세 > **P2P 설정 탭** | 카드1 구매자 수수료율 오버라이드 (글로벌 토글, 0=면제 뱃지 — 매장에만 의미) + 카드2 **P2P 쉐어율** 단일 입력 ("이 파트너가 하위 매장(또는 자신)의 P2P 거래 수수료에서 받을 몫") |

---

## 4. 테스트 체크리스트 (v3)

1. **쉐어 설정**: A 총판 페이지 share 1%, 하위 매장 share 0.5% 저장 → 각 partners 행에 기록.
2. **체인 검증**: 매장 effective fee 2%에서 체인 합 2.5%가 되는 설정 시도 → 400 + 위반 매장 목록. 글로벌 fee를 3%→1%로 내릴 때 체인 합 초과 매장 존재 → 409 → force 저장.
3. **주문 생성**: 매장 주문 → `p2p_deposit_orders.fee_rate` 스냅샷 + `p2p_order_shares`에 체인 행(쉐어>0인 파트너만).
4. **집계**: §1.2 예시 → `settlement_daily_fees`에 매장/B/A 3개 PARTNER 행 + (잔여>0이면) SYSTEM 행, fee_source='P2P', 금액 5,000/5,000/10,000원 상당 USDT.
5. **깊은 트리**: A>B>M에서 A·B·M 각자 수취. 중간 총판 share=0이면 그 총판 행 없음.
6. **면제 매장**: fee=0 → feeAmount=0, p2p_order_shares 미기록, 집계 행 없음. 체인 검증에서도 제외 확인.
7. **스냅샷 격리**: 주문 생성 후 쉐어율 변경 → 기존 주문 정산은 스냅샷 기준.
8. **멱등성/격리/E2E/회귀**: v2.3 체크리스트 6~9항 동일.

---

## 5. 배포 순서

1. ~~운영 DDL~~ ✅ v2.4 적용 완료 (2026-06-11)
2. 백엔드 수정 (§2) → 전 모듈 `compileJava` → `git push origin main` (CI/CD)
3. admin-ui push (cryptoments-admin)
4. 배포 후: 파트너 페이지에서 쉐어율 설정 → 주문 스냅샷 확인 → 익일 집계 검증
5. **배포 확인 후** `UPDATE partners SET p2p_fee_rate = NULL WHERE p2p_fee_rate = 0` (면제 유지 파트너 사전 제외) — 이 시점부터 글로벌 2% 과금

---

## 6. 폐기 이력

- v1.0: 글로벌 기본 + 파트너 3값 오버라이드 (시스템/총판 분배율 명시 설정)
- v2.0: 글로벌 단일률 + 총판/매장 글로벌 분배
- v2.1: v2.0 + 파트너별 3값 오버라이드 — **백엔드 구현됨** → v3.0으로 변경 지침 발행
- v3.0 (확정): 체인 쉐어 — 받는 파트너 자신에 설정, 모든 상위 각자 수취, 잔여=시스템
