# 지각 TORQ 완료(late completion) 사후 정산 — 재발방지 구현 지침서

> **작성 배경 (2026-06-30)**: TORQ Trade #161(=torq_trades #159, 한경수 50만원, 입금 #133, 매칭 #165 TORQ 레그)에서
> escrow 만료로 cryptoments 매칭이 먼저 FAILED 종결된 뒤, TORQ가 약 1시간 뒤 수동 강제수락(FORCE_RELEASED→COMPLETED,
> RELEASE_TO_BUYER)으로 USDT를 방출했으나 **구매자 크레딧/입금 처리가 안 됨**.
>
> **실측 타임라인**:
> - 23:57:33 매칭 #165 만료 → 23:57:38 만료잡이 FAILED 종결, 입금 #133 EXPIRED.
> - 01:05:58 TORQ COMPLETED 웹훅 도착 → `handleCompleted`가 `confirmBankTransfer(165)` 호출했으나
>   매칭이 FAILED라 **no-op**(confirmBankTransfer 가드: BANK_PENDING/CREATED/DISPUTED만 진행).
> - 01:06:06 LP→MASTER 322.79 USDT 온체인 입금 → BusinessEventClassifier가 FUNDING으로 분류(구매자 크레딧 아님).
> - 결과: torq_trades #159 COMPLETED지만 매칭 #165 FAILED·입금 #133 EXPIRED, ledger/settlement 0건.
>
> **근본 원인**: TORQ escrow 만료와 cryptoments 매칭 30분 창의 수명주기 불일치 + **지각 완료에 대한 사후 정산 경로 부재**.
> confirmBankTransfer가 종결된 매칭에서 조용히 no-op하고, 들어온 USDT는 FUNDING으로 흘러 구매자 미귀속.

---

## 설계 방침

`handleCompleted`(p2pMatchId 경로)에서 `confirmBankTransfer`가 **만료-FAILED 매칭을 만나 no-op한 경우**,
사후 정산(`reconcileLateTorqLeg`)으로 빠진 구매자 크레딧을 **1회만** 기록하고(이중크레딧 방지), 입금이 이미
EXPIRED여도 COMPLETED로 마무리한다. 기존 검증된 정산 경로(`creditTorqLeg`)를 그대로 재사용한다.

**멱등/이중크레딧 안전**: `reconcileLateTorqLeg`는 매칭이 정확히 `FAILED`일 때만 동작.
- 정상 케이스: confirmBankTransfer가 BANK_PENDING→SETTLED 처리 후, reconcile은 status≠FAILED라 skip.
- 지각 케이스: confirmBankTransfer no-op(FAILED) → reconcile이 1회 정산.
- LP→MASTER 입금은 FUNDING(원장 0건)으로만 잡혀 있으므로 `creditTorqLeg`의 P2P_SETTLEMENT 크레딧이 단일 크레딧(이중 아님).

## 변경 파일 (3개)

| 파일 | 변경 |
|------|------|
| `core/p2p/P2pSettlementService.java` | `reconcileLateTorqLeg`(public) + `completeDepositAfterLateSettle`(private) 추가 |
| `core/p2p/P2pMatchingService.java` | `reconcileLateTorqCompletion`(public) 위임 추가 |
| `core/torq/TorqService.java` | `handleCompleted`에서 confirmBankTransfer 직후 reconcile 호출 |

---

## 1. P2pSettlementService.java

기존 `adminSettleDisputedTorqLeg`(462행) / `creditTorqLeg`(354, private) / `markDepositOrderCompleted`(690, private) 인근에 추가.

```java
    /**
     * 지각 TORQ 완료 사후 정산 — escrow 만료로 매칭이 FAILED 종결된 뒤 TORQ가 늦게 COMPLETED(buyer 릴리즈)된 경우,
     * 빠진 구매자 크레딧을 사후에 1회 기록한다. (2026-06-30 trade #159 사고 대응)
     *
     * <p><b>멱등</b>: 매칭이 정확히 FAILED일 때만 정산한다. 정상/진행중/이미정산(SETTLED)/취소는 skip → 이중크레딧 없음.
     * LP→MASTER 입금은 FUNDING(원장 미기록)으로만 잡혀 있어 여기서의 P2P_SETTLEMENT 크레딧이 단일 크레딧이다.
     */
    @Transactional
    public void reconcileLateTorqLeg(Long matchId) {
        P2pMatch match = matchRepo.findOne(matchId);
        if (match == null) return;
        if (match.getLegType() != P2pLegType.TORQ) return;
        if (match.getStatus() != P2pMatchStatus.FAILED) return; // 만료로 FAILED 종결된 건만 사후 정산

        log.warn("지각 TORQ 완료 사후 정산(만료 후 늦은 COMPLETED): matchId={}, deposit={}",
                matchId, match.getDepositOrderId());
        creditTorqLeg(match); // 구매자 USDT 크레딧 + 매칭 SETTLED + deposits 기록 + finalize
        completeDepositAfterLateSettle(match.getDepositOrderId()); // EXPIRED 입금이면 명시적 완료
    }

    /**
     * 지각 정산 후 입금 주문 완료 보정 — finalizeDepositOrderIfAllTerminal은 입금이 이미 EXPIRED면 즉시 return하므로,
     * 사후 정산으로 SETTLED leg가 생긴 경우 EXPIRED→COMPLETED를 명시적으로 처리한다.
     */
    private void completeDepositAfterLateSettle(Long depositOrderId) {
        P2pDepositOrder dpo = depositOrderRepo.findOne(depositOrderId);
        if (dpo == null) return;
        if (dpo.getStatus() == P2pDepositStatus.COMPLETED) return;

        List<P2pMatch> matches = matchRepo.findByDepositOrderId(dpo.getId());
        boolean anySettled = matches.stream().anyMatch(m -> m.getStatus() == P2pMatchStatus.SETTLED);
        boolean noneInProgress = matches.stream().noneMatch(m ->
                m.getStatus() == P2pMatchStatus.CREATED
                        || m.getStatus() == P2pMatchStatus.BANK_PENDING
                        || m.getStatus() == P2pMatchStatus.BANK_CONFIRMED
                        || m.getStatus() == P2pMatchStatus.SETTLING
                        || m.getStatus() == P2pMatchStatus.DISPUTED);
        if (anySettled && noneInProgress) {
            markDepositOrderCompleted(dpo);
            log.info("지각 정산 후 입금 주문 완료 보정(EXPIRED→COMPLETED): order={}", dpo.getOrderCode());
        }
    }
```

