---
title: Funds Ledger and Escrow State Machine Specification
tags: [specification, funds-ledger, escrow, state-machine, canonical]
created: 2026-05-24
status: canonical
reviewers: [backend, security, product]
---

# Funds Ledger and Escrow State Machine Specification

> This document is the **canonical specification** for money movement, immutable ledger entries, derived balance views, and the full escrow/payment/dispute state machine for the Amanat platform. All implementations, API contracts, and data models must conform to this specification. Any prior document that conflicts with this specification is superseded.

## Cross-references

- [[Threat Model - Amanat Escrow Platform]] -- T05 (double payout), T06 (dispute bypass), T18 (insider manipulation), T19 (seller price manipulation), T23 (state machine inconsistency)
- [[Platform Logical Audit - 2026-05-24]] -- Findings 1, 5, 9, 20, 21
- [[Backend Stack Security and Refactor Assessment - 2026-05-24]] -- Phase 2: Payment and ledger extraction
- [[Escrow Flow]] -- current escrow state machine (superseded by this document)
- [[Payment Flow - SHKeeper]] -- SHKeeper pay-in path
- [[Payment Flow - DePay & Web3]] -- Web3 pay-in path
- [[Dispute Flow]] -- dispute lifecycle (status enums superseded by this document)
- [[Payout Flow]] -- seller payout mechanics
- [[Payment]] -- current Payment data model
- [[PurchaseRequest]] -- current PurchaseRequest data model
- [[Dispute]] -- current Dispute data model
- [[SellerOffer]] -- current SellerOffer data model

---

# Part 1: Funds Account and Ledger Model

## 1.1 FundsAccount

A `FundsAccount` is created per purchase request when a payment intent is first initiated. It is the single source of truth for all money movement associated with a transaction.

### Schema

| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `accountId` | UUID (string) | yes | auto-generated | Primary key |
| `purchaseRequestId` | ObjectId | yes | -- | Reference to [[PurchaseRequest]] |
| `buyerId` | ObjectId | yes | -- | Buyer user reference |
| `sellerId` | ObjectId | yes | -- | Seller user reference |
| `sellerOfferId` | ObjectId | yes | -- | Accepted [[SellerOffer]] reference |
| `currency` | string | yes | `"USDT"` | Settlement currency (USDT, USDC) |
| `grossAmountPaid` | Decimal128 | yes | `0` | Total amount received from buyer (gross, before fees) |
| `providerFees` | Decimal128 | no | `0` | Fees taken by the payment provider (SHKeeper, gas, etc.) |
| `platformFees` | Decimal128 | no | `0` | Platform commission |
| `heldAmount` | Decimal128 | no | `0` | Amount held in escrow (not disputed) |
| `disputedAmount` | Decimal128 | no | `0` | Amount under dispute hold |
| `releasableAmount` | Decimal128 | no | `0` | Amount available for release or refund |
| `releasedAmount` | Decimal128 | no | `0` | Total amount released to seller |
| `refundedAmount` | Decimal128 | no | `0` | Total amount refunded to buyer |
| `status` | string (enum) | yes | `"active"` | `ACTIVE`, `SETTLED`, `CANCELLED` |
| `providerReference` | string | no | -- | External provider payment ID (SHKeeper external_id, etc.) |
| `escrowWalletAddress` | string | no | -- | On-chain address holding the funds |
| `settlementTxHash` | string | no | -- | Final settlement on-chain transaction hash |
| `idempotencyKey` | string | yes | auto-generated | Prevents duplicate account creation for the same intent |
| `createdAt` | Date | yes | `Date.now()` | Creation timestamp |
| `updatedAt` | Date | yes | `Date.now()` | Last modification timestamp |
| `settledAt` | Date | no | -- | When the account reached SETTLED status |
| `cancelledAt` | Date | no | -- | When the account reached CANCELLED status |

### Status enum

| Value | Description |
|-------|-------------|
| `ACTIVE` | Funds are in motion or held. Ledger entries can still be appended. |
| `SETTLED` | All funds have been released and/or refunded. No further entries allowed except adjustments. |
| `CANCELLED` | The transaction was cancelled before any funds were received. |

### Indexes

- `{ purchaseRequestId: 1 }` (unique) -- one account per purchase request
- `{ buyerId: 1, status: 1 }` -- buyer dashboard queries
- `{ sellerId: 1, status: 1 }` -- seller dashboard queries
- `{ providerReference: 1 }` (sparse) -- webhook lookup
- `{ idempotencyKey: 1 }` (unique) -- prevents duplicate creation

---

## 1.2 LedgerEntry (Immutable, Append-Only)

Every financial event creates an immutable ledger entry. Entries are never modified or deleted after creation.

### Schema

| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `entryId` | UUID (string) | yes | auto-generated | Primary key |
| `accountId` | UUID (string) | yes | -- | Reference to FundsAccount |
| `entryType` | string (enum) | yes | -- | See entry type enum below |
| `amount` | Decimal128 | yes | -- | Amount for this entry (always positive) |
| `currency` | string | yes | -- | Currency of the amount |
| `runningBalance` | Object | yes | -- | Snapshot of all balances after this entry (see below) |
| `providerTxHash` | string | no | -- | On-chain transaction hash (if applicable) |
| `actor` | Object | yes | -- | Who/what triggered this entry |
| `actor.type` | string (enum) | yes | -- | `SYSTEM`, `ADMIN`, `BUYER`, `SELLER`, `PROVIDER_WEBHOOK`, `CRON_JOB` |
| `actor.userId` | ObjectId | no | -- | User ID if actor is a person |
| `actor.serviceName` | string | no | -- | Service name if actor is automated (e.g., `"PaymentCoordinator"`) |
| `idempotencyKey` | string | yes | -- | Prevents duplicate entries |
| `sourceEvent` | Object | no | -- | Reference to the originating event |
| `sourceEvent.webhookDeliveryId` | string | no | -- | Provider webhook delivery ID |
| `sourceEvent.disputeId` | ObjectId | no | -- | Dispute reference |
| `sourceEvent.payoutInstructionId` | string | no | -- | Payout instruction reference |
| `sourceEvent.adminActionId` | string | no | -- | Admin action audit reference |
| `description` | string | no | -- | Human-readable description |
| `createdAt` | Date | yes | `Date.now()` | Immutable creation timestamp |

### Entry type enum

| Value | Direction | Description |
|-------|-----------|-------------|
| `PAY_IN` | Credit (+) | Buyer payment received and confirmed |
| `PROVIDER_FEE` | Debit (-) | Fee deducted by the payment provider |
| `PLATFORM_FEE` | Debit (-) | Platform commission deducted |
| `HOLD` | Neutral (allocation) | Funds moved to held status in escrow |
| `DISPUTE_HOLD` | Neutral (allocation) | Funds moved from held to disputed due to dispute |
| `RELEASE` | Debit (-) | Funds released to seller |
| `REFUND` | Debit (-) | Funds refunded to buyer |
| `ADJUSTMENT` | +/- | Manual correction by admin (requires step-up auth) |
| `REVERSAL` | +/- | Reversal of a previous entry (e.g., failed payout, cancelled hold) |

### Running balance snapshot

Each entry includes a snapshot of the FundsAccount balances immediately after the entry is applied:

