# P2P 분쟁 프로세스 설계

작성일: 2026-08-14
상태: **설계 확정 단계** — 구현은 자정 배포 이후
관련: [P2P_MATCHES_INTEGRITY.md](./P2P_MATCHES_INTEGRITY.md) · [P2P_ASYNC_MATCHING_DESIGN.md](./P2P_ASYNC_MATCHING_DESIGN.md)

---

## 1. 원칙

1. **`p2p_matches` 는 거래 원장과 상태 기록만 담는다.** 분쟁 이력은 별도로 분리한다.
2. **모든 거래는 Cryptoments 에서 발생한다.** 따라서 **발생부터 판정 직전까지 전 과정이 우리 통제 하에 있어야 한다.**
3. **판정은 원화 수취처를 가진 쪽이 한다.** 그쪽만 입금 여부를 확인할 수 있기 때문이다.
4. **증빙 원본은 Cryptoments 에 남는다.** 외부에는 사본을 전달한다.
5. **입금자의 해소 노력은 기록되고 판정자에게 전달되어야 한다.**

### 1.1 통제 범위

```
발생 ─────────────── 진행 ─────────────── 판정 ──── 집행
└──────── Cryptoments 통제 ────────┘   레그 소유자   Cryptoments
```

판정만 위임하고, 그 앞뒤는 전부 우리가 쥔다. 판정 결과의 **집행**(정산·취소·잠금 해제)도 우리 몫이다.

### 1.2 판정 주체와 근거

**판정은 입금을 확인할 수 있는 쪽이 한다.** 확인 권한이 없으면 판정할 수 없다.

| 레그 | 원화 수취처 | 확인 수단 | 판정자 |
|---|---|---|---|
| TORQ | LP 계좌 | TORQ 내부 조회 (우리 접근 불가) | **TORQ** |
| P2P | 판매자 회원 계좌 | **CODEF 스크래핑** | **Cryptoments** |
| PARTNER | 파트너 서비스계좌 | **CODEF 스크래핑** | **Cryptoments** |

> **정정 (2026-08-14)** — 초안은 PARTNER 판정을 파트너에게 뒀으나 사실이 달랐다. 파트너 서비스계좌에도 스크래핑이 걸려 있어 **우리가 확인하고 있다**(계좌 id 167 / 파트너 49 / `scraping_status=ACTIVE` / 동의 만료 2027-08-09). 실측으로 PARTNER 레그 21건 전부 스크래핑 참조(`20260626_000714_3000000` 형식)와 입금자명이 채워져 있다.
>
> 따라서 **외부 판정은 TORQ 하나뿐**이고, 파트너 판정 UI·통지·전달 경로는 필요 없다.

### 1.3 정책 — 파트너 서비스계좌는 반드시 CODEF 스크래핑으로 확인한다

**파트너 계좌의 입금 확인은 예외 없이 스크래핑을 통한다.** 파트너의 자체 확인이나 진술에 의존하지 않는다. 그래야 확인 근거가 우리에게 남고 판정 권한이 우리에게 성립한다.

⚠️ **현재 게이트가 없다.** `fillRemainderWithPartner` 는 서비스계좌를 `findByOwnerTypeAndOwnerId(PARTNER, partnerId)` 로 찾아 `.get(0)` 을 쓸 뿐 **스크래핑 상태를 보지 않는다.** 스크래핑 없는 계좌가 등록된 파트너에게 PARTNER 레그가 배정되면 **입금 확인 수단이 없는 레그**가 생기고 판정 주체도 사라진다.

지금은 PARTNER 서비스계좌가 전체 1개(파트너 49, `scraping_status=ACTIVE`)라 우연히 성립하고 있다.

**조치**

```
PARTNER 레그 생성 조건에 scraping_status = ACTIVE AND status = ACTIVE 게이트 추가
게이트 불통과 시 → PARTNER 레그 스킵 (TORQ 와 동일하게 조용히 다음으로)
파트너 서비스계좌 등록 시 스크래핑 동의를 필수로
```

동의 만료도 감시 대상이다. `scraping_consent_expires_at` 이 지나면 확인 수단이 사라지므로, 만료 전 갱신을 유도하고 만료 시 PARTNER 레그를 중단한다.

