# Guide #53 — 파트너 UI 개선 지침서

> **작성일**: 2026-03-25
> **대상**: partner-api (Spring Boot) + partner-ui (Vue 3)
> **선행 조건**: Guide #50~#52 적용 완료 상태
> **예상 소요**: 4~6시간

---

## 변경 요약

| # | 항목 | 영향 범위 | 유형 |
|---|------|-----------|------|
| 1 | 대시보드 잔액 → USD 합산 (USDT + USDC, Native 제외) | Backend + Frontend | 기능 변경 |
| 2 | USDC 비활성화 (`currencies.is_active = 0`) | DB + Backend | 데이터 변경 |
| 3 | 입금 내역 개선 (날짜 필터 + 정보 보강) | Frontend + (Backend 일부) | 기능 개선 |
| 4 | 출금 내역 개선 (날짜 필터 + 정보 보강) | Frontend + (Backend 일부) | 기능 개선 |
| 5 | 결제 링크 관리 (정보 표시 + 취소/비활성화) | Backend + Frontend + Enum | 신규 기능 |
| 6 | 입금 주소 → 파트너 소유만 필터링 | Backend 버그 수정 | 보안 버그 |
| 6-B | 결제 링크 목록 → 파트너 소유만 필터링 | Backend 버그 수정 | 보안 버그 |
| 7 | 집금 현황 메뉴 제거 | Frontend | 메뉴 정리 |
| 8 | 출금 가능 잔액 (MASTER - 미실현 수수료, 지갑 앱 스타일) | Backend + Frontend | 기능 추가 |
| 9-A | 서버 측 출금 잔액 검증 — 미실현 수수료 차감 누락 | Core (WithdrawalService) | 로직 버그 |
| 9-B | 결제 링크 목록 필터 파라미터 유실 (status/partnerUserId/search) | Backend (Controller→Service) | 버그 |
| 10 | 출금 요청 → Modal 전환 + 메뉴 제거 | Frontend | UX 개선 |

---

## 1. 대시보드 잔액 — USD 합산

### 현재 문제

`GET /api/partner/balances` 가 통화별 개별 행을 반환 (USDT×3네트워크, USDC×2네트워크, BNB, POL, TRX 등).
파트너 입장에서 Native 토큰 잔액은 의미 없고, USDT/USDC 를 네트워크별로 분리 표시하면 혼란.

### 목표

대시보드 KPI 카드 "가용 잔액" 영역에 **USD 합산 단일 숫자** 표시.
- USDT + USDC 합산 (스테이블코인 = USD 1:1 가정)
- Native 토큰 (BNB, POL, TRX) 제외
- `currencies.is_active = 0` 인 통화 제외

### 1-A. Backend 변경 — PartnerBalanceMapper

**파일**: `partner-api/.../mapper/PartnerBalanceMapper.java`

현재 `findPartnerBalances` 쿼리에 필터 조건 추가:

**Before:**
```java
@Select("SELECT c.symbol AS currency_code, c.id AS currency_id," +
        "  COALESCE(SUM(wb.balance), 0) AS total_balance" +
        "  FROM wallet_addresses wa" +
        "  JOIN wallet_balances wb ON wa.id = wb.wallet_address_id" +
        "  JOIN currencies c ON wb.currency_id = c.id" +
        "  WHERE wa.partner_id = #{partnerId}" +
        "    AND wa.wallet_type IN ('HOT','MASTER')" +
        "  GROUP BY c.id, c.symbol" +
        "  ORDER BY total_balance DESC")
List<BalanceResponse> findPartnerBalances(@Param("partnerId") Long partnerId);
```

**After:**
```java
@Select("SELECT c.symbol AS currency_code, c.id AS currency_id," +
        "  COALESCE(SUM(wb.balance), 0) AS total_balance" +
        "  FROM wallet_addresses wa" +
        "  JOIN wallet_balances wb ON wa.id = wb.wallet_address_id" +
        "  JOIN currencies c ON wb.currency_id = c.id" +
        "  WHERE wa.partner_id = #{partnerId}" +
        "    AND wa.wallet_type IN ('HOT','MASTER')" +
        "    AND c.is_active = 1" +
        "    AND c.currency_type = 'TOKEN'" +
        "  GROUP BY c.id, c.symbol" +
        "  ORDER BY total_balance DESC")
List<BalanceResponse> findPartnerBalances(@Param("partnerId") Long partnerId);
```

핵심 변경:
- `c.is_active = 1` → 비활성 통화 제외
- `c.currency_type = 'TOKEN'` → Native 토큰 (BNB/POL/TRX) 제외

### 1-B. 신규 메서드 — USD 합산 잔액

대시보드 전용으로 단일 합산 값을 반환하는 메서드 추가:

**PartnerBalanceMapper.java** 에 추가:
```java
/**
 * USD 합산 잔액 (USDT + USDC, is_active=1, TOKEN만).
 * 대시보드 KPI 카드용.
 */
@Select("SELECT COALESCE(SUM(wb.balance), 0)" +
        "  FROM wallet_addresses wa" +
        "  JOIN wallet_balances wb ON wa.id = wb.wallet_address_id" +
        "  JOIN currencies c ON wb.currency_id = c.id" +
        "  WHERE wa.partner_id = #{partnerId}" +
        "    AND wa.wallet_type IN ('HOT','MASTER')" +
        "    AND c.is_active = 1" +
        "    AND c.currency_type = 'TOKEN'" +
        "    AND c.is_stablecoin = 1")
BigDecimal findTotalUsdBalance(@Param("partnerId") Long partnerId);
```

### 1-C. DashboardSummaryResponse 변경

**파일**: `partner-api/.../dto/response/DashboardSummaryResponse.java`

필드 추가:
```java
/** USD 합산 가용 잔액 (USDT + USDC) */
private BigDecimal totalUsdBalance;
```

### 1-D. PartnerDashboardService 변경

**파일**: `partner-api/.../service/PartnerDashboardService.java`

PartnerBalanceMapper 주입 후, getSummary()에서 호출:

```java
private final PartnerDashboardMapper partnerDashboardMapper;
private final PartnerBalanceMapper partnerBalanceMapper;  // 추가

public DashboardSummaryResponse getSummary(Long partnerId, Boolean includeSubPartners) {
    return DashboardSummaryResponse.builder()
            .todayDepositCount(partnerDashboardMapper.countTodayDeposits(partnerId))
            .todayDepositAmount(partnerDashboardMapper.sumTodayDepositAmount(partnerId))
            .todayWithdrawalCount(partnerDashboardMapper.countTodayWithdrawals(partnerId))
            .todayWithdrawalAmount(partnerDashboardMapper.sumTodayWithdrawalAmount(partnerId))
            .pendingWithdrawalCount(partnerDashboardMapper.countPendingWithdrawals(partnerId))
            .totalUsdBalance(partnerBalanceMapper.findTotalUsdBalance(partnerId))  // 추가
            .build();
}
```

### 1-E. Frontend — DashboardView.vue

**파일**: `partner-ui/src/views/partner/DashboardView.vue`

**KPI 카드 "가용 잔액" 변경:**

Before: `balances` 배열에서 첫 번째 항목의 `totalBalance` 표시 + 나머지 통화 수 카운트.

After: `summary.totalUsdBalance` 단일 값 표시:
```html
<!-- 가용 잔액 KPI 카드 -->
<div class="kpi-value">
  <AmountDisplay :amount="summary.totalUsdBalance" :decimals="2" /> USD
</div>
```

