# W2 — 회원 UI 마무리 (백엔드 → 프론트)

작성 2026-08-18 · repo `cryptoments`(open-api, core) + `cryptoments-admin`(p2p-ui)
선행 완료: T1-d(잔액 원장 전환), U4(분쟁), U5(관리자 원장 타임라인), W1-A(회원 금액 정정)

---

## 배경

금액 표시는 원장 기준으로 정리됐다. **남은 건 "회원이 그 금액을 검증할 수단"과 몇 가지 정합성이다.**

```
T1-d 로 회원이 보는 금액이 바뀌었다.
그런데 관리자에겐 원장 타임라인을 줬고(U5) 정작 당사자인 회원은 근거를 볼 수 없다.
금액 문의가 곧장 CS 로 간다.
```

**Phase 1(백엔드) → Phase 2(프론트)** 순서다. Phase 2 는 Phase 1 의 필드에 의존한다.

---

# Phase 1 — 백엔드 (open-api, core)

## A. 회원 원장 조회 API ★

```
GET /p2p/page/orders/{code}/ledger
→ { balance, entries: [...] }
```

- 관리자용(`GET /admin/p2p/orders/withdraw/{id}/ledger`, U5)의 **회원 축약본**이다.
  관리자 매퍼·DTO 를 그대로 쓰지 말고, 회원 노출 기준으로 별도 응답을 만들어라
- 소유 검증: 기존 `verifyOrderOwnership(code)` 를 쓴다. **새 검증을 만들지 마라**
- 세션: 이 컨트롤러가 이미 쓰는 방식을 그대로 따른다 (바꾸면 401)
- 엔트리 0건이어도 200 + 빈 배열. 404 금지
- 누적 잔액(running balance)을 서버에서 채워라 — 회원이 암산하게 하지 마라 (U5 와 같은 원칙)

### A1. 회원 노출 기준 ⚠️

원장 엔트리 컬럼: `id · withdraw_order_id · type · amount_krw · match_id · memo · created_at`

```
노출     type · amountKrw · runningBalance · createdAt · matchCode(있으면)
숨김     id · withdrawOrderId · matchId(내부 PK) · memo
```

**`memo` 를 그대로 흘리지 마라.** 내부 문구가 섞인다 —
예: RELEASE 의 memo 는 `resolution.name()`(`CANCELLED`/`CONVERTED` 등),
LOCK/UNLOCK 은 다른 형식일 수 있다. **먼저 적재 지점(`P2pWithdrawLedgerService`)에서
실제로 어떤 값이 들어가는지 확인하고**, 회원에게 뜻이 통하는 것만 골라 매핑하라.
모르는 값은 **생략**한다(W1-A 의 `reason` 처리와 같은 원칙).

## B. `P2pPageOrderResponse` 필드 3개

| 필드 | 이유 |
|---|---|
| `settledUsdt` | 확인증의 정산 USDT. **지금은 프론트가 레그를 합산한다** — 서버가 정본을 줘야 한다. 산식은 W1-A 가 프론트에 구현한 것(정산된 레그의 `usdtAmount` 합)과 같아야 한다 |
| `updatedAt` | 종결 시각 폴백(`completedAt → cancelledAt → updatedAt → createdAt`)의 3단계가 서버 미제공으로 **죽은 코드**다. EXPIRED 건이 생성일로 표시된다 |
| (목록) `manualPendingCount` | 대시보드 `ActiveOrderItem` 에만 있고 `/orders` 목록엔 없다. 프론트가 `0` 을 하드코딩하다 W1-A 에서 제거했다 |

**`settledUsdt` 는 배치로 구하라.** 목록에서 주문마다 매칭을 다시 조회하면 N+1 이다.

## C. 전환 거부(REJECTED) 상태 보존 ⚠️ 신중히

`P2pWithdrawService.rejectUsdtConvert` 가 거부 시 전환 필드를 **전부 null 로 되돌린다**
(`usdtConvertStatus`·주소·요청시각·요청 KRW/USDT). 그래서 프론트가 거부를 **관측할 수 없고**,
회원 화면에서 요청 카드가 아무 설명 없이 사라진다.

**변경**: 거부 사실을 남긴다.

```
usdtConvertStatus = REJECTED 로 보존 (null 로 지우지 않는다)
거부 사유·시각을 남길 수 있으면 남긴다 (기존 컬럼으로 가능한 범위에서. DDL 금지)
```

⚠️ **재요청을 막으면 안 된다.** 지금은 필드를 지워서 자연스럽게 재요청이 가능했다.
상태를 보존하면 **재요청 게이트가 막힐 수 있다.** 반드시 확인하라:

```
core   requestUsdtConvert 의 사전 조건 (이미 요청 중인지 판정하는 부분)
core   canConvert 성격의 게이트
화면   전환 버튼 노출 조건
```