---

## 2. 현재 상태 (실측)

분쟁 42건 — TORQ 37 · P2P 4 · PARTNER 1.

### 2.1 발생 경로가 셋인데 하나가 우회한다

```
DEPOSITOR   :1553   상태 전이 + 재잠금 + 이벤트 + 자금 격리 표식
WITHDRAWER  :1668   상태 전이 + 재잠금 + 이벤트 + 라운드 시작
TORQ_LP      :787   상태 전이만                            ← 우회
```

`markDisputedFromTorq` 는 상태와 분쟁 필드만 찍는다. **재잠금·이벤트·기한이 없다.** 그런데 분쟁의 88%가 이 경로다.

파생된 결과가 전부 여기서 나온다.

| 증상 | 원인 |
|---|---|
| `dispute_events` 3행 / 40건 미기록 | TORQ 발생이 이벤트를 안 남김 |
| `dispute_round` 42건 전부 `0` | 라운드 시작은 WITHDRAWER 경로뿐 |
| 매칭 978 — 시작점 없이 `EVIDENCE_SUBMITTED` 만 존재 | 발생 미기록 후 증빙만 남음 |

### 2.2 증거가 비어 있다

```
분쟁 42건 → 사유 8건 · 증빙 3건
TORQ_LP 34건 → 사유 0
```

우리 매핑 버그가 아니다. 원천인 `torq_trades` 도 분쟁 50건 중 `dispute_statement` 가 3건뿐이다. **TORQ 웹훅이 사유를 실어 보내지 않는다.**

> **TORQ 는 자사 운영 서비스다.** 따라서 이것은 "받을 수 없는 데이터"가 아니라 **보내도록 고치면 되는 데이터**다. plumdesk 와 마찬가지로 Cryptoments 에 맞춰 설계할 수 있다.
>
> 보강 대상 둘:
> - **분쟁 제기 페이로드에 사유** — 현재 34건 전부 없음
> - **완료 웹훅에 해소 사유** — 분쟁이 걸렸다가 완료된 21건 중 18건이 `resolution` 없음. 실패 웹훅은 코드를 주는데 완료 웹훅은 안 줘서 생긴 비대칭

### 2.3 증빙이 거래에 귀속되지 않는다

```
파일   /opt/cryptoments/uploads/evidence   7개 · 3.8MB
참조   3개                                 ← 4개 고아
```

업로드가 `matchId` 를 받지 않는다. URL 만 돌려주고 분쟁 제출에 붙이는 건 클라이언트 몫이라, 안 붙이면 고아가 되고 붙였어도 `dispute_evidence_url` 단일 컬럼이 덮어써지면 참조가 끊긴다.

### 2.4 노력이 판정자에게 안 간다

| 레그 | 최초 제기 | **재제출** |
|---|---|---|
| TORQ | `torqService.submitEvidence` ✓ | **✗** |
| P2P · PARTNER | 우리 DB ✓ | 우리 DB ✓ |

우리가 판정하는 두 레그는 문제없다. **외부 판정인 TORQ 만 재제출이 전달되지 않는다.**

`submitDisputeEvidence` 는 매칭 컬럼에 최신 증빙만 캐시하고 이벤트를 남길 뿐, **판정자에게 전달하지 않는다.** 구매자가 세 번 올리면 매칭엔 마지막 것만 남고 TORQ 엔 첫 번째만 갔다.

### 2.5 그 외

- **기한 미적용** — `dispute_due_at` 42건 전부 비어 있으나 이는 **Phase 1 정책**이다([P2P_MANUAL_CONFIRM_DISPUTE_RULES.md](./P2P_MANUAL_CONFIRM_DISPUTE_RULES.md) §5-2). 다만 "관리자 판단 참고용 표시"로는 쓰기로 했는데 값 자체가 안 채워지고 있다 — `REQUEST_MORE` 경로가 0건이라 채울 지점을 안 탔다
- **PARTNER 레그 스크래핑 게이트 없음** — 생성 시 `scraping_status` 를 보지 않는다 (§1.3)
- **`resolution` 겸직** — 97건 중 분쟁은 42건. `TORQ_EXPIRED` 24건 등 만료 사유가 섞여 있음
- **강제 청산이 무담보 크레딧을 만듦** — `adminSettleDisputedTorqLeg` 주석: *"TORQ와 무관(장부 사후 정합은 운영 몫)"*. §3.3.1 에서 위탁 원칙으로 해소

