# 핸드오프 — CS 도구: VA 세션 강제 종료

2026-09-19. **다른 워크트리에서 작업할 것.** 이 문서만 읽고 착수할 수 있게 썼다.

---

## 0. 왜 필요한가

진행 중인 VA 세션을 **운영자가 끝낼 수단이 없다.**

| 상황 | 지금 |
|---|---|
| 구매자가 취소 | 주문 취소 → 레그 종결 → 폴러가 세션 거둠 ✅ |
| 착수 기한 초과 | 〃 ✅ |
| **구매자가 그냥 이탈** | **TTL 30분을 기다린다** |
| **운영자가 끊고 싶다** | **없음** |

그 30분 동안 그 회원은 **다음 VA 를 못 쓴다**(`ACTIVE_SESSION_EXISTS`). 활성 주문 판정 키가
`(partnerId, partnerUserId)` 라 **결제링크를 지워도** 그 회원은 같은 주문으로 되돌아온다.

☠️ **결제링크 취소는 이 문제를 풀지 않는다.** 링크 취소는 「아직 안 쓴 링크를 거둬들이는」
것이고, 이미 들어온 거래를 죽이지 않는 것이 **의도**다(`MatchingLinkService.cancelLink` 주석:
「이체 중일 수 있는 거래를 뒤에서 무효화하면 구매자가 보낸 원화가 갈 곳을 잃는다」).

실사용 계기: 테스트를 반복할 때마다 30분을 기다리거나 회원 ID 를 바꿔야 했다.

---

## 1. 무엇을 만드나

**엔드포인트 하나.** CS 가 살아 있는 VA 세션을 끊고, 그 회원의 막힘을 푼다.

```
POST /api/admin/cs/vap/sessions/{vapSessionId}/force-close
     body: { "reason": "..." }        ← 운영 기록용. 공급자에게도 전달된다
```

---

## 2. 이미 있는 것 — **새로 만들지 말 것**

| 조각 | 위치 | 비고 |
|---|---|---|
| 조회 | `VapCsService.findByMember` · `findByOrder` | admin-api |
| 엔드포인트 자리 | `CsToolController` — `/vap/sessions`, `.../by-order/{id}`, `.../extension` | 같은 컨트롤러에 붙인다 |
| **취소 본체** | `VapSessionService.cancelIfLive(session, reason)` | core. ☠️ **이걸 쓴다** |
| 응답 DTO | `VapSessionCsResponse` | 그대로 재사용 |
| 세션 조회 | `VapSessionRepository.findByVapSessionId` | |

`cancelIfLive` 의 결과는 셋이고 **그대로 의미를 살려 답해야 한다**:

| `CancelResult` | 뜻 | API 응답 |
|---|---|---|
| `CANCELLED` | 우리가 끊었다. 그 회원의 막힘이 풀렸다 | 200 |
| `ALREADY_SETTLED` | 이미 종결이라 손댈 게 없었다 | 200 (성공으로 답한다 — 목적은 달성됐다) |
| `FAILED` | **취소가 닿지 않았다. 세션은 살아 있다** | 409/502 — ☠️ **성공으로 답하지 말 것** |

---

## 3. ☠️ 지켜야 할 것

### A. 돈이 들어온 세션은 끊지 않는다

```java
if (session.getReceivedKrw() != null && session.getReceivedKrw() > 0) → 거절
if (vap_deposits 에 그 세션의 행이 하나라도 있으면        → 거절
```

늦은 정산이 `VapLegCloser.reviveAsSettled` 로 레그를 되살리는 경로가 있다. 여기서 공급자
세션을 닫으면 **그 길을 우리가 끊는다**. 「입금이 되었으면 입금 된 만큼 보낸다」가 그쪽 편이다.

> 공급자에게 물어 확인하는 편이 더 안전하다 — 우리 미러가 낡았을 수 있다.
> `VapClient.getDeposit(sessionId)` 로 `receivedKrw`·`deposits[]` 를 보고 판정한다.

### B. 레그와 주문을 **함께** 정리할지 정해야 한다

세션만 끊으면 **레그는 `CREATED` 로 남는다**. 그러면 그 회원은 여전히 활성 주문에 묶인다 —
`ACTIVE_SESSION_EXISTS` 는 풀리지만 우리 쪽 활성 주문 판정은 그대로다.

**권장**: 세션 취소 성공 뒤 `VapLegCloser.closeIfSessionEnded(session)` 를 부른다. 세션이
이미 종결 상태이므로 그 메서드가 레그를 닫고 `finalizeDepositOrderIfAllTerminal` 까지 한다 —
**새 종결 경로를 만들지 않는다**.

☠️ 순서: **세션 취소 → 미러 갱신 → 레그 종결**. 뒤집으면 레그를 닫은 뒤 세션 취소가 실패해
「우리는 끝냈는데 공급자는 열려 있는」 상태가 된다(그건 폴러의
`releaseIfLegAlreadyEnded` 가 나중에 줍지만, 굳이 만들 상태가 아니다).

### C. 감사 로그

누가·언제·왜 끊었는지 남긴다. 기존 `AuditAction` enum 규약을 따른다
(`v2-docs/SPRING_AUDIT_LOG_REFACTOR_GUIDE.md`).

---

## 4. 테스트

| 이름 | 고정할 것 |
|---|---|
| 살아 있는 세션을 끊는다 | `cancelIfLive` 호출 + 레그 종결까지 |
| ☠️ 입금이 있으면 거절한다 | `receivedKrw > 0` · `deposits[]` 비어 있지 않음 **둘 다** |
| 이미 종결이면 성공으로 답한다 | `ALREADY_SETTLED` → 200 |
| ☠️ 취소가 닿지 않으면 실패로 답한다 | `FAILED` → 성공 아님. **삼키면 그 회원이 막힌 채 남는다** |
| 남의 파트너 세션은 못 끊는다 | 권한 |

---

## 5. 하지 말 것

- ❌ **새 취소 경로를 만들지 말 것** — `cancelIfLive` 와 `closeIfSessionEnded` 를 조합한다
- ❌ **`FAILED` 를 성공으로 뭉개지 말 것** — 그 회원은 여전히 막혀 있다
- ❌ **입금이 있는 세션에 「강제」 옵션을 두지 말 것** — 돈이 갈 곳을 잃는다
- ❌ 결제링크 취소에 이 동작을 얹지 말 것 — §0 의 이유로 **의도적으로** 분리돼 있다

---

## 6. 참고

- 순서 정본 · 불변식: `v2-docs/VA_LANDING_UX_DESIGN.md` **§0**
- VA 전반: `v2-docs/VAP_LEG_OPEN_ITEMS.md`
- 계약: `v2-docs/vap/PARTNER_WEBHOOK_CONTRACT_v1.4.md`
