# 수수료 재정의 구현 핸드오프 (IntelliJ 구현용 번들)

- **작성일**: 2026-06-24
- **대상**: cryptoments-backend (common / core / admin-api)
- **설계 근거**: `FEE_POLICY_REDEFINITION_2026_06_24.md`, `SETTLEMENT_SYSTEM_OVERVIEW.md`
- **범위**: ① P2P PARTNER 레그 수수료 면제, ② P2P 출금자 매칭 보너스 신규
- **현행 유지**: 일반 입금 수수료 트리(`aggregateDailyFees`)는 **변경 없음**

> ⚠️ Cowork는 코드를 직접 수정하지 않는다. 본 문서를 IntelliJ에서 구현. DDL은 CI/CD 미포함 → 운영 DB 직접 적용(맨 아래 배포 노트).

---

## 0. 변경 요약

| # | 종류 | 대상 | 내용 |
|---|---|---|---|
| D1 | DDL | `system_settings` | `p2p.withdraw_bonus_rate` 글로벌 키 추가 |
| D2 | DDL | `partners` | `p2p_withdraw_bonus_rate` 오버라이드 컬럼 |
| D3 | DDL | `p2p_matches` | `withdraw_bonus_rate` 스냅샷 컬럼 |
| D4 | DDL | `settlement_daily_fees` | `fee_role` 컬럼 |
| D5 | DDL | `settlement_daily_fees` | UK에 `fee_role` 포함하도록 재정의 |
| C1 | enum | `common.enums.FeeRole` | BUYER_SHARE / WITHDRAW_BONUS / SYSTEM 신규 |
| C2 | entity | `Partner`, `P2pMatch`, `SettlementDailyFee` | 신규 컬럼 매핑 |
| C3 | core | `P2pFeeResolver` | `resolveWithdrawBonusRate(Partner)` 추가 |
| C4 | core | `P2pMatchingService.createMatch` | P2P 레그 보너스율 스냅샷 |
| C5 | core | `P2pSettlementService.completeSettlement` | **PARTNER 레그 수수료 면제** |
| C6 | common | `SettlementMapper` | 보너스 집계 쿼리 신규 |
| C7 | core | `SettlementService.aggregateDailyP2pFees` | 보너스 행 + SYSTEM 차감 + max(0) |
| C8 | admin-api | 파트너 설정 검증 | 쉐어합 + 보너스 ≤ 수수료율 상한 |

**구현 순서**: D1~D5 (DDL) → C1 (enum) → C2 (entity) → C3 (resolver) → C4 (매칭 스냅샷) → C5 (면제) → C6 (매퍼) → C7 (집계) → C8 (상한) → 테스트.

---

## 1. DDL (D1~D5)

> ✅ **운영 DB(cryptoments_db) 적용 완료 (2026-06-24, Cowork)**. 검증: 컬럼 3종 생성, UK 8컬럼(fee_role 포함) 재정의, 글로벌 키 0.002 확인. `CRYPTOMENTS_V2_DDL.sql` 본문 반영 완료. 마이그레이션 파일: `migrations/FEE_REDEFINITION_MIGRATION_2026_06_24.sql`. **IntelliJ는 코드(C1~C8)만 구현하면 됨.**

```sql
-- D1: 글로벌 보너스율 기본값
INSERT INTO system_settings (setting_key, setting_value, description, value_type) VALUES
('p2p.withdraw_bonus_rate', '0.002',
 'P2P 출금자 매칭 보너스 글로벌 기본율 (소수 비율, 0.002=0.2%). 출금측 파트너 지급, 시스템 수익분에서 차감',
 'NUMBER');

-- D2: 파트너 오버라이드 (NULL=글로벌)
ALTER TABLE partners
  ADD COLUMN p2p_withdraw_bonus_rate DECIMAL(10,6) DEFAULT NULL
  COMMENT '출금자 매칭 보너스율 오버라이드 (소수 비율, 거래액 기준 절대율). NULL=글로벌 p2p.withdraw_bonus_rate';

-- D3: 매칭 시점 보너스율 스냅샷
ALTER TABLE p2p_matches
  ADD COLUMN withdraw_bonus_rate DECIMAL(10,6) NOT NULL DEFAULT 0
  COMMENT 'P2P 레그 매칭 생성 시 출금파트너 effective 보너스율 스냅샷. TORQ/PARTNER 레그=0';

-- D4: 정산 일별 수수료 역할 구분
ALTER TABLE settlement_daily_fees
  ADD COLUMN fee_role VARCHAR(20) NOT NULL DEFAULT 'BUYER_SHARE'
  COMMENT 'BUYER_SHARE(구매자측 매장/총판) / WITHDRAW_BONUS(출금자 보너스) / SYSTEM(시스템 잔여)';

-- D5: UK 재정의 — fee_role 포함 (출금파트너=구매자측 총판 충돌 방지)
ALTER TABLE settlement_daily_fees DROP INDEX uk_daily_participant;
ALTER TABLE settlement_daily_fees
  ADD UNIQUE KEY uk_daily_participant
  (settlement_date, source_partner_id, participant_type, participant_partner_id,
   currency_id, network_id, fee_source, fee_role);
```

