# VA 랜딩 UX 설계 — 처음부터

2026-09-18. VA 레그(가상계좌 KRW 온램프, 공급자 VAP)의 **구매자 화면**을 설계한다.
백엔드는 구현돼 있고 UI 는 **한 줄도 없다** — `widget-ui/src` 전체에서 `vaAvailable` ·
`checkoutUrl` · `REDIRECT` · `VAP` 가 0건이다.

---

## 0. ☠️ 순서 정본 — <b>여기서 벗어나면 다시 깨진다</b>

> 2026-09-19. 이 순서를 <b>세 번 놓쳐</b> 운영에서 세 번 깨졌다. 구현·리뷰 전에 이 절을 먼저 읽는다.

```
① 매칭 창      P2P → LP → VA 랜딩 구간 열림           vaAvailable = true
                   ↓
② 화면 전환    위젯이 p2p-va 로 간다                   ☠️ 네트워크 호출 없음. 즉시.
                   ↓
③ 랜딩이 발급  진입 시 POST /va/issue                  구매자는 「발급 중」을 본다
                   ↓
④ 서버         공급자 세션 생성 → 엔진 레그 등록       ☠️ 등록 거절이면 세션도 되돌린다
                   ↓
⑤ 성공         rememberVaReturn → location.replace(checkoutUrl)
   실패         랜딩에 실패 화면 → [매칭 재시도] · [취소]
                   ↓
⑥ 공급자       본인인증 → 계좌발급 → 입금 → "입금 접수가 끝났어요"
                   ↓
⑦ 복귀         /va-return → 원래 자리 → VA 대기 패널   ☠️ 여기서 발급하지 않는다
                   ↓
⑧ 대기         입금 기다림 → 입금 확인 → USDT 전송 중 → 완료
```

### 불변식 넷 — 어긴 것이 그대로 사고가 됐다

| # | 불변식 | 어겼을 때 실제로 난 일 |
|---|---|---|
| **A** | <b>레그는 발급 <u>성공의 결과</u>다</b>. 발급 전에 레그는 없다 | — |
| **B** | <b>발급은 ③ 랜딩 화면에서만</b> 한다. 폴링 콜백에서 하지 않는다 | 발급을 매칭 화면의 폴링에서 했다. 응답이 <b>13초</b> 걸리는 동안 구매자가 볼 화면이 없어, 그사이 레그가 생기자 <b>매칭 결과 화면</b>이 떴다 |
| **C** | <b>복귀(⑦)는 발급하지 않는다</b> | 복귀마다 발급하면 그 회원이 `ACTIVE_SESSION_EXISTS` 로 <b>자기 자신에게</b> 막힌다 |
| **D** | <b>VA 레그는 이체 화면의 대상이 아니다</b> | 계좌번호 자리에 체크아웃 URL 이 그려졌다. 그 체크를 누르면 <b>보내지 않은 돈</b>을 보냈다고 신고한다 |

### ☠️ B 를 특히 조심할 것

가장 흔한 실수이고, <b>가장 그럴듯해 보이는 축약</b>이다. 「어차피 발급하고 바로 나갈 건데
화면을 한 번 더 거칠 필요가 있나」로 읽히기 때문이다.

있다. <b>기다림에는 자기 화면이 필요하다.</b> 그 화면이 없으면 기다리는 시간이 <b>다른
화면의 시간</b>이 되고, 그 다른 화면은 그동안 자기 논리대로 움직인다(레그가 생기면
`homeState` 가 바뀐다). 실패도 마찬가지다 — 렌더될 곳이 없으면 `console.warn` 으로 끝난다.

---

## 1. 이 설계를 지배하는 한 가지 차이

|  | P2P · TORQ | **VA** |
|---|---|---|
| 돈을 보내는 곳 | 우리가 준 **계좌번호** | 공급자 **체크아웃 페이지** |
| 구매자가 있는 곳 | **우리 위젯 안** | **우리 앱을 떠난다** |
| 그 사이에 우리가 보는 것 | 화면 그대로 | **아무것도 못 본다** |
| 「보냈다」의 근거 | 사용자 자기신고 + 대사 | **웹훅/폴링뿐** |

