# 파트너 "지불 수수료" 투명성 — 거래 내역 화면 강화 구현 지침서

- 작성일: 2026-08-07
- 배경: `runbooks/troubleshooting.md` §39 · §40
- 대상 모듈: `partner-api` (+ `cryptoments-admin/partner-ui`)
- DDL 변경: **없음**

---

## 0. 문제

파트너가 "잔액이 줄었는데 이유를 모르겠다"고 문의한다(§39, 고래 23.37 USDT).
거래 내역 화면에 `유형: 수수료` 필터가 이미 있어서 **자기가 낸 수수료는 볼 수 있다**
(고래 원장 FEE 34건, 합 40.398118). 그런데도 문의를 못 막았다. 이유는:

- 원장의 `FEE` 행은 **입금 시점에 건별로 흩어져** 있다(0.677200, 2.826855, …).
- 실제로 잔액이 줄어든 사건은 **다음날 01:00 정산 인출 23.371226** 인데,
  이건 **원장에 아무 행도 남기지 않는다**(이중차감 방지라 DEBIT 을 찍지 않는 것이 맞다).

즉 **파트너가 찾고 싶은 숫자(23.37)와 화면에 있는 숫자(건별 FEE)가 서로 연결되지 않는다.**
이 다리를 놓는 것이 이번 작업의 전부다.

## 1. 결정 (오너, 2026-08-07)

1. **배분 상세는 공개하지 않는다** — "내가 낸 수수료 총액"과 "실제 지갑에서 나간 시점·금액"만 보여준다.
   시스템 몫 / 상위 총판 몫으로 어떻게 쪼개졌는지는 **표시하지 않는다**(상하위 수익구조 노출 방지).
2. **정산 화면에 탭을 새로 만들지 않는다.** 기존 **거래 내역 화면을 강화**한다.

## 2. 화면 구성 (거래 내역 화면)

기존 목록 위에 **수수료 요약 카드**를 추가하고, 그 아래 **"수수료 정산 인출" 목록**을 둔다.
기존 원장 목록·필터·페이지네이션은 **변경하지 않는다**.

```
[수수료 요약]  기간 내 지불 수수료 12.34 USDT
               ├ 정산 인출 완료  11.00 USDT
               └ 인출 대기        1.34 USDT   (발생 파트너·통화·네트워크별 10 USD 이상 시 인출)

[수수료 정산 인출]   ← 원장에 남지 않는 이벤트. 이 목록이 §39 의 답이다.
 2026-08-07 01:00   23.371226 USDT   TRON   기간 08-05~08-06   [tx]
 2026-08-05 01:00   12.075851 USDT   TRON   기간 07-15~08-04   [tx]

[거래 내역]  (기존 목록 — 무변경)
```

- 인출 금액은 `settlement_realizations.total_share_amount`(**실제 온체인 이동액**)를 쓴다.
  `total_fee_amount` 는 **쓰지 않는다** — 두 값을 나란히 보이면 배분 상세가 역산된다(결정 1 위반).
- 통화별로 섞이지 않게 통화 심볼을 반드시 표시한다.

---

## 3. 수정 대상

| # | 파일 | 작업 |
|---|---|---|
| 3-1 | `partner-api/.../dto/response/PartnerFeeSummaryResponse.java` (신규) | 요약 DTO |
| 3-2 | `partner-api/.../dto/response/PartnerFeeSweepResponse.java` (신규) | 인출 이력 DTO |
| 3-3 | `partner-api/.../mapper/PartnerBalanceMapper.java` | 쿼리 2개 추가 |
| 3-4 | `partner-api/.../service/PartnerBalanceService.java` | 메서드 2개 추가 |
| 3-5 | `partner-api/.../controller/PartnerBalanceController.java` | 엔드포인트 2개 추가 |
| 3-6 | `partner-ui/src/api/types/balance.ts` · `services/balance.service.ts` | 타입·호출 추가 |
| 3-7 | `partner-ui/src/views/partner/balance/TransactionHistoryView.vue` | 요약 카드 + 인출 목록 |

## 3-1. `PartnerFeeSummaryResponse`

```java
/** 통화별 지불 수수료 요약 — 거래 내역 화면 상단 카드용. */
@Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor
public class PartnerFeeSummaryResponse {
    /** 통화 심볼 (USDT 등) */
    private String currencySymbol;
    /** 기간 내 지불한 수수료 총액 (원장 FEE 합) */
    private BigDecimal paidTotal;
    /** 그중 정산 인출이 완료되어 지갑에서 실제로 빠져나간 금액 */
    private BigDecimal sweptTotal;
    /** 아직 지갑에 남아 있는(인출 대기) 수수료 금액 */
    private BigDecimal pendingTotal;
}
```