**통화별 잔액 섹션 변경:**

- `balanceService.getBalances()` 는 이제 활성 TOKEN만 반환 (Native 제외)
- 기존: 통화별 raw 리스트 (USDT×3, USDC×2, BNB, POL, TRX)
- 변경: 통화 심볼별 그룹핑 (USDT: 합산, USDC: 합산) → 또는 잔액 현황 페이지로 유도

```html
<!-- 통화별 잔액 (간소화) -->
<div v-for="balance in balances" :key="balance.currencyId" class="balance-row">
  <span>{{ balance.currencyCode }}</span>
  <span><AmountDisplay :amount="balance.totalBalance" :decimals="6" /></span>
</div>
```

> **참고**: `balances` API 가 이미 Native 제외 + is_active=1 필터를 적용하므로, 프론트엔드 추가 필터는 불필요.

---

## 2. USDC 비활성화

### 목적

현재 USDT 100% 사용, USDC 미사용 상태. 화면 혼란 방지를 위해 USDC를 표시하지 않도록 비활성화.
데이터는 유지하되 UI에서만 숨김.

### DB 업데이트 (직접 실행)

```sql
-- USDC 비활성화 (is_active = 0)
UPDATE currencies SET is_active = 0 WHERE symbol = 'USDC';

-- 확인
SELECT id, symbol, network_id, currency_type, is_stablecoin, is_active
FROM currencies ORDER BY id;
```

예상 결과:
```
id | symbol | network_id | currency_type | is_stablecoin | is_active
---+--------+------------+---------------+---------------+----------
 2 | USDT   | 2 (BSC)    | TOKEN         | 1             | 1
 3 | USDT   | 3 (PLG)    | TOKEN         | 1             | 1
 4 | USDT   | 4 (TRON)   | TOKEN         | 1             | 1
 6 | USDC   | 2 (BSC)    | TOKEN         | 1             | 0  ← 비활성
 7 | USDC   | 3 (PLG)    | TOKEN         | 1             | 0  ← 비활성
 9 | BNB    | 2 (BSC)    | NATIVE        | 0             | 1
10 | POL    | 3 (PLG)    | NATIVE        | 0             | 1
11 | TRX    | 4 (TRON)   | NATIVE        | 0             | 1
```

### SAMPLE_DATA.sql 반영

`v2-docs/SAMPLE_DATA.sql` 의 currencies INSERT 블록에서 USDC 행의 `is_active` 값을 `0`으로 변경:

```sql
-- USDC (BSC) — is_active: 1 → 0
-- USDC (PLG) — is_active: 1 → 0
```

### 영향 범위

`is_active` 필터가 적용되는 곳:
- `findPartnerBalances()` — 항목 1에서 이미 추가
- `findTotalUsdBalance()` — 항목 1에서 이미 추가
- **추가 필요**: master-data API 에서 통화 목록 반환 시에도 `is_active = 1` 필터 적용
  → `PartnerMasterDataController` 의 currencies 엔드포인트 확인 필요

**PartnerMasterDataService** 또는 해당 쿼리에서:
```sql
-- 파트너 API 통화 목록: Native 완전 배제 + 비활성 제외
WHERE is_active = 1 AND currency_type = 'TOKEN'
```

**핵심 정책**: 파트너 API 전체에서 Native 토큰(BNB/POL/TRX)은 노출하지 않음.
Native 토큰은 가스비 용도로 시스템 내부에서만 사용하며 파트너에게는 무의미.
→ master-data currencies 목록, 잔액 조회, 드롭다운 필터 등 모든 곳에서 `currency_type = 'TOKEN'` 조건 적용.

---

## 3. 입금 내역 개선

### 현재 문제

- 날짜 범위 필터 없음 (Backend 는 `from`/`to` 파라미터 지원하나 UI 미구현)
- `partnerUserId` (사용자 식별자) 가 목록에 표시되지 않음
- 상세 정보 부족 (Drawer 또는 상세 페이지에서 네트워크명, 수수료율, 확정 시각 등 미표시)

### 3-A. Backend — 변경 불필요

`PartnerDepositController.getDeposits()` 이미 지원하는 파라미터:
- `from`, `to` (날짜 범위)
- `status`, `currencyId`, `networkId`, `depositType`, `partnerUserId`, `search`

모든 필터가 Backend 에 이미 구현되어 있음. **Frontend만 수정.**

### 3-B. Frontend — DepositListView.vue

**파일**: `partner-ui/src/views/partner/deposits/DepositListView.vue`

#### 날짜 범위 필터 추가

필터 영역에 날짜 선택기 추가:
```html
<!-- 필터 바 -->
<div class="filter-bar">
  <!-- 기존 필터들 유지 -->
  <StatusSelect v-model="filters.status" />
  <CurrencySelect v-model="filters.currencyId" />

  <!-- 신규: 날짜 범위 -->
  <DateRangePicker
    v-model:from="filters.from"
    v-model:to="filters.to"
    placeholder="기간 선택"
  />

  <!-- 신규: 사용자 ID 필터 -->
  <input v-model="filters.partnerUserId" placeholder="사용자 ID" />
</div>
```

> **참고**: DateRangePicker 컴포넌트가 없으면 별도 생성 필요. 또는 두 개의 `<input type="date">` 로 대체 가능.

#### 목록 컬럼 추가

현재 컬럼: depositCode, createdAt, currency, network, amount, fee, netAmount, status, txHash

**추가할 컬럼:**

| 컬럼 | 필드 | 위치 |
|------|------|------|
| 사용자 ID | `partnerUserId` | depositCode 다음 |
| 입금 방식 | `depositType` (HD_WALLET / EXTERNAL / DECIMAL_MATCH) | network 다음 |

```html
<Column header="사용자 ID" field="partnerUserId">
  <template #body="{ data }">
    {{ data.partnerUserId || '-' }}
  </template>
</Column>
```

#### 상세 Drawer/페이지 정보 보강

Deposit 상세 조회 (`GET /deposits/{depositId}`) 응답에서 추가 표시할 정보:

| 항목 | 필드 | 설명 |
|------|------|------|
| 입금 세션 ID | `depositSessionId` | 관련 세션 링크 |
| 파트너 참조 | `partnerReference` | 외부 주문번호 |
| 사용자 ID | `partnerUserId` | 입금한 사용자 |
| 입금 주소 | `toAddress` | TO 주소 (AddressDisplay 컴포넌트) |
| 발신 주소 | `fromAddress` | FROM 주소 |
| 확정 시각 | `confirmedAt` | 블록체인 확정 시각 |
| TX 확인 수 | `confirmations` | 현재 블록 확인 횟수 |
| 입금 방식 | `depositType` | HD_WALLET / EXTERNAL / DECIMAL_MATCH |
| 수수료 | `feeAmount` | 수수료 금액 |
| 순 입금액 | `netAmount` | 수수료 차감 후 |
| KRW 환율 | `priceKrw` | 입금 시점 환율 |
| USD 환율 | `priceUsd` | 입금 시점 환율 |

