# 회원 화면을 잔액 모델로 (2026-08-21)

repo `cryptoments` (open-api) + `cryptoments-admin` (p2p-ui)
**Phase A 만 구현한다. Phase B 는 명세만 — 착수하지 마라.**

---

## 오너 판정

> "우린 p2p 출금 원장 처리 하고 있잖아. 기존과 다르게. 그런데 왜 이전 UI 인거지?
>  **출금 건에 대한 매칭 대기가 아니라, 잔액에 대한 매칭이잖아.**"

## 무엇이 어긋났나

화면이 **두 관점을 동시에** 보여준다.

```
히어로     매칭 대기 잔액 27,500원          ← 잔액(pool). 원장 파생, 활성 주문 전체 합산
카드       매칭 대기 13,790 / 매칭 대기 13,710  ← 주문 행 단위
```

회원 입장에서 저 둘로 갈린 이유는 **"예전에 출금 요청을 두 번 했다"는 이력뿐**이다.
매칭 엔진은 이미 여러 주문에 걸쳐 분할하고, 잠금(`p2p_partner_locks`)도 `(파트너, 체인)` 단위이며,
잔액도 원장 파생이다 — **백엔드는 이미 pool 인데 화면만 주문 단위다.**

## ☠️ 이 작업 최대의 함정 — 진입점이 사라진다

주문 카드를 그냥 지우면 **회원이 할 수 있는 일이 없어진다.**

```
주문 상세 진입점 5개 중 4개가 주문 카드/목록이다
  DashboardView:94   진행 중 거래 카드
  DashboardView:318  지난 거래 카드
  OrdersView:59      목록 두 탭
  EntryView:32       PIN 딥링크 (코드를 이미 알 때만)
  DashboardView:102  확인할 입금 카드   ← 유일하게 주문 카드와 독립
```

그런데 **회원 행위는 전부 매칭 단위**다:

| 행위 | 단위 | 현재 위치 |
|---|---|---|
| 입금 확인 | 매칭 | `OrderDetailView:365` → `ManualConfirmSheet` (**유일한 사용처**) |
| 문제 신고 · 분쟁 증빙 | 매칭 | `DisputeView` (상세 경유) |
| USDT 전환 요청 | 주문 | `ConvertRequestView` (상세 경유) |

특히 **분쟁은 대시보드 응답에 신호 필드가 아예 없다** — `hasDispute` 가 상세의 matches 배열에서만
계산된다(`OrderDetailView:98`). 카드를 지우면 회원이 분쟁을 **발견할 방법이 없어진다.**

> **그래서 이 작업은 "카드를 지우는 것"이 아니라 "카드의 단위를 주문 → 매칭으로 바꾸는 것"이다.**
> 회원이 실제로 행동하는 단위를 전면에 올리는 것이 잔액 모델의 진짜 내용이다.

---

# Phase A — 화면을 잔액 + 매칭으로

```
잔액 하나        히어로 (이미 있음 — 유지)
   ↓
진행 중인 거래   카드 1장 = 매칭 1건   ← 여기가 바뀐다
   ↓
주문            화면에서 사라진다 (상세는 남되 링크는 부차적)
```

## A-1. 대시보드 응답에 매칭을 추가 — `open-api`

`P2pPageDashboardResponse` 에 **`activeMatches[]`** 를 신설한다.
`activeOrders[]` 는 **지우지 마라** — 하위호환 + Phase B 에서 쓴다.

```
ActiveMatchItem {
    matchId, matchCode, orderCode,          ← orderCode 는 상세·분쟁·확인증 라우팅에 필요
    krwAmount, usdtAmount,
    status,                                  ← 매칭 상태 (원문 그대로. 프론트에서 매핑)
    buyerName,                               ← 이미 상세에 있는 것과 같은 출처
    needsManualConfirm,                      ← 이 매칭이 회원의 입금 확인을 기다리는가
    hasDispute,                              ← ★ 대시보드에 처음 나가는 신호
    deadlineAt                               ← 남은 시간 표시용 (없으면 null)
}
```

