# P2P 회원 영업 자동 마감(10분) + 잔여 부분 취소 지침서

> 작성 2026-08-31 / 대상: Cowork 서브에이전트 / 오너 확정 설계

## 0. 무엇을 만드나

**출금자를 한없이 기다리게 두지 않는다.** 회원의 거래 상태(영업 중 / 일시 중지)에 10분
수명을 주고, 10분 안에 안 팔린 잔여는 회수해 **파트너가 자체 지급할 재원**으로 돌린다.

```
P2P 출금 주문 등록 (t0)  ← 등록할 때마다 리셋
  → t0 + 10분 동안만 매칭 후보에 노출
  → 10분 경과 → 회원 거래상태 자동 [일시 중지]   ← 잡이 하는 일은 여기까지
  → 그 사이 성립된 매칭(CREATED·BANK_PENDING)은 전부 그대로 진행 — 건드리지 않는다
  → 미판매 잔여는 **파트너가 명시적으로 회수**하고 회원에게 원화를 자체 지급
  → 레그가 다 끝나면 기존 경로가 주문을 종결
```

☠️ **잔여 회수를 자동화하지 않는다 (2026-08-31 오너 확정).** 회수(원금 CREDIT)는
**파트너에게 지급 의무를 발생시키는 행위**인데 지급 자체는 시스템 밖(파트너 자체 회계)이다.
자동으로 하면 파트너가 모르는 채 의무만 생겨 — 회원은 "돈이 안 들어온다" 하고 파트너는
무슨 얘긴지 모르는 상태가 된다. 이 기능이 없애려던 바로 그 상황이다.
파트너가 지급을 실행하면서 회수를 누르면 인지 문제가 함께 사라진다.

**이것은 주문 단위가 아니라 회원 단위다.** 화면(회원 설정 > 거래 상태)의
`영업 중 / 일시 중지` 토글이 그대로 대상이며, `p2p_members.trading_paused` 가 이미
매칭 후보 SQL 의 게이트다:

```java
// P2pMatchingMapper.MATCHABLE_JOIN
JOIN p2p_members wm ON wm.partner_id = o.partner_id
 AND wm.partner_user_id = o.partner_user_id AND wm.trading_paused = 0
```

☠️ **출금 주문에는 손대지 않는다.** `P2pWithdrawStatus.EXPIRED` 를 쓰지 마라. 주문 상태·
`matched_at`·원장은 그대로다. 2026-06-11 "출금 주문 자동 만료 없음" 정책은 **여전히 유효**하다 —
이번 변경은 주문이 아니라 **회원의 영업 상태**에 시한을 두는 것이다. 둘을 섞으면 주문이
자동 종결되어 그 정책을 깨뜨린다.

---

## Phase 1 — 영업 자동 마감

### 1-1. DDL (오케스트레이터가 선행 적용, 서브에이전트는 실행 금지)

```sql
ALTER TABLE p2p_members
  ADD COLUMN trading_resumed_at DATETIME(6) NULL
    COMMENT '영업 중 전환 시각 — 자동 마감(10분) 기산점. 일시중지면 NULL'
    AFTER trading_paused,
  ADD INDEX idx_trading_auto_pause (trading_paused, trading_resumed_at);
```

인덱스는 마감 잡의 스캔 대상(`trading_paused = 0 AND trading_resumed_at < ?`)이다.

**백필**: 기존 `trading_paused = 0` 회원은 `trading_resumed_at` 이 NULL 이다.
NULL 처리 규칙은 §1-3 참조 — **백필하지 않는다.**

> 중지 사유(AUTO/MANUAL) 컬럼은 두지 않는다. §1-2-A 대로 주문 등록이면 **무조건 재개**라
> 사유를 구분할 필요가 없다(오너 확정).

### 1-2. 시각 기록 — 설정 지점 전부

기산점은 **출금 주문 등록 시점**이다(오너 확정). 주문을 새로 올릴 때마다 10분이 리셋된다 —
새로 내놓은 물건은 항상 온전한 10분을 받는다. 토글로 영업을 켜는 것도 리셋이다.

`trading_paused` / `trading_resumed_at` 을 다뤄야 하는 곳은 **5곳**이다. 한 곳이라도 빠지면
그 경로로 올라온 주문은 영영 마감되지 않거나(NULL) 즉시 마감된다.