---

## 3. 설계

### 3.1 발생 — 단일 진입점

세 경로가 각자 상태를 찍는 구조를 **하나의 진입점**으로 모은다.

```
openDispute(matchId, raisedBy, source, reason, evidenceRef)
  ① 상태 전이       p2p_matches.status = DISPUTED
  ② 자금 처리       레그별 (아래 3.2)
  ③ 헤더 생성       p2p_disputes
  ④ 이벤트 append   dispute_events  RAISED
  ⑤ 통지            판정자 + 관련자
```

TORQ 웹훅은 이 진입점의 **세 번째 호출자**가 된다. 외부가 분쟁을 열더라도 우리 쪽에서 일어나야 할 일은 우리가 한다.

> 발생 **조건**과 **권한**은 레그별로 다를 수 있다(TORQ 는 LP 가 자기 기준으로 판단). 그것까지 통제하려 하지 않는다. **우리가 통제하는 것은 발생 이후 우리 쪽에서 일어나는 일이다.**

### 3.2 발생 시 자금 처리

| 레그 | 잠금 상태 | 발생 시 |
|---|---|---|
| P2P | 출금자 USDT 잠금 있음 | **재잠금** — 실패 시 `relock_failed` 로 회계 격리 |
| PARTNER | 잠금 없음 (FIAT) | 없음 |
| TORQ | 잠금 없음 (LP 자기 자금 방출) | 없음 |

TORQ·PARTNER 에 재잠금이 없는 것은 정상이다. 잠글 우리 자금이 없다.

### 3.3 판정 — 라우팅과 수신

```
판정 요청 라우팅
  TORQ            → 이미 TORQ 가 판정 중 (우리는 결과 수신자)
  P2P · PARTNER   → 관리자 콘솔 — 둘 다 스크래핑으로 확인 가능

판정 결과 수신
  외부(TORQ) → 웹훅 → 우리 상태로 전이
  내부       → 직접 전이
```

**외부 판정은 TORQ 하나뿐이다.** 나머지 둘은 우리가 확인하고 우리가 판정한다.

### 3.3.1 TORQ 레그는 위탁 — 강제 청산하지 않는다

**TORQ 건의 분쟁 해결은 TORQ 가 책임진다. Cryptoments 는 TORQ 처리까지 대기한다.**

우리 역할은 셋뿐이다.

```
대기    TORQ 판정까지 DISPUTED 유지
안내    구매자에게 "LP 확인 중" — 우리가 개입할 수 없음을 명확히
독촉    기한 초과 시 TORQ 재조회 · 무응답 지속 시 관리자 알림
```

**`adminSettleDisputedTorqLeg` 는 분쟁 판정 도구에서 제외한다.** 관리자가 TORQ 레그에 `CONFIRM` 을 눌러 강제 크레딧하는 경로를 없앤다.

이유는 그 행위가 **온체인 뒷받침 없는 크레딧**이기 때문이다. 실측 매칭 49 가 그 형태다.

```
TORQ escrow 69 = EXPIRED · tx 없음      → LP 가 USDT 를 방출하지 않음
ledger CREDIT 9.806537 (파트너 1)        → 그런데 원장에는 크레딧
description "P2P TORQ 레그 입금 — pm_e92a0bf13773"   ← 정상 크레딧과 모양이 동일
```

우리가 구매자를 구제하고 손실을 떠안은 것인데, 그 사실이 어디에도 남지 않아 정상 입금과 구분되지 않는다. 온체인 잔액과 원장이 벌어지는데 원인이 기록에 없다.

**위탁 원칙을 세우면 이 문제가 통째로 사라진다.** 구매자의 원화는 LP 계좌에 있으므로 그 돈의 처분(환불·재처리)도 TORQ 몫이다. 우리가 USDT 를 대신 낼 이유가 없다.