`REQUESTED` 만 "진행 중"으로 보고 `REJECTED` 는 재요청 가능하게 해야 한다.
**게이트를 확인하지 않고 상태만 보존하면 회원이 영영 재요청을 못 한다 — 더 나쁜 결과다.**

판단이 어려우면 **구현하지 말고 보고하라.**

---

# Phase 1 결과 (2026-08-19 완료) — Phase 2 착수 전 반드시 읽을 것

```
A  회원 원장 API      GET /p2p/page/orders/{code}/ledger        완료
B  응답 필드 3개      updatedAt · settledUsdt · manualPendingCount  완료
F  BigDecimal 직렬화  회원 페이지 DTO 3종에 @JsonFormat(STRING)  완료 (서버에서 해결)
C  전환 거부 보존     ❌ 미구현 — Phase 2 와 같은 배포 단위로 묶어야 한다
```

## ⚠️ C 를 Phase 2 에서 함께 처리해야 하는 이유

백엔드 게이트 6곳은 전부 `== REQUESTED` 판정이라 `REJECTED` 를 보존해도 **재요청이 막히지 않는다**
(확인 완료). **막는 건 프론트다.**

```
p2p-ui OrderDetailView   canConvert = ... && !order.usdtConvertStatus
                         버튼도 v-if="!order.usdtConvertStatus"
```

상태를 보존하는 순간 **버튼이 렌더 자체가 안 된다.** 배포 잡이 spring / p2p-ui 각각 수동 ▶ 라
백엔드만 먼저 나가면 거부당한 회원이 재요청 수단을 잃는 창이 실재한다.

**Phase 2 에서 반드시 한 배포 단위로:**

```
1  core  rejectUsdtConvert 가 usdtConvertStatus 를 REJECTED 로 보존
         (나머지 4필드는 계속 null 로 비운다 — 재요청 시 새 값으로 채워진다)
2  p2p-ui  canConvert / 버튼 조건을 !status → status !== 'REQUESTED' 로
3  p2p-ui  REJECTED 상태 표시 (요청 카드가 설명 없이 사라지지 않게)
```

부수 확인 필요: `partner-api P2pUsdtConvertResponse.from` 이 `status==null && address==null` 로
null 을 반환한다. REJECTED 보존 시 **status 만 있고 나머지 null 인 객체**가 파트너 콘솔로 간다.
자금 리스크는 없으나(승인은 409 로 차단) 표시 노이즈를 확인하라.

## Phase 2 가 소비할 응답 필드

```
GET /p2p/page/orders/{code}/ledger
  balance: number
  entries: [ type · amountKrw(부호 유지) · runningBalance · matchCode? · releaseReason? · createdAt ]
  → id · matchId · memo 없음. releaseReason 은 CONVERTED|CANCELLED|FORCE_SETTLED 뿐

P2pPageOrderResponse 신규
  updatedAt?           전 응답
  settledUsdt?         /orders · /orders/{code} · /receipt 만. 정산 레그 0건이면 필드 없음
  manualPendingCount?  같은 3개 경로만 (dashboard.recentOrders · convert-request 에는 없음)

타입 변경 number → string
  usdtAmount · usdtConvertRequestedUsdt · remainderUsdt · exchangeRate · settledUsdt
  match.usdtAmount · dashboard.activeOrders[].usdtAmount
  → types.ts 의 `| number` 유니온을 제거하고 string 으로 확정
```

---

# Phase 2 — 프론트 (p2p-ui)

## D. 원장 화면

주문 상세(`OrderDetailView`)에서 진입하는 **"입출 내역"** 을 만든다. 별도 화면이든 접이식이든
모바일에 맞는 쪽을 골라라(기존 UX 패턴을 따를 것).

- 시간순, 타입별 한글 라벨, 부호 유지, 누적 잔액
- 마지막 누적 잔액이 **화면의 잔여와 일치**해야 한다 — 어긋나면 그 자체가 신호다
- 엔트리 0건이면 "기록 없음"이 아니라 **"과거 건 — 상세 기록이 남아 있지 않습니다"**
  (T1-b 백필 이전 주문일 수 있다)

타입 한글 라벨 (관리자 화면과 같은 표현을 쓰되, 회원 눈높이로):

```
CHARGE   판매 등록
LOCK     구매자 매칭
UNLOCK   매칭 해제
SETTLE   입금 확정
RELEASE  잔여 회수
```

> 관리자용은 "충전/매칭 예약/예약 해제/입금 확정/잔여 회수" 다. **회원에게는 운영 용어를 쓰지 마라.**
> 위 표현이 어색하면 더 나은 걸 제안하되, 기존 회원 화면 톤(존댓말·평이함)을 따라라.