```html
<!-- 상세 Drawer 예시 -->
<DetailDrawer :visible="drawerVisible" @close="drawerVisible = false">
  <DetailRow label="입금 코드" :value="detail.depositCode" />
  <DetailRow label="사용자 ID" :value="detail.partnerUserId" />
  <DetailRow label="입금 방식" :value="detail.depositType" />
  <DetailRow label="입금 주소">
    <AddressDisplay :address="detail.toAddress" :network-id="detail.networkId" />
  </DetailRow>
  <DetailRow label="발신 주소">
    <AddressDisplay :address="detail.fromAddress" :network-id="detail.networkId" />
  </DetailRow>
  <DetailRow label="금액">
    <AmountDisplay :amount="detail.amount" /> {{ detail.currencyCode }}
  </DetailRow>
  <DetailRow label="수수료">
    <AmountDisplay :amount="detail.feeAmount" />
  </DetailRow>
  <DetailRow label="순 입금액">
    <AmountDisplay :amount="detail.netAmount" />
  </DetailRow>
  <DetailRow label="TX Hash">
    <TxHashLink :hash="detail.txHash" :network-id="detail.networkId" />
  </DetailRow>
  <DetailRow label="확정 시각" :value="formatDate(detail.confirmedAt)" />
  <DetailRow label="상태">
    <StatusBadge :status="detail.status" />
  </DetailRow>
  <DetailRow label="생성 시각" :value="formatDate(detail.createdAt)" />
</DetailDrawer>
```

---

## 4. 출금 내역 개선

### 현재 문제

입금 내역과 동일한 문제: 날짜 필터 미노출, 상세 정보 부족.

### 4-A. Backend — 변경 불필요

`PartnerWithdrawalController.getWithdrawals()` 이미 지원:
- `from`, `to` (날짜 범위)
- `status`, `currencyId`, `withdrawalType`, `partnerUserId`, `search`

### 4-B. Frontend — WithdrawalListView.vue

**파일**: `partner-ui/src/views/partner/withdrawals/WithdrawalListView.vue`

#### 날짜 범위 필터 추가

입금 내역과 동일 패턴으로 DateRangePicker 추가.

#### 목록 컬럼 추가

현재 컬럼 + 추가:

| 추가 컬럼 | 필드 | 설명 |
|-----------|------|------|
| 사용자 ID | `partnerUserId` | 요청자 |
| 출금 유형 | `withdrawalType` | PARTNER_REQUEST / SETTLEMENT / API |
| 수신 주소 | `toAddress` | 출금 목적지 (축약 표시) |

#### 상세 Drawer 정보 보강

| 항목 | 필드 | 설명 |
|------|------|------|
| 출금 코드 | `withdrawalCode` | 고유 코드 |
| 사용자 ID | `partnerUserId` | 요청자 |
| 출금 유형 | `withdrawalType` | 유형 |
| 수신 주소 | `toAddress` | 목적지 주소 (AddressDisplay) |
| 금액 | `amount` | 요청 금액 |
| 수수료 | `feeAmount` | 출금 수수료 |
| 실제 출금액 | `actualAmount` | 수수료 포함 차감액 |
| TX Hash | `txHash` | 트랜잭션 해시 |
| 요청 시각 | `createdAt` | 요청 시각 |
| 승인 시각 | `approvedAt` | 승인 시각 |
| 확정 시각 | `confirmedAt` | 블록체인 확정 |
| 상태 | `status` | 현재 상태 (10단계) |
| 거부 사유 | `rejectReason` | REJECTED 시 사유 |
| 파트너 참조 | `partnerReference` | 외부 참조 코드 |

---

## 5. 결제 링크 관리

### 현재 문제

1. 결제 링크 생성만 가능, 생성 후 관리 불가
2. 링크 URL / 결제 정보 표시 없음
3. 취소(CANCELLED) / 비활성화(DEACTIVATED) Action 없음

### 5-A. PaymentLinkStatus enum 변경

**파일**: `common/.../enums/PaymentLinkStatus.java`

```java
public enum PaymentLinkStatus {
    /** 활성 */
    ACTIVE,
    /** 사용 완료 */
    USED,
    /** 만료 */
    EXPIRED,
    /** 파트너가 수동 취소 */
    CANCELLED,
    /** 파트너가 비활성화 (재활성화 가능) */
    DEACTIVATED;
}
```

### 5-B. DDL 변경

**파일**: `v2-docs/CRYPTOMENTS_V2_DDL.sql`

`payment_links` 테이블의 `status` ENUM에 값 추가:

```sql
-- Before:
`status` ENUM('ACTIVE','USED','EXPIRED') NOT NULL DEFAULT 'ACTIVE'

-- After:
`status` ENUM('ACTIVE','USED','EXPIRED','CANCELLED','DEACTIVATED') NOT NULL DEFAULT 'ACTIVE'
```

DB 마이그레이션:
```sql
ALTER TABLE payment_links
  MODIFY COLUMN `status` ENUM('ACTIVE','USED','EXPIRED','CANCELLED','DEACTIVATED')
  NOT NULL DEFAULT 'ACTIVE'
  COMMENT '상태: ACTIVE/USED/EXPIRED/CANCELLED/DEACTIVATED';
```

### 5-C. Backend — 신규 엔드포인트

**파일**: `partner-api/.../controller/PartnerDepositController.java`

2개 엔드포인트 추가:

```java
/**
 * 결제 링크 취소 (ACTIVE → CANCELLED).
 * ACTIVE 상태에서만 가능.
 */
@PostMapping("/payment-links/{linkId}/cancel")
public PaymentLink cancelPaymentLink(@PathVariable Long linkId) {
    Long partnerId = getSession().getPartnerId();
    return partnerDepositService.cancelPaymentLink(partnerId, linkId);
}

/**
 * 결제 링크 비활성화 (ACTIVE → DEACTIVATED).
 * 나중에 재활성화 가능.
 */
@PostMapping("/payment-links/{linkId}/deactivate")
public PaymentLink deactivatePaymentLink(@PathVariable Long linkId) {
    Long partnerId = getSession().getPartnerId();
    return partnerDepositService.deactivatePaymentLink(partnerId, linkId);
}
```

선택적으로 재활성화도 추가 가능:
```java
/**
 * 결제 링크 재활성화 (DEACTIVATED → ACTIVE).
 */
@PostMapping("/payment-links/{linkId}/activate")
public PaymentLink activatePaymentLink(@PathVariable Long linkId) {
    Long partnerId = getSession().getPartnerId();
    return partnerDepositService.activatePaymentLink(partnerId, linkId);
}
```

### 5-D. Backend — Service 로직

**파일**: `partner-api/.../service/PartnerDepositService.java`