> **무한 대기 방어는 만들지 않는다.** TORQ 레그는 우리 자금 잠금이 없어(LP 자기 자금 방출) 대기가 길어져도 우리 비용이 발생하지 않는다. 무응답 영구화는 운영으로 해소한다.
>
> 과거 3건(매칭 49 · 52 · 165)은 그대로 둔다. 정책은 이후 건부터 적용한다.

**결과 어휘를 통일한다.** 최종 결과는 둘 중 하나다.

```
CONFIRM   입금 확인됨 → 정산 진행
CANCEL    입금 미확인 → 레그 실패 · 잠금 해제
```

외부 원본 코드(`TORQ_DISPUTE_BUYER_TIMEOUT`, `TORQ_DISPUTE_REJECTED` 등)는 **원본 그대로 보존**하되 결과는 공통 어휘로 매핑한다. 안 그러면 판정 통계마다 매핑 테이블이 필요하다.

`TORQ_EXPIRED` 같은 만료 사유는 분쟁 판정이 아니므로 `p2p_matches.close_reason` 으로 보낸다. `resolution` 겸직을 여기서 푼다.

### 3.4 증빙 — 귀속 · 보존 · 전달 · 보호

**귀속** — 업로드가 `matchId` 를 받는다. 저장과 동시에 `dispute_events` 에 `EVIDENCE_SUBMITTED` 를 append 한다. 고아가 구조적으로 생기지 않고, 제출 순간이 곧 기록이다.

**보존** — `p2p_matches.dispute_evidence_url` 단일 컬럼을 없앤다. 이벤트가 전량을 갖는다. 덮어쓰기가 사라진다.

**전달** — 이벤트마다 전달 대상·시각·성공 여부를 남긴다.

```
TORQ            → torqService.submitEvidence (재제출도 전달)
P2P · PARTNER   → 관리자 알림 (우리가 판정하므로 외부 전달 없음)
```

전달 실패를 기록해야 "구매자는 냈는데 판정자는 못 봤다"를 판별할 수 있다. 지금은 그 상태를 알 방법이 없다.

**보호** — 현재 `https://api.cryptoments.cc/uploads/evidence/{partnerId}_{UUID8}.{ext}` 가 인증 없이 열린다. 8자리 UUID 는 32비트라 추측 방어로도 약하다. 남의 계좌 이체 내역 이미지이므로 세션 검증 후 스트리밍하거나 서명된 URL 로 바꾼다.

> ⚠️ **보관 위치** — app-01 로컬 디스크(`/opt/cryptoments/uploads/evidence`)다. 서버 교체·재프로비저닝 시 유실되고 백업 대상인지 확인되지 않았다. "증빙이 우리에게 남는다"의 전제 조건이므로 별도 확인이 필요하다.

---

## 4. 데이터 모델

### 4.1 `p2p_disputes` (신설 — 헤더)

분쟁은 **감시 대상**이다. 기한 임박·장기 미해결을 주기적으로 찾아야 하는데, 이벤트를 접어서 구하는 것은 비효율적이다. 인덱스 있는 행이 필요하다.

> 확정 스펙(`P2P_MANUAL_CONFIRM_DISPUTE_RULES.md` §5-3)은 `dispute_round` / `dispute_waiting_on` / `dispute_due_at` 을 **매칭 컬럼**으로 뒀다. 당시 분쟁 헤더 테이블이 없었기 때문이다. **값과 의미는 그대로 두고 위치만 헤더로 옮긴다** — 분쟁 프로세스의 상태이지 레그의 상태가 아니다.

```
id · match_id · leg_type
raised_by · raised_at · source
authority          판정 주체 (TORQ / CRYPTOMENTS / PARTNER)
status             OPEN / WAITING_EVIDENCE / WAITING_JUDGMENT
                   REJECTED / IN_SUPPORT / RESOLVED
support_ticket_id  plumdesk 티켓 참조 (nullable)
support_opened_at
due_at             이번 라운드 기한 (6h)
round
result             CONFIRM / CANCEL
result_raw         외부 원본 코드 보존
resolved_at · resolved_by
```

### 4.2 `dispute_events` (기존 — 이력, append-only)