```
runningBalance: {
  grossPaid: Decimal128,
  providerFees: Decimal128,
  platformFees: Decimal128,
  held: Decimal128,
  disputed: Decimal128,
  releasable: Decimal128,
  released: Decimal128,
  refunded: Decimal128
}
```

### Immutability rules

1. Ledger entries MUST be append-only. No UPDATE or DELETE operations are permitted.
2. The `createdAt` field is set once and MUST never be modified.
3. The `entryId` MUST be a UUID v4 generated at creation time.
4. The collection SHOULD use MongoDB's `capped: false` with application-level enforcement (no update/delete hooks).

### Indexes

- `{ accountId: 1, createdAt: 1 }` -- chronological query per account
- `{ accountId: 1, entryType: 1 }` -- balance derivation queries
- `{ accountId: 1, idempotencyKey: 1 }` (unique) -- prevents duplicate entries
- `{ providerTxHash: 1 }` (sparse) -- lookup by on-chain transaction
- `{ "sourceEvent.disputeId": 1 }` -- dispute-related entries
- `{ "sourceEvent.webhookDeliveryId": 1 }` (sparse) -- webhook idempotency

---

## 1.3 FundsBalance (Derived View)

Balances are NOT stored as mutable fields. They are derived by summing ledger entries. The `runningBalance` snapshot on each entry provides a cache, but the canonical source is always the aggregation of ledger entries.

### Derivation formulas

```
grossPaid      = SUM(amount) WHERE entryType = PAY_IN
providerFees   = SUM(amount) WHERE entryType = PROVIDER_FEE
platformFees   = SUM(amount) WHERE entryType = PLATFORM_FEE
released       = SUM(amount) WHERE entryType = RELEASE
refunded       = SUM(amount) WHERE entryType = REFUND
held           = SUM(amount) WHERE entryType = HOLD
              - SUM(amount) WHERE entryType = RELEASE AND heldFunds = true
              - SUM(amount) WHERE entryType = REFUND AND heldFunds = true
              + SUM(amount) WHERE entryType = REVERSAL AND reversedType = HOLD
disputed       = SUM(amount) WHERE entryType = DISPUTE_HOLD
              - SUM(amount) WHERE entryType = RELEASE AND disputedFunds = true
              - SUM(amount) WHERE entryType = REFUND AND disputedFunds = true
              + SUM(amount) WHERE entryType = REVERSAL AND reversedType = DISPUTE_HOLD
releasable     = grossPaid - providerFees - platformFees - held - disputed - released - refunded
```

### Balance invariant

The following invariant MUST hold at all times:

```
grossPaid = providerFees + platformFees + released + refunded + releasable + held + disputed
```

This invariant MUST be checked:
1. After every ledger entry append.
2. During scheduled reconciliation.
3. Before any release or refund operation.

If the invariant is violated, the system MUST:
1. Halt all release and refund operations for the affected account.
2. Log an alert with severity `critical`.
3. Quarantine the account (set status to `ACTIVE` but flag for manual review).
4. Notify the operations team.

---

## 1.4 Reconciliation

### When reconciliation runs

| Trigger | Frequency | Description |
|---------|-----------|-------------|
| Scheduled | Every 6 hours | Automatic reconciliation job |
| Provider event | On webhook delivery | Cross-check webhook data against ledger |
| On-demand | Manual | Admin triggers reconciliation from dashboard |
| Post-migration | Once | After ledger backfill from legacy Payment records |

### What reconciliation compares

1. **Provider balance vs ledger-derived balance**: Compare the amount reported by the payment provider (SHKeeper invoice status, on-chain wallet balance) against the sum of ledger entries.
2. **On-chain transactions vs ledger entries**: For each `providerTxHash` in the ledger, verify the on-chain transaction exists with matching amount, sender, and recipient.
3. **Payment records vs ledger entries**: Cross-reference legacy `Payment` records with `LedgerEntry` records for consistency.

### Provider-specific event mapping

| Provider Event | Ledger Entry | Mapping |
|----------------|-------------|---------|
| SHKeeper webhook `PAID` | PAY_IN | `amount = balance_fiat`, `idempotencyKey = external_id + "PAID"` |
| SHKeeper webhook `OVERPAID` | PAY_IN + ADJUSTMENT | PAY_IN for expected amount; ADJUSTMENT for surplus |
| SHKeeper webhook `PARTIAL` | PAY_IN | `amount = balance_fiat` (partial amount received) |
| SHKeeper webhook `EXPIRED` | REVERSAL | Reverse any partial PAY_IN entries |
| SHKeeper fee deduction | PROVIDER_FEE | `amount = fee_percent * grossAmount` |
| Web3 verification success | PAY_IN | `amount = verified on-chain amount`, `idempotencyKey = txHash` |
| Payout task completed | RELEASE | `amount = payout amount`, `idempotencyKey = task_id` |
| Admin manual refund | REFUND | `amount = refund amount`, `idempotencyKey = admin-tx-hash` |

### Mismatch handling

| Severity | Condition | Response |
|----------|-----------|----------|
| `info` | Ledger-derived balance matches provider balance within 0.01 currency units | No action, log success |
| `warning` | Difference between 0.01 and 1.00 currency units | Log warning, flag for review, operations notified |
| `critical` | Difference greater than 1.00 currency units | Halt operations on the account, quarantine, create admin alert, require manual resolution |
| `critical` | Missing on-chain transaction for a recorded entry | Halt operations, quarantine, investigate potential fraud (addresses [[T05]], [[T18]]) |

---

## 1.5 Idempotency

### Idempotency key rules

1. Every ledger entry MUST have an `idempotencyKey`.
2. The uniqueness constraint is: `(accountId, idempotencyKey)`.
3. Attempting to insert a duplicate entry with the same `(accountId, idempotencyKey)` MUST be rejected with a conflict error.
4. The system MUST return the existing entry's details on duplicate rejection (not an error that hides the data).

### Idempotency key sources

| Entry Type | Idempotency Key Source | Format |
|------------|----------------------|--------|
| `PAY_IN` (SHKeeper) | Webhook `external_id` + status | `shk:{external_id}:PAID` |
| `PAY_IN` (Web3) | On-chain transaction hash | `w3:{transactionHash}` |
| `PROVIDER_FEE` | Payment reference + `"fee"` | `{paymentRef}:fee` |
| `PLATFORM_FEE` | Payment reference + `"commission"` | `{paymentRef}:commission` |
| `HOLD` | Account ID + `"hold"` | `{accountId}:hold` |
| `DISPUTE_HOLD` | Dispute ID | `dispute:{disputeId}` |
| `RELEASE` | Payout instruction ID or task ID | `release:{taskId}` or `release:{adminTxHash}` |
| `REFUND` | Refund instruction ID or admin tx hash | `refund:{instructionId}` or `refund:{adminTxHash}` |
| `ADJUSTMENT` | Admin action ID | `adj:{adminActionId}` |
| `REVERSAL` | Reference to reversed entry's idempotency key + `"reverse"` | `rev:{originalKey}` |

### FundsAccount idempotency

- Account creation is keyed by `purchaseRequestId` (one account per purchase request).
- The `idempotencyKey` field on FundsAccount is set to `account:{purchaseRequestId}`.
- Attempting to create a second account for the same purchase request MUST return the existing account.