> `<script>` 매퍼 아님(순수 DDL)이라 `<` 이슈 없음. 운영 적용 순서는 D4 → D5(컬럼 먼저, UK 나중).

---

## 2. enum / entity (C1, C2)

**C1 — `common/enums/FeeRole.java` 신규**

```java
public enum FeeRole {
    BUYER_SHARE,    // 구매자측 매장/총판 쉐어 (기존 + 입금 트리 분배도 이 값)
    WITHDRAW_BONUS, // 출금자 보너스 (신규)
    SYSTEM          // 시스템 잔여
}
```

**C2 — entity 컬럼 매핑 추가**

```java
// Partner.java
@XColumn("p2p_withdraw_bonus_rate")
private BigDecimal p2pWithdrawBonusRate;   // NULL 허용 (오버라이드)

// P2pMatch.java
@XColumn("withdraw_bonus_rate")
private BigDecimal withdrawBonusRate;      // 매칭 스냅샷, 기본 0

// SettlementDailyFee.java
@XColumn("fee_role")
private FeeRole feeRole;                    // VARCHAR enum
```

> 기존 DEPOSIT 트리 집계(`aggregateDailyFees`)가 생성하는 행도 `fee_role`을 채워야 함 — PARTNER 행=BUYER_SHARE, SYSTEM 행=SYSTEM. (해당 메서드의 빌더에 `.feeRole(...)` 추가. 분배 로직 자체는 불변)

---

## 3. core — 보너스율 해석 (C3)

**`P2pFeeResolver.java` — 메서드 추가** (기존 `resolveEffectiveFeeRate` 패턴 동일)

```java
public static final String KEY_WITHDRAW_BONUS_RATE = "p2p.withdraw_bonus_rate";
public static final BigDecimal DEFAULT_WITHDRAW_BONUS_RATE = new BigDecimal("0.002");

/** 글로벌 출금자 보너스율 (system_settings p2p.withdraw_bonus_rate, 기본 0.002). */
public BigDecimal globalWithdrawBonusRate() {
    return readSetting(KEY_WITHDRAW_BONUS_RATE, DEFAULT_WITHDRAW_BONUS_RATE);
}

/** 출금파트너의 effective 보너스율 — 오버라이드 ?? 글로벌. */
public BigDecimal resolveWithdrawBonusRate(Partner withdrawPartner) {
    return withdrawPartner.getP2pWithdrawBonusRate() != null
            ? withdrawPartner.getP2pWithdrawBonusRate()
            : globalWithdrawBonusRate();
}
```

---

## 4. core — 매칭 생성 시 보너스율 스냅샷 (C4)

**`P2pMatchingService.createMatch` (라인 532~)** — P2P 레그만 스냅샷, TORQ/PARTNER는 0.

```java
private void createMatch(P2pWithdrawOrder wo, P2pDepositOrder dpo,
                         long krwAmount, Long networkId, P2pLegType legType) {
    BigDecimal usdtAmount = BigDecimal.valueOf(krwAmount)
            .divide(wo.getExchangeRate(), 18, RoundingMode.DOWN);

    // [신규] P2P 레그만 출금파트너 보너스율 스냅샷
    BigDecimal bonusRate = BigDecimal.ZERO;
    if (legType == P2pLegType.P2P) {
        Partner withdrawPartner = partnerRepository.findOne(wo.getPartnerId());
        if (withdrawPartner != null) {
            bonusRate = feeResolver.resolveWithdrawBonusRate(withdrawPartner);
        }
    }

    P2pMatch match = P2pMatch.builder()
            .matchCode("pm_" + generateId())
            .withdrawOrderId(wo.getId())
            .depositOrderId(dpo.getId())
            .legType(legType)
            .krwAmount(krwAmount)
            .usdtAmount(usdtAmount)
            .exchangeRate(wo.getExchangeRate())
            .withdrawBonusRate(bonusRate)        // [신규]
            .status(P2pMatchStatus.CREATED)
            .expiresAt(LocalDateTime.now().plusMinutes(MATCH_EXPIRY_MINUTES))
            .build();
    // ... 이하 동일
}
```