```java
/**
 * 결제 링크 취소.
 */
public PaymentLink cancelPaymentLink(Long partnerId, Long linkId) {
    PaymentLink link = paymentLinkRepository.findOne(linkId);
    if (link == null || !partnerId.equals(link.getPartnerId())) {
        throw new NotFoundException(ErrorCodes.PAYMENT_LINK_NOT_FOUND);
    }
    if (link.getStatus() != PaymentLinkStatus.ACTIVE) {
        throw new BadRequestException(ErrorCodes.INVALID_STATUS_TRANSITION.code(),
            "ACTIVE 상태에서만 취소 가능합니다. 현재: " + link.getStatus());
    }
    link.setStatus(PaymentLinkStatus.CANCELLED);
    paymentLinkRepository.save(link);
    return link;
}

/**
 * 결제 링크 비활성화.
 */
public PaymentLink deactivatePaymentLink(Long partnerId, Long linkId) {
    PaymentLink link = paymentLinkRepository.findOne(linkId);
    if (link == null || !partnerId.equals(link.getPartnerId())) {
        throw new NotFoundException(ErrorCodes.PAYMENT_LINK_NOT_FOUND);
    }
    if (link.getStatus() != PaymentLinkStatus.ACTIVE) {
        throw new BadRequestException(ErrorCodes.INVALID_STATUS_TRANSITION.code(),
            "ACTIVE 상태에서만 비활성화 가능합니다. 현재: " + link.getStatus());
    }
    link.setStatus(PaymentLinkStatus.DEACTIVATED);
    paymentLinkRepository.save(link);
    return link;
}

/**
 * 결제 링크 재활성화.
 */
public PaymentLink activatePaymentLink(Long partnerId, Long linkId) {
    PaymentLink link = paymentLinkRepository.findOne(linkId);
    if (link == null || !partnerId.equals(link.getPartnerId())) {
        throw new NotFoundException(ErrorCodes.PAYMENT_LINK_NOT_FOUND);
    }
    if (link.getStatus() != PaymentLinkStatus.DEACTIVATED) {
        throw new BadRequestException(ErrorCodes.INVALID_STATUS_TRANSITION.code(),
            "DEACTIVATED 상태에서만 재활성화 가능합니다. 현재: " + link.getStatus());
    }
    link.setStatus(PaymentLinkStatus.ACTIVE);
    paymentLinkRepository.save(link);
    return link;
}
```

### 5-E. ErrorCodes 추가

**파일**: `common/.../exception/ErrorCodes.java`

```java
public static final ErrorCode PAYMENT_LINK_NOT_FOUND =
    new ErrorCode("601", "결제 링크를 찾을 수 없습니다.");
public static final ErrorCode INVALID_STATUS_TRANSITION =
    new ErrorCode("602", "유효하지 않은 상태 전이입니다.");
```

> 기존에 이미 있으면 재사용. 없으면 추가.

### 5-F. Frontend — PaymentLinksView.vue 개선

**파일**: `partner-ui/src/views/partner/deposits/PaymentLinksView.vue`

#### 목록 컬럼 보강

현재: title, linkCode, amount, status, createdAt, expiresAt

**추가/변경:**

| 컬럼 | 필드 | 설명 |
|------|------|------|
| 결제 URL | `linkCode` → URL 변환 | 복사 버튼 포함 |
| 통화 | `currencyId` → 통화명 | 네트워크 + 통화 |
| 입금 방식 | `depositMethod` | HD_WALLET 등 |
| 사용자 ID | `partnerUserId` | 요청 사용자 |
| 금액 | `amount` | 고정 금액 또는 "사용자 입력" |
| Action | — | 상태별 버튼 |

#### 결제 URL 생성 규칙

Widget 페이지 기반. v1 동일 포맷:
```javascript
// 결제 링크 URL = widget 도메인 + linkCode
const WIDGET_BASE_URL = 'https://widget.cryptoments.cc';
const paymentUrl = `${WIDGET_BASE_URL}/link/${link.linkCode}`;
```

> **참고**: `WIDGET_BASE_URL`은 환경 변수(`.env`)로 관리. 개발/운영 분리.

#### Action 버튼 (상태별)

| 현재 상태 | 가능한 Action | 버튼 |
|-----------|--------------|------|
| ACTIVE | 취소, 비활성화, URL 복사 | 🔴 취소 / ⏸️ 비활성화 / 📋 복사 |
| DEACTIVATED | 재활성화 | ▶️ 활성화 |
| USED | 없음 (읽기 전용) | — |
| EXPIRED | 없음 (읽기 전용) | — |
| CANCELLED | 없음 (읽기 전용) | — |

```html
<!-- Action 컬럼 -->
<Column header="" style="width: 180px">
  <template #body="{ data }">
    <div class="action-buttons">
      <!-- URL 복사 (ACTIVE만) -->
      <button v-if="data.status === 'ACTIVE'"
              @click="copyPaymentUrl(data.linkCode)"
              class="btn-icon" title="URL 복사">
        📋
      </button>

      <!-- 비활성화 (ACTIVE → DEACTIVATED) -->
      <button v-if="data.status === 'ACTIVE'"
              @click="deactivateLink(data.id)"
              class="btn-warning" title="비활성화">
        비활성화
      </button>

      <!-- 취소 (ACTIVE → CANCELLED) -->
      <button v-if="data.status === 'ACTIVE'"
              @click="cancelLink(data.id)"
              class="btn-danger" title="취소">
        취소
      </button>

      <!-- 재활성화 (DEACTIVATED → ACTIVE) -->
      <button v-if="data.status === 'DEACTIVATED'"
              @click="activateLink(data.id)"
              class="btn-success" title="재활성화">
        활성화
      </button>
    </div>
  </template>
</Column>
```

#### API 서비스 추가

**파일**: `partner-ui/src/api/services/deposit.service.ts`

```typescript
/** 결제 링크 취소 */
cancelPaymentLink(linkId: number) {
  return api.post(`/api/partner/payment-links/${linkId}/cancel`);
},

/** 결제 링크 비활성화 */
deactivatePaymentLink(linkId: number) {
  return api.post(`/api/partner/payment-links/${linkId}/deactivate`);
},

/** 결제 링크 재활성화 */
activatePaymentLink(linkId: number) {
  return api.post(`/api/partner/payment-links/${linkId}/activate`);
},
```

---

## 6. 입금 주소 — 파트너 소유만 필터링

### 현재 문제 (보안 버그)

`PartnerDepositService.getDepositAddresses()` 가 `partner_id`를 무시하고 **전체 시스템 주소 30개**를 반환:

```java
// ❌ BUG: partnerId 파라미터를 무시
public XPage<WalletAddress> getDepositAddresses(XPagination pagination, Long partnerId) {
    return walletAddressRepository.findAll(pagination);
}
```

### 수정 방법

#### 6-A. PartnerDepositMapper 에 쿼리 추가

**파일**: `partner-api/.../mapper/PartnerDepositMapper.java` (기존 파일) 또는 새 mapper

```java
/**
 * 파트너 소유 입금 주소 조회 (HOT 지갑만).
 * 파트너의 HOT 지갑 = 입금 수신용 주소.
 */
@Select("<script>" +
        "SELECT * FROM wallet_addresses" +
        " WHERE partner_id = #{partnerId}" +
        "   AND wallet_type = 'HOT'" +
        " <if test='networkId != null'>AND network_id = #{networkId}</if>" +
        " <if test='isActive != null and isActive'>" +
        "   AND status = 'ACTIVE'" +
        " </if>" +
        "</script>")
XPage<WalletAddress> searchPartnerDepositAddresses(
    XPagination pagination,
    @Param("partnerId") Long partnerId,
    @Param("networkId") Long networkId,
    @Param("isActive") Boolean isActive,
    Class<?> cls);
```

#### 6-B. Service 수정

**파일**: `partner-api/.../service/PartnerDepositService.java`

```java
// ✅ FIX: 파트너 소유 HOT 지갑만 반환
public XPage<WalletAddress> getDepositAddresses(XPagination pagination, Long partnerId,
                                                  Long networkId, Boolean isActive) {
    return partnerDepositMapper.searchPartnerDepositAddresses(
        pagination, partnerId, networkId, isActive, WalletAddress.class);
}
```

#### 6-C. Controller 확인

`PartnerDepositController.getDepositAddresses()` 에서 `getSession().getPartnerId()` 를 서비스에 전달하는지 확인. 이미 전달하고 있다면 서비스만 수정하면 됨.

### 예상 결과