---

# Part 2: Escrow State Machine

## 2.1 Canonical Status Enums

The following enums are the single source of truth. All previous definitions in data model files, API reference documents, and flow documents that conflict with these are superseded.

### 2.1.1 PurchaseRequest statuses

**Canonical enum:**

| Value | Description | Deprecated aliases |
|-------|-------------|-------------------|
| `pending` | Request created, awaiting seller offers | `pending_payment` (Data Model), `draft` (API Reference) |
| `received_offers` | At least one offer received | -- |
| `in_negotiation` | Buyer and seller actively negotiating | -- |
| `payment` | Payment captured, awaiting seller acknowledgment | `active` (Data Model -- deprecated) |
| `processing` | Seller acknowledged, preparing order | -- |
| `delivery` | Seller has shipped or is delivering | -- |
| `delivered` | Buyer has received the goods/service | -- |
| `confirming` | Delivery confirmed, awaiting escrow release | -- |
| `completed` | Escrow released to seller, transaction done | -- |
| `seller_paid` | Payout confirmed on chain (subset of completed) | -- |
| `cancelled` | Transaction cancelled | -- |

**Ghost states resolved:**

| Ghost State | Resolution | Migration |
|-------------|-----------|-----------|
| `draft` | Removed from canonical enum. Requests are created as `pending`. No `draft` state exists. | Map any `draft` records to `pending`. |
| `finalized` | Removed from canonical enum. `completed` is the terminal state after escrow release. Ratings and feedback are captured as sub-fields of `completed`, not as a separate status. | Map any `finalized` records to `completed`. |
| `archived` | Removed from canonical enum. Archival is a display concern, not a state-machine concern. Use a separate `isArchived` boolean flag or `archivedAt` timestamp. | Map any `archived` records to `completed` with `archivedAt` set. |
| `pending_payment` | Removed. Replaced by `pending`. | Map any `pending_payment` records to `pending`. |
| `active` | Removed. Replaced by `payment` (post-payment) or `pending` (pre-payment). | Map any `active` records to their correct state based on whether payment has been captured. |

**Previous document sources:**

| Document | Status values used | Alignment |
|----------|-------------------|-----------|
| [[PurchaseRequest]] Data Model | `pending_payment`, `pending`, `active`, `received_offers`, `in_negotiation`, `payment`, `processing`, `delivery`, `delivered`, `confirming`, `completed`, `seller_paid`, `cancelled` | `pending_payment`, `active` deprecated |
| [[Marketplace API]] | `draft`, `pending`, `payment`, `processing`, `delivery`, `delivered`, `seller_paid`, `completed`, `cancelled` | `draft` deprecated |
| [[Purchase Request Flow]] | `pending`, `received_offers`, `in_negotiation`, `payment`, `processing`, `delivery`, `delivered`, `confirming`, `completed`, `finalized`, `archived`, `cancelled` | `finalized`, `archived` deprecated |

### 2.1.2 Payment statuses

**Canonical enum:**

| Value | Description | Deprecated aliases |
|-------|-------------|-------------------|
| `PENDING` | Payment intent created, awaiting funds | `pending` |
| `PROCESSING` | Funds detected, awaiting confirmation | `processing` |
| `COMPLETED` | Funds confirmed and escrow funded | `completed`, `confirmed` (skipped) |
| `FAILED` | Payment failed (expired, rejected, reverted) | `failed` |
| `CANCELLED` | Payment cancelled before funds arrived | `cancelled` |
| `RELEASED` | Escrow released to seller | -- |
| `REFUNDED` | Escrow refunded to buyer | -- |

**Ghost states resolved:**

| Ghost State | Resolution | Migration |
|-------------|-----------|-----------|
| `confirmed` | Removed. The SHKeeper webhook maps `PAID` directly to `COMPLETED`. `confirmed` was an intermediate state that no automated flow sets. | Map any `confirmed` records to `COMPLETED`. |

### 2.1.3 Payment escrowState values

**Canonical enum:**

| Value | Description | Deprecated aliases |
|-------|-------------|-------------------|
| `FUNDED` | Buyer payment confirmed, funds held in escrow | `funded` |
| `PARTIALLY_FUNDED` | Partial payment received, awaiting full amount | `partial` (Escrow Flow, Payment Flow - SHKeeper) |
| `RELEASABLE` | Delivery confirmed, funds ready for release | `releasable` |
| `DISPUTED` | Active dispute hold, all releases/refunds blocked | -- (NEW -- addresses [[T06]]) |
| `RELEASING` | Payout initiated, awaiting on-chain confirmation | `releasing` |
| `RELEASED` | Payout confirmed on chain, funds transferred to seller | `released` |
| `REFUNDING` | Refund initiated, awaiting on-chain confirmation | -- |
| `REFUNDED` | Refund confirmed on chain, funds returned to buyer | `refunded` |
| `FAILED` | Payout or refund failed on chain, retry required | `failed` |
| `CANCELLED` | Payment expired or cancelled before funding | -- |

**Ghost states resolved:**

| Ghost State | Resolution | Migration |
|-------------|-----------|-----------|
| `partial` | Replaced by `PARTIALLY_FUNDED`. Previously used in [[Escrow Flow]] and [[Payment Flow - SHKeeper]] but absent from the [[Payment]] data model. | Map any `partial` escrowState values to `PARTIALLY_FUNDED`. |

**Key addition: `DISPUTED` state**

The `DISPUTED` escrow state is the primary mitigation for [[T06]] (dispute bypass -- release during active dispute). This state:

1. Is set automatically when a dispute is opened against a funded payment.
2. Blocks all `RELEASE` and `REFUND` operations.
3. Can only be cleared by dispute resolution or admin override with step-up authentication.
4. Replaces the previous (non-functional) "hold flag" described in the [[Dispute API]].

**Key addition: `REFUNDING` state**

The `REFUNDING` state provides parity with `RELEASING` -- when a refund is initiated, the escrow state moves to `REFUNDING` until the on-chain transaction is confirmed, at which point it moves to `REFUNDED`.

**Previous document sources:**

| Document | escrowState values used | Alignment |
|----------|------------------------|-----------|
| [[Payment]] Data Model | `funded`, `releasable`, `released`, `refunded`, `releasing`, `failed` | Add `DISPUTED`, `PARTIALLY_FUNDED`, `REFUNDING`, `CANCELLED` |
| [[Escrow Flow]] | `funded`, `partial`, `releasable`, `releasing`, `released`, `refunded`, `failed` | `partial` -> `PARTIALLY_FUNDED`, add `DISPUTED`, `REFUNDING`, `CANCELLED` |
| [[Payment Flow - SHKeeper]] | `funded`, `partial`, `releasable`, `releasing`, `released`, `refunded`, `cancelled` | Same as Escrow Flow |
| [[Payment API]] | `unfunded`, `funded`, `released`, `refunded` | `unfunded` replaced by null/PENDING status; add missing states |

### 2.1.4 Dispute statuses and resolution actions

This is the resolution of the three mutually incompatible enum sets identified in [[Platform Logical Audit - 2026-05-24]] Finding 5.

**Canonical Dispute status enum:**