| # | 위치 | 트리거 | 할 일 |
|---|---|---|---|
| 1 | `P2pMemberService.setTradingPaused(memberId, paused)` L237 | 회원 본인(설정 화면) | `false` → `paused=0, resumedAt=now` / `true` → `paused=1, resumedAt=null` |
| 2 | `P2pMemberService.pauseTradingByPartner(token, partnerId, paused)` L265 | 파트너 콘솔 | 동일 |
| 3 | `admin-api P2pMemberManagementService` L199 | 관리자 | core 위임이므로 **추가 작업 없음** — 위임을 유지할 것 |
| 4 | `P2pMemberService` 회원 생성 L97 `.tradingPaused(false)` | 신규 회원 | `.tradingResumedAt(now)` |
| 5 | **`WithdrawalService.approveAsP2p`** (주문 생성 지점, L1471 부근) | **P2P 출금 주문 등록** | §1-2-A 규칙 |

`P2pWithdrawService.createOrder` L218 도 주문을 만들지만 **운영 실적 0건**인 죽은 경로다
(정상 경로는 출금 요청 → P2P 전환). 그래도 같은 규칙을 적용해 두 경로가 갈리지 않게 한다.

#### §1-2-A 주문 등록 시 규칙 — 무조건 재개 (오너 확정)

주문이 등록되면 **회원 상태와 무관하게** 영업을 켜고 10분을 리셋한다:

```java
memberMapper.updateTradingState(memberId, false, LocalDateTime.now());
```

| 회원 현재 상태 | 처리 |
|---|---|
| 영업 중 | `resumedAt = now` (10분 리셋) |
| 일시 중지 (자동 마감이든 직접 눌렀든) | `paused = 0, resumedAt = now` — **재개** |

**왜 직접 누른 중지까지 덮나** — 새 출금 주문을 올린다는 것 자체가 "팔겠다"는 최신 의사표시라
이전 중지와 모순된다. 그리고 원금은 요청 시점에 **전액 DEBIT** 되므로, 재개하지 않으면
자금은 묶였는데 매칭은 영영 안 붙는 주문이 조용히 쌓인다(출금 주문에는 자동 만료가 없다).
회원도 파트너도 그 사실을 모른 채 방치되는 것이 더 나쁘다.

☠️ 대신 **회원에게 알림을 보낸다** — 파트너가 대신 출금을 걸어 재개된 경우 회원은 자기가
꺼둔 영업이 켜진 것을 모른다. `pauseTradingByPartner` 가 이미 "파트너가 …" 알림을 보내는
선례가 있으니 같은 경로를 쓴다. 문구: "출금 주문이 등록되어 영업이 다시 시작되었습니다."

⚠️ 이 갱신은 주문 생성 **트랜잭션 안에서** 처리한다. 주문만 만들어지고 회원 상태 갱신이
실패하면 영영 마감되지 않는(또는 매칭되지 않는) 주문이 남는다.

⚠️ `modify()` 는 **null 필드를 SET 하지 않는다**(Axim MyBatis). 일시중지로 갈 때
`trading_resumed_at` 을 NULL 로 되돌리려면 `modify()` 로는 안 된다 —
`P2pMemberService.rejectUsdtConvert` 가 같은 문제를 `withdrawRepo.update()` 로 푼 선례가 있다
(`P2pWithdrawService` L849-853 주석 참조). **전용 UPDATE 매퍼 메서드**를 만들어라:

```java
@Update("UPDATE p2p_members SET trading_paused = #{paused}, "
      + "trading_resumed_at = #{resumedAt}, updated_at = NOW(6) WHERE id = #{id}")
int updateTradingState(@Param("id") Long id,
                       @Param("paused") boolean paused,
                       @Param("resumedAt") LocalDateTime resumedAt);
```