Partner 1 기준:
- Before: 30개 전체 주소 (모든 파트너 + ADMIN + GAS + RELAYER 등)
- After: 6개 (Partner 1의 HOT 지갑 3개 — BSC, PLG, TRON) 또는 네트워크 필터 적용 시 더 적음

---

## 6-B. 결제 링크 목록 — 파트너 소유만 필터링

### 현재 문제 (보안 버그)

입금 주소와 동일한 패턴. `getPaymentLinks()` 가 `partnerId`를 무시하고 전체 결제 링크를 반환:

```java
// ❌ BUG: partnerId 파라미터를 무시
public XPage<PaymentLink> getPaymentLinks(XPagination pagination, Long partnerId) {
    return paymentLinkRepository.findAll(pagination);
}
```

### 수정 방법

#### Mapper 추가

**파일**: `partner-api/.../mapper/PartnerDepositMapper.java` (기존 파일)

```java
/**
 * 파트너 소유 결제 링크 조회.
 */
@Select("<script>" +
        "SELECT * FROM payment_links" +
        " WHERE partner_id = #{partnerId}" +
        " <if test='status != null'>AND status = #{status}</if>" +
        " <if test='partnerUserId != null and partnerUserId != \"\"'>" +
        "   AND partner_user_id = #{partnerUserId}" +
        " </if>" +
        " <if test='search != null and search != \"\"'>" +
        "   AND (link_code LIKE CONCAT('%', #{search}, '%')" +
        "        OR title LIKE CONCAT('%', #{search}, '%'))" +
        " </if>" +
        "</script>")
XPage<PaymentLink> searchPartnerPaymentLinks(
    XPagination pagination,
    @Param("partnerId") Long partnerId,
    @Param("status") String status,
    @Param("partnerUserId") String partnerUserId,
    @Param("search") String search,
    Class<?> cls);
```

#### Service 수정

```java
// ✅ FIX: 파트너 소유 결제 링크만 반환
public XPage<PaymentLink> getPaymentLinks(XPagination pagination, Long partnerId,
                                           String status, String partnerUserId, String search) {
    return partnerDepositMapper.searchPartnerPaymentLinks(
        pagination, partnerId, status, partnerUserId, search, PaymentLink.class);
}
```

---

## 7. 집금 현황 메뉴 제거

### 변경 내용

사이드바에서 "집금 현황" 메뉴 아이템 제거. 파트너에게 집금(collection) 상세는 불필요한 내부 운영 정보.

### 7-A. Frontend — constants.ts

**파일**: `partner-ui/src/utils/constants.ts`

"입금 관리" 그룹에서 "집금 현황" 항목 제거:

```typescript
// Before: 입금 관리 하위 6개
// 입금 내역, 입금 세션, 결제 링크, 입금 주소, 집금 현황, Axim Pay

// After: 입금 관리 하위 5개
// 입금 내역, 입금 세션, 결제 링크, 입금 주소, Axim Pay
```

`집금 현황` (partner-collections) 항목을 menuItems 배열에서 삭제.

### 7-B. Frontend — router 정리 (선택사항)

`router/index.ts` 에서 `partner-collections` 라우트는 유지해도 무방 (URL 직접 접근 시 404 방지).
메뉴에서만 제거하면 충분.

---

## 8. 출금 가능 잔액 표시 (MASTER 지갑 기준)

### 현재 문제

출금 요청 시 "현재 얼마까지 출금 가능한지" 정보가 없음.
파트너가 잔액을 모른 채 출금을 요청하면 잔액 부족(400)으로 실패할 수밖에 없음.

### 출금 가능 잔액 공식

출금은 MASTER 지갑에서만 실행되지만, MASTER에는 시스템의 미실현 수수료가 포함되어 있으므로:

```
출금 가능 = MASTER 잔액 - 미실현 수수료(SYSTEM unrealized_balance)
```

상세:
```sql
-- MASTER 잔액 (통화별)
MASTER_BALANCE = SUM(wallet_balances.balance)
  WHERE wallet_addresses.partner_id = ?
    AND wallet_addresses.wallet_type = 'MASTER'
    AND currencies.currency_id = ?

-- 시스템 미실현 수수료 (통화별)
SYSTEM_UNREALIZED = settlement_balances.unrealized_balance
  WHERE participant_type = 'SYSTEM'
    AND participant_partner_id = ?  -- 해당 파트너에서 발생한 수수료
    AND currency_id = ?

-- 출금 가능 (통화별)
AVAILABLE = MASTER_BALANCE - SYSTEM_UNREALIZED
```

> **HOT 지갑 잔액은 포함하지 않음** — HOT은 입금 수신 전용이고, 집금(collection)을 통해 MASTER로 이동된 후에야 출금 가능.

### 8-A. Backend — 신규 엔드포인트

**파일**: `partner-api/.../controller/PartnerWithdrawalController.java`

```java
/**
 * 출금 가능 잔액 조회 (MASTER 잔액 - 미실현 수수료).
 * 통화+네트워크별 출금 가능 잔액 반환.
 *
 * @return 출금 가능 잔액
 * @response 200 조회 성공
 * @group 출금 관리
 * @auth true
 */
@GetMapping(name = "출금 가능 잔액 조회", value = "/available-balance")
public WithdrawalAvailableBalanceResponse getAvailableBalance() {
    Long partnerId = getSession().getPartnerId();
    return partnerWithdrawalService.getAvailableBalance(partnerId);
}
```

### 8-B. DTO

**파일**: `partner-api/.../dto/response/WithdrawalAvailableBalanceResponse.java` (신규)

```java
@Getter @Builder
@NoArgsConstructor @AllArgsConstructor
public class WithdrawalAvailableBalanceResponse {

    /** 통화+네트워크별 출금 가능 잔액 */
    private List<CurrencyBalance> currencies;

    @Getter @Builder
    @NoArgsConstructor @AllArgsConstructor
    public static class CurrencyBalance {
        /** 통화 ID */
        private Long currencyId;
        /** 통화 심볼 (USDT 등) */
        private String currencyCode;
        /** 네트워크 ID */
        private Long networkId;
        /** 네트워크명 (BSC, Polygon, TRON) */
        private String networkName;
        /** 출금 가능 잔액 = MASTER 잔액 - 미실현 수수료 */
        private BigDecimal availableBalance;
    }
}
```

### 8-C. Mapper

**파일**: `partner-api/.../mapper/PartnerWithdrawalMapper.java` (기존 또는 신규)

```java
/**
 * 출금 가능 잔액 = MASTER 잔액 - SYSTEM 미실현 수수료 (통화+네트워크별).
 * LEFT JOIN으로 settlement_balances가 없는 통화도 포함.
 */
@Select("SELECT c.id AS currency_id, c.symbol AS currency_code," +
        "  bn.id AS network_id, bn.network_name AS network_name," +
        "  COALESCE(SUM(wb.balance), 0) - COALESCE(sb.unrealized_balance, 0) AS available_balance" +
        "  FROM wallet_addresses wa" +
        "  JOIN wallet_balances wb ON wa.id = wb.wallet_address_id" +
        "  JOIN currencies c ON wb.currency_id = c.id" +
        "  JOIN blockchain_networks bn ON c.network_id = bn.id" +
        "  LEFT JOIN settlement_balances sb" +
        "    ON sb.participant_type = 'SYSTEM'" +
        "    AND sb.participant_partner_id = #{partnerId}" +
        "    AND sb.currency_id = c.id" +
        "    AND sb.network_id = bn.id" +
        "  WHERE wa.partner_id = #{partnerId}" +
        "    AND wa.wallet_type = 'MASTER'" +
        "    AND c.is_active = 1" +
        "    AND c.currency_type = 'TOKEN'" +
        "  GROUP BY c.id, c.symbol, bn.id, bn.network_name, sb.unrealized_balance" +
        "  ORDER BY available_balance DESC")
List<WithdrawalAvailableBalanceResponse.CurrencyBalance> findAvailableBalances(
    @Param("partnerId") Long partnerId);
```