| Value | Description | Previously in |
|-------|-------------|---------------|
| `OPEN` | Dispute created, awaiting admin assignment | Data Model: `pending`; API: `open`; Flow: `pending` |
| `UNDER_REVIEW` | Admin assigned, investigation in progress | Data Model: `in_progress`, `waiting_response`; API: `under_review`; Flow: `in_progress` |
| `RESOLVED_BUYER` | Resolved in favor of buyer (refund/partial refund) | API: `resolved_buyer` |
| `RESOLVED_SELLER` | Resolved in favor of seller (release) | API: `resolved_seller` |
| `RESOLVED_SPLIT` | Split decision (partial refund + partial release) | -- (NEW) |
| `REJECTED` | Dispute rejected as invalid or duplicate | Data Model: `rejected` |
| `CLOSED` | Dispute closed (terminal) | Data Model: `closed`; Flow: `closed` |

**Canonical Dispute resolution action enum:**

| Value | Description | Escrow Effect | Previously in |
|-------|-------------|---------------|---------------|
| `REFUND` | Full refund to buyer | escrowState -> REFUNDING -> REFUNDED | Data Model: `refund`; Flow: `refund` |
| `PARTIAL_REFUND` | Partial refund to buyer, partial release to seller | escrowState -> REFUNDING/RELEASING -> split | Data Model: `compensation`; Flow: `partial`; API: `split` |
| `RELEASE` | Full release to seller | escrowState -> RELEASING -> RELEASED | Flow: `release`; API: `seller` |
| `REJECT` | No financial action, escrow returns to previous state | escrowState -> FUNDED (or RELEASABLE) | Flow: `reject`; API: N/A |
| `WARNING` | Seller warned but no financial action | No escrow change | Data Model: `warning_seller` |
| `BAN_SELLER` | Seller banned, funds refunded to buyer | escrowState -> REFUNDING -> REFUNDED | Data Model: `ban_seller` |
| `NO_ACTION` | No financial or disciplinary action | No escrow change | Data Model: `no_action` |

**Previous document sources and their incompatibilities:**

| Document | Status enum | Resolution action enum | Conflict |
|----------|-------------|----------------------|----------|
| [[Dispute]] Data Model | `pending`, `in_progress`, `waiting_response`, `resolved`, `rejected`, `closed` | `refund`, `replacement`, `compensation`, `warning_seller`, `ban_seller`, `no_action` | Uses `resolved` (ambiguous), `replacement` (not financial), `waiting_response` (substate of `in_progress`) |
| [[Dispute API]] | `open`, `under_review`, `resolved_buyer`, `resolved_seller`, `closed` | `buyer`, `seller`, `split` (as `decision`) | Uses outcome as status, `decision` instead of `action`, no `REJECTED` state |
| [[Dispute Flow]] | `pending`, `in_progress`, `resolved`, `closed` | `refund`, `partial`, `release`, `reject` | Uses `partial` (ambiguous), conflates status and action, missing `REJECTED` |

**Resolution rationale:**

1. `RESOLVED_BUYER`, `RESOLVED_SELLER`, `RESOLVED_SPLIT` encode the outcome directly in the status, eliminating ambiguity about who the resolution favored (matching the API design).
2. `UNDER_REVIEW` replaces both `in_progress` and `waiting_response`. The distinction between "admin is actively investigating" and "waiting for a party response" is tracked in the dispute timeline, not in the status.
3. `REJECTED` is distinct from `RESOLVED_*` because a rejection means the dispute was invalid, while a resolution means a judgment was rendered.
4. `replacement` is not a financial action -- it is an operational action between buyer and seller. It is removed from the canonical financial resolution enum. If needed, it can be tracked in the dispute timeline as a non-financial resolution.
5. `PARTIAL_REFUND` replaces `partial`, `compensation`, and the `split` decision, providing a clear financial description.

---

## 2.2 State Transition Diagrams

### 2.2.1 PurchaseRequest lifecycle

```mermaid
stateDiagram-v2
    [*] --> pending : buyer creates request
    pending --> received_offers : first SellerOffer saved
    pending --> cancelled : buyer cancels (no offers)

    received_offers --> in_negotiation : buyer engages seller
    received_offers --> payment : offer accepted + payment captured
    received_offers --> cancelled : buyer cancels

    in_negotiation --> received_offers : counter rejected, more offers pending
    in_negotiation --> payment : offer accepted + payment captured
    in_negotiation --> cancelled : buyer cancels

    payment --> processing : seller acknowledges
    payment --> cancelled : dispute resolved as refund (pre-shipment)

    processing --> delivery : seller marks shipped
    processing --> DISPUTED : dispute opened

    delivery --> delivered : buyer confirms delivery (code or fast-track)
    delivery --> DISPUTED : dispute opened

    delivered --> confirming : delivery code verified or fast-track
    delivered --> DISPUTED : dispute opened

    confirming --> completed : escrow released
    confirming --> DISPUTED : dispute opened

    DISPUTED --> payment : dispute rejected (no action)
    DISPUTED --> processing : dispute rejected (no action)
    DISPUTED --> delivery : dispute rejected (no action)
    DISPUTED --> delivered : dispute rejected (no action)
    DISPUTED --> confirming : dispute rejected (no action)
    DISPUTED --> confirming : dispute RESOLVED_SELLER (release)
    DISPUTED --> cancelled : dispute RESOLVED_BUYER (refund)

    completed --> seller_paid : payout confirmed on chain
    seller_paid --> [*]

    cancelled --> [*]
```

Note: `DISPUTED` on PurchaseRequest is triggered by dispute creation and reverts to the previous state if the dispute is rejected or resolved with no action. This is a transient overlay state -- the underlying state is preserved in the dispute record.

### 2.2.2 Payment + escrowState lifecycle

```mermaid
stateDiagram-v2
    [*] --> PENDING : payment intent created
    PENDING --> PROCESSING : funds detected on chain
    PENDING --> CANCELLED : intent expired / buyer cancelled

    PROCESSING --> COMPLETED : funds confirmed
    PROCESSING --> FAILED : verification failed

    COMPLETED --> FUNDED : escrowState set to FUNDED
    FUNDED --> PARTIALLY_FUNDED : partial payment received (escrowState only)
    PARTIALLY_FUNDED --> FUNDED : top-up reaches threshold

    FUNDED --> RELEASABLE : delivery confirmed
    FUNDED --> DISPUTED : dispute opened
    RELEASABLE --> DISPUTED : dispute opened

    DISPUTED --> FUNDED : dispute rejected / no action
    DISPUTED --> RELEASABLE : dispute RESOLVED_SELLER (release authorized)
    DISPUTED --> REFUNDING : dispute RESOLVED_BUYER (refund authorized)

    RELEASABLE --> RELEASING : payout initiated
    RELEASING --> RELEASED : payout confirmed on chain
    RELEASING --> FAILED : payout failed on chain

    REFUNDING --> REFUNDED : refund confirmed on chain
    REFUNDING --> FAILED : refund failed on chain

    FAILED --> RELEASING : admin retries payout
    FAILED --> REFUNDING : admin retries refund

    RELEASED --> [*]
    REFUNDED --> [*]
    CANCELLED --> [*]
```

### 2.2.3 Dispute lifecycle