## E. 서버 필드 소비

```
ReceiptView       정산 USDT 를 서버 settledUsdt 로 교체 — 프론트 레그 합산 제거
OrderHistoryCard  종결 시각 폴백이 이제 실제로 동작 (updatedAt 도착)
OrdersView        manualPendingCount 표시 복원
OrderDetailView   전환 거부(REJECTED) 상태 표시 — Phase 1-C 가 구현된 경우에만
```

## F. 타입 정정

`api/types.ts` 가 BigDecimal 필드를 `string` 으로 선언했는데 **서버는 JSON number 로 보낸다.**
(`open-api` 에 `ToStringSerializer` 가 없고 `write-bigdecimal-as-plain` 만 있다.)

런타임은 포맷터가 방어하지만, `usdt4` 가 number 경로에서 `toFixed(8)` 을 타 **double 손실 후 포맷**된다.

- 실제 응답형을 **먼저 확인**하고(서버 DTO 의 `@JsonFormat(shape=STRING)` 유무) 타입을 맞춰라
- 서버가 `@JsonFormat(shape = JsonFormat.Shape.STRING)` 을 붙이는 게 정답이면 **그렇게 하고 보고**하라
  (Phase 1 범위 확장 — 고정밀 USDT 는 문자열이 안전하다)

## G. 상태 라벨 통일

`utils/status.ts` 에 같은 상태의 한글이 3벌 있다.

```
FULLY_MATCHED   "매칭 완료" / "입금 확인 중" / "매칭됨 · 구매자 입금 확인 중"
SETTLING        "정산 중" / "마무리 중" / "입금 확인 완료 · 정산 중"
```

맥락별로 다른 표현이 필요한 것(짧은 배지 vs 설명문)은 정당하다. **정당한 것과 중복인 것을 가려서**
불필요한 벌만 없애라. `STATUS_DESCRIPTIONS` 는 호출부가 0건이니 쓰이게 하거나 지워라.

---

## 코딩 규칙

- Java 17 · Lombok(`@Data` 금지) · DTO 멤버 JavaDoc 필수
- MyBatis `@Mapper` + `@Select` interface. XML 금지. `<script>` 내 `<`·`<=`·`<>` 금지 → `&lt;`/`!=`
- **DDL 금지. DB 접속 금지**
- **원장에 쓰는 코드를 건드리지 마라.** 읽기만
- T1-d 이후 금지 구역 유지: `computeRemainder` · USDT 전환 게이트 · 초과 확인 가드 · `refund_type` 차단
- **금액 산식을 프론트에 만들지 마라.** 서버 값을 그린다
- 회원 문구는 존댓말·평이한 표현. 운영 용어·내부 코드 노출 금지

## 완료 기준

```
1  ./gradlew :core:compileJava :open-api:compileJava 통과
2  p2p-ui 빌드 통과 (타입 에러 0)
3  회원 원장 API 가 남의 주문에 접근을 막고, 0건이어도 200
4  회원 응답에 memo·내부 PK 가 없다
5  확인증 정산 USDT 가 서버 값이다 (프론트 합산 0건)
6  원장 화면의 마지막 누적 잔액 = 주문 잔여
7  REJECTED 를 보존했다면 재요청이 여전히 가능하다 (게이트 확인 근거 제시)
8  프론트에 금액 산식이 없다
```

## 보고 형식

- Phase 별 수정 파일:라인 + 한 줄
- **A1 에서 memo 를 어떻게 처리했는지** (실제 값 종류 + 노출/생략 판단)
- **C 의 재요청 게이트 확인 결과** — 구현했다면 근거, 안 했다면 이유
- F 에서 실제 응답형이 무엇이었는지, 서버/프론트 중 어느 쪽을 고쳤는지
- 지침이 실제 코드와 어긋난 지점 — **고치지 말고 먼저 보고**
- 빌드 결과

## 착수 전 필수 확인

```
open-api/.../controller/p2p/P2pWithdrawPageController.java   (세션·소유검증·기존 응답)
open-api/.../dto/p2p/P2pPageOrderResponse.java
admin-api/.../dto/response/P2pWithdrawLedgerResponse.java     (U5 관리자판 — 참고)
admin-api/.../mapper/P2pWithdrawLedgerEntryMapper.java        (U5 조회 — 참고)
core/.../p2p/P2pWithdrawLedgerService.java                    (memo 에 실제로 뭐가 들어가는지)
core/.../p2p/P2pWithdrawService.java                          (rejectUsdtConvert · 재요청 게이트)
p2p-ui/src/api/types.ts · utils/status.ts · utils/format.ts
p2p-ui/src/views/OrderDetailView.vue · ReceiptView.vue
```

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