# 입금 수수료율 가드 — 하위 생성 경로 + 어드민 UI 통일 지침서 (Phase 2)

- 작성일: 2026-08-08
- 배경: `runbooks/troubleshooting.md` §45, Phase 1 = backend `d38c647`(admin-api)
- 대상: `cryptoments/partner-api`, `cryptoments-admin/admin-ui`
- DDL 변경: **없음**

---

## 0. 남은 구멍

Phase 1 에서 admin-api 생성 경로는 고쳤으나 **두 곳이 남았다.**

| 경로 | 상태 | 문제 |
|---|---|---|
| admin-api 파트너 생성 | ✅ `d38c647` | — |
| **partner-api 하위 파트너 생성** | ❌ | 총판은 검증 자체를 스킵, null/0 그대로 저장 |
| **admin-ui 수수료 탭** | ❌ | 총판에게 입력란 미노출 → **값을 볼 수도 고칠 수도 없음** |

### 0-1. partner-api — 총판 검증 스킵

```java
// PartnerSubMgmtService.validateFees
if (partnerType == PartnerType.MERCHANT && depositFeeRate != null) {   // ← DISTRIBUTOR 면 통과
    if (depositFeeRate.compareTo(calculatedMinFeeRate) < 0) throw FEE_RATE_OUT_OF_RANGE;
    ...
}
```
- `partnerType == MERCHANT` 조건 때문에 **하위 총판은 하한 검사를 아예 안 탄다.**
- `depositFeeRate == null` 이면 MERCHANT 도 검사를 건너뛰고, `createSubPartner`(L~197)가
  `.depositFeeRate(depositFeeRate)` 로 **null 을 그대로 저장**한다.

### 0-2. admin-ui — 총판에게 입력란 미노출

```
PartnerFeeTab.vue
  L33   const isMerchant = computed(() => props.partner.partnerType === 'MERCHANT')
  L168  <div v-if="isMerchant">                                  ← 입력란 렌더 안 됨
  L98   depositFeeRate: isMerchant.value ? form.value.depositFeeRate : undefined   ← 저장 시 제외
  L80   if (isMerchant.value) { ...검증... }
  L205  <span v-if="isMerchant">실제: ...</span>                  ← 조회 표시도 숨김
```
운영에서 `deposit_fee_rate = 0` 인 10곳이 **전부 최상위 DISTRIBUTOR** 였던 이유가 이것이다.
UI 가 총판에게 이 필드를 통째로 가려서 **설정할 방법 자체가 없었다.**

---

## 1. 방침 (Phase 1 과 동일 규칙)

> `deposit_fee_rate = 0` 은 "미설정"이 아니라 **밴드 위반**이다.
> 단, **`min_fee_rate = 0` 인 파트너는 0 이 정합**이므로 그대로 허용한다.
> 판정은 언제나 **`minFeeRate > 0` 일 때만**.

파트너 타입(MERCHANT/DISTRIBUTOR)으로 분기하지 않는다. **총판도 사용자 입금(`USER_DEPOSIT`)을 직접 받으므로
요율이 필요하다** — 실제로 피터·3D Holdem·샤크가 그렇다.

---

## 2. 수정 대상

| # | 파일 | 작업 |
|---|---|---|
| 2-1 | `partner-api/.../service/PartnerSubMgmtService.java` | 생성 시 기본값 보정 + `validateFees` 타입 조건 제거 |
| 2-2 | `admin-ui/src/views/partners/tabs/PartnerFeeTab.vue` | `isMerchant` 게이트 제거 |

### 2-1. `PartnerSubMgmtService`

**(a) 생성 시 기본값** (`createSubPartner`, L~158 / 저장 L~197)

```java
BigDecimal depositFeeRate = fees.getDepositFeeRate();
...
BigDecimal calculatedMinFeeRate = distributorMinFeeRate.add(parentFeeRate);

// depositFeeRate 미지정/0 이고 하한이 있으면 하한으로 설정한다. (2026-08-08)
//   0 은 밴드 위반이다 — 그대로 저장하면 aggregateDailyFees 가 집계를 건너뛰어
//   min_fee_rate 로 약속된 시스템 몫이 유실된다. (§45)
//   단, calculatedMinFeeRate = 0 이면 0 이 정합이므로 그대로 둔다.
if ((depositFeeRate == null || depositFeeRate.signum() <= 0)
        && calculatedMinFeeRate != null && calculatedMinFeeRate.signum() > 0) {
    log.info("하위 파트너 depositFeeRate 미지정/0 — minFeeRate 로 기본 설정: requested={}, minFeeRate={}",
            fees.getDepositFeeRate(), calculatedMinFeeRate);
    depositFeeRate = calculatedMinFeeRate;
}
```
- ★보정한 값을 **`validateFees` 호출과 엔티티 저장 양쪽에 동일하게** 사용할 것.