```
p2p_match_id · dispute_id · event_type · source · actor
reason · amount_krw · evidence_url · payload_json
delivered_to · delivered_at · delivery_ok    ← 신설 (전달 추적)
created_at
```

`event_type`: `RAISED` / `EVIDENCE_SUBMITTED` / `REQUEST_MORE` / `REJECTED` / `ESCALATED_TO_SUPPORT` / `SUPPORT_CONCLUDED` / `RESOLVED_CONFIRM` / `RESOLVED_CANCEL`

> ⚠️ **`logDisputeEvent` 의 예외 삼킴을 재검토한다.** 현재 `catch (Exception e) { log.warn(...) }` 로 흐름을 막지 않는다. 보조 로그일 때는 옳지만 **정본이 되면 조용한 누락이 원본인지 결손인지 구분 불가**가 된다.

### 4.3 `p2p_matches` 에서 걷어낼 것

```
분쟁      disputed_at · dispute_reason · dispute_evidence_url · dispute_submitted_by
          dispute_source · dispute_round · dispute_waiting_on · dispute_due_at
분쟁해결  resolved_at · resolution · resolved_by · resolve_memo
```

`status = DISPUTED` 만 남긴다. 레그의 현재 상태이므로 원장에 속한다.

> ⚠️ **순서 엄수.** 지금 걷어내면 분쟁 이력이 통째로 사라진다(`dispute_events` 3행). ① 이벤트 적재 복구 → ② 과거 42건 백필 → ③ 컬럼 제거.
>
> 백필 시 TORQ_LP 34건은 사유가 없어 껍데기만 생긴다. 그래도 옮겨야 컬럼을 제거할 수 있다 — 그 34건의 유일한 기록이 거기다.

---

## 5. 상담 연동 (plumdesk)

반려된 분쟁을 사람이 이어받는 채널. **plumdesk 는 자사 시스템이므로 Cryptoments 에 맞춰 설계한다.**

### 5.1 전환은 구매자의 명시적 선택

자동 승격이 아니다. 반려 결과를 받은 구매자가 위젯에서 **"고객센터 문의"** 를 누르면 티켓이 생성되고 plumdesk 대화창으로 이동한다.

이 설계의 이점은 **반려에서 끝나는 경로가 생긴다는 것**이다. 구매자가 반려를 수긍하면 티켓 없이 종결되고, 그 사실이 데이터로 남는다. 지금 설계에는 "반려됐지만 더 진행하지 않음" 상태가 없어 전부 관리자에게 흘러가게 돼 있었다.

### 5.2 상담원 업무 형태 — 두 화면

```
plumdesk 티켓      대화 + 거래 컨텍스트
Cryptoments 어드민  분쟁 상세 + 판정 도구
```

상담원이 두 화면을 함께 본다. 따라서 **plumdesk 가 우리 판정 API 를 호출할 필요가 없다.** 상담 결론은 상담원이 어드민에서 직접 `REQUEST_MORE` 또는 판정으로 입력한다.

연동은 사실상 단방향이다.

```
Cryptoments → plumdesk   티켓 생성 + 컨텍스트 + 어드민 딥링크
plumdesk → Cryptoments   없음
```

결합도가 낮아 plumdesk 장애가 분쟁 처리를 막지 않는다. 티켓 생성 실패는 재시도 대상일 뿐 분쟁은 계속 굴러간다.

### 5.3 티켓에 담을 컨텍스트

상담원이 대화창을 여는 순간 다 보여야 한다. 안 그러면 첫 마디가 "어떤 건이신가요"가 되고, 구매자는 위젯에서 이미 설명한 것을 다시 말한다.

```
거래     order_code · match_code · leg_type · krw/usdt · 환율
상대방   계좌 스냅샷 (은행 · 계좌번호 · 예금주)
분쟁     제기 시각 · 제기자 · 사유 · 라운드
증빙     제출 이력 + 인증된 파일 참조
반려     판정자 · 반려 시각 · 사유
링크     Cryptoments 어드민 분쟁 상세 딥링크
```

### 5.4 세션 승계

위젯에서 plumdesk 로 이동할 때 재인증을 요구하면 그 자리에서 이탈한다. `matchId` 와 신원을 담은 **서명된 토큰**을 넘기고 plumdesk 가 검증한다.