- 대상: 활성 주문(`PENDING/PARTIALLY_MATCHED/FULLY_MATCHED/SETTLING`)에 속한 **종결되지 않은 매칭**
- 정렬: **회원이 지금 해야 할 일이 위로** — 분쟁 > 수동확인 대기 > 나머지, 그 안에서 최신순
- 필드 산출은 **`OrderDetailView` 가 이미 받는 상세 응답과 같은 출처**를 써라.
  새 산식을 만들지 마라 — 상세와 목록이 다른 값을 보이면 그게 다음 버그다
- ⚠️ N+1 금지. 활성 주문 id 목록으로 매칭을 **한 번에** 조회하라
  (`P2pWithdrawLedgerService.balances(List<Long>)` 가 쓰는 패턴 참고)
- `hasDispute` 판정 로직은 상세와 **같은 것을 재사용**하라. 상세에만 있으면 core 로 올려라

## A-2. `manualPending` 을 매칭 목록으로 흡수

지금 `manualPending` 은 `count / totalKrw / singleOrderCode / singleMatchId` 로,
**1건일 때만** 시트를 열고 여러 건이면 목록으로 보낸다(`DashboardView:98-110`).

`activeMatches` 에 `needsManualConfirm` 이 생기므로 그 분기가 필요 없다 —
**카드에서 바로 확인 시트를 연다.** `manualPending` 필드 자체는 응답에 **남겨 둬라**(하위호환).

## A-3. 화면 — `p2p-ui`

### `DashboardView.vue`

```
히어로                        무변경 (이미 잔액 관점)
"진행 중 거래" + OrderProgressCard  →  "진행 중인 거래" + MatchCard
지난 거래 / 빈 상태 / 전체 보기 버튼   무변경
```

- **히어로 산식·문구를 건드리지 마라.** `summary` 는 서버 정본이고 프론트 폴백(`:49-62`)도 그대로 둬라
  (T1-d — 구 컬럼 폴백이 유령 잔여를 만든 이력이 있다)
- `OrderProgressCard.vue` 는 **삭제하지 마라.** `OrdersView` 가 계속 쓴다

### `MatchCard.vue` (신규)

카드 1장 = 매칭 1건. 회원이 **무엇을 해야 하는지**가 첫 줄에 보여야 한다.

```
분쟁          "문제 신고됨 · 13,790원"        → 분쟁 화면으로
수동확인 대기  "입금 확인 필요 · 13,790원"     → 확인 시트 바로 열기 (한 탭)
그 외         "구매자 입금 대기 · 13,790원"   → 주문 상세로
```

- **버튼을 여러 개 달지 마라.** 카드 = 지금 해야 할 행동 하나. p2p-ui 의 기존 원탭 원칙
- 남은 시간이 있으면 보조 텍스트로. 없으면 생략 — 빈 자리를 만들지 마라
- 색·간격·컴포넌트는 **기존 카드에서 가져와라.** 새 디자인 언어를 만들지 마라

### `OrdersView.vue`

**손대지 마라.** 주문 이력은 주문 단위가 맞다 — 환율·계좌·확인증·잔여처리가 전부 주문 단위 기록이다.

## A-4. 잃는 정보를 어디서 보게 할 것인가

잔액으로 합치면 아래가 홈에서 사라진다. **전부 주문 상세에 이미 있다** — 새로 만들지 말고
`OrdersView`/`OrderDetailView` 로 가는 길만 살아 있으면 된다.

```
주문별 환율 · USDT 액면        주문마다 다르다 (생성 시 확정 + 2h 리프레시)
주문별 4분류 분해 · 진행 상태
주문별 수취 계좌
전환 요청/거부 상태
주문 코드                      CS 문의 시 회원이 대는 식별자
```