그래서 VA 화면의 본질은 <b>「이체 지시」가 아니라 「핸드오프 + 복귀 대기」</b>다.
기존 이체 화면을 고쳐 쓰려던 접근이 어긋난 지점이 여기다 — 그 화면의 모든 장치
(계좌 복사 · 연락처 저장 · 본인명의 경고 · **「이체했습니다」 체크**)는 <b>구매자가 앱 안에
머문다</b>는 전제 위에 서 있다.

### ☠️ 특히 「이체했습니다」 체크는 <b>있으면 안 된다</b>

P2P 에서 그 체크는 **판정이 아니라 신고**다 — 판매자 확인과 대사가 뒤에 있다.
VA 에는 그 뒤가 없다. 입금 판정은 `DEPOSIT.SETTLED` **하나**뿐이고, 사용자 자기신고가
레그를 움직이면 「입금이 안된건 안보낸다」가 화면 단계에서 깨진다.

**VA 에서 사용자는 상태를 바꿀 수 없다.** 바꿀 수 있는 것은 <b>떠날지</b>와 <b>그만둘지</b>뿐이다.

### 책임 경계 — 공급자 화면은 <b>어디서 끝나는가</b> (2026-09-18 공급자 확인)

체크아웃의 마지막 화면은 `ClosedView` — <b>「입금 접수가 끝났어요」</b>다.

```
StatusHero   입금 접수가 끝났어요
             └ closeReason 별 한 줄 (FUNDED / EXPIRED / PARTNER)
AmountCard   확인된 입금(receivedKrw) · 요청 금액 · 입금 계좌
NoticeBox    이 요청으로는 더 이상 입금하지 마세요
[돌아가기]   returnUrl 이 있으면 버튼, 없으면 "이 창을 닫고 …" 문구
```

☠️ <b>「USDT 를 보냈다」는 화면이 없다.</b> 체크아웃의 끝은 <b>KRW 입금 접수 종료</b>까지이고,
그 뒤의 귀속·환율 확정·지급·온체인 확정은 <b>구매자에게 전혀 보이지 않는다</b>.

<b>그래서 §3 의 ③ 대기 화면은 선택이 아니라 필수다.</b> 그것이 없으면 구매자는 자기가 USDT 를
받았는지 <b>알 방법이 아예 없다</b> — 「입금은 접수됐다」에서 끝나 버린다. 우리 화면이 맡는 구간이
정확히 <b>그 마지막 한 칸</b>이다.

| 구간 | 누구 화면 |
|---|---|
| 본인인증 → 계좌 발급 → KRW 입금 → **접수 종료** | 공급자 |
| **귀속 → 지급 → 온체인 확정 → 수령 확인** | **우리** |

### ☠️ 공급자 화면은 <b>스스로 갱신되지 않는다</b>

계좌 발급 후 `OpenView` 에는 <b>폴링이 없다</b> — 사용자가 새로고침을 눌러야 `ClosedView` 로
넘어간다. 즉 이체를 마친 구매자가 <b>공급자 화면에서는 아무 변화도 못 보는</b> 시간이 있다.

<b>반대로 우리는 웹훅으로 안다.</b> 그 구간에서 <b>우리 화면이 더 빠른 상태 표면</b>이라는 뜻이고,
이것이 ③ 화면의 버튼 설계를 바꾼다(§3 참조).

---

## 2. 구매자 여정

```
[매칭 중]  P2P 3분 → LP 2분
              ↓ 둘 다 못 잡음 + VA 자격·재원 OK
① [VA 제안]   "가상계좌로 받기"            ← vaAvailable = true
              ↓ 누름 → POST /va/issue → checkoutUrl
② [핸드오프]  "발급 페이지로 이동합니다"     ← 세션 발급됨
              ↓ 이동
    ─────── 공급자 영역 ───────────────────────────────────
      본인인증 → 계좌발급 → KRW 입금 → "입금 접수가 끝났어요"
      ☠️ 여기까지다. USDT 이야기는 한 줄도 없다
      ☠️ 자동 갱신 없음 — 사용자가 새로고침해야 종료 화면이 뜬다
    ───────────────────────────────────────────────────────
              ↓ 돌아옴 / 안 돌아옴 / 탭을 닫음
③ [대기]      "입금을 확인하고 있습니다"     ← 재진입해도 여기
              ↓ DEPOSIT.SETTLED
④ [완료]      기존 결과 화면 재사용
```