> `creditTorqLeg`/`markDepositOrderCompleted`는 동일 클래스 private이라 직접 호출 가능.
> `adminSettleDisputedTorqLeg`가 이미 `creditTorqLeg`를 직접 호출하는 선례 있음(DISPUTED 가드).
> 필요한 타입(`P2pMatch`, `P2pMatchStatus`, `P2pDepositOrder`, `P2pDepositStatus`, `P2pLegType`, `List`)은 이미 import됨 — 미존재 시 추가.

## 2. P2pMatchingService.java

`TorqService`가 `P2pSettlementService`를 직접 주입받지 않으므로(현재 `matchingService`만 보유), 위임 메서드를 추가.
`settlementService` 필드는 이미 존재(`startSettlementForMatch`/`finalizeDepositOrderIfAllTerminal` 호출에 사용).

```java
    /** 지각 TORQ 완료 사후 정산 위임 — TorqService.handleCompleted에서 confirmBankTransfer 직후 호출. */
    public void reconcileLateTorqCompletion(Long matchId) {
        settlementService.reconcileLateTorqLeg(matchId);
    }
```

> 별도 @Transactional 불필요(위임 대상 `reconcileLateTorqLeg`가 @Transactional).

## 3. TorqService.java — handleCompleted

`handleCompleted`의 p2pMatchId 분기(598~606행)에서 `confirmBankTransfer` 호출 **직후** reconcile 호출 1줄 추가.

**기존:**
```java
        if (completed.getP2pMatchId() != null) {
            torqTradeRepository.modify(completed);
            syncLinkStatus(completed);
            matchingService.confirmBankTransfer(completed.getP2pMatchId(), "TORQ_" + completed.getEscrowId());
            log.info("TORQ 통합매칭 레그 완료 전파: tradeId={}, escrowId={}, p2pMatch={}",
                    trade.getId(), trade.getEscrowId(), completed.getP2pMatchId());
            // TODO(게이트 ON 전): LP→MASTER Deposit을 internal funding으로 분류(설계 §6-A)
            return;
        }
```

**변경 (reconcile 1줄 추가):**
```java
        if (completed.getP2pMatchId() != null) {
            torqTradeRepository.modify(completed);
            syncLinkStatus(completed);
            matchingService.confirmBankTransfer(completed.getP2pMatchId(), "TORQ_" + completed.getEscrowId());
            // 지각 완료 대비: 매칭이 만료로 FAILED 종결됐다면 confirmBankTransfer가 no-op → 사후 정산(이중크레딧 방지 내장)
            matchingService.reconcileLateTorqCompletion(completed.getP2pMatchId());
            log.info("TORQ 통합매칭 레그 완료 전파: tradeId={}, escrowId={}, p2pMatch={}",
                    trade.getId(), trade.getEscrowId(), completed.getP2pMatchId());
            return;
        }
```

---

## 보정(기존 trade #159)은 이 배포로 자동 해결되지 않음 — 별도 트리거 필요

배포 후에도 이미 처리된 trade #159는 웹훅이 재전송되지 않으므로 **수동 트리거**가 필요하다(별도 단계에서 결정).
가능한 경로: ① 관리자 콘솔에서 매칭 #165를 DISPUTED로 되돌린 뒤 "분쟁 강제 확인(CONFIRM)" 실행
(`adminSettleDisputedTorqLeg`), ② 또는 `reconcileLateTorqCompletion(165)`를 일회성으로 트리거.
어느 경로든 결과 검증: ledger에 P2P_SETTLEMENT 크레딧 322.79 USDT **정확히 1건**, 매칭 #165 SETTLED, 입금 #133 COMPLETED, 이중크레딧 없음.

---

## 검증 체크리스트 (구현 후)

1. **컴파일**: `./gradlew :core:compileJava :open-api:compileJava :scheduler:compileJava :admin-api:compileJava` SUCCESS.
2. **멱등/이중크레딧**: reconcile이 `status == FAILED`에서만 creditTorqLeg를 타는지. 정상(BANK_PENDING→SETTLED) 케이스에서 reconcile이 skip되는지 코드 리뷰.
3. **EXPIRED 완료 보정**: `completeDepositAfterLateSettle`이 anySettled && 진행중 leg 없음일 때만 COMPLETED 처리하는지.
4. **회귀**: 정상 TORQ 완료 흐름(만료 전 COMPLETED) 영향 없는지 — confirmBankTransfer 정상 정산 후 reconcile no-op.
5. **위임/주입**: TorqService→matchingService.reconcileLateTorqCompletion→settlementService.reconcileLateTorqLeg 경로가 올바르게 연결됐는지.

## 배포

`cryptoments-backend` → `git push origin main` → CI/CD 자동 배포. DDL 변경 없음.
