# Guide #66 — 출금 FAILED 취소 허용 + 입출금 콜백 이벤트 정리

**작성일**: 2026-03-26
**대상**: core (Spring), partner-api (Spring), partner-ui (Vue)

---

## Part A — 출금 FAILED 상태 취소 허용

### 현황

| 메서드 | 허용 상태 | 호출자 |
|--------|----------|--------|
| `cancel()` (admin) | REQUESTED, PENDING_APPROVAL, **FAILED** ✅ | admin-api |
| `cancelByPartner()` | REQUESTED, PENDING_APPROVAL ❌ **FAILED 누락** | partner-api |

스크린샷의 `WD_P1_007` (실패 상태, 150 USDT)처럼, 파트너가 직접 FAILED 건을 취소할 수 없는 상태.

### 수정: `cancelByPartner()` — FAILED 추가

**파일**: `core/src/main/java/com/cryptoments/core/withdrawal/WithdrawalService.java`
**위치**: `cancelByPartner()` 메서드 (~line 255)

```diff
  if (withdrawal.getStatus() != WithdrawalStatus.REQUESTED
-         && withdrawal.getStatus() != WithdrawalStatus.PENDING_APPROVAL) {
+         && withdrawal.getStatus() != WithdrawalStatus.PENDING_APPROVAL
+         && withdrawal.getStatus() != WithdrawalStatus.FAILED) {
      throw new ConflictException(ErrorCodes.WITHDRAWAL_CANCELLATION_NOT_ALLOWED,
-             "취소 불가 상태입니다: " + withdrawal.getStatus() + " (REQUESTED, PENDING_APPROVAL만 가능)");
+             "취소 불가 상태입니다: " + withdrawal.getStatus() + " (REQUESTED, PENDING_APPROVAL, FAILED만 가능)");
  }
```

### UI 변경: 출금 목록/상세에서 FAILED 행에 취소 버튼 노출

현재 출금 목록에서 FAILED 상태 행에는 액션이 없음. 취소 버튼을 추가:

**출금 목록 뷰** (해당 template 영역):

```html
<!-- 기존: REQUESTED/PENDING_APPROVAL만 취소 버튼 -->
<!-- 변경: FAILED도 취소 가능 -->
<Button v-if="['REQUESTED', 'PENDING_APPROVAL', 'FAILED'].includes(row.status)"
        variant="ghost" size="sm" class="text-red-600"
        @click="cancelWithdrawal(row.id)">
  취소
</Button>
```

### 비즈니스 로직 영향

FAILED 취소 시 `cancel()` 내부에서:

1. `status` → `CANCELLED` 변경
2. `unfreeze()` 호출 → 동결된 잔액을 MASTER 가용잔액으로 복원
3. `saveStatusHistory()` → 이력 기록
4. `notificationService.send()` → WITHDRAWAL_CANCELLED 웹훅 + 텔레그램

> ⚠️ FAILED 건은 온체인 TX가 실패한 것이므로, 실제 자금이 이동하지 않았음.
> 취소(unfreeze)가 안전한 작업임.

---

## Part B — 입출금 콜백 이벤트 전체 맵

### B-1. 출금(Withdrawal) 콜백 이벤트

```
출금 상태 머신:

  REQUESTED ──► PENDING_APPROVAL ──► APPROVED ──► PROCESSING ──► BROADCASTING ──► CONFIRMED
      │              │     │                                                          │
      │              │     └──► REJECTED ⓦⓣ                                        │
      │              │                                                                │
      └──────────────┴──────────► CANCELLED ⓦⓣ                                     │
                     │                                                                │
                     └──────── FAILED ⓦⓣ ◄── (TX 실패)                              │
                                  │                                                   │
                                  └──► CANCELLED ⓦⓣ (본 가이드 추가)                │
                                                                                      │
                                  CONFIRMED ⓦⓣ ──► SETTLED                          │

ⓦ = Webhook 콜백    ⓣ = Telegram 알림
```