> `partnerRepository`, `feeResolver` 주입 확인. TORQ 레그는 `createMatch`를 거치지 않고 `fillRemainderWithTorq`에서 직접 빌드하므로 `withdrawBonusRate`는 빌더 기본 0 그대로 둔다(명시 0 세팅 권장).

---

## 5. core — PARTNER 레그 수수료 면제 (C5)

**`P2pSettlementService.completeSettlement` (라인 300~321)** — PARTNER 레그면 수수료 0.

```java
long feeKrwThis = (match.getLegType() == P2pLegType.PARTNER)
        ? 0L                                    // [신규] PARTNER 레그 면제
        : computeProportionalFeeKrw(dpo, match);

BigDecimal p2pFeeUsdt = BigDecimal.ZERO;
if (feeKrwThis > 0) {
    p2pFeeUsdt = BigDecimal.valueOf(feeKrwThis)
            .divide(match.getExchangeRate(), 18, RoundingMode.DOWN);
}

// CREDIT(전액) — 변경 없음 (라인 308~312)
// FEE — p2pFeeUsdt==0이면 자연히 미기록 (라인 316~321 그대로)
```

근거: TORQ 레그는 이미 `creditTorqLeg`에서 면제. PARTNER 레그도 면제되면 **수수료는 P2P 레그에만** 남는다(FEE정의서 §2).

---

## 6. common — 보너스 집계 쿼리 (C6)

**`SettlementMapper.java` — 신규 메서드** (기존 `aggregateDailyP2pSharesByDate` 패턴)

```java
@Select("<script>"
        + "SELECT "
        + "  le.partner_id AS depositPartnerId, "
        + "  wo.partner_id AS withdrawPartnerId, "
        + "  le.currency_id AS currencyId, "
        + "  le.network_id AS networkId, "
        + "  COALESCE(SUM(CASE WHEN le.entry_type = 'FEE' AND dpo.fee_rate > 0 "
        + "    THEN le.amount * pm.withdraw_bonus_rate / dpo.fee_rate ELSE 0 END), 0) AS bonusAmount "
        + "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_withdraw_orders wo ON wo.id = pm.withdraw_order_id "
        + "WHERE DATE(le.created_at) = #{date} "
        + "  AND le.reference_type = 'P2P_SETTLEMENT' "
        + "  AND le.entry_type = 'FEE' "
        + "  AND pm.leg_type = 'P2P' "
        + "  AND pm.withdraw_bonus_rate > 0 "
        + "GROUP BY le.partner_id, wo.partner_id, le.currency_id, le.network_id "
        + "HAVING bonusAmount > 0"
        + "</script>")
List<Map<String, Object>> aggregateDailyP2pWithdrawBonusByDate(@Param("date") String date);
```

> ⚠️ `<script>` 내 `>`는 안전, `<`는 절대 직접 사용 금지(`&lt;`). CLAUDE.md 운영장애 규칙.

---

## 7. core — 집계 확장 (C7)

**`SettlementService.aggregateDailyP2pFees` (라인 491~557)** — 3가지만 추가.