## 3-2. `PartnerFeeSweepResponse`

```java
/** 수수료 정산 인출 1건 — 원장에 남지 않는 온체인 이동 이벤트. */
@Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor
public class PartnerFeeSweepResponse {
    /** settlement_realizations.id */
    private Long id;
    /** 인출 완료 시각 */
    private LocalDateTime completedAt;
    /** 실제 온체인 이동액 (total_share_amount) */
    private BigDecimal amount;
    /** 통화 심볼 */
    private String currencySymbol;
    /** 네트워크 심볼 */
    private String networkSymbol;
    /** 집계 기간 시작 */
    private LocalDate periodStart;
    /** 집계 기간 종료 */
    private LocalDate periodEnd;
    /** 온체인 트랜잭션 해시 */
    private String txHash;
}
```

## 3-3. `PartnerBalanceMapper` — 쿼리 2개

```java
/**
 * 통화별 지불 수수료 요약 (발생 파트너 기준).
 *
 * <p>paid = 원장 FEE 합(입금 시점 차감), swept/pending = settlement_daily_fees 의
 * 상태별 share_amount 합. daily_fees 는 참여자별로 행이 나뉘지만 <b>합산해 총액만</b> 낸다
 * (배분 상세 비공개 정책, 2026-08-07).
 */
@Select("<script>" +
        "SELECT c.symbol AS currency_symbol," +
        "  COALESCE(f.paid, 0) AS paid_total," +
        "  COALESCE(d.swept, 0) AS swept_total," +
        "  COALESCE(d.pending, 0) AS pending_total" +
        " FROM currencies c" +
        " LEFT JOIN (" +
        "   SELECT le.currency_id, SUM(le.amount) AS paid FROM ledger_entries le" +
        "    WHERE le.partner_id = #{partnerId} AND le.entry_type = 'FEE'" +
        "    <if test='from != null'>AND le.created_at &gt;= #{from}</if>" +
        "    <if test='to != null'>AND le.created_at &lt; DATE_ADD(#{to}, INTERVAL 1 DAY)</if>" +
        "    GROUP BY le.currency_id) f ON f.currency_id = c.id" +
        " LEFT JOIN (" +
        "   SELECT sdf.currency_id," +
        "     SUM(CASE WHEN sdf.status = 'REALIZED' THEN sdf.share_amount ELSE 0 END) AS swept," +
        "     SUM(CASE WHEN sdf.status = 'UNREALIZED' THEN sdf.share_amount ELSE 0 END) AS pending" +
        "    FROM settlement_daily_fees sdf" +
        "    WHERE sdf.source_partner_id = #{partnerId}" +
        "    <if test='from != null'>AND sdf.settlement_date &gt;= #{from}</if>" +
        "    <if test='to != null'>AND sdf.settlement_date &lt;= #{to}</if>" +
        "    GROUP BY sdf.currency_id) d ON d.currency_id = c.id" +
        " WHERE COALESCE(f.paid,0) != 0 OR COALESCE(d.swept,0) != 0 OR COALESCE(d.pending,0) != 0" +
        " ORDER BY paid_total DESC" +
        "</script>")
List<PartnerFeeSummaryResponse> findFeeSummary(@Param("partnerId") Long partnerId,
                                               @Param("from") String from,
                                               @Param("to") String to);

/**
 * 수수료 정산 인출 이력 (발생 파트너 기준, 완료분만).
 *
 * <p>⚠️ {@code settlement_realizations.partner_id} 는 <b>발생 파트너</b>이고 수익자가 아니다
 * (participant_* 컬럼이 수익자). 이 화면은 "내 지갑에서 나간 금액"을 보여주므로 partner_id 로 조회한다.
 * <p>⚠️ 금액은 {@code total_share_amount}(실제 온체인 이동액)만 노출한다.
 * {@code total_fee_amount} 를 함께 주면 배분 상세가 역산된다.
 */
@Select("SELECT sr.id, sr.completed_at, sr.total_share_amount AS amount," +
        "  c.symbol AS currency_symbol, bn.chain_symbol AS network_symbol," +
        "  sr.period_start, sr.period_end, sr.tx_hash" +
        " FROM settlement_realizations sr" +
        " LEFT JOIN currencies c ON c.id = sr.currency_id" +
        " LEFT JOIN blockchain_networks bn ON bn.id = sr.network_id" +
        " WHERE sr.partner_id = #{partnerId} AND sr.status = 'COMPLETED'" +
        " ORDER BY sr.completed_at DESC")
XPage<PartnerFeeSweepResponse> findFeeSweeps(XPagination pagination,
                                             @Param("partnerId") Long partnerId,
                                             Class<?> cls);
```