| 이벤트 | 트리거 | Webhook Payload | Telegram | 잔액 변동 |
|--------|--------|-----------------|----------|----------|
| **WITHDRAWAL_CONFIRMED** | 온체인 TX 확인 (블록 컨펌) | `{"event":"WITHDRAWAL_CONFIRMED","withdrawalId":N,"txHash":"0x..."}` | ✅ 출금 확인 알림 | 동결 해제 → 차감 확정 |
| **WITHDRAWAL_FAILED** | 온체인 TX 실패 | `{"event":"WITHDRAWAL_FAILED","withdrawalId":N}` | ✅ 출금 실패 알림 | 동결 유지 (수동 처리 대기) |
| **WITHDRAWAL_REJECTED** | 관리자/파트너가 거부 | `{"event":"WITHDRAWAL_REJECTED","withdrawalId":N}` | ✅ 출금 거부 알림 (사유 포함) | 동결 해제 → 가용잔액 복원 |
| **WITHDRAWAL_CANCELLED** | 파트너/관리자가 취소 | `{"event":"WITHDRAWAL_CANCELLED","withdrawalId":N}` | ✅ 출금 취소 알림 | 동결 해제 → 가용잔액 복원 |

**콜백 미발생 상태 전이:**

| 전이 | 이유 |
|------|------|
| REQUESTED → PENDING_APPROVAL | 내부 상태 (파트너에게 노출 불필요) |
| PENDING_APPROVAL → APPROVED | 내부 상태 (처리 진행 중) |
| APPROVED → PROCESSING | 내부 상태 (Relayer가 TX 준비) |
| PROCESSING → BROADCASTING | 내부 상태 (TX 전파 중) |
| CONFIRMED → SETTLED | 내부 정산 프로세스 |

### B-2. 입금(Deposit) 콜백 이벤트

```
입금 상태 머신:

  DETECTED ──► CONFIRMING ──► CONFIRMED ⓦⓣ ──► NOTIFIED ──► COLLECTING ──► COLLECTED ──► SETTLED
                                                                              │
                                                                      FAILED (집금 실패)

입금 세션:

  CREATED ──► WAITING ──► RECEIVED ──► COMPLETED
     │           │
     │           └──► EXPIRED (30분 초과)
     └──────────► CANCELLED (수동 취소)
```

| 이벤트 | 트리거 | Webhook Payload | Telegram | 잔액 변동 |
|--------|--------|-----------------|----------|----------|
| **DEPOSIT_CONFIRMED** | 온체인 TX 블록 확인 완료 | `{"event":"DEPOSIT_CONFIRMED","depositId":N,"amount":"100.50","txHash":"0x..."}` | ✅ 입금 확인 알림 | HOT/POOL 잔액 +amount |
| **LARGE_DEPOSIT** | 입금액 > 대량 기준 | (Telegram only) | ✅ 대량 입금 경고 | — |

**입금은 취소/반려 콜백 없음:**
- 입금은 온체인에서 발생하는 이벤트이므로, 시스템이 취소할 수 없음
- 환불이 필요하면 **별도 출금(REFUND 타입)** 생성으로 처리
- 입금 세션 만료(EXPIRED)는 "결제 미완료"일 뿐 입금 자체가 취소되는 것이 아님

### B-3. 입금 세션(Deposit Session) 이벤트

현재 입금 세션 상태 변경에 대한 **파트너 콜백은 미구현**. 필요 시 추가 가능:

| 이벤트 (미구현) | 트리거 | 용도 |
|----------------|--------|------|
| `DEPOSIT_SESSION_EXPIRED` | 세션 30분 초과 만료 | 파트너에게 결제 미완료 알림 |
| `DEPOSIT_SESSION_COMPLETED` | 입금 수신 → 세션 완료 | 파트너에게 결제 성공 알림 |

> 현재는 `DEPOSIT_CONFIRMED`만 발송. 세션 기반 결제 플로우에서는 세션 상태 콜백도 추가하는 것이 좋을 수 있음.

---

## Part C — 콜백 전송 메커니즘

### C-1. 2채널 알림 시스템

```
이벤트 발생
   ├─ Webhook HTTP POST → 파트너 callbackUrl
   │    ├─ 성공 (2xx) → SUCCESS
   │    └─ 실패 → RETRYING (30s → 60s → 300s → 900s → 3600s)
   │         └─ 5회 실패 → FAILED (최종)
   │
   └─ Telegram 메시지 → 파트너 chatId/threadId
        └─ 실패 시 로그만 (재시도 없음)
```

### C-2. Webhook Payload 공통 구조