### 8-D. Service

**파일**: `partner-api/.../service/PartnerWithdrawalService.java`

```java
public WithdrawalAvailableBalanceResponse getAvailableBalance(Long partnerId) {
    List<WithdrawalAvailableBalanceResponse.CurrencyBalance> currencies =
        partnerWithdrawalMapper.findAvailableBalances(partnerId);

    return WithdrawalAvailableBalanceResponse.builder()
            .currencies(currencies)
            .build();
}
```

### 8-E. Frontend — 출금 요청 폼 (지갑 앱 스타일)

출금 목록 상단에 별도 잔액 카드를 두지 않음.
일반 지갑 앱처럼 **출금 요청 폼 안에서** 자연스럽게 표시:

```
┌─────────────────────────────────────┐
│  출금 요청                           │
│                                     │
│  네트워크      [BSC           ▼]    │
│  통화          [USDT          ▼]    │
│                                     │
│  금액          [              ]     │
│                출금 가능: 120.50     │
│                                     │
│  수신 주소     [              ]     │
│                                     │
│            [ 출금 요청 ]            │
└─────────────────────────────────────┘
```

**구현 포인트:**

1. 출금 폼 진입 시 `GET /api/partner/withdrawals/available-balance` 호출
2. 통화+네트워크 선택 시 해당 `availableBalance` 를 금액 필드 하단에 표시
3. 입력 금액 > 가용 잔액이면 즉시 경고 (서버 호출 전 클라이언트 유효성)
4. "출금 가능" 클릭 시 금액 필드에 전액 자동 입력 (MAX 버튼)

```html
<!-- 출금 요청 폼 — 금액 입력 영역 -->
<div class="amount-field">
  <label>금액</label>
  <div class="input-wrapper">
    <input v-model="form.amount" type="number" placeholder="0.00" />
    <button class="max-btn" @click="fillMaxAmount">MAX</button>
  </div>
  <div class="available-hint" v-if="selectedBalance">
    출금 가능: {{ formatAmount(selectedBalance.availableBalance) }}
    {{ selectedBalance.currencyCode }}
  </div>
  <div class="error-hint" v-if="isAmountExceeded">
    출금 가능 금액을 초과했습니다.
  </div>
</div>
```

```typescript
// 선택된 통화+네트워크에 해당하는 가용 잔액 찾기
const selectedBalance = computed(() => {
  if (!form.currencyId || !form.networkId) return null;
  return availableBalances.value.find(
    b => b.currencyId === form.currencyId && b.networkId === form.networkId
  );
});

// MAX 버튼
function fillMaxAmount() {
  if (selectedBalance.value) {
    form.amount = selectedBalance.value.availableBalance;
  }
}

// 초과 검증
const isAmountExceeded = computed(() => {
  if (!selectedBalance.value || !form.amount) return false;
  return Number(form.amount) > Number(selectedBalance.value.availableBalance);
});
```

---

## 9. 주의사항 — 기존 코드 버그 2건

### 9-A. 서버 측 출금 잔액 검증 — 미실현 수수료 미차감

**파일**: `core/.../withdrawal/WithdrawalService.java` — `checkMasterBalance()`

현재 코드:
```java
private void checkMasterBalance(Long partnerId, Long networkId, Long currencyId, BigDecimal amount) {
    List<WalletAddress> masterWallets = walletAddressRepository
            .findByPartnerIdAndWalletType(partnerId, WalletType.MASTER);
    WalletAddress masterWallet = masterWallets.stream()
            .filter(w -> networkId.equals(w.getNetworkId()))
            .findFirst()
            .orElseThrow(() -> new NotFoundException(ErrorCodes.PARTNER_WALLET_NOT_FOUND));
    WalletBalance balance = walletBalanceRepository
            .findByWalletAddressIdAndCurrencyId(masterWallet.getId(), currencyId);
    if (balance == null || balance.getBalance().compareTo(amount) < 0) {
        throw new ConflictException(ErrorCodes.INSUFFICIENT_BALANCE);
    }
}
```

**문제**: `balance.getBalance()` = MASTER 전체 잔액과 비교.
시스템의 미실현 수수료(`settlement_balances.unrealized_balance WHERE participant_type='SYSTEM'`)가 차감되지 않아,
실제로는 파트너 몫이 아닌 수수료 영역까지 출금 가능한 상태.

**수정 방향**:
```java
private void checkMasterBalance(Long partnerId, Long networkId, Long currencyId, BigDecimal amount) {
    // 기존: MASTER 지갑 찾기 (동일)
    List<WalletAddress> masterWallets = walletAddressRepository
            .findByPartnerIdAndWalletType(partnerId, WalletType.MASTER);
    WalletAddress masterWallet = masterWallets.stream()
            .filter(w -> networkId.equals(w.getNetworkId()))
            .findFirst()
            .orElseThrow(() -> new NotFoundException(ErrorCodes.PARTNER_WALLET_NOT_FOUND));

    WalletBalance balance = walletBalanceRepository
            .findByWalletAddressIdAndCurrencyId(masterWallet.getId(), currencyId);
    BigDecimal masterBalance = (balance != null) ? balance.getBalance() : BigDecimal.ZERO;

    // 신규: 시스템 미실현 수수료 차감
    SettlementBalance systemSettlement = settlementBalanceRepository
            .findByParticipantTypeAndParticipantPartnerIdAndCurrencyIdAndNetworkId(
                ParticipantType.SYSTEM, partnerId, currencyId, networkId);
    BigDecimal unrealizedFee = (systemSettlement != null)
            ? systemSettlement.getUnrealizedBalance() : BigDecimal.ZERO;

    BigDecimal availableBalance = masterBalance.subtract(unrealizedFee);

    if (availableBalance.compareTo(amount) < 0) {
        throw new ConflictException(ErrorCodes.INSUFFICIENT_BALANCE);
    }
}
```

> **중요**: 클라이언트 측 경고는 UX용이고, 이 서버 검증이 실제 보호 장치. 항목 8의 `findAvailableBalances()` 쿼리와 동일 공식 적용해야 클라이언트-서버 간 일관성 유지.

**추가 필요 의존성**: `SettlementBalanceRepository` 를 `WithdrawalService`에 주입.

### 9-B. 결제 링크 목록 — 필터 파라미터 유실

**파일**: `partner-api/.../controller/PartnerDepositController.java`

현재 코드:
```java
@GetMapping(name = "결제 링크 목록 조회", value = "/payment-links")
public XPage<PaymentLink> getPaymentLinks(
        @XPaginationDefault(column = "id") XPagination pagination,
        @RequestParam(required = false) String status,       // ← 받지만
        @RequestParam(required = false) String partnerUserId, // ← 받지만
        @RequestParam(required = false) String search) {      // ← 받지만
    Long partnerId = getSession().getPartnerId();
    return partnerDepositService.getPaymentLinks(pagination, partnerId);  // ← 여기서 유실!
}
```