- 홈 하단 **"거래 내역 전체 보기"** 버튼(`:345`)이 그 통로다 — 지우지 마라
- `MatchCard` 에 주문 코드를 작게라도 노출할지는 구현자 판단. **단, 카드를 복잡하게 만들지 마라**

## A-5. 절대 규칙 (Phase A)

- **DDL·DML 금지. DB 접속 금지. 운영 서버 접속 금지**
- **백엔드 자금 흐름을 건드리지 마라** — 원장·잠금·매칭·정산·수수료 전부 무변경.
  이번은 **조회와 화면**뿐이다
- `activeOrders` · `manualPending` 필드를 응답에서 **제거하지 마라** (하위호환)
- 히어로 금액 산식(`P2pPageAmounts`)을 건드리지 마라 — 파트너 콘솔과 공유한다
- 금액 산식을 프론트에 새로 만들지 마라 (T1-d)
- 매칭 단위 회원 행위(입금 확인·분쟁·증빙)의 **진입점이 하나라도 사라지면 실패다**
- MyBatis `<script>` 안에 `<` `<=` `<>` 금지 — SAXParseException 으로 전 서비스 다운
- `IXRepository.modify()` 는 non-null 만 갱신 · `save()` 는 PK 반환
- 새 라이브러리 금지 · **git commit / push 하지 마라**

## A-6. 완료 기준

```
1  ★ 홈 "진행 중인 거래" 카드 = 매칭 1건 (주문 아님)
2  ★ 입금 확인이 카드에서 한 탭에 열린다
3  ★ 분쟁이 홈에서 보인다 — hasDispute 가 대시보드 응답에 있다
4  ★ 진입점 손실 0 — 입금확인·분쟁·증빙·전환·확인증 전부 도달 가능함을 경로로 증명
5  히어로 무변경 (diff 로 증명)
6  activeOrders · manualPending 필드 유지
7  매칭 조회에 N+1 없음 — 쿼리 횟수를 코드로 설명
8  hasDispute·buyerName 등이 상세 응답과 같은 출처 (다른 산식 아님)
9  OrdersView · OrderProgressCard 무변경
10 ./gradlew :open-api:compileJava 통과 · p2p-ui 빌드 통과
11 git commit·push 하지 않았다
```

## A-7. 보고

- 수정/신규 파일:라인 + 한 줄
- **1~4 를 코드 경로로 증명**하라. 특히 4는 행위별로 "어느 화면 → 어느 화면" 을 적어라
- 쿼리 횟수 (활성 주문 N개, 매칭 M개일 때)
- 지침이 실제 코드와 어긋난 지점 — 고치지 말고 먼저 보고
- 빌드 결과

## A-8. 착수 전 필수 확인

```
open-api/.../p2p/P2pWithdrawPageController.java     :83 활성필터 · :149-296 dashboard · :215-258 ActiveOrderItem
open-api/.../dto/p2p/P2pPageDashboardResponse.java
core/.../p2p/P2pPageAmounts.java                    :50-66 금액 산식 (건드리지 말 것)
p2p-ui/src/views/DashboardView.vue                  :40 activeOrders · :98-110 manualPending · :308-313 카드
p2p-ui/src/components/OrderProgressCard.vue
p2p-ui/src/views/OrderDetailView.vue                :98 hasDispute · :326-374 매칭 행 · :365 확인 버튼
p2p-ui/src/router/index.ts
p2p-ui/src/api/types.ts
```

지침과 다르면 **멈추고 보고하라.**

---

# Phase B — 잔액 단위 반환 (명세만. **착수 금지**)

오너 판정: **"잔액 단위 반환"** — 회원이 "매칭 대기 잔액 중 N원 반환"을 요청하면
어느 주문에서 나왔는지는 시스템이 FIFO 로 소진한다.

## 왜 Phase A 와 분리하나

지금 구조는 **주문당 잔여 처리 1회**를 전제로 못 박혀 있다.