```json
{
  "event": "WITHDRAWAL_CONFIRMED",   // 이벤트 타입
  "withdrawalId": 456,               // 참조 ID (deposit이면 depositId)
  "amount": "100.50",                // 금액 (DEPOSIT_CONFIRMED만)
  "txHash": "0xabc..."              // TX 해시 (CONFIRMED 이벤트만)
}
```

### C-3. 파트너 측 콜백 처리 가이드

파트너가 webhook URL에서 처리해야 할 이벤트:

```
switch (event) {
  case "DEPOSIT_CONFIRMED":
    // 입금 확정 → 고객 잔액 충전 or 주문 완료 처리
    break;
  case "WITHDRAWAL_CONFIRMED":
    // 출금 성공 → 내부 출금 상태 완료 처리
    break;
  case "WITHDRAWAL_FAILED":
    // 출금 실패 → 재시도 or 고객 알림
    break;
  case "WITHDRAWAL_REJECTED":
    // 출금 거부 → 고객에게 사유 전달
    break;
  case "WITHDRAWAL_CANCELLED":
    // 출금 취소 → 고객 잔액 복원 확인
    break;
}
```

**응답 규칙:**
- HTTP 200~299 → 전송 성공
- 그 외 → 재시도 (30s, 60s, 5m, 15m, 1h)
- 5회 실패 → FAILED (수동 확인 필요)

---

## Part D — 누락된 콜백 이벤트 분석

### D-1. 현재 구현된 이벤트 (5개)

| # | 이벤트 | 구현 위치 | Webhook | Telegram |
|---|--------|---------|---------|----------|
| 1 | DEPOSIT_CONFIRMED | DepositService.confirmDeposit() | ✅ | ✅ |
| 2 | WITHDRAWAL_CONFIRMED | WithdrawalService.onTxConfirmed() | ✅ | ✅ |
| 3 | WITHDRAWAL_FAILED | WithdrawalService.onTxFailed() | ✅ | ✅ |
| 4 | WITHDRAWAL_REJECTED | WithdrawalService.reject() | ✅ | ✅ |
| 5 | WITHDRAWAL_CANCELLED | WithdrawalService.cancel() / cancelByPartner() | ✅ | ✅ |

### D-2. 추가 고려 이벤트

| # | 이벤트 | 트리거 | 우선순위 | 근거 |
|---|--------|--------|---------|------|
| 6 | DEPOSIT_SESSION_COMPLETED | 입금 세션 완료 | P1 | 결제 링크 사용 시 결제 성공 알림 필요 |
| 7 | DEPOSIT_SESSION_EXPIRED | 입금 세션 만료 | P2 | 미결제 추적용 |
| 8 | DEPOSIT_COLLECTED | 집금 완료 (HOT→MASTER) | P3 | 정산 추적용 (대부분 파트너 불필요) |
| 9 | WITHDRAWAL_APPROVED | 출금 승인 완료 | P2 | 승인 대기 건 처리 알림 |
| 10 | BALANCE_LOW | GAS/MASTER 잔액 부족 | P1 | 운영 알림 (Telegram only) |

### D-3. 구현 권장 우선순위

**Phase 1 (즉시):**
- ✅ FAILED 취소 허용 (Part A) — `cancelByPartner()` 수정
- P1: DEPOSIT_SESSION_COMPLETED — 결제 링크 플로우의 핵심

**Phase 2 (운영 안정화 후):**
- P2: WITHDRAWAL_APPROVED, DEPOSIT_SESSION_EXPIRED

**Phase 3 (선택):**
- P3: DEPOSIT_COLLECTED (파트너 요청 시)

---

## 체크리스트

| # | 항목 | 파일 | 상태 |
|---|------|------|------|
| 1 | `cancelByPartner()` FAILED 상태 추가 | `core/withdrawal/WithdrawalService.java` | ☐ |
| 2 | 에러 메시지 수정 (FAILED 포함) | 동일 | ☐ |
| 3 | partner-ui 출금 목록 FAILED 행에 취소 버튼 추가 | 출금 목록 뷰 | ☐ |
| 4 | partner-ui 출금 상세 FAILED 상태에 취소 버튼 추가 | 출금 상세 뷰 | ☐ |
| 5 | FAILED → CANCELLED 테스트 (잔액 복원 확인) | 브라우저 + DB | ☐ |
| 6 | WITHDRAWAL_CANCELLED webhook 수신 확인 | webhook 로그 | ☐ |