```java
// (기존) totals, shares 조회 후 ...
List<Map<String, Object>> bonuses = settlementMapper.aggregateDailyP2pWithdrawBonusByDate(dateStr); // [신규]

Map<String, BigDecimal> shareSumByKey = new HashMap<>();
Map<String, BigDecimal> bonusSumByKey = new HashMap<>();   // [신규]

// (기존) ② 쉐어 행 — saveP2pDailyFee(..., role=BUYER_SHARE) + shareSumByKey 누적

// [신규] ②-b 보너스 행
for (Map<String, Object> b : bonuses) {
    Long merchantId   = num(b, "depositPartnerId");   // SYSTEM 차감 그룹 키
    Long withdrawId   = num(b, "withdrawPartnerId");  // 보너스 수취자
    Long currencyId   = num(b, "currencyId");
    Long networkId    = num(b, "networkId");
    BigDecimal bonusAmt = dec(b, "bonusAmount");

    String key = groupKey(merchantId, currencyId, networkId);
    Map<String, Object> t = totalsByKey.get(key);
    BigDecimal totalCredit = t != null ? dec(t, "totalCredit") : BigDecimal.ZERO;
    BigDecimal totalFee    = t != null ? dec(t, "totalFee")    : BigDecimal.ZERO;

    saveP2pDailyFee(savedFees, date, merchantId, ParticipantType.PARTNER, withdrawId,
            currencyId, networkId, totalCredit, totalFee, bonusAmt, FeeRole.WITHDRAW_BONUS);
    count++;
    bonusSumByKey.merge(key, bonusAmt, BigDecimal::add);
}

// ① SYSTEM 잔여 — 보너스도 차감 + max(0)
for (Map<String, Object> t : totals) {
    // ...
    BigDecimal sharesSum = shareSumByKey.getOrDefault(key, BigDecimal.ZERO);
    BigDecimal bonusSum  = bonusSumByKey.getOrDefault(key, BigDecimal.ZERO);   // [신규]
    BigDecimal systemShare = totalFee.subtract(sharesSum).subtract(bonusSum);  // [변경]
    if (systemShare.signum() < 0) systemShare = BigDecimal.ZERO;               // [신규] 클램프
    if (systemShare.compareTo(BigDecimal.ZERO) > 0) {
        saveP2pDailyFee(..., systemShare, FeeRole.SYSTEM);
    }
}
```

- `saveP2pDailyFee` 시그니처에 `FeeRole feeRole` 파라미터 추가(빌더에 `.feeRole(feeRole)`).
- 기존 쉐어 호출은 `FeeRole.BUYER_SHARE`로 전달.
- 실현(`realizeFees`)·잔액 누적(`accumulateUnrealizedBalance`)은 **변경 없음** — 보너스 행도 PARTNER 참여자라 그대로 흐른다.

---

## 8. admin-api — 상한 검증 (C8)

파트너 수수료/쉐어/보너스 **설정 저장 시** 검증(§FEE정의서 2.3):

```
구매자측 체인 쉐어 합 + 출금 보너스율 ≤ effective_fee_rate
```

- 구매자측 쉐어(매장+상위총판 p2p_share_rate 합)와 출금 보너스(`p2p_withdraw_bonus_rate ?? 글로벌`)가 effective 수수료율을 넘지 않도록 컨트롤러/서비스 검증에 추가.
- 두 파트너가 독립 설정하므로 설정 단계만으로 완전 보장은 불가 → 집계의 `max(0)` 클램프(C7)가 최종 안전장치(역마진 차단).

---

## 9. 회귀 테스트 체크리스트

1. **PARTNER 레그 면제**: P2P+PARTNER 혼합 매칭에서 `ledger_entries` FEE 합 = P2P 레그분만. PARTNER 레그 FEE=0.
2. **보너스 적립**: P2P 단독 매칭 정산 후 일일 집계 → 출금파트너 `settlement_daily_fees(fee_role=WITHDRAW_BONUS)` 행 생성, `unrealized_balance` 증가.
3. **합 보존**: 매장·일자 단위 `Σ(BUYER_SHARE)+Σ(WITHDRAW_BONUS)+SYSTEM = totalFee`.
4. **UK 충돌 회피**: 출금파트너 = 구매자측 총판인 케이스에서 BUYER_SHARE·WITHDRAW_BONUS 행이 **둘 다** 적재(누락 없음).
5. **레그 격리**: TORQ/PARTNER 레그는 보너스 행 미생성(`leg_type='P2P'` 한정).
6. **상한**: 쉐어합+보너스 > 수수료율로 설정 시도 → C8 차단. 우회 시 C7 클램프로 SYSTEM=0(음수 방지).
7. **멱등성**: 동일 일자 집계 재실행 → 중복 행/중복 누적 없음.
8. **입금 트리 무변경**: DEPOSIT 집계 결과가 기존과 동일(+ fee_role만 채워짐).

---

## 10. 배포 노트

- **코드(common/core/admin-api)**: `git push origin main` → GitLab CI/CD 자동 빌드·배포.
- **DDL(D1~D5)**: CI/CD 미포함 → 운영 DB 직접 적용. 순서 D1→D2→D3→D4→D5(컬럼 후 UK).
  ```bash
  ssh cryptoments-bastion "ssh db-01 'mysql -u cryptoments -p\"<PW>\" cryptoments_db -e \"<ALTER...>\"'"
  ```
- **적용 순서 권장**: DDL 먼저(하위호환 — 신규 컬럼 기본값으로 기존 코드 무영향) → 코드 배포 → 검증.
- `CRYPTOMENTS_V2_DDL.sql` 본문에도 D1~D5 반영(Single Source of Truth).