### 5.5 첨부 — 판정 근거는 우리로

상담 중 구매자가 plumdesk 에 파일을 올릴 수 있다. 그중 **판정 근거가 된 것은 Cryptoments 로 가져온다.** 나중에 "왜 그렇게 판정했나"를 물었을 때 두 시스템을 뒤지지 않기 위함이다.

- 단순 참고 자료(스크린샷 등) → plumdesk 에 둔다
- 판정 근거 → 상담원이 어드민에서 **증빙으로 등록** → `/uploads/evidence` 저장 + `dispute_events` append

원칙(§1-4)이 유지되는 지점이다.

### 5.6 우리 쪽에 남기는 것

대화 전문은 복사하지 않는다. plumdesk 가 정본이다.

```
전환 시각 · 티켓 참조 · 요청한 자료 · 상담 결론
```

이 넷은 문자열로 우리 이벤트에 남긴다. 티켓이 정리되거나 시스템이 바뀌어도 판정 근거는 남는다. 오늘 확인된 dangling 사례(계좌 참조 11건, 증빙 고아 4건)와 같은 실수를 반복하지 않기 위함이다.

---

## 6. 기한

**이미 확정돼 있다.** [P2P_MANUAL_CONFIRM_DISPUTE_RULES.md](./P2P_MANUAL_CONFIRM_DISPUTE_RULES.md) §1 이 정본이며 이 문서는 그것을 따른다.

| 시계 | 시작점 | 값 |
|---|---|---|
| 구매자 이체 기한 | 매칭 생성 | **30분** — 미이체 시 FAILED |
| 판매자 확인 목표 (수동) | **입금완료 신고** | **10분** — 목표치, 초과해도 자동실패 아님 |
| 리마인더 C2 | SLA 초과 후 | 반복 (10분 간격 제안) |
| 에스컬레이션 C3 | 미확인 지속 | 관리자 통지 — 자동 종결 아님 |
| 분쟁 라운드 재제출 | 관리자 재요청 시 | **6시간** |
| 전체 분쟁 상한 | — | **고정 상한 없음. 관리자 강제** |

### 6.1 안전 규칙

**판매자 확인 시계는 매칭이 아니라 입금완료 신고 기준이다.** 판매자는 돈이 들어와야 확인할 수 있기 때문이다.

그 결과 **수동 매칭의 `BANK_PENDING` 은 30분 매칭 만료 자동 FAILED 대상에서 제외**된다. 구매자가 28분에 이체 신고하면 판매자 확인이 30분을 넘길 수 있고, 제외하지 않으면 확인 도중 매칭이 죽어 구매자 입금분 사고가 난다.

### 6.2 Phase 1 — 자동 판정 없음

초기에는 자동 default 판정을 켜지 않는다. 6시간 기한과 "미제출 쪽 불리" 자동 처리는 **Phase 2** 이며, 지금은 기한을 **관리자 판단 참고용 표시**로만 쓴다.

> 다만 표시용으로도 값이 안 채워지고 있다(`dispute_due_at` 42건 전부 NULL). `REQUEST_MORE` 경로가 0건이라 채울 지점을 안 탔다. 상담 연동(§5)으로 `REQUEST_MORE` 가 실제로 발생하기 시작하면 이 값이 채워진다.

---

## 7. 미결

| 항목 | 내용 |
|---|---|
| **Phase 2 활성화 시점** | 6h 기한 자동 처리(미제출 불리 자동 판정)를 언제 켤지. 현재 Phase 1 = 전건 관리자 수동 |
| **증빙 보관** | app-01 로컬 디스크 유지 여부, 백업 대상 확인 |
| **`relock_failed` 거취** | 분쟁 표식이나 내용은 "이 레그의 자금이 격리 상태"라 원장 성격. 0건이라 판단할 실물 없음 |

---

## 8. 범위 밖

- TORQ 페이로드 보강 (분쟁 사유 · 완료 해소 사유) — **자사 서비스이므로 실행 가능**. TORQ 쪽 작업이라 별건으로 트래킹
- `dispute_events` 다중 파일 첨부 — 현재 총 7개라 이벤트당 1파일로 충분