Controller에서 `status`, `partnerUserId`, `search` 3개 파라미터를 받으나,
Service 호출 시 `(pagination, partnerId)` 만 전달 → **필터 3개 전부 무시**.

**수정**: 항목 6-B의 `searchPartnerPaymentLinks` Mapper로 교체하면 자동 해결.

Controller도 서비스 호출 시 파라미터 전달하도록 수정:
```java
@GetMapping(name = "결제 링크 목록 조회", value = "/payment-links")
public XPage<PaymentLink> getPaymentLinks(
        @XPaginationDefault(column = "id") XPagination pagination,
        @RequestParam(required = false) String status,
        @RequestParam(required = false) String partnerUserId,
        @RequestParam(required = false) String search) {
    Long partnerId = getSession().getPartnerId();
    return partnerDepositService.getPaymentLinks(pagination, partnerId,
            status, partnerUserId, search);  // ✅ 파라미터 전달
}
```

> 이 수정은 항목 6-B (결제 링크 partner_id 필터) 와 함께 적용.

---

## 10. 출금 요청 → Modal 전환 + 메뉴 제거

### 현재 구조

사이드바 "출금 관리" 그룹:
- 출금 내역 (`/withdrawals`)
- 출금 요청 (`/withdrawals/new` — 별도 페이지)

### 변경 후

사이드바 "출금 관리" 그룹:
- 출금 내역 (`/withdrawals`) — **여기에 "출금 요청" 버튼 포함**

출금 요청은 별도 페이지가 아닌, 출금 내역 목록 상단 버튼 → **Modal/Dialog** 로 처리.

### 10-A. 사이드바 메뉴 변경

**파일**: `partner-ui/src/utils/constants.ts`

"출금 관리" 그룹에서 "출금 요청" 항목 제거:

```typescript
// Before: 출금 관리 하위 2개
// 출금 내역, 출금 요청

// After: 출금 관리 하위 1개
// 출금 내역
```

### 10-B. 출금 내역 목록에 요청 버튼

**파일**: `partner-ui/src/views/partner/withdrawals/WithdrawalListView.vue`

목록 상단 액션 영역에 버튼 추가:

```html
<div class="page-header">
  <h2>출금 내역</h2>
  <button class="btn-primary" @click="showWithdrawalModal = true">
    출금 요청
  </button>
</div>

<!-- 출금 요청 Modal -->
<WithdrawalRequestModal
  v-model:visible="showWithdrawalModal"
  @success="onWithdrawalCreated"
/>
```

### 10-C. WithdrawalRequestModal 컴포넌트 (신규)

**파일**: `partner-ui/src/components/withdrawal/WithdrawalRequestModal.vue` (신규)

지갑 앱 스타일 Modal:

```
┌──────────────────────────────────────┐
│  ✕                    출금 요청       │
│                                      │
│  네트워크      [BSC            ▼]    │
│  통화          [USDT           ▼]    │
│                                      │
│  금액          [               ] MAX │
│                출금 가능: 120.50      │
│                                      │
│  수신 주소     [               ]     │
│   또는         [화이트리스트   ▼]    │
│                                      │
│  파트너 참조   [               ]     │
│  (선택)                              │
│                                      │
│         [ 취소 ]  [ 출금 요청 ]      │
└──────────────────────────────────────┘
```

**핵심 동작:**

1. Modal 열릴 때 `GET /api/partner/withdrawals/available-balance` 호출
2. 네트워크+통화 선택 → 해당 `availableBalance` 표시
3. MAX 버튼 → 전액 자동 입력
4. 초과 입력 → 즉시 경고
5. 수신 주소: 직접 입력 또는 화이트리스트 드롭다운 선택
6. "출금 요청" 클릭 → `POST /api/partner/withdrawals` 호출
7. 성공 → Modal 닫기 + 목록 새로고침 + 성공 토스트
8. 실패 → Modal 유지 + 에러 메시지 표시

```vue
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue';
import { withdrawalService } from '@/api/services/withdrawal.service';

const props = defineProps<{ visible: boolean }>();
const emit = defineEmits(['update:visible', 'success']);

const availableBalances = ref([]);
const whitelist = ref([]);
const form = ref({
  networkId: null,
  currencyId: null,
  amount: '',
  toAddress: '',
  whitelistId: null,
  partnerReference: '',
});

onMounted(async () => {
  const [balanceRes, whitelistRes] = await Promise.all([
    withdrawalService.getAvailableBalance(),
    withdrawalService.getWhitelist(),
  ]);
  availableBalances.value = balanceRes.currencies;
  whitelist.value = whitelistRes;
});

const selectedBalance = computed(() => {
  if (!form.value.currencyId || !form.value.networkId) return null;
  return availableBalances.value.find(
    b => b.currencyId === form.value.currencyId
      && b.networkId === form.value.networkId
  );
});

const isAmountExceeded = computed(() => {
  if (!selectedBalance.value || !form.value.amount) return false;
  return Number(form.value.amount) > Number(selectedBalance.value.availableBalance);
});

function fillMax() {
  if (selectedBalance.value) {
    form.value.amount = selectedBalance.value.availableBalance.toString();
  }
}

async function submit() {
  const res = await withdrawalService.createWithdrawal(form.value);
  emit('success', res);
  emit('update:visible', false);
}
</script>
```

### 10-D. 기존 출금 요청 페이지 정리

- `WithdrawalNewView.vue` (또는 유사한 파일) → 삭제 또는 미사용 처리
- `router/index.ts` → `/withdrawals/new` 라우트 제거 (또는 `/withdrawals` 로 redirect)

---

## 실행 순서 체크리스트

### Phase A: DB 즉시 수정

```sql
-- 1. USDC 비활성화
UPDATE currencies SET is_active = 0 WHERE symbol = 'USDC';

-- 2. DDL ALTER (payment_links status enum 확장)
ALTER TABLE payment_links
  MODIFY COLUMN `status` ENUM('ACTIVE','USED','EXPIRED','CANCELLED','DEACTIVATED')
  NOT NULL DEFAULT 'ACTIVE'
  COMMENT '상태: ACTIVE/USED/EXPIRED/CANCELLED/DEACTIVATED';

-- 3. 확인
SELECT id, symbol, is_active FROM currencies;
SHOW COLUMNS FROM payment_links LIKE 'status';
```

### Phase B: Backend (IntelliJ — Spring Boot)

