# Phase 7 Backend Contract — Refunds + Reconciliation Companion to [PRODUCT-PLAN-v3.1-DELIVERY-PLAN.md](../PRODUCT-PLAN-v3.1-DELIVERY-PLAN.md) Phase 7 (Sprints 7.1–7.3). Extends [Phase 1](PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md) §6 (payment state machine). **Status: ready to build.** `requestRefund(id)` exists today only as a mock gateway method; `reconcil*` and `settlement*` return zero hits anywhere in the codebase. --- ## 1. Refunds ```ts interface Refund { id: string; orderId: string; orderLineIds: string[]; // which lines this refund covers - partial refunds must specify amount: Money; reason: string; actor: string; // user id who initiated it, never anonymous status: 'requested' | 'approved' | 'processing' | 'completed' | 'failed'; requestedAt: string; completedAt?: string; routing: RoutingContext; // copied verbatim from the original Payment, never recomputed } ``` A refund always carries the routing context of the payment it reverses. It is copied, not re-resolved — a store suspended after the payment must still be refundable. ``` POST /api/admin/v2/orders/{orderId}/refunds { orderLineIds, amount, reason } GET /api/admin/v2/orders/{orderId}/refunds ``` A `Refund` updates `Payment.status` to `refunded` or `partially_refunded` (Phase 1 §6.1) and emits `refund.requested`/`refund.completed` on the Phase 2 event bus. ## 2. Reconciliation ```ts interface ReconciliationRecord { id: string; orderId: string; providerPaymentId?: string; internalAmount: Money; providerAmount?: Money; matchStrategy: 'provider_payment_id' | 'merchant_reference' | 'amount_currency_fallback'; result: 'matched' | 'unmatched' | 'duplicate' | 'amount_mismatch' | 'status_mismatch'; resolvedBy?: string; resolvedAt?: string; resolutionNote?: string; routing: RoutingContext; // from the Payment; makes every row attributable to one payment point } ``` Process (per plan §7.3): ``` 1. Collect internal paid orders for a period. 2. Fetch provider transactions/events for the same period. 3. Match by providerPaymentId, falling back to merchant reference, falling back to amount+currency. 4. Classify: matched / unmatched / duplicate / amount_mismatch / status_mismatch. 5. Surface the non-matched set in backoffice with controlled, audited resolution. ``` ``` GET /api/admin/v2/reconciliation/queue?marketplaceId=&companyId=&projectId=&leafNodeId=&result= POST /api/admin/v2/reconciliation/{id}/resolve { note } ``` Step 3's `merchant_reference` strategy matches on `RoutingContext.merchantReference` — the partner-supplied value, stored verbatim (Phase 1 §6.5). The queue is filterable at every hierarchy level so an unmatched set can be narrowed to one payment point without a join the backoffice has to build itself. ## 3. Settlements ```ts interface Settlement { id: string; sellerId: string; periodStart: string; periodEnd: string; grossAmount: Money; commission: Money; refunds: Money; netPayout: Money; status: 'pending' | 'paid'; } ``` ``` GET /api/seller/v1/finance/settlements GET /api/admin/v2/finance/settlements?sellerId=&companyId=&projectId=&storeId=&period= ``` ### 3.1 Seller split happens after routing Added 2026-08-18. `Seller` is deliberately **not** a level in the partner hierarchy ([PARTNER-PROVISIONING-API-CONTRACT.md §10.2](PARTNER-PROVISIONING-API-CONTRACT.md)). Order of operations: ``` payment -> routed to exactly one payment point (Phase 1 §6.5, frozen at checkout) -> reconciled at that payment point -> split across the sellers whose lines the order contains (this phase) ``` - A `Settlement` belongs to one seller **within one store**. A seller trading in two stores gets two settlements per period, never one merged row. - Splitting never rewrites `RoutingContext`. The money arrived at one payment point; the split decides who is owed from it. - `grossAmount` summed across a store's settlements for a period must reconcile against that store's matched reconciliation rows for the same period. A mismatch is a reconciliation defect, not a rounding tolerance. ## 4. Provider breadth (open business question) Current flow supports QR and card only, via one custom provider integration. Adding wallets/BNPL is an explicit open business decision (not answered in Sprint 0.1) — this contract's `PaymentIntent`/`Payment` shapes from Phase 1 §6 are provider-agnostic already, so a new provider is a new adapter behind the same state machine, not a schema change. No action needed here until that business decision is made. ## 5. Idempotency belongs in the schema, not in a handler Added 2026-08-21 (FH-2.2). [Phase 1 §6.3–6.4](PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md) already require that a replayed webhook be a no-op and that order creation be idempotent. Both are stated as *behaviour*. Behaviour written as an `if` gets deleted by someone refactoring in eighteen months, and the failure mode is a double charge. Make the database refuse instead: ``` UNIQUE (payment.idempotency_key) UNIQUE (payment_webhook_event.provider, payment_webhook_event.event_key) ``` Handling: - **Payment creation.** `Idempotency-Key` is required on the create call. If a payment already exists for that key: same order and marketplace → return the existing payment unchanged; different order or marketplace → `409`, never silently create a second one. - **Webhook receipt.** Insert the event row *first*. A unique-violation is the duplicate signal — respond `{ accepted: true, duplicate: true }` and stop. Only a successful insert proceeds to apply the status change. Mark `processedAt` after applying, so a crash between insert and apply is visible as an unprocessed row rather than a lost event. - **`event_key`** is the provider's event id where one exists, and `sha256(rawBody)` where it does not. A provider that sends no event id must still be replay-safe. - **Signature verification runs against the raw request body**, before any parsing or re-serialization. Verify-after-parse is verify-nothing. - **Poll as reconciliation, not as primary.** A scheduled job re-checks provider status for payments still `pending` within the last 24 hours and applies the result through the same state-machine path as the webhook. Transient provider failures are swallowed; the next tick retries. Never a fixed delay, never a UI-driven poll standing in for a missed webhook. **Acceptance:** the same provider event delivered twice completes the order once, moves stock once, and emits one notification. This is scenario 10 of the acceptance list in [FORK-HARVEST-TODO.md](../FORK-HARVEST-TODO.md) and is a required e2e test. ## 6. What the frontend will start doing once this ships - Wire the mock `requestRefund(id)` to a real endpoint. - Build the backoffice **Payments & Finance** section (missing from admin nav today): payments, refunds, reconciliation queue, unmatched events, settlements. - Reconciliation-queue resolution UI with full audit trail.