```mermaid
stateDiagram-v2
    [*] --> OPEN : buyer or seller opens dispute
    OPEN --> UNDER_REVIEW : admin assigned
    OPEN --> REJECTED : admin rejects (duplicate / spam / invalid)
    OPEN --> CLOSED : auto-close (e.g. requester cancelled)

    UNDER_REVIEW --> RESOLVED_BUYER : admin resolves in buyer favor
    UNDER_REVIEW --> RESOLVED_SELLER : admin resolves in seller favor
    UNDER_REVIEW --> RESOLVED_SPLIT : admin splits funds
    UNDER_REVIEW --> REJECTED : admin rejects after review

    RESOLVED_BUYER --> CLOSED : financial action completed
    RESOLVED_SELLER --> CLOSED : financial action completed
    RESOLVED_SPLIT --> CLOSED : financial actions completed
    REJECTED --> CLOSED : admin closes

    CLOSED --> [*]
```

### 2.2.4 Cross-entity interaction diagram

```mermaid
stateDiagram-v2
    state "FundsAccount" as FA {
        [*] --> ACTIVE : account created
        ACTIVE --> SETTLED : released + refunded = grossPaid - fees
        ACTIVE --> CANCELLED : no funds received
        SETTLED --> [*]
        CANCELLED --> [*]
    }

    state "LedgerEntry" as LE {
        [*] --> PAY_IN : funds received
        PAY_IN --> HOLD : funds allocated to held
        HOLD --> DISPUTE_HOLD : dispute opened
        DISPUTE_HOLD --> RELEASE : dispute resolved for seller
        DISPUTE_HOLD --> REFUND : dispute resolved for buyer
        HOLD --> RELEASE : delivery confirmed, payout
        HOLD --> REFUND : pre-shipment cancellation
        RELEASE --> [*]
        REFUND --> [*]
    }

    state "Dispute" as DS {
        [*] --> OPEN : created
        OPEN --> UNDER_REVIEW : admin assigned
        UNDER_REVIEW --> RESOLVED_BUYER : refund ordered
        UNDER_REVIEW --> RESOLVED_SELLER : release ordered
        UNDER_REVIEW --> RESOLVED_SPLIT : split ordered
        RESOLVED_BUYER --> CLOSED : refund executed
        RESOLVED_SELLER --> CLOSED : release executed
        RESOLVED_SPLIT --> CLOSED : split executed
    }

    DS --> FA : OPEN triggers DISPUTE_HOLD on FundsAccount
    DS --> FA : RESOLVED_BUYER triggers REFUND entry
    DS --> FA : RESOLVED_SELLER triggers RELEASE entry
```

---

## 2.3 Valid Transitions Table

### 2.3.1 PurchaseRequest transitions

| From | To | Trigger | Preconditions | Side effects |
|------|----|---------|--------------|-------------|
| -- | `pending` | Buyer creates request | Buyer authenticated; category exists; title + description provided | Create PurchaseRequest; fan-out notification to sellers |
| `pending` | `received_offers` | First SellerOffer saved | Valid offer; seller is active | Add offer to `offers[]`; emit `purchase-request-update` |
| `pending` | `cancelled` | Buyer cancels | No accepted offer; no payment | Set `cancelledAt`; emit `request-cancelled` |
| `received_offers` | `in_negotiation` | Buyer engages seller | At least one pending offer | Emit `purchase-request-update` |
| `received_offers` | `payment` | Payment captured (SHKeeper PAID or Web3 verified) | escrowState = FUNDED; FundsAccount exists | Set `selectedOfferId`; create chat; emit cascade |
| `received_offers` | `cancelled` | Buyer cancels | No payment | Set `cancelledAt` |
| `in_negotiation` | `received_offers` | Counter rejected, more offers pending | No accepted offer | Emit `purchase-request-update` |
| `in_negotiation` | `payment` | Payment captured | escrowState = FUNDED; FundsAccount exists | Set `selectedOfferId`; create chat; emit cascade |
| `in_negotiation` | `cancelled` | Buyer cancels | No payment | Set `cancelledAt` |
| `payment` | `processing` | Seller acknowledges | Payment COMPLETED; escrowState FUNDED | Emit `purchase-request-update` |
| `payment` | `cancelled` | Dispute resolved as refund (pre-shipment) | Dispute status RESOLVED_BUYER | Set `cancelledAt`; escrowState -> REFUNDING |
| `processing` | `delivery` | Seller marks shipped | Seller is selected seller | Set `deliveryInfo.shippedAt`; emit update |
| `processing` | `DISPUTED` | Dispute opened | Buyer or seller opens dispute | Create Dispute; escrowState -> DISPUTED |
| `delivery` | `delivered` | Buyer confirms (code or fast-track) | Delivery code valid OR fast-track authorized | Set `deliveryConfirmedAt` |
| `delivery` | `DISPUTED` | Dispute opened | Buyer or seller opens dispute | Create Dispute; escrowState -> DISPUTED |
| `delivered` | `confirming` | Delivery code verified | Code matches; not already confirmed | Set `deliveryCodeUsedAt` |
| `delivered` | `DISPUTED` | Dispute opened | Buyer or seller opens dispute | Create Dispute; escrowState -> DISPUTED |
| `confirming` | `completed` | Escrow released | escrowState = RELEASED; FundsAccount settled or partially settled | Set `completedAt`; emit notification |
| `confirming` | `DISPUTED` | Dispute opened | Buyer or seller opens dispute | Create Dispute; escrowState -> DISPUTED |
| `DISPUTED` | `confirming` | Dispute RESOLVED_SELLER or REJECTED | Dispute status terminal | escrowState -> RELEASABLE or previous state |
| `DISPUTED` | `cancelled` | Dispute RESOLVED_BUYER | Dispute status RESOLVED_BUYER | escrowState -> REFUNDING; set `cancelledAt` |
| `completed` | `seller_paid` | Payout confirmed on chain | escrowState = RELEASED; on-chain tx verified | Set `seller_paid`; FundsAccount settled |

### FORBIDDEN PurchaseRequest transitions

| From | To | Reason |
|------|----|--------|
| `payment` | `pending` | Cannot regress after payment captured |
| `processing` | `received_offers` | Cannot regress after seller acknowledges |
| `completed` | `cancelled` | Cannot cancel a completed transaction |
| `seller_paid` | any non-terminal | `seller_paid` is terminal |
| `cancelled` | any | `cancelled` is terminal |
| `payment` | `payment` | No self-transitions |
| any non-DISPUTED | `DISPUTED` without dispute | DISPUTED only via dispute creation |
| `DISPUTED` | `completed` | Must go through confirming first (release path) |
| `DISPUTED` | `cancelled` without dispute | Only dispute resolution can trigger cancellation from DISPUTED |

### 2.3.2 Payment + escrowState transitions

| From escrowState | To escrowState | Trigger | Preconditions | Side effects |
|-----------------|---------------|---------|--------------|-------------|
| null (PENDING) | `PARTIALLY_FUNDED` | SHKeeper PARTIAL webhook | `Payment.status` = PENDING | LedgerEntry PAY_IN (partial) |
| null (PENDING) | `FUNDED` | SHKeeper PAID/OVERPAID webhook or Web3 verification | `Payment.status` = PENDING or PARTIALLY_FUNDED | LedgerEntry PAY_IN; LedgerEntry HOLD; FundsAccount ACTIVE |
| `PARTIALLY_FUNDED` | `FUNDED` | Top-up reaches threshold | cumulative PAY_IN >= expected amount | LedgerEntry PAY_IN (top-up); LedgerEntry HOLD (full) |
| `FUNDED` | `RELEASABLE` | Buyer confirms delivery OR auto-release timer | No active dispute; FundsAccount releasable >= 0 | LedgerEntry (no entry -- held becomes releasable via derived balance) |
| `FUNDED` | `DISPUTED` | Dispute opened | Dispute created for this payment | LedgerEntry DISPUTE_HOLD; escrowState set to DISPUTED (addresses [[T06]]) |
| `RELEASABLE` | `DISPUTED` | Dispute opened | Dispute created | LedgerEntry DISPUTE_HOLD; block all payouts |
| `DISPUTED` | `FUNDED` | Dispute REJECTED or no action | Dispute status terminal | LedgerEntry REVERSAL (reverse DISPUTE_HOLD) |
| `DISPUTED` | `RELEASABLE` | Dispute RESOLVED_SELLER | Dispute status RESOLVED_SELLER; seller wallet present | LedgerEntry REVERSAL (reverse DISPUTE_HOLD); funds become releasable |
| `DISPUTED` | `REFUNDING` | Dispute RESOLVED_BUYER or RESOLVED_SPLIT | Dispute status terminal; releasable >= refund amount | LedgerEntry REVERSAL; LedgerEntry REFUND |
| `RELEASABLE` | `RELEASING` | Admin/system initiates payout | No active dispute; releasable >= release amount; seller wallet present | Create outgoing Payment; LedgerEntry RELEASE |
| `RELEASING` | `RELEASED` | Payout confirmed on chain | On-chain tx hash verified; `Payment.status` -> RELEASED | FundsAccount released += amount; emit notification |
| `RELEASING` | `FAILED` | Payout failed on chain | On-chain tx reverted | LedgerEntry REVERSAL; admin alerted |
| `REFUNDING` | `REFUNDED` | Refund confirmed on chain | On-chain tx hash verified; `Payment.status` -> REFUNDED | FundsAccount refunded += amount; emit notification |
| `REFUNDING` | `FAILED` | Refund failed on chain | On-chain tx reverted | LedgerEntry REVERSAL; admin alerted |
| `FAILED` | `RELEASING` | Admin retries payout | Admin step-up auth; seller wallet present | New LedgerEntry RELEASE |
| `FAILED` | `REFUNDING` | Admin retries refund | Admin step-up auth; buyer wallet present | New LedgerEntry REFUND |

### FORBIDDEN escrowState transitions

| From | To | Reason | Threat addressed |
|------|----|--------|-----------------|
| `FUNDED` | `RELEASING` | Must pass through RELEASABLE first | [[T05]] |
| `DISPUTED` | `RELEASING` | Cannot release during active dispute | [[T06]] |
| `DISPUTED` | `RELEASED` | Must resolve dispute first | [[T06]] |
| `RELEASED` | `FUNDED` | Cannot undo a confirmed release | [[T05]] |
| `REFUNDED` | `FUNDED` | Cannot undo a confirmed refund | [[T05]] |
| `RELEASED` | `REFUNDED` | Cannot refund after release | [[T05]] |
| `REFUNDED` | `RELEASED` | Cannot release after refund | [[T05]] |
| `FUNDED` | `FUNDED` | No self-transitions | [[T23]] |
| `RELEASED` | `RELEASED` | No self-transitions (prevents double release) | [[T05]] |
| any | `FUNDED` (after `RELEASED`/`REFUNDED`) | Terminal states are terminal | [[T05]], [[T23]] |
| `DISPUTED` | `RELEASABLE` (without dispute resolution) | Only dispute resolution clears disputed state | [[T06]] |

### 2.3.3 Dispute transitions

| From | To | Trigger | Preconditions | Side effects |
|------|----|---------|--------------|-------------|
| -- | `OPEN` | Buyer or seller creates dispute | PurchaseRequest exists; initiator is buyer or seller; no other OPEN/UNDER_REVIEW dispute for same request | Create Dispute; create group chat; escrowState -> DISPUTED (if FUNDED/RELEASABLE); set `responseDeadline = now + 48h`; set `deadline = now + 7d` |
| `OPEN` | `UNDER_REVIEW` | Admin assigned | Admin authenticated with admin role | Set `adminId`; add admin to chat participants; emit notification |
| `OPEN` | `REJECTED` | Admin rejects | Admin authenticated; dispute is duplicate, spam, or invalid | Set `rejectedAt`; escrowState -> previous state (reverse DISPUTE_HOLD) |
| `OPEN` | `CLOSED` | Auto-close (requester cancelled) | Requester cancels the dispute | Set `closedAt` |
| `UNDER_REVIEW` | `RESOLVED_BUYER` | Admin resolves in buyer favor | Admin authenticated; admin is assigned admin; resolution action provided | Set `resolution`; escrowState -> REFUNDING; LedgerEntry REFUND |
| `UNDER_REVIEW` | `RESOLVED_SELLER` | Admin resolves in seller favor | Admin authenticated; admin is assigned admin; resolution action provided | Set `resolution`; escrowState -> RELEASABLE |
| `UNDER_REVIEW` | `RESOLVED_SPLIT` | Admin splits funds | Admin authenticated; refundAmount + releaseAmount <= releasable | Set `resolution` with amounts; escrowState -> RELEASING + REFUNDING |
| `UNDER_REVIEW` | `REJECTED` | Admin rejects after review | Admin authenticated | Set `rejectedAt`; escrowState -> previous state |
| `RESOLVED_BUYER` | `CLOSED` | Refund executed | escrowState = REFUNDED; on-chain tx confirmed | Set `closedAt` |
| `RESOLVED_SELLER` | `CLOSED` | Release executed | escrowState = RELEASED; on-chain tx confirmed | Set `closedAt` |
| `RESOLVED_SPLIT` | `CLOSED` | Both release and refund executed | escrowState = RELEASED + REFUNDED | Set `closedAt` |
| `REJECTED` | `CLOSED` | Admin closes | Admin authenticated | Set `closedAt` |

### FORBIDDEN Dispute transitions

| From | To | Reason |
|------|----|--------|
| `CLOSED` | any | `CLOSED` is terminal |
| `RESOLVED_BUYER` | `UNDER_REVIEW` | Cannot reopen after resolution |
| `RESOLVED_SELLER` | `UNDER_REVIEW` | Cannot reopen after resolution |
| `RESOLVED_SPLIT` | `UNDER_REVIEW` | Cannot reopen after resolution |
| `REJECTED` | `UNDER_REVIEW` | Cannot reopen after rejection |
| `OPEN` | `RESOLVED_BUYER` | Must be assigned to admin first |
| `OPEN` | `RESOLVED_SELLER` | Must be assigned to admin first |
| `OPEN` | `RESOLVED_SPLIT` | Must be assigned to admin first |
| `UNDER_REVIEW` | `OPEN` | Cannot regress |

---

## 2.4 Dispute Hold Enforcement

Dispute holds are the primary defense against [[T06]] (release during active dispute) and support [[T18]] (insider manipulation detection).

### Hold creation

1. When a dispute is created (`Dispute.status = OPEN`), the system MUST:
   a. Create a `LedgerEntry` of type `DISPUTE_HOLD` on the associated FundsAccount.
   b. Set `Payment.escrowState = DISPUTED` on all funded payments associated with the PurchaseRequest.
   c. Log the transition in the Dispute timeline.
   d. This MUST happen atomically within a single MongoDB transaction (or with compensating actions if transactions are unavailable).

2. If the FundsAccount has no funds (e.g., dispute on an unfunded request), the dispute is still created but no `DISPUTE_HOLD` entry is made. The dispute is informational only.

3. If multiple payments exist for the same PurchaseRequest (should not happen with canonical model, but defensively): all funded payments MUST be placed in `DISPUTED` state.

### Hold checking

Every release and refund operation MUST check for active dispute holds BEFORE executing:

```
function checkDisputeHold(accountId):
    activeDisputes = Dispute.find({
        purchaseRequestId: account.purchaseRequestId,
        status: { $in: [OPEN, UNDER_REVIEW] }
    })
    if (activeDisputes.length > 0):
        throw Error("Cannot release/refund: active dispute hold exists")
```

This check MUST occur:
1. In the FundsService (not just in the controller) -- service-layer enforcement.
2. After acquiring any distributed lock (to prevent TOCTOU races).
3. On both the `Payment.escrowState` field AND the FundsAccount ledger (defense in depth).

### Hold release

A dispute hold is cleared only when:

1. **Dispute resolved**: `Dispute.status` transitions to `RESOLVED_BUYER`, `RESOLVED_SELLER`, `RESOLVED_SPLIT`, `REJECTED`, or `CLOSED`.
2. The resolution handler creates the appropriate `LedgerEntry` (RELEASE, REFUND, or REVERSAL).
3. The `Payment.escrowState` transitions from `DISPUTED` to the next state based on resolution.

### Admin override

An admin MAY override a dispute hold under the following conditions:

1. **Step-up authentication required**: The admin must re-authenticate (password + 2FA) before overriding.
2. **Audit trail**: An `ADJUSTMENT` ledger entry is created with:
   - `actor.type = ADMIN`
   - `actor.userId = adminId`
   - `sourceEvent.adminActionId` set to a unique action ID
   - `description` containing the reason for override
3. **Two-person approval**: For accounts holding more than a configurable threshold (default: 1000 USDT), two separate admin approvals are required.
4. **Alert**: An alert is sent to the operations team for every admin override.
5. **Rate limit**: An admin can override at most 3 disputes per hour. Exceeding this triggers a security alert.

---

## 2.5 Release and Refund Preconditions

### 2.5.1 Release to seller

**Preconditions (ALL must be true):**

| Condition | Check |
|-----------|-------|
| Escrow state | `escrowState` is `RELEASABLE` or (`DISPUTED` with dispute `RESOLVED_SELLER`) |
| No active disputes | No Dispute with status `OPEN` or `UNDER_REVIEW` for this PurchaseRequest |
| Sufficient releasable balance | `FundsAccount.releasable >= releaseAmount` (derived from ledger) |
| Balance invariant | `grossPaid = providerFees + platformFees + released + refunded + releasable + held + disputed` |
| Seller wallet present | `seller.profile.walletAddress` is set and valid (`^0x[0-9a-fA-F]{40}$`) |
| No concurrent release | Distributed lock acquired for this FundsAccount |
| Payment not already released | No existing `LedgerEntry` of type `RELEASE` with the same idempotency key |
| Offer price not modified | `SellerOffer.price.amount` matches the amount at payment creation time (addresses [[T19]]) |

**Execution steps:**

1. Acquire distributed lock (Redis) for `accountId`.
2. Re-check all preconditions inside the lock.
3. Create `LedgerEntry` of type `RELEASE` with idempotency key.
4. Set `Payment.escrowState = RELEASING`.
5. Initiate on-chain transfer (SHKeeper payout or manual admin signing).
6. On on-chain confirmation: set `Payment.escrowState = RELEASED`.
7. Update `FundsAccount.releasedAmount` (derived from ledger).
8. Release distributed lock.
9. If `released + refunded + fees = grossPaid`, set `FundsAccount.status = SETTLED`.

### 2.5.2 Refund to buyer

**Preconditions (ALL must be true):**

| Condition | Check |
|-----------|-------|
| Escrow state | `escrowState` is `FUNDED`, `RELEASABLE`, `DISPUTED`, or `PARTIALLY_FUNDED` |
| Dispute resolved (if disputed) | If `escrowState = DISPUTED`, Dispute status must be `RESOLVED_BUYER` or `RESOLVED_SPLIT` |
| OR pre-shipment cancellation | PurchaseRequest status is `payment` or earlier (no delivery started) |
| OR admin override | Admin with step-up auth has authorized the refund |
| Sufficient releasable balance | `FundsAccount.releasable >= refundAmount` (derived from ledger) |
| Balance invariant | Invariant holds before refund |
| Buyer wallet present | `Payment.blockchain.sender` (original buyer wallet) is available |
| No concurrent refund | Distributed lock acquired for this FundsAccount |

**Execution steps:**

1. Acquire distributed lock for `accountId`.
2. Re-check all preconditions inside the lock.
3. Create `LedgerEntry` of type `REFUND` with idempotency key.
4. Set `Payment.escrowState = REFUNDING`.
5. Build refund transaction payload (destination = buyer's original wallet).
6. Admin signs and broadcasts (or automated if SHKeeper payout).
7. On on-chain confirmation: set `Payment.escrowState = REFUNDED`.
8. Release distributed lock.
9. If `released + refunded + fees = grossPaid`, set `FundsAccount.status = SETTLED`.

### 2.5.3 Partial release and refund

Partial operations are allowed only in the following cases:

1. **Split dispute resolution** (`RESOLVED_SPLIT`): The admin specifies `refundAmount` and `releaseAmount` where `refundAmount + releaseAmount <= releasable`.
2. **Partial refund of overpayment**: If the buyer overpaid, the surplus can be refunded separately. `refundAmount = grossAmountPaid - expectedAmount`.
3. **Admin override with justification**: Admin may specify a custom amount with step-up auth and audit trail.

**Amount tracking for partial operations:**

- Each partial operation creates a separate `LedgerEntry` with its specific amount.
- The FundsAccount tracks cumulative `releasedAmount` and `refundedAmount` (derived from ledger sums).
- Multiple partial operations MAY occur for a single FundsAccount, but the invariant `released + refunded + fees <= grossPaid` must always hold.
- A FundsAccount reaches `SETTLED` when `released + refunded + providerFees + platformFees = grossPaid` and `held = 0` and `disputed = 0`.

### 2.5.4 Admin override additional checks

For any admin-initiated release or refund that deviates from the standard flow:

| Check | Requirement |
|-------|-------------|
| Step-up authentication | Admin must re-authenticate (password + 2FA) |
| Reason required | A non-empty `reason` string must be provided |
| Audit entry | `LedgerEntry` of type `ADJUSTMENT` with full context |
| Two-person approval (high value) | If amount > threshold (default 1000 USDT), second admin must approve |
| Alert | Operations team notified immediately |
| Rate limit | Max 5 admin overrides per hour per admin |

---

## 2.6 Ghost States and Cleanup

The following "ghost states" were identified in [[Platform Logical Audit - 2026-05-24]] as status values that exist in enums but are not set by any automated flow.

### 2.6.1 Assessment

| Ghost State | Entity | Assessment | Resolution |
|-------------|--------|------------|------------|
| `confirmed` (Payment.status) | Payment | No automated flow sets this. SHKeeper maps PAID directly to `completed`. The data model includes it as an intermediate between `processing` and `completed`, but the webhook skips it. | **Remove from canonical enum.** Any records currently in `confirmed` should be migrated to `COMPLETED`. |
| `partial` (Payment.escrowState) | Payment | Used by Escrow Flow and Payment Flow - SHKeeper but NOT present in the Payment data model enum. This is a documentation/implementation gap. | **Replace with `PARTIALLY_FUNDED` in canonical enum.** Add to the Payment model's `escrowState` enum. Migrate any records in `partial` to `PARTIALLY_FUNDED`. |
| `draft` (PurchaseRequest.status) | PurchaseRequest | Referenced only in the Marketplace API status transition description. No code creates requests in `draft` state; requests are always `pending`. | **Remove from canonical enum.** No migration needed (no records should exist in `draft`). |
| `finalized` (PurchaseRequest.status) | PurchaseRequest | Referenced in Purchase Request Flow as post-ratings state. No automated flow sets it. | **Remove from canonical enum.** Use `completed` as the terminal state. Track ratings via `PurchaseRequest.rating` and `Review` model. Map any `finalized` records to `completed`. |
| `archived` (PurchaseRequest.status) | PurchaseRequest | Referenced in Purchase Request Flow as 30-day idle state. No automated job sets it. | **Remove from canonical enum.** Use an `archivedAt` timestamp or `isArchived` boolean instead. Map any `archived` records to `completed` with `archivedAt` set. |
| `active` (PurchaseRequest.status) | PurchaseRequest | Listed in Data Model enum but semantically ambiguous (could mean pre-payment or post-payment). | **Remove from canonical enum.** Pre-payment is `pending`/`received_offers`/`in_negotiation`. Post-payment is `payment`. Map any `active` records to the appropriate state based on payment status. |
| `pending_payment` (PurchaseRequest.status) | PurchaseRequest | Listed in Data Model enum but no flow creates this state. Payment creates a Payment record, not a PurchaseRequest status change to `pending_payment`. | **Remove from canonical enum.** Map any `pending_payment` records to `pending`. |
| `replacement` (Dispute.resolution.action) | Dispute | Listed in Data Model enum. Not a financial action; cannot be represented in the ledger. | **Remove from canonical resolution action enum.** Track as a non-financial timeline event in the dispute. |

### 2.6.2 Migration strategy

**Phase 1: Data audit (before enforcement)**

Run a data audit script to identify all records currently in ghost states:

```javascript
// Identify ghost state records
db.payments.find({ status: "confirmed" })
db.payments.find({ escrowState: "partial" })
db.purchaserequests.find({ status: { $in: ["draft", "finalized", "archived", "active", "pending_payment"] } })
db.disputes.find({ "resolution.action": "replacement" })
```

**Phase 2: Migration (with ledger)**

For each ghost state, migrate records to the canonical state:

| Ghost State | Migration Target | Migration Action |
|-------------|-----------------|-----------------|
| `Payment.status = "confirmed"` | `"COMPLETED"` | Update status; verify escrowState is FUNDED |
| `Payment.escrowState = "partial"` | `"PARTIALLY_FUNDED"` | Update escrowState; verify partial amount in metadata |
| `PurchaseRequest.status = "draft"` | `"pending"` | Update status |
| `PurchaseRequest.status = "finalized"` | `"completed"` | Update status; verify escrow is RELEASED |
| `PurchaseRequest.status = "archived"` | `"completed"` | Update status; set `archivedAt = updatedAt` |
| `PurchaseRequest.status = "active"` | Determined by payment status | If payment exists: `"payment"`. If not: `"pending"` or `"received_offers"` based on offers |
| `PurchaseRequest.status = "pending_payment"` | `"pending"` | Update status |
| `Dispute.resolution.action = "replacement"` | `"NO_ACTION"` | Update action; add timeline note that replacement was agreed |

**Phase 3: Enum enforcement**

After migration:

1. Update all model schemas to use only the canonical enum values.
2. Add Mongoose enum validation that rejects any value not in the canonical enum.
3. Add a `pre('save')` hook that validates state transitions against the valid transitions tables.
4. Run the data audit script again to verify zero ghost state records.

**Phase 4: Documentation update**

After enforcement, update all referencing documents:

- [[Payment]] data model: update status and escrowState enums
- [[PurchaseRequest]] data model: update status enum
- [[Dispute]] data model: update status and resolution.action enums
- [[Payment API]]: update status model section
- [[Dispute API]]: update status and decision enums
- [[Marketplace API]]: update status transition descriptions
- [[Escrow Flow]]: update state machine diagram
- [[Dispute Flow]]: update state machine diagram
- [[Purchase Request Flow]]: update state machine diagram

---

## Appendix A: Threat Traceability

| Threat | Addressed by |
|--------|-------------|
| [[T05]] Double payout / double release | Immutable ledger with idempotency keys; distributed locks; releasable balance derived from ledger (not stored); FundsAccount status enforcement |
| [[T06]] Dispute bypass (release during active dispute) | `DISPUTED` escrow state; mandatory dispute hold check before every release/refund; service-layer enforcement |
| [[T18]] Insider fund manipulation | Immutable append-only ledger; admin override requires step-up auth; two-person approval for high-value operations; audit trail on every entry; reconciliation detects discrepancies |
| [[T19]] Seller price manipulation post-acceptance | Release precondition checks offer price matches payment-time snapshot; offer status `accepted` is immutable after payment |
| [[T23]] State machine inconsistency | Single canonical enum set; valid transition tables enforced at service layer; ghost states removed; pre-save validation hooks |

## Appendix B: Glossary

| Term | Definition |
|------|-----------|
| **FundsAccount** | The financial ledger account for a single purchase request, tracking all money movement |
| **LedgerEntry** | An immutable, append-only record of a single financial event |
| **escrowState** | The state of funds held by the platform, independent of payment processing status |
| **Dispute hold** | A mechanism that blocks all fund movement while a dispute is active |
| **Releasable balance** | Funds available for release to seller or refund to buyer (derived from ledger) |
| **Running balance** | A snapshot of all balance fields appended to each ledger entry for query efficiency |
| **Ghost state** | A status value that exists in an enum but is never set by any automated flow |
| **Step-up authentication** | Re-authentication required for high-risk admin operations (release, refund, override) |
| **Distributed lock** | A Redis-based mutex preventing concurrent operations on the same FundsAccount |

## Appendix C: Implementation Order

1. Add `FundsAccount` and `LedgerEntry` models to the database.
2. Build `FundsService` with idempotency enforcement and balance derivation.
3. Implement `EscrowStateMachine` service with valid transition validation.
4. Add `DISPUTED` state to Payment model and dispute hold logic to DisputeService.
5. Integrate FundsService into existing payment webhooks (SHKeeper and Web3).
6. Integrate FundsService into release and refund flows.
7. Add distributed locking for FundsAccount operations.
8. Build reconciliation job.
9. Migrate ghost state records.
10. Update all referencing documentation.