이 매퍼 하나로 모든 전이를 처리한다 — 설정 지점을 하나로 유지하기 위해서다
(`admin-api P2pMemberManagementService` L199 주석의 *"core 위임 — trading_paused 컬럼을
직접 UPDATE 하지 않는다(설정 지점을 하나로 유지)"* 와 같은 원칙).

### 1-3. 자동 마감 잡

**신규**: `scheduler/.../job/P2pTradingAutoPauseJob.java`

```java
@Scheduled(fixedDelayString = "PT30S")
```

30초 주기. 근거: 10분 창에서 30초 오차는 무해하고, 기존 `P2pMatchExpiryJob`·
`P2pDepositLinkExpiryJob` 이 같은 주기다(관례 일치).

대상 조회:
```sql
SELECT id FROM p2p_members
 WHERE trading_paused = 0
   AND trading_resumed_at IS NOT NULL
   AND trading_resumed_at < #{threshold}      -- now − 설정분
 LIMIT 200
```

☠️ **`trading_resumed_at IS NULL` 은 건드리지 마라.** 기존 회원(백필 안 함)과 과도기 건이
전부 NULL 이라, NULL 을 마감 대상에 넣으면 **배포 직후 전 회원이 일시중지된다.**
NULL = "이 회원은 자동 마감 대상이 아니다"로 둔다. 그 회원이 다음에 영업 토글을 한 번
누르면 그때부터 규칙에 편입된다.

처리: 회원별로 `updateTradingState(id, true, null)` + 구조화 로그 + 회원 텔레그램 알림(§1-5).
한 회원 실패가 배치를 멈추면 안 되므로 **건별 try/catch**.

☠️ **잡은 여기까지다.** 잔여 회수(`recoverRemainder`)를 부르지 마라 — Phase 2 §2-5 참조.

### 1-4. 마감 시간 설정값

`system_settings` 에 키를 추가한다(이 시스템은 P2P 파라미터를 전부 여기 둔다 — `p2p.*` 14개):

```sql
INSERT INTO system_settings (setting_key, setting_value, description) VALUES
('p2p.trading_auto_pause_minutes', '10',
 'P2P 회원 영업 중 자동 마감 시간(분). 영업 전환 시점부터 이 시간이 지나면 자동 일시중지');
```

코드에서는 기존 설정 조회 헬퍼를 그대로 쓰고, **부재 시 기본값 10**. 0 이하이면 기능 off
(마감 안 함) — 운영 중 급히 끌 수 있어야 한다.

### 1-5. 알림

회원 텔레그램 개인 알림을 **보낸다**. `setTradingPaused` 에 "에코 금지"(회원이 방금 누른 걸
다시 알리지 않는다) 원칙이 있지만, 자동 마감은 **회원이 누른 것이 아니므로** 알려야 한다.
기존 `notifyMemberAfterCommit` / `P2pMemberEvent` 패턴을 따르고, 새 이벤트 값이 필요하면
추가하라. 문구는 "영업 시간이 종료되어 매칭이 마감되었습니다. 계속하려면 영업을 다시
시작해 주세요." 수준.

☠️ 미판매 잔여가 남았으면 **"파트너가 확인 후 직접 지급합니다"** 로 안내한다. 회수는 파트너가
누르는 것이라 시점을 우리가 모르므로, "곧 입금됩니다" 처럼 단정하지 마라.

### 1-6. 회원 화면 — 남은 시간

회원 설정 화면(`거래 상태` 카드)에 남은 시간을 보여준다.

- 서버가 `tradingResumedAt` 과 `tradingAutoPauseMinutes` 를 응답에 실어 준다
  (`OpenApiP2pFacade` L552 / `MemberInfoAssembler` L174 의 `tradingPaused` 옆).
- ☠️ **남은 시간은 서버 값으로만 계산한다.** 위젯의 기존 규칙이다
  (`widget-ui/src/locales/ko.js` L474 주석: *"남은 시간은 서버가 준 값으로만 계산한다"*).
  클라이언트 시계로 만료를 판정하지 마라.
- 표시 예: `영업 중 · 07:12 남음`. 0 이 되면 폴링으로 서버 상태를 다시 받아 반영한다
  (클라이언트가 스스로 "일시 중지"로 그리지 않는다 — 실제 마감은 잡이 한다).

---

## Phase 2 — 잔여 부분 취소

### 2-0. 목적 — 오너 확정

**출금자는 한없이 기다릴 수 없다.** 최장 10~20분 안에 결론이 나야 한다.
10분 안에 안 팔린 잔여는 **파트너가 자체적으로 출금해 준다**.

시스템이 할 일은 **파트너가 자체 지급할 재원을 만들어 주는 것**이다 — 잔여를 회수하면
원금이 파트너 운영원장으로 CREDIT 되고, 파트너는 그걸 재원으로 회원에게 원화를 직접
지급한다. **그 원화 지급은 시스템 밖(파트너 자체 회계)이다.** 새 leg 타입도 새 정산 경로도
만들지 않는다.

☠️ 그래서 **회수 실행은 파트너가 누른다**(§2-5). CREDIT 이 곧 지급 의무의 발생이므로,
자동으로 하면 파트너가 모르는 채 의무만 생긴다.

```
100만원 출금 등록 (T0: 수수료 확정 + 원금 전액 DEBIT)
  → 10분 경과, 50만원 매칭됨 → 회원 영업 마감(매칭 차단)
  [미매칭 50만]  파트너가 회수 실행 → 원금 환급 CREDIT → 회원에게 50만원 자체 지급
  [진행중 50만]  손대지 않는다
        ├ 정상 완료 → 거래 완료
        └ 분쟁·실패로 취소 → 금액이 주문으로 복귀 → §2-6 수동 처리
  → 레그가 다 끝나면 기존 경로가 주문을 종결
```

☠️ **재등록이 없으므로 수수료를 두 번 물지 않는다.** 수수료는 T0 확정·환급 없음
(`chargeUpfrontFee`, `approveAsP2p` L1540)이라, "자동 회수 → 회원이 다시 등록" 모델이었다면
10분마다 수수료가 반복 부과됐을 것이다. 파트너 자체 지급 모델은 그 문제가 없다.

### 2-1. 왜 새 메서드가 필요한가

지금 `cancelOrder` 는 **진행 중 레그(CREATED·BANK_PENDING)가 하나라도 있으면 409**
(`P2P_CANCEL_BLOCKED_BY_ACTIVE_LEG` = "1032") 로 전체 취소를 막고, 통과해도 **주문을 종결**한다.
여기서 필요한 것은 **진행 중 레그를 살려둔 채 미매칭 잔여만 회수하고 주문은 유지**하는 동작이다.

### 2-2. 조각은 이미 있다

- 출금 원장에 `RELEASE(−잔여)` 가 있다 (`P2pWithdrawLedgerService.release`).
- 원금 환급은 `applyRemainderResolution`(L1078)이 `release` + `settlementService.credit` 로 한다.
- 매칭 후보 쿼리가 `WHERE 원장잔액 > 0` 이므로 **잔여를 RELEASE 하면 자동으로 후보에서 빠진다** —
  별도 배제 조건이 필요 없다.

기존 3경로(`cancelOrder`/`forceSettle`/`completeDirectWithdrawal`)와의 **유일한 차이는
주문을 종결하지 않는다**는 것이다.

### 2-3. 신규 메서드

`P2pWithdrawService.recoverRemainder(String orderCode, String reason)`

☠️ **진행 중 레그는 종류를 불문하고 건드리지 않는다** (오너 확정).
`CREATED`(구매자가 아직 송금 안 함)도 **살려둔다** — 이미 성립한 매칭이므로 구매자의 이체
기한까지 기다린다. `BANK_PENDING` 은 말할 것도 없다.

따라서 `cancelActiveMatchesForWithdrawOrder` 를 **호출하지 않는다.** 그 헬퍼는 CREATED 를
취소하고 BANK_PENDING 을 분쟁 파킹시킨다 — 둘 다 여기서는 하면 안 되는 일이다.

순서:

1. `matchingMapper.findWithdrawOrderByCodeForUpdate(orderCode)` — 주문 행 FOR UPDATE 선잠금
2. 상태 ∈ {`PENDING`, `PARTIALLY_MATCHED`} 아니면 skip(자동 경로) / 409(수동 경로)
3. 분쟁 레그 있으면 skip — **분쟁 중에는 금액을 건드리지 않는다**
   (`matchingService.hasDisputedMatchForWithdrawOrder`)
4. `principalRefundTarget(order)` — ☠️ 상태를 덮기 전에
5. **잔여 = `withdrawLedger.balance(orderId)`**
6. 잔액 ≤ 0 이면 skip — 회수할 것이 없다
7. `release(orderId, 잔여, "AUTO_PAUSE")` + `settlementService.credit(환급)`
   - 환급액 산식은 기존과 동일: `orderRepricer.remainingUsdt(orderId, refundTarget.getAmount())`
     (= `W − Σ 미실패 레그 usdt`). ☠️ 비례식 `W × 잔여KRW / K` 를 쓰지 마라 — 재가격으로 K 가
     움직여 성립하지 않는다(`P2pWithdrawService` L1144-1146).
8. **주문 상태·`cancelled_at`·`refund_type`·`remainder_resolution` 을 건드리지 않는다.**
   진행 중 레그가 끝나면 기존 경로가 주문을 종결한다.
9. 회원 알림 + 구조화 로그

#### ⚡ 왜 "CREATED 를 살려둔다"가 계산 없이 성립하나

**출금 원장 잔액이 이미 미매칭 잔여다.** 매칭이 생길 때 `LOCK(−매칭KRW)` 이 적히므로
(`P2pMatchingService.createMatch` L2093) 진행 중 레그의 금액은 잔액에서 **이미 빠져 있다**.
그래서 5번의 `balance(orderId)` 를 그대로 회수하면 CREATED·BANK_PENDING 은 자동으로 보존된다 —
레그를 뒤져 금액을 빼는 코드가 필요 없다.

`P2pMatchingMapper.LEDGER_BALANCE` 가 매칭 후보 판정에 쓰는 값과 **같은 정의**다.

### 2-4. ☠️ 멱등 — 새로 설계해야 한다

기존 재진입 방지는 `refund_type != null` / `remainder_resolution != null` 하나뿐인데,
**잔여 회수는 한 주문에 여러 번 일어날 수 있으므로**(마감 → 복귀 → 재등록 후 재마감)
그 장치를 쓸 수 없다. 그리고 §2-3 8번대로 그 컬럼들을 채우지도 않는다.

방어선은 **원장 잔액 자체**로 잡는다:
- 주문 행을 `FOR UPDATE` 로 잡은 뒤 잔액을 읽고, 잔액 ≤ 0 이면 skip(§2-3 6번).
- `release` 는 그 잔액만큼만 적으므로 연속 호출 시 두 번째는 잔액 0 → skip 된다.
- `p2p_withdraw_entries` 의 `UNIQUE (withdraw_order_id, match_id, type)` 는 `match_id = NULL`
  이라 RELEASE 중복을 **막지 못한다**(MySQL NULL 규약). 잠금 + 잔액 검사가 유일한 방어선이니
  **행 잠금을 절대 빼지 마라.**
- 잡이 30초마다 도는데 회원 마감(`paused=1`)과 잔여 회수가 **같은 트랜잭션**이면 다음 폴에서
  그 회원이 대상에서 빠지므로 중복 실행 자체가 안 생긴다. 트랜잭션을 쪼개지 마라.

### 2-5. 호출자 — 사람이 아니라 잡이다

☠️ **자동 마감 잡은 `recoverRemainder` 를 부르지 않는다.** 잡은 회원 거래상태만 바꾼다.

```
자동 마감 잡 (30초 주기)
  ├ updateTradingState(memberId, true, null)   ← 영업 마감 (매칭 차단)
  ├ 열린 주문 수는 세기만 한다                  ← 운영 가시성용 로그
  └ 회원 알림 1회 ("잔여는 파트너가 확인 후 직접 지급")
```

**회수는 파트너가 명시적으로 실행한다** — Phase 3 의
`POST /api/partner/p2p/withdraw-orders/{orderCode}/recover-remainder`.
파트너는 어차피 회원에게 원화를 지급하는 주체이므로, 지급을 실행하면서 회수를 누른다.

**회원이 누르는 버튼은 만들지 않는다.** 오너 확정: "명시적으로 사용자가 취소하는 건 기존에
비해 의미가 없다" — 회원이 눌러도 자금은 파트너 원장으로 갈 뿐 회원에게 원화가 가지 않는다.

### 2-6. 마감 뒤 복귀 금액 — 수동 (오너 확정)

진행 중이던 레그가 나중에 분쟁·실패로 취소되면 `UNLOCK(+금액)` 이 적혀 원장 잔액이 다시
양수가 된다. 그 금액은 **자동으로 회수하지 않는다** — 분쟁 결과를 사람이 보고 정리한다.

⚡ **이건 별도 가드가 필요 없다.** 자동 마감 잡은 `trading_paused = 0` 인 회원만 스캔하는데,
그 회원은 이미 마감돼 `paused = 1` 이다. 잡이 다시 집어가지 않는다.

⚠️ 단, 그 회원이 나중에 새 주문을 올리면 §1-2-A 로 영업이 재개되고(`paused = 0`), 10분 뒤
마감 잡이 **복귀 금액까지 함께 회수**한다. 이건 의도된 동작이다 — 그 시점엔 회원이 다시
팔겠다는 의사를 밝힌 것이고, 안 팔린 것은 어차피 파트너가 지급할 몫이다.

관리자·HQ 콘솔에서 복귀 금액을 정리할 수단은 기존 `cancelOrder`(전체 취소)와
USDT 전환이 이미 있다. 필요가 확인되면 그때 수동 회수 버튼을 추가한다.

---

## Phase 3 — 파트너 P2P 주문 취소 + 잔여 회수 (백엔드만)

> 오너 확정 2026-08-31: 파트너도 **취소(전체 종결)** 와 **회수(잔여만)** 를 둘 다 할 수 있어야 한다.
> **파트너 UI 는 다른 쪽에서 만든다 — 이 작업은 백엔드 API 까지다.**

| 동작 | core 메서드 | 의미 |
|---|---|---|
| 취소 | `P2pWithdrawService.cancelOrder(orderCode, reason)` | 주문 **종결**. 진행 중 레그가 있으면 409(1032) |
| 회수 | `P2pWithdrawService.recoverRemainder(orderCode, reason)` | 미매칭 잔여만 환급, **주문 유지**. 진행 중 레그는 살려둔다 |

☠️ 두 엔드포인트를 **하나로 합치지 마라.** 취소는 종결이고 회수는 유지다 — 파트너가 무엇을
누르는지 스스로 알아야 한다. 회수는 실패해도(잔액 0·분쟁) 예외 없이 `0` 을 반환하므로,
파트너 API 는 회수액을 응답에 실어 "0원 회수됨"을 파트너가 구분할 수 있게 한다.

### 부활시킬 것

`P2pController.cancelWithdrawOrder` L236-271 이 지금 **410 GoneException**
(`P2P_PARTNER_CANCEL_RETIRED` = "1034") 이다. 폐지 사유가 코드에 남아 있다:
*"파트너 경로에는 OTP 도 감사 로그도 없어서 취소 권한을 HQ 콘솔·관리자 콘솔로 모았다."*

되살리려면 **그 사유를 없애고** 부활시킨다:
- `@RequiresOtp(description = "P2P 출금 주문 취소")` — `PartnerWithdrawalController` L152/L238 선례
- `partnerAuditLogService` 기록 — `HqP2pActionService.onWithdrawOrder` L249-261 골격이 유일한 선례
- 소유 검증: 주문의 `partner_id == getSession().getPartnerId()`, 아니면 **404**
- ☠️ **취소 게이트를 복제하지 마라.** 상태·분쟁·진행 중 레그 판정은 core `cancelOrder` 단일
  지점이다 (`HqP2pActionService` L55-56: *"취소 게이트를 여기 복제하지 마라"*).

---

## 코딩 규칙

- Java 17, `@Data` 금지, Lombok 은 `@Getter @Setter @Builder(toBuilder=true) @NoArgsConstructor @AllArgsConstructor`
- 예외는 `ErrorCodes` 상수 참조. 문자열 코드 하드코딩 금지
- MyBatis 는 `@XRepository`/`@Mapper` interface 메서드. **XML 매퍼 금지**
- ☠️ `<script>` 안에서 `<`, `<=`, `<>` 금지 — `!=` / `&lt;` / CDATA (2026-06-11 전 서비스 다운)
- DTO 멤버에 JavaDoc 필수
- 기존 주석의 "왜"를 지우지 말 것. 새 주석도 무엇이 아니라 **왜**를 적을 것

## 완료 기준

1. `./gradlew :common:compileJava :core:compileJava :admin-api:compileJava :partner-api:compileJava :open-api:compileJava :scheduler:compileJava` 성공
2. DDL 과 엔티티 양방향 정합 — DDL 에 있는데 코드에 없는 것 + 코드에 있는데 DDL 에 없는 것(팬텀 컬럼) 둘 다 확인
3. `trading_paused` 를 바꾸는 **5개 지점** 전부에서 `trading_resumed_at` 이 함께 처리되는지
   코드로 제시 (특히 §1-2-A 주문 등록 시 무조건 재개)
4. 자동 마감 잡이 `trading_resumed_at IS NULL` 을 대상에서 제외하는지 확인
5. 일시 중지 상태에서 주문을 등록하면 영업이 재개되고 회원 알림이 나가는지 확인
6. `recoverRemainder` 가 `cancelActiveMatchesForWithdrawOrder` 를 **부르지 않는지**,
   주문 상태·`cancelled_at`·`refund_type`·`remainder_resolution` 을 **건드리지 않는지** 확인
7. 마감 + 잔여 회수가 **한 트랜잭션**인지 확인 (쪼개면 중복 회수 위험)
8. 시나리오 검증: 100만원 주문 → 50만원 매칭(CREATED 유지) → 10분 마감 →
   원장 잔액 50만원이 RELEASE 되고 원금 50만원어치 USDT 가 CREDIT 되는지,
   CREATED 레그가 **살아 있는지**
5. **커밋·push 하지 말 것.** 변경 파일 목록과 diff 요약만 보고 — 오케스트레이터가 리뷰 후 판정한다