**②와 ③은 같은 화면의 두 상태다.** 별도 화면으로 나누면 「돌아왔는데 처음 보는 화면」이 된다.

---

## 2.1 랜딩 진입까지의 흐름 — <b>코드에서 뽑은 실제 조건</b>

### 타임라인 (현재 설정값 기준)

```
t=0       매칭 창 시작 (POST /order/{code}/start)
│
├ 0~180s   P2P 만 시도            matching.policy.p2p-phase-seconds = 180
├ 180~300s 잔여를 LP 에 요청       lp-phase-seconds = 120
│
▼ t=300s   ★ VA 랜딩 열림 ★       vaLandingStartsAt()
├ 300~420s VA 랜딩 구간           va-landing-phase-seconds = 120
│
▼ t=420s   창 닫힘 → awaitingDecision (유예 300s)
```

☠️ <b>이 시각은 고정이 아니다.</b> `effectiveLpPhase()` 가 `useLp` 를 보므로, 그 파트너가
`torq_enabled=0` 이면 LP 구간이 사라져 <b>VA 랜딩이 t=180s 에 열린다</b>.
(운영 실측 2026-09-18: 전 파트너 `torq_enabled=1` → 위 타임라인이 맞다)

### 게이트 — <b>전부 통과해야</b> 랜딩이 뜬다

| # | 조건 | 어디서 | 실패하면 |
|---|---|---|---|
| G1 | `partners.krw_enabled && vap_enabled` | `JdbcPartnerRoutingProfileAdapter` → `profile.useVap()` | 창에 VA 꼬리가 **아예 안 생긴다** |
| G2 | 요청액 ≥ `matching.policy.va-min-krw`(**300,000**) | `MatchingPolicyFactory.snapshot` | 위와 같다 — 하한 미달 주문은 **VA 꼬리조차 잡지 않는다** |
| G3 | 엔진을 거친 세션 | `engineSession=true` | 위젯이 `vaAvailable` 을 못 받는다(오류 아님) |
| G4 | 매칭 창이 열려 있다 | `VaRoutingRule.landingOpen` | 닫힌 뒤엔 `awaitingDecision` 이다 |
| G5 | `now ≥ vaLandingStartsAt()` | 〃 | **아직 이르다** — 더 싼 구간(P2P·LP)을 건너뛰지 않는다 |
| G6 | `remainingAmountKrw > 0` | `VaRoutingRule.decide` | `SKIP_NO_RESIDUAL` |
| G7 | **레그가 하나도 없다** | 〃 | `SKIP_LEG_PRESENT` — ☠️ VA 는 **단독**이다. P2P·LP 가 <b>일부라도</b> 잡혔으면 VA 는 없다 |
| G8 | 공급자가 지금 그 금액을 받는다 | `P2pWidgetController.vaLiquidityAllows` → `VapLiquidityCache.mayAccept` | 랜딩을 **안 띄운다**. ☠️ 「아니오」만 막는다 — 조회 실패는 통과 |
| G9 | 10,000 ≤ 금액 ≤ 20,000,000 | 발급 시 `vap.landing.min/max-krw` | 발급 거절 (랜딩은 이미 떴다) |

☠️ <b>G1·G2 와 G4~G8 은 성질이 다르다.</b> 앞의 둘은 <b>창을 만들 때</b> 한 번 접혀
`policy_json` 에 박히고, 뒤의 것들은 <b>폴링할 때마다</b> 다시 평가된다. 그래서 파트너 설정을
중간에 바꿔도 <b>진행 중인 주문의 창은 바뀌지 않는다</b>.

### 신호가 위젯까지 오는 경로

```
엔진 MatchSession
  └ VaRoutingRule.landingOpen(session, now)
      └ MatchingEngineClient.findState(orderId).vaAvailable()
          └ GET /api/p2p/order/{orderCode}/matching        ← 위젯이 이미 폴링 중
              └ P2pWidgetMatchingStateResponse
                   { engineSession, status, awaitingDecision, deadlineAt, vaAvailable }
                                                              ↑ ☠️ 아무도 안 읽는다
```

☠️ <b>백엔드는 다 돼 있다.</b> `vaAvailable` 은 위젯이 이미 폴링하는 그 응답에 실려 오고,
`awaitingDecision` 과 <b>상호배타</b>다. 빠진 것은 <b>프론트에서 이 필드를 읽는 코드</b>뿐이다 —
`engineMatching.value.vaAvailable` 한 줄이 시작점이고, 그 위에 §3 ①의 화면을 얹는다.

### 랜딩을 누른 뒤 (참고 — 여기부터는 §3 ②)

```
POST /api/p2p/order/{orderCode}/va/issue
  ├ vaAvailable 재확인(라우터와 갈라지지 않게 다시 읽는다)
  ├ VapSessionService.issue(...) → 공급자 세션 + checkoutUrl   TTL 30분
  └ registerVaLeg(...)  → 엔진에 VA 레그 등록
```

---

## 3. 화면 스펙

### ① VA 제안 — `p2p-deposit` 의 **네 번째 상태**

`engineAwaitingDecision` · `engineMatchingEnded` · (매칭 중) 옆에 `vaAvailable` 을 둔다.
`awaitingDecision` 과 **상호배타**이므로 분기가 겹치지 않는다.

| 요소 | 내용 | 왜 |
|---|---|---|
| 제목 | 「가상계좌로 받으시겠어요?」 | 실패가 아니다. 대안 제시다 |
| 설명 | 본인인증이 필요하고, **다른 페이지로 이동**한다는 사실 | ☠️ 앱을 떠난다는 것을 **누르기 전에** 알린다. 이동 후에 알면 이탈한다 |
| 금액 | 주문 금액 | 기존 `amount_info` 재사용 |
| 남은 시간 | 매칭 창 잔여 | 이미 있는 `engineCountdownText` |
| 주 버튼 | 「가상계좌 발급받기」 | |
| 보조 | 「그만두기」 | |

☠️ **이 화면에 계좌번호가 없다.** 아직 발급 전이다 — 있는 것처럼 보이면 안 된다.

### ② 핸드오프 + ③ 대기 — 새 단계 `p2p-va`

세션이 발급된 뒤의 화면. **재진입의 착지점**이기도 하다.

| 요소 | 내용 | 왜 |
|---|---|---|
| 상태 문구 | **3단**: ⓐ 이동 전 「발급 페이지로 이동합니다」 → ⓑ 입금 전 「입금을 기다리고 있습니다」 → ⓒ 입금 확인 후 「입금을 확인했어요. USDT 를 보내는 중입니다」 | ☠️ ⓒ 가 **공급자 화면에 없는 구간**이다. 구매자가 결과를 아는 유일한 창구다 |
| 금액 | 보낼 원화 + **확인된 입금 누계** | 분할 입금이 정상이다(§5 B-2) |
| 만료 | **공급자 세션 만료**까지 남은 시간 | ☠️ 우리 레그 시계가 아니다. 실질 마감은 VAP 세션이다 |
| 주 버튼 | 「발급 페이지 열기」 — **ⓐⓑ 에서만 주 버튼**. ⓒ 에서는 **보조로 내리거나 감춘다** | ☠️ 공급자 화면은 자동 갱신되지 않아, 입금 뒤에 다시 열면 **낡은 OpenView** 가 뜬다. 우리는 이미 입금을 아는데 그쪽은 「입금을 기다립니다」를 보여 준다 — **우리 화면이 맞는데 구매자는 그쪽을 믿는다** |
| 보조 | 「그만두기」 — **ⓒ 에서는 없앤다** | 돈이 이미 들어왔다. 취소할 것이 없다 |
| **없는 것** | 계좌 복사 · 연락처 저장 · 본인명의 경고 · **「이체했습니다」 체크** | §1 |

### ④ 완료 / 실패

기존 `p2p-result-confirmed` · `p2p-failed` 를 그대로 쓴다. VA 전용 결과 화면을 만들지 않는다 —
결과의 뜻이 레그 종류와 무관하기 때문이다.

---

## 3.5 페이지 둘로 나눈다 — <b>랜딩</b>과 <b>복귀·대기</b> (2026-09-18 결정)

### 왜 나누는가

랜딩의 일은 <b>「발급하고 내보낸다」</b>이고, 복귀의 일은 <b>「받아서 기다린다」</b>다.
한 주소가 둘을 겸하면 <b>같은 URL 에 두 가지 뜻</b>이 생긴다:

| 들어온 경로 | 해야 할 일 |
|---|---|
| 매칭 화면에서 처음 진입 | **발급하고 전환** |
| 체크아웃에서 복귀 | **발급하지 말고 대기** |
| 위젯을 다시 열어 재진입 | **발급하지 말고 대기** |

「진입 시 발급」을 그대로 두면 <b>복귀할 때마다 다시 발급</b>한다 — 409(창이 닫혔으면)이거나
<b>중복 세션</b>이다.

☠️ <b>나누면 멱등을 랜딩이 떠안지 않는다.</b> 재진입자를 복귀 페이지로 보내는 것은 <b>라우팅의
일</b>이 되고, 랜딩은 <b>매칭 화면에서만 들어오는 단일 입구</b>로 남는다.

### 페이지 ① 랜딩 — `p2p-va`

<b>진입 = 발급</b>이다. 버튼이 없다.

```
진입
 ├ POST /api/p2p/order/{orderCode}/va/issue
 │    ├ 성공 → checkoutUrl 로 전환 (전환 직전 rememberVaReturn())
 │    └ 실패 → 매칭 실패 화면 — [재시도] · [취소]
 └ 그동안: 로딩 (되돌아갈 수 없는 화면이 아님을 알린다)
```

☠️ <b>여기서만 발급한다.</b> 다른 어떤 화면도 `va/issue` 를 부르지 않는다.

### 페이지 ② 복귀·대기 — `/va-return` → `p2p-va-wait`

<b>절대 발급하지 않는다.</b> 들어오는 문이 <b>둘</b>이다:

| 문 | 경로 |
|---|---|
| 체크아웃의 「돌아가기」 | `returnUrl` = `/va-return?order={orderCode}` |
| 위젯을 다시 열었다 | 주문 상태가 VA 레그를 가리키면 이 상태로 |

☠️ <b>두 문이 같은 상태로 모여야 한다.</b> `returnUrl` 이 없는 복귀(탭을 닫았다가 다시 열기)는
<b>어차피 처리해야 하는 경로</b>다 — 그걸 처리하면 `returnUrl` 은 「빨라지는 수단」이지
「없으면 깨지는 것」이 아니게 된다.

#### 상태

| 상태 | 화면 | 주 동작 |
|---|---|---|
| 세션 살아있음 · 입금 없음 | 「입금을 기다리고 있습니다」 | **[이어서 진행]** — 보관한 `checkoutUrl` |
| 입금 확인됨 · 지급 전 | 「입금을 확인했어요. USDT 를 보내는 중입니다」 | 없음 (☠️ 취소도 없다 — 돈이 들어왔다) |
| 정산 완료 | 기존 결과 화면으로 | — |
| 세션 만료 · 입금 없음 | 실패 | 재시도 · 취소 |

#### ☠️ 복귀는 <b>eKYC 와 같은 규약</b>을 쓴다

이미 있는 것을 그대로 따른다 — `/ekyc-return` · `rememberEkycReturn()` · `takeEkycReturn()`.

- 나가기 직전 <b>현재 URL 을 `localStorage` 에 보관</b>한다. 위젯 URL 에 이미 상태가 들어 있어,
  복귀 라우트가 그 주소로 되돌리면 <b>있던 자리</b>로 정확히 돌아간다
- 꺼내면서 <b>지운다</b>. 남기면 다음 왕복이 엉뚱한 옛 화면으로 간다
- 저장값이 <b>이 오리진이 아니면 버린다</b> — 사용자가 손댈 수 있는 저장소다
- 세션 토큰도 `localStorage` 라 <b>탭을 옮겨도 살아남는다</b>. 새 탭으로 열든 같은 탭으로
  전환하든 복귀가 성립한다

<b>eKYC 와 다른 점 하나</b> — eKYC 의 복귀 URL 은 「모든 사용자에게 같은 고정값」이라 누구인지
담을 수 없다. 반면 VA 의 `returnUrl` 은 <b>요청별 필드</b>라 <b>주문 코드를 실을 수 있다</b>.
그래서 `localStorage` 가 비었거나 막힌 브라우저에서도 <b>`?order=` 로 최소한 어느 주문인지는
안다</b> — 두 겹이다.

---

## 4. ☠️ 이 설계의 진짜 난제 — <b>돌아오지 않는 경우</b>

구매자는 **돌아온다는 보장이 없다.** 탭을 닫거나, 인앱 브라우저가 죽거나, 입금만 하고 잊는다.

**전제를 뒤집는다: 복귀는 선택이고, 판정은 복귀와 무관하다.**

- 화면 상태는 `returnUrl` 복귀로 정하지 **않는다**. 서버 상태(폴링)로만 정한다 —
  복귀는 「빨리 알게 되는 것」일 뿐이고, 안 돌아와도 웹훅이 레그를 끝낸다
- 구매자가 나중에 위젯을 다시 열면 **③ 대기 화면**에 착지한다.
  `resolveBankInfo` 가 `checkoutUrl` 을 계속 주는 이유가 그것이다
- 세션이 만료된 뒤 돈이 도착하면 **연장 세션**(계약 §8)이 받는다 — CS 경로가 이미 있다

### `VAP_RETURN_URL` — <b>자금엔 불필요, 화면엔 사실상 필수</b>

돈의 흐름은 복귀 없이도 닫힌다(위 세 줄). 그러나 **구매자 경험은 다르다**:

공급자의 <b>모든 종료 화면</b>(입금 접수 종료 · 본인인증 불가 · 발급 전 만료 · 취소)은
`returnUrl` 이 있으면 <b>「돌아가기」 버튼</b>을, 없으면 <b>「이 창을 닫고 원래 화면으로
돌아가 주세요」 문구</b>를 낸다.

☠️ <b>같은 탭으로 보냈다면 닫을 창이 없다.</b> 구매자는 뒤로가기를 찾아야 하고, 그 화면은
「끝났다」고 말하고 있다 — <b>막다른 길</b>이다. 그래서 D-1 과 묶인다:

| 이동 방식 | `returnUrl` |
|---|---|
| **같은 탭** | ☠️ **필수** — 없으면 막다른 길 |
| **새 탭** | 없어도 문구가 성립한다(창을 닫으면 원 탭이 있다). 그래도 있는 편이 낫다 |

---

## 5. 백엔드에 더 필요한 것

| # | 필요한 것 | 왜 |
|---|---|---|
| B-1 | 활성 주문 응답에 **VA 세션 만료 시각** | 화면이 공급자 세션 기준 카운트다운을 그려야 한다. 지금은 `checkoutUrl` 만 내려간다 |
| B-2 | **부분 입금 진행률**(`receivedKrw` / `expectedAmountKrw`) | 1회 200만 한도라 **분할 입금이 정상**이다. 「보냈는데 화면이 그대로」로 보이면 다시 보낸다 — 오입금의 전형적 경로 |
| B-3 | `returnUrl` 을 **주문별로 만든다** | 지금은 `vap.landing.return-url` 정적 1값이라 <b>어느 주문인지 담을 수 없다</b>. `returnUrl` 은 요청별 필드이므로(`VapDepositCreateRequest`) `{widgetBase}/va-return?order={orderCode}` 로 만들면 된다. ☠️ 본문이 멱등 판정에 들어가므로 <b>같은 주문은 항상 같은 값</b>이어야 한다 |

☠️ **B-2 를 빼면 안 된다.** 2,000만 주문은 최소 10회 분할이고, 그 사이 화면이 침묵하면
구매자는 자기가 보낸 것이 안 갔다고 판단한다.

---

## 6. 범위 밖

- **VAP 체크아웃 페이지 자체** — 공급자 소유다. 우리가 그리지 않는다
- 파트너/관리자 화면 — CS 연장 경로는 admin-api 에 이미 있다

---

## 7. 결정된 것 (2026-09-18)