- [ ] **common**: PaymentLinkStatus enum에 `CANCELLED`, `DEACTIVATED` 추가
- [ ] **common**: ErrorCodes에 `PAYMENT_LINK_NOT_FOUND`, `INVALID_STATUS_TRANSITION` 추가 (이미 없으면)
- [ ] **partner-api**: PartnerBalanceMapper — `findPartnerBalances` 에 `is_active=1`, `currency_type='TOKEN'` 필터 추가
- [ ] **partner-api**: PartnerBalanceMapper — `findTotalUsdBalance()` 신규 메서드 추가
- [ ] **partner-api**: DashboardSummaryResponse — `totalUsdBalance` 필드 추가
- [ ] **partner-api**: PartnerDashboardService — `findTotalUsdBalance` 호출 추가
- [ ] **partner-api**: PartnerDepositController — `/payment-links/{linkId}/cancel`, `/deactivate`, `/activate` 3개 엔드포인트 추가
- [ ] **partner-api**: PartnerDepositService — `cancelPaymentLink`, `deactivatePaymentLink`, `activatePaymentLink` 구현
- [ ] **partner-api**: PartnerDepositService.getDepositAddresses — `partner_id` 필터 버그 수정
- [ ] **partner-api**: PartnerDepositMapper — `searchPartnerDepositAddresses` 쿼리 추가
- [ ] **partner-api**: PartnerDepositService.getPaymentLinks — `partner_id` 필터 버그 수정 (6-B)
- [ ] **partner-api**: PartnerDepositMapper — `searchPartnerPaymentLinks` 쿼리 추가 (6-B)
- [ ] **partner-api**: PartnerMasterDataService — currencies 조회 시 `is_active = 1` + `currency_type = 'TOKEN'` 필터 추가 (Native 완전 배제)
- [ ] **partner-api**: WithdrawalAvailableBalanceResponse DTO 신규 생성 (항목 8)
- [ ] **partner-api**: PartnerWithdrawalMapper — `findAvailableBalances()` 추가 (MASTER - SYSTEM unrealized, 항목 8)
- [ ] **partner-api**: PartnerWithdrawalService — `getAvailableBalance()` 구현 (항목 8)
- [ ] **partner-api**: PartnerWithdrawalController — `GET /available-balance` 엔드포인트 추가 (항목 8)
- [ ] **core**: WithdrawalService.checkMasterBalance — 미실현 수수료 차감 로직 추가 (항목 9-A)
- [ ] **core**: WithdrawalService에 SettlementBalanceRepository 주입 추가 (항목 9-A)
- [ ] **partner-api**: PartnerDepositController.getPaymentLinks — status/partnerUserId/search 파라미터 서비스에 전달 (항목 9-B)
- [ ] 컴파일 확인: `./gradlew :core:compileJava && ./gradlew :partner-api:compileJava`

### Phase C: Frontend (VS Code — Vue 3)

- [ ] DashboardView.vue — KPI "가용 잔액" → `summary.totalUsdBalance` USD 합산 표시
- [ ] DashboardView.vue — 통화별 잔액 섹션 간소화 (Native 제외는 Backend에서 처리)
- [ ] DepositListView.vue — DateRangePicker 추가 + `partnerUserId` 컬럼 추가
- [ ] DepositListView.vue — 상세 Drawer 정보 보강 (주소, 확정 시각, 수수료 등)
- [ ] WithdrawalListView.vue — DateRangePicker 추가 + 상세 정보 보강
- [ ] PaymentLinksView.vue — 결제 URL 표시 + Action 버튼 (취소/비활성화/재활성화)
- [ ] PaymentLinksView.vue — 컬럼 보강 (통화, 입금 방식, 사용자 ID)
- [ ] deposit.service.ts — `cancelPaymentLink`, `deactivatePaymentLink`, `activatePaymentLink` API 추가
- [ ] DepositAddressesView.vue — (Backend 수정 후) 정상 필터 확인
- [ ] constants.ts — 사이드바에서 "집금 현황" 메뉴 제거
- [ ] withdrawal.service.ts — `getAvailableBalance()` API 추가 (항목 8)
- [ ] constants.ts — 사이드바 "출금 관리" 그룹에서 "출금 요청" 메뉴 제거 (항목 10)
- [ ] WithdrawalListView.vue — 상단에 "출금 요청" 버튼 추가 (항목 10)
- [ ] WithdrawalRequestModal.vue — 신규 Modal 컴포넌트 (지갑 앱 스타일 폼 + 출금 가능 표시 + MAX + 화이트리스트) (항목 8+10)
- [ ] WithdrawalNewView.vue — 삭제 또는 미사용 처리 + 라우터 정리 (항목 10)

### Phase D: DDL + SAMPLE_DATA 반영

- [ ] `v2-docs/CRYPTOMENTS_V2_DDL.sql` — payment_links.status ENUM 업데이트
- [ ] `v2-docs/SAMPLE_DATA.sql` — USDC is_active = 0 반영

### Phase E: 검증

- [ ] 대시보드 → USD 합산 잔액 표시 확인
- [ ] 대시보드 → USDC, Native 미표시 확인
- [ ] 입금 내역 → 날짜 필터 동작 확인
- [ ] 입금 내역 → 사용자 ID 컬럼 표시 확인
- [ ] 출금 내역 → 날짜 필터 동작 확인
- [ ] 결제 링크 → 생성 후 URL 표시 + 복사 확인
- [ ] 결제 링크 → 취소/비활성화/재활성화 동작 확인
- [ ] 입금 주소 → 파트너 소유 HOT 지갑만 표시 (6개 이하) 확인
- [ ] 사이드바 → 집금 현황 메뉴 미표시 확인
- [ ] 출금 요청 폼 → 통화 선택 시 "출금 가능: N" 표시 확인 (MASTER - 미실현 수수료)
- [ ] 출금 요청 폼 → MAX 버튼 클릭 시 전액 자동 입력 확인
- [ ] 출금 요청 폼 → 초과 입력 시 클라이언트 경고 표시 확인
- [ ] 출금 요청 → 미실현 수수료 초과 금액 서버 측 거부(409) 확인 (항목 9-A)
- [ ] 결제 링크 → status/partnerUserId/search 필터 동작 확인 (항목 9-B)
- [ ] 출금 내역 → "출금 요청" 버튼 클릭 → Modal 오픈 확인 (항목 10)
- [ ] 출금 요청 Modal → 네트워크/통화 선택 → 출금 가능 잔액 표시 확인 (항목 8+10)
- [ ] 출금 요청 Modal → MAX 버튼 + 초과 경고 + 화이트리스트 선택 확인 (항목 8+10)
- [ ] 출금 요청 Modal → 성공 시 목록 새로고침 확인 (항목 10)
- [ ] 사이드바 → "출금 요청" 메뉴 미표시 확인 (항목 10)

---

## 상태 전이 다이어그램

### PaymentLink Status

```
ACTIVE ──────→ USED         (입금 세션 생성 시 자동)
   │
   ├────────→ EXPIRED      (만료 시각 도달 시 자동)
   │
   ├────────→ CANCELLED    (파트너 수동 취소 — 되돌릴 수 없음)
   │
   └────────→ DEACTIVATED  (파트너 수동 비활성화 — 재활성화 가능)
                  │
                  └──→ ACTIVE  (재활성화)
```

---

## 근본 원인 요약

| 항목 | 근본 원인 | 카테고리 |
|------|-----------|---------|
| 대시보드 잔액 혼란 | 통화별 raw 출력, Native 포함, USD 합산 없음 | UX 설계 |
| USDC 혼란 | 미사용 통화가 화면에 표시됨 | 데이터 관리 |
| 입금/출금 필터 부족 | Backend 지원하나 Frontend 미구현 | Frontend 미완 |
| 결제 링크 관리 불가 | Lifecycle Action 미구현 (생성만 가능) | 기능 부재 |
| 입금 주소 전체 노출 | `findAll()` 호출 시 `partnerId` 무시 | 보안 버그 |
| 결제 링크 전체 노출 | `findAll()` 호출 시 `partnerId` 무시 (동일 패턴) | 보안 버그 |
| 출금 시 수수료 영역 출금 가능 | `checkMasterBalance()`가 미실현 수수료 미차감 | 로직 버그 |
| 결제 링크 필터 무동작 | Controller→Service 파라미터 전달 누락 | 버그 |
| 집금 메뉴 불필요 노출 | 내부 운영 정보가 파트너에게 노출 | UX 설계 |