```
금액 파라미터 없음        전 경로가 computeRemainder(order) = krw − confirmed 전액
재실행 차단 센티널        assertRemainderProcessable 이 refund_type != null 을 영구 차단으로 쓴다
                          → 1회차 부분 반환에서 세우면 2회차가 1027 로 막힌다
요청 스냅샷이 주문 컬럼    usdt_convert_requested_krw/usdt/address/at/status
                          → "여러 주문에 걸친 1건의 반환 요청"을 담을 수 없다
종결이 한 세트            applyRemainderResolution 이 RELEASE + remainder_* + 주문 종결을 한 번에
```

## 그래도 DDL 없이 갈 수 있는 길이 있다

☠️ **가장 위험한 지점부터**: 매칭 후보 SQL(`P2pMatchingMapper` `MATCHABLE_WHERE:125`)이
`usdt_convert_status != 'REQUESTED'` 인 주문을 **통째로 제외**한다.
부분 반환을 요청하면 **남은 잔여까지 매칭 풀에서 빠진다.**

해법은 후보 SQL 의 가용액을 화면과 같은 산식으로 맞추는 것이다:

```
지금   available = 원장잔액,  단 convert REQUESTED 주문은 후보에서 제외
이후   available = 원장잔액 − 전환요청액        ← P2pPageAmounts.waitingKrw 와 동일
```

**이러면 DDL 없이 부분 반환이 성립하고, 매칭 가용액이 화면 표기와 일치하게 된다.**
지금은 둘이 갈려 있다 — 화면은 `waitingKrw` 를 보여주는데 매처는 다른 기준을 쓴다.

## 나머지 필요한 것

```
1  금액을 받는 요청 DTO/엔드포인트           현 P2pPageConvertRequest 에는 금액이 없다
2  활성 주문 전체를 FIFO 로 일괄 FOR UPDATE   단건(orderCode) 잠금밖에 없다
3  부분 RELEASE 헬퍼                        원장은 이미 부분을 표현할 수 있다
                                            (RELEASE 는 match_id NULL → UNIQUE 가 안 걸린다)
4  refund_type 센티널을 부분과 분리
5  부분 반환 시 주문 상태 = 열어 둔다         기존 관례(failMatch:1387)와 일치
6  completeP2pConvertedWithdrawal 우회       무조건 COMPLETED + 웹훅이라 부분에 맞지 않는다
```

## ☠️ Phase B 착수 전 반드시 정리할 것

**데드락.** 매처는 후보 10건을 `ORDER BY 원장잔액 DESC, created_at ASC` 로 **한 SELECT 안에서**
잠근다. FIFO 반환이 `created_at ASC` 로 **한 건씩 순차 잠그면 잠금 순서가 달라 상호 데드락**이다.
반환도 **한 SELECT 로 일괄 잠그거나** 매처와 같은 정렬 규약을 따라야 한다.
전역 규약은 `wo → match` 순이다.

## 재사용 가능한 것 (다시 만들지 마라)

```
주문별 반환 가능 잔여   P2pWithdrawLedgerService.balance(orderId) / balances(List)  — T1-c 정본
화면과 같은 가용액      P2pPageAmounts.of() 의 waitingKrw
금액 소진 루프 선례     P2pMatchingService:501-530 / :655-680 (정렬만 다름)
부분 인출 원장 표현      RELEASE 다중 행 허용 (DDL :2942-2944 에 명시)
```

---

## 참고 — 이번 범위 밖이지만 열려 있는 것

**미매칭 P2P 출금 원금이 파트너 가용잔액에서 차감되지 않는다** (2026-08-21 실측 130 USDT).
`P2P_PENDING` 은 `pendingWithdrawalHold` 에서 제외되고(`WithdrawalMapper:53`)
잠금은 매칭 시점에야 걸린다(`P2pLockService:74`). 그 사이 원금은 어디에도 잡히지 않는다.
수수료만 요청 시점으로 옮겨졌고 원금은 옛 모델 그대로여서 생긴 비대칭이다.
**자금 흐름 변경이라 별도 배치로 다룬다.**