| # | 결정 | 근거 |
|---|---|---|
| D-1 | **자동 전환** — 버튼 없음 | 버튼은 입금자에게 <b>모호성</b>을 준다. 좀비 걱정은 공급자가 맡는다 — TTL 30분에 `SESSION.CLOSED(EXPIRED, receivedKrw=0)` 가 오고 `VapLegCloser` 가 레그를 닫는다(웹훅을 놓쳐도 폴러가 백스톱) |
| D-2 | **같은 탭 리다이렉트** | ☠️ 선택이 아니다. 자동 전환에는 <b>사용자 제스처가 없어</b> `window.open()` 이 브라우저에 막힌다. `location.href` 만 동작한다 |
| D-3 | **`returnUrl` 필수** | D-2 의 귀결. 같은 탭인데 복귀 경로가 없으면 공급자 종료 화면의 「이 창을 닫아 주세요」가 <b>막다른 길</b>이 된다 |
| D-4 | **페이지 둘** — 랜딩 / 복귀·대기 | §3.5 |
| D-5 | **사전 고지 필수** | 버튼이 없애는 모호성은 「무엇을 고를지」다. 자동 전환은 <b>다른 모호성</b>을 만든다 — 기다리던 사람이 갑자기 낯선 도메인에 있다. 예고가 그것을 「예정된 일」로 바꾼다 |
| D-6 | 전환 시점 = **즉시** | 카운트다운은 「취소」가 없으면 무의미하고, 있으면 방금 없앤 버튼이 되돌아온다 |
| D-7 | `Decision.VA` 는 **보류** | 자동 전환이면 「놓친 사람」이 없다. 발급 실패 후 창이 닫힌 경우만 남는데, 필요해지면 그때 붙인다 |
| D-8 | `usePartner` 는 **범위 밖** | `PARTNER_CHECK` 는 창 계산에 들어가지 않는 별도 상태다 |

### ☠️ VA 전용 파트너는 완충이 없다

`useP2p=false, useLp=false, useVap=true` 면 `vaLandingStartsAt()` 이 <b>창이 열리는 순간</b>이다
(§2.1 조합표 4행). 자동 전환과 겹치면 <b>「진행」을 누르자마자 낯선 도메인</b>이다 — 다른 조합엔
있는 「상대를 찾고 있어요」 완충이 여기엔 없다.
그래서 이 파트너에서는 <b>금액 확인 화면</b>(「진행」 버튼 직전)에 D-5 의 고지가 있어야 한다.

---

## 8. 배포 순서

☠️ <b>`vap_enabled` 가 전 파트너 0 이라, 깃발을 켜기 전까지 모든 단계가 운영상 무효과다.</b>
순서를 정하는 것은 위험이 아니라 <b>의존성</b>이다.

| # | 단계 | 잡 | 운영 영향 |
|---|---|---|---|
| P0 | **DDL v2.25 + v2.26** | 수동 (승인) | 없음 — `ADD COLUMN`(nullable) · `CREATE TABLE` |
| P1 | 백엔드 (MR !97 — 현재분 + B-1~B-4 + `transferType` 누락) | `spring:deploy-production` ▶ | **0**. 재시작으로 **VAP env 활성화** |
| P2 | 위젯 | `widget-ui:deploy` ▶ | **0** — `vaEligible`/`vaAvailable` 이 항상 false라 오늘과 동일 |
| P3 | 파일럿 파트너 1곳 `vap_enabled=1` | DML (승인) | ← **여기서 처음 실제 변화** |

> P1 은 <b>합쳤다</b>(2026-09-18 결정). 나누면 `spring:deploy-production` 재시작이 한 번 더 생기고,
> 그것이 기존 P2P·TORQ 트래픽에 영향을 주는 <b>유일한 지점</b>이다. 나눠서 얻는 것은 리뷰 크기뿐인데
> P1 만으로는 아무것도 검증할 수 없어 어차피 P3 에서 한 번에 본다.

### 하드 제약 셋

1. **P0 → P1** — DDL 없이 코드가 뜨면 `vap_sessions` 를 읽는 <b>모든</b> 경로가 깨진다
2. **P1 → P2** — 위젯이 없는 필드를 읽으면 화면이 빈다
3. ☠️ **P2 → P3** — <b>구위젯 상태로 깃발을 켜면</b> 구매자가 깨진 계좌 카드를 본다
   (계좌번호 자리에 체크아웃 URL · 연락처 저장 버튼 · 「이체했습니다」 체크)

### 킬 스위치는 재배포가 아니다

**`vap_enabled=0`** 이 가장 빠르고, 성질도 정확하다 — 정책은 세션 생성 시 `policy_json` 에
<b>스냅샷</b>되므로 <b>진행 중인 주문은 그대로 끝나고</b> 새 주문만 VA 를 타지 않는다.
진행 중인 구매자를 중간에 끊지 않는다.

코드 롤백은 그다음이다(CI 가 `.jar.prev` 를 남긴다). DDL 롤백은 <b>코드 롤백 이후</b>에만
가능하다 — 코드가 살아 있는데 컬럼을 지우면 그 순간 깨진다.