**(b) `validateFees` — 타입 조건 제거**

```java
// 파트너 타입으로 분기하지 않는다 — 총판도 사용자 입금을 직접 받는다. (2026-08-08)
// 하한이 0 이면 0 이 정합이므로 검사하지 않는다.
if (depositFeeRate != null && calculatedMinFeeRate != null && calculatedMinFeeRate.signum() > 0
        && depositFeeRate.compareTo(calculatedMinFeeRate) < 0) {
    throw new PartnerSubMgmtException(PartnerSubMgmtException.FEE_RATE_OUT_OF_RANGE, ...);
}
// 상한 검사는 기존 동작 유지 — 0 은 상한을 넘을 수 없다.
if (depositFeeRate != null && depositFeeRate.signum() > 0
        && maxFeeCap != null && depositFeeRate.compareTo(maxFeeCap) > 0) {
    throw new PartnerSubMgmtException(PartnerSubMgmtException.FEE_RATE_OUT_OF_RANGE, ...);
}
```
- `maxFeeCap > parentMaxFeeCap` 검사(맨 앞)는 **그대로 둔다.**
- 수정 경로(L~281 `depositFeeRate 변경`)는 이미 `minFeeRate ≤ 값 ≤ maxFeeCap` 를 검사하지만,
  **0 이 들어오면 하한 미만으로 걸려 거부**된다. 생성과 일관되게 **0 이면 하한으로 보정**하도록 맞출 것
  (거부보다 보정이 낫다 — 총판이 0 을 넣는 것은 "면제 의도"가 아니라 미입력에 가깝다).

### 2-2. `PartnerFeeTab.vue`

- **L168 `v-if="isMerchant"` 제거** — 모든 파트너 타입에서 입력란·조회 표시가 보이게.
- **L98** `depositFeeRate: form.value.depositFeeRate` 로 변경(타입 무관 전송).
- **L80** 검증 블록의 `if (isMerchant.value)` 제거.
- **L192 / L205** 표시 조건에서도 `isMerchant` 제거.
- `isMerchant` 가 다른 용도로 쓰이지 않으면 선언(L33)도 제거.
- ⚠️ **최소 수수료율(minFeeRate) 표시는 그대로 유지**한다(자동 계산, 읽기 전용).
- ⚠️ 입력 검증: `depositFeeRate < minFeeRate` 이면 저장 전에 토스트로 막거나 minFeeRate 로 보정.
  서버도 막지만 화면에서 즉시 피드백을 주는 편이 낫다.

---

## 3. 완료 기준

1. `./gradlew :partner-api:compileJava` 성공, `admin-ui` `npm run build` 성공.
2. 하위 파트너 생성 시 `depositFeeRate` 미지정/0 + `minFeeRate>0` → **minFeeRate 로 저장**.
3. `minFeeRate = 0` 이면 `depositFeeRate = 0` **여전히 허용**(회귀 방지).
4. `validateFees` 에서 **`partnerType` 분기가 사라질 것** — 총판도 하한 검사를 탄다.
5. 상한 검사·`maxFeeCap > parentMaxFeeCap` 검사 동작 불변.
6. 어드민 수수료 탭에서 **DISTRIBUTOR 도 입금 수수료율이 보이고 수정 가능**할 것.
7. 어드민에서 하한 미만 입력 시 저장이 막히거나 하한으로 보정될 것.
8. CLAUDE.md 규칙 준수. DDL 없음.

## 4. 배포 후 검증

```sql
-- 신규 하위 파트너 생성 후: deposit_fee_rate >= min_fee_rate 인지
SELECT id, name, partner_type, parent_partner_id, deposit_fee_rate, min_fee_rate, max_fee_cap, created_at
  FROM partners WHERE created_at >= CURDATE() ORDER BY id DESC;

-- 상시 점검 — 0건이어야 정상
SELECT id, name, deposit_fee_rate, min_fee_rate FROM partners
 WHERE deposit_fee_rate = 0 AND min_fee_rate > 0;
```
화면: 어드민 → 파트너 상세 → 수수료 탭에서 **총판도 입금 수수료율 입력란이 보이는지**.

## 5. 범위 밖

- 기존 10곳 데이터 보정 — **2026-08-09 00:00 예약 실행분**과 중복 금지.
- `min_fee_rate = 0` 인 DISTRIBUTOR 22곳의 요율 정책 — 별건.
- 파트너 콘솔(partner-ui)의 하위 파트너 생성 화면 — 이번 범위 아님. 서버가 보정하므로 동작은 안전하다.