⚠️ `XPage` 매퍼 3규칙 준수: (1) 첫 파라미터 `XPagination`, (2) 마지막 `Class<?> cls`, (3) 반환 `XPage<T>`.
**ORDER BY 외에 LIMIT/COUNT 를 직접 쓰지 말 것** — `XResultInterceptor` 가 처리한다.
`<script>` 안에서 `<`/`<=` 직접 사용 금지 — 위 쿼리처럼 `&gt;` `&lt;` 를 쓸 것.

## 3-4 ~ 3-5. 서비스 · 컨트롤러

`PartnerBalanceService` 에 `getFeeSummary(partnerId, from, to)` / `getFeeSweeps(partnerId, pagination)` 추가.
`PartnerBalanceController` 에 기존 스타일(`@GetMapping(name=..., value=...)`)로:

- `GET /api/partner/fees/summary` (name = "지불 수수료 요약")
- `GET /api/partner/fees/sweeps` (name = "수수료 정산 인출 이력")

`XSessionController<PartnerSessionData>` 의 세션에서 partnerId 를 얻는 기존 패턴을 그대로 따를 것.

## 3-6 ~ 3-7. 프론트

- `balance.service.ts` 에 `getFeeSummary(params)` / `getFeeSweeps(params)` 추가.
- `TransactionHistoryView.vue`:
  - 상단에 요약 카드. 통화가 여러 개면 통화별로 나열.
  - "인출 대기" 옆에 설명 문구: `발생 파트너·통화·네트워크별 10 USD 이상 모이면 인출됩니다`
  - 그 아래 "수수료 정산 인출" 목록(기존 `DataTable` 컴포넌트 재사용). 비어 있으면 섹션 숨김.
  - **기존 원장 목록·필터는 손대지 않는다.**
  - 금액 표시는 `AmountDisplay` 재사용, 통화 심볼 병기.

---

## 4. 완료 기준

1. `./gradlew :partner-api:compileJava` 및 `partner-ui` `npm run build` 성공.
2. 인출 목록 금액이 `total_share_amount` 일 것. `total_fee_amount` 를 응답에 포함하면 **리젝**.
3. 참여자별 배분(시스템 몫 / 총판 몫)이 응답 어디에도 노출되지 않을 것.
4. `settlement_realizations` 조회가 `partner_id`(발생 파트너) 기준일 것 — `participant_partner_id` 아님.
5. 기존 원장 목록 API(`/api/partner/ledger`)와 화면 동작이 변하지 않을 것.
6. XPage 매퍼 3규칙 준수, `<script>` 내 `<`/`<=` 미사용.
7. DDL/마이그레이션 없음.
8. CLAUDE.md 규칙 준수 (`@Data` 금지, DTO 멤버 주석 필수, XML 매퍼 금지).

## 5. 검증 (고래 P349288 기준 기대값)

```sql
-- 요약: paid 40.398118 / swept 36.157436 / pending 4.240683 (TRON+BSC 합, 통화별로 분리 표시)
SELECT currency_id, entry_type, SUM(amount) FROM ledger_entries
 WHERE partner_id = 39 AND entry_type = 'FEE' GROUP BY 1, 2;
SELECT currency_id, status, SUM(share_amount) FROM settlement_daily_fees
 WHERE source_partner_id = 39 GROUP BY 1, 2;

-- 인출 이력: 2건
--   2026-08-07 01:00:10  23.371226278  TRON  (08-05~08-06)  d368fe40...
--   2026-08-05 01:00:13  12.075850968  TRON  (07-15~08-04)  7a2c7a44...
SELECT id, completed_at, total_share_amount, period_start, period_end, tx_hash
  FROM settlement_realizations WHERE partner_id = 39 AND status = 'COMPLETED' ORDER BY completed_at DESC;
```

화면에서 **23.371226 인출 행이 보이면 §39 문의는 파트너가 스스로 해소할 수 있다.** 그것이 합격 기준이다.

## 6. 범위 밖

- 정산 화면에 탭 추가 — 하지 않는다(결정 2).
- MERCHANT 정산 화면 빈 상태 안내 / 메뉴 숨김 — 별건.
- §40 미실현 정체분 처리 정책(임계치 무시 스윕 / 소멸) — 별건. 정해지면 "인출 대기" 설명 문구를 갱신할 것.
