# Synchron payments — route purchases and permit card-on-file

Written 2026-09-29 from the client task "Synchron Route Purchasing, Shopping Cart, Permit Payment Setup & Integration Preparation" (`docs/TASKS-2026-09-29-SYNCHRON-PURCHASING.md`). Purpose: HHA sells **routing products** (Express Route, Extended Route) at HHA-set prices and lets a customer put a **card on file** for Synchron permit processing. Synchron Permits owns the Stripe account, the checkout session, the stored card and the money. HHA never touches card data and never marks anything paid on its own.

**Payments are not operational.** Everything below is built against an HHA-side contract (`src/lib/integrations/synchron-payments/types.ts`); the live adapter throws `SynchronApiNotConfiguredError` on every call until the items in "Waiting on Synchron" are connected and verified end to end. See the closing section.

## The two workflows

| | Route purchase | Permit order (Buy More Permits) |
|---|---|---|
| What is sold | Routing product from `routing_products` (HHA price) | Synchron permit processing (Synchron price, unknown to HHA) |
| When money moves | **Before** the route is released: cart → purchase → Synchron checkout → verified confirmation → `paid` → routing released | **Later**, by Synchron, against the card on file; HHA only confirms a card exists and the customer confirmed it |
| Shape | One `purchases` row per checkout attempt, `purchase_items` per route, `payment_references` per Synchron session | Two-step dialog: form → card-on-file check → Synchron-hosted card + CVV confirmation → permit request released (email + `service_requests` rows, `payment_method_ref` set) |
| Prepaid credits | Untouched (D1): a route credit still grants the route with one click, no cart | n/a |
| Trip / order | Always one trip, always the trip's existing `synchron_order_token` (no new order is created) | Same |

They are **never one checkout** (D7). A permit request is never a cart item; the trip's purchase history lists both kinds side by side.

## Rules

1. **Only `applyVerifiedPaymentEvent` sets `paid`** (`src/lib/data/purchases.ts`). It is reached from the signed Synchron webhook, from reconciliation (cron, admin, return page) and — in development only — from the mock. No page, button, return URL or admin action can set `paid`. There is no "mark as paid" anywhere (§24).
2. **The mock never runs in production.** `synchronPaymentsMode()` returns `live` whenever `NODE_ENV === 'production'`, whatever `SYNCHRON_PAYMENTS_MODE` says, and `isTrustedPaymentSource('mock')` is false there, so a mock confirmation can never move a real purchase.
3. **HHA never holds Synchron's Stripe key, a card number or a CVV.** No `STRIPE_*` env var exists in this repo. The only card data HHA ever sees is `CardOnFile` (brand, last4, expiry, adapter id). Card entry and card + CVV confirmation happen on a Synchron-hosted page HHA opens; HHA receives a setup reference / confirmation token, nothing else.
4. **Prices come from `routing_products`.** The browser sends a `product_code`; the server resolves the price (`src/lib/data/routing-products.ts`). Synchron's own flat routing price is not represented.
5. **Snapshots.** `purchase_items` copies `unit_price_cents`, `line_total_cents`, `currency` and `product_version` at purchase time. Changing the catalog later never changes what a historical purchase shows.
6. **Payment status and fulfillment status are separate fields** (`purchases.purchase_status` vs `purchase_items.fulfillment_status`) with their own transition tables in `src/lib/domain/purchases.ts`.
7. **Idempotency.** The same `idempotency_key` yields one purchase; the same `external_event_id` from Synchron is applied once (partial unique index on `purchase_events`).
8. **Billing context is resolved server-side** from the active mode (`src/lib/data/billing-context.ts`): a dispatcher at Company A and a broker at Company B cannot see or create purchases across those contexts.
9. **Everything is logged** through `logPurchaseEvent` → `purchase_events`, mirrored into `operation_events` (`area: 'billing'`) via `safeOperationDetail` so no secret or card data is ever written.

## Where things live

| Piece | Path |
|---|---|
| Adapter contract (the HHA-side interface Synchron must be mapped onto) | `src/lib/integrations/synchron-payments/types.ts` |
| Adapter selection, mock-scenario helpers, trusted-source rule | `src/lib/integrations/synchron-payments/index.ts` |
| Live adapter (every method throws `SynchronApiNotConfiguredError`) | `src/lib/integrations/synchron-payments/live.ts` |
| Mock adapter + `MOCK_SCENARIOS` (development only) | `src/lib/integrations/synchron-payments/mock.ts` |
| Purchase lifecycle rules, labels, return-page copy (pure) | `src/lib/domain/purchases.ts` |
| Product code ↔ route type, `formatCents` (pure) | `src/lib/domain/routing-products.ts` |
| Permit payment gate flag (`PERMIT_PAYMENT_READINESS_GATE`) | `src/lib/domain/permit-payment.ts` |
| Catalog reads | `src/lib/data/routing-products.ts` |
| Cart, purchase creation, checkout, `applyVerifiedPaymentEvent`, `reconcilePurchase`, `billingForPurchase` | `src/lib/data/purchases.ts` |
| Purchase audit trail (`logPurchaseEvent`) | `src/lib/data/purchase-events.ts` |
| Billing context resolution (which company pays) | `src/lib/data/billing-context.ts` |
| Permit request drafts (two-step Buy More Permits) | `src/lib/data/permit-request-drafts.ts` |
| API guards for cart / purchase routes | `src/lib/purchase-api.ts` |
| Cart API | `src/app/api/trips/[id]/cart/route.ts`, `…/cart/items/route.ts`, `…/cart/items/[itemId]/route.ts` |
| Create purchase from cart, list trip purchases | `src/app/api/trips/[id]/purchases/route.ts` |
| Purchase read / checkout / cancel / reconcile (owner or admin) | `src/app/api/purchases/[purchaseId]/route.ts`, `…/checkout`, `…/cancel`, `…/reconcile` |
| Permit request POST (readiness gate, `payment_confirmation_token`) | `src/app/api/trips/[id]/requests/route.ts` |
| Buy More Permits step 1 → draft (one pending draft per user per trip) | `src/app/api/trips/[id]/permit-request-drafts/route.ts` |
| Buy More Permits step 2: readiness check (always asks Synchron / mock) | `src/app/api/trips/[id]/permit-payment-readiness/route.ts` |
| Buy More Permits step 2: "Add Card Securely" → Synchron-hosted card-setup page | `src/app/api/trips/[id]/permit-payment/card-setup/route.ts` |
| Buy More Permits step 2: pick a card → Synchron-hosted card + CVV confirmation | `src/app/api/trips/[id]/permit-payment/confirm-card/route.ts` |
| Buy More Permits step 2: complete after return (re-checks the session, then one `service_requests` row per state; idempotent) | `src/app/api/trips/[id]/permit-payment/complete/route.ts` |
| Buy More Permits two-step dialog (extracted from the trip workspace) | `src/components/app/buy-permits-dialog.tsx` |
| Route matcher: `/purchases` requires a session | `src/proxy.ts` |
| Synchron webhook (HMAC `verifySynchronSignature`; `order.created`, `permit.attached`, `purchase.payment_confirmed`, `purchase.payment_failed`, `purchase.refunded`) | `src/app/api/webhooks/synchron/route.ts` |
| Scheduled reconciliation (Bearer `CRON_SECRET`) | `src/app/api/internal/purchase-reconciliation/route.ts` |
| Checkout return page (never changes status; polls reconcile) | `src/app/purchases/[ref]/return/page.tsx`, `return-status.tsx` |
| Trip workspace: purchase control, cart bar, purchase history | `src/components/app/route-purchase.tsx`, `src/components/app/route-purchase-history.tsx`, `src/components/app/trip-workspace/trip-workspace.tsx` |
| Dev-preview mock checkout page (pick paid / failed / cancelled / pending) | `src/app/dev-preview/synchron-checkout/[ref]/page.tsx`, `mock-checkout.tsx`, `src/app/api/dev-preview/synchron-payments/outcome/route.ts` |
| Dev-preview mock card-setup / card-confirmation page | `src/app/dev-preview/synchron-card-setup/[setupRef]/`, `src/app/api/dev-preview/synchron-payments/card-setup/route.ts` |
| Admin reconciliation page (surface `purchases`, admin only) | `src/app/admin/purchases/page.tsx`, `purchase-actions.tsx` |
| Admin actions API (`reconcile` / `flag` / `unflag`) | `src/app/api/admin/purchases/route.ts` |
| Surface registry entry | `src/lib/domain/moderator-access.ts` (`purchases`) |
| Types | `src/types/db.ts` (bottom: `RoutingProduct`, `Purchase`, `PurchaseItem`, `PaymentReference`, `PurchaseEvent`, `TripCart`, `TripCartItem`, `PermitRequestDraft`) |
| Tests | `tests/purchases-*.test.ts` (Task 13 matrix A–L), `tests/permissions.test.ts`, `tests/moderator-access.test.ts`, `tests/synchron-contract.test.ts` |

Tables: `routing_products`, `purchases`, `purchase_items`, `payment_references`, `purchase_events`, `trip_carts`, `trip_cart_items`, `permit_request_drafts`; new column `service_requests.payment_method_ref`; new `operation_events.area` value `billing`.

## Migrations and verify scripts

| Migration | Adds | Verify |
|---|---|---|
| `supabase/migrations/0045_routing_products.sql` | `routing_products` catalog seeded with `express_route` (199¢) and `extended_route` (1000¢) | `node scripts/verify-migration-0045.mjs` |
| `supabase/migrations/0046_purchases.sql` | `purchases`, `purchase_items`, `payment_references` | `node scripts/verify-migration-0046.mjs` |
| `supabase/migrations/0047_purchase_events_carts_permit_drafts.sql` | `purchase_events`, `trip_carts`, `trip_cart_items`, `permit_request_drafts`, `service_requests.payment_method_ref`, `billing` operation area | `node scripts/verify-migration-0047.mjs` |
| `supabase/migrations/0048_permit_draft_setup_reference.sql` | `permit_request_drafts.setup_reference` | covered by the 0047 script's table read; the column is optional |

All tables have RLS enabled with no policies; access is service-role only, authorization in app code, as everywhere else in this repo. The verify scripts are read-only (`select … limit 1`) and exit 1 when a table or column is missing.

## Environment variables

| Var | Default | Meaning |
|---|---|---|
| `SYNCHRON_PAYMENTS_MODE` | unset (= `live`) | `mock` selects the in-process mock adapter. Ignored when `NODE_ENV=production`. |
| `SYNCHRON_PAYMENTS_MOCK_SCENARIO` | `card_on_file` | Default mock scenario when the request does not choose one. |
| `PERMIT_PAYMENT_READINESS_GATE` | unset (= off) | `true` makes `POST /api/trips/[id]/requests` refuse a `permit_request` without a `payment_confirmation_token`. Off this phase so the FB / driver one-step dialogs keep working (D6). |
| `SYNCHRON_CALLBACK_SECRET` | — | HMAC-SHA256 secret for `POST /api/webhooks/synchron`, shared with Synchron. Payment events reuse the same signature scheme as `order.created` / `permit.attached`. |
| `CRON_SECRET` | — | Bearer token for `POST /api/internal/purchase-reconciliation` (and the existing hha-sync endpoint). |

No `STRIPE_*` variable exists or may be added (§12). `SYNCHRON_API_BASE_URL` / `SYNCHRON_API_TOKEN` are the read-only export-API credentials used by `scripts/synchron/`; the live payments adapter will reuse that naming once the real contract arrives, but reads nothing today.

## Demoing locally with the mock

1. `.env.local`: `SYNCHRON_PAYMENTS_MODE=mock` (and `NODE_ENV` not production — `npm run dev`).
2. Pick a scenario per request with header `x-hha-mock-scenario: <scenario>` or query `?mock=<scenario>`, or set `SYNCHRON_PAYMENTS_MOCK_SCENARIO` as the default. Scenarios (`MOCK_SCENARIOS` in `mock.ts`):
   `card_on_file`, `card_missing`, `card_setup_pending`, `card_setup_failed`, `checkout_created`, `payment_pending`, `payment_confirmed`, `payment_failed`, `multi_item_paid`, `multi_purchase`, `routing_processing`, `routing_completed`, `api_unavailable`.
3. **Route purchase:** open a CD trip (`/cd-trip-workspace/<ref>`), click "Purchase route" on permits with no credit left, "Review cart" in the cart bar, then "Complete Purchase". The mock `requestRouteCheckout` returns a `checkoutUrl` on `/dev-preview/synchron-checkout/<purchase_reference>`, where you choose **paid / failed / cancelled / pending**. That page posts the outcome to `/api/dev-preview/synchron-payments/outcome`, which goes through the same `applyVerifiedPaymentEvent` path a real callback would, then sends you back to `/purchases/<ref>/return`. The return page only reads and reconciles; it never sets status.
4. **Permit card on file:** open Buy More Permits, fill step 1, "Continue". With `card_missing` the readiness is `card_required` and `requestCardSetup` opens `/dev-preview/synchron-card-setup/<setupRef>`; with `card_on_file` you pick a mock card and `confirmCardUse` opens the same dev page as the "card + CVV confirmation". Choose **confirmed / failed / pending**; on return the draft is resumed and the permit request is released with `payment_method_ref` set.
5. `api_unavailable` makes every adapter call throw `SynchronUnavailableError`: the cart, uploaded documents and drafts stay intact and a `synchron_api_error` event is written.
6. Admin view: `/admin/purchases` lists purchases with items, payment references, the latest `synchron_api_error`, a "Mismatch — review" marker when Synchron's last reported status disagrees with the local one, an event timeline per row and the two allowed actions (re-run reconciliation, flag / clear flag).

## Waiting on Synchron

Nothing in this table is invented: no URL, payload or auth scheme has been assumed. Each row is the HHA-side method it has to be mapped onto in `src/lib/integrations/synchron-payments/types.ts`, all of which currently throw `SynchronApiNotConfiguredError` in `live.ts`.

| # | Needed from Synchron (task §30) | HHA adapter method / place |
|---|---|---|
| 1 | Checkout creation endpoint: create a Stripe Checkout Session for one HHA purchase (reference, order token, lines, total, return / cancel URLs) | `requestRouteCheckout(req: RoutePurchaseRequest): Promise<CheckoutSession>` |
| 2 | Checkout Session response shape: session id, hosted checkout URL, expiry, Synchron's own purchase id if any | `CheckoutSession { synchronPurchaseToken, stripeCheckoutSessionId, checkoutUrl, expiresAt }` |
| 3 | Purchase / order-item persistence contract: does Synchron store the HHA purchase reference and line items on its side, and under which ids | `createRoutePurchase(req): Promise<{ synchronPurchaseToken }>` |
| 4 | Payment webhook payload and confirmation mechanism (signed callback for paid / failed / refunded, stable event id, session + payment-intent ids) | `POST /api/webhooks/synchron` events `purchase.payment_confirmed` / `purchase.payment_failed` / `purchase.refunded` → `applyVerifiedPaymentEvent`; report shape `PurchaseStatusReport` |
| 5 | Status query for one purchase (used when a callback is missed) | `getRoutePurchaseStatus(purchaseReference, billing)` |
| 6 | Partial-refund / reconciliation capability: authoritative status including partial refunds | `reconcilePurchaseStatus(purchaseReference, billing): Promise<PurchaseStatusReport>` |
| 7 | Route fulfillment contract: per-item processing status and route deliverables (links, GPX, Hummer) after payment | `getRoutePurchaseItems(purchaseReference, billing): Promise<PurchaseItemReport[]>` |
| 8 | Card-on-file endpoint: does this HHA user / carrier have a usable card, safe summary only | `getCustomerPaymentStatus(billing): Promise<CustomerPaymentStatus>` |
| 9 | Card-setup endpoint: Synchron-hosted (Stripe-hosted) page to add a card, plus a status query for the resulting setup reference | `requestCardSetup(billing, returnUrl): Promise<CardSetupSession>` and `getCardSetupStatus(setupReference, billing): Promise<CardSetupStatus>` |
| 10 | **Synchron-hosted card-selection + CVV confirmation page** for one permit order (client decision D4; HHA may never render a CVV field), returning a one-time `paymentConfirmationToken` | `confirmCardUse(billing, cardId, returnUrl): Promise<CardSetupSession>` → `getCardSetupStatus(...).paymentConfirmationToken` |
| 11 | Permit pre-authorization behaviour: what Synchron does with the confirmation token when the permit order is submitted (hold, later charge, receipt reference for `service_requests.payment_method_ref`) | `submitPermitOrder(req: PermitOrderSubmission): Promise<PermitOrderReceipt>` |
| 12 | HHA ↔ Synchron authentication for all of the above (token, signing, IP allow-list) and which of HHA's existing tokens (`profiles.source_token`, `companies.source_token`, `trips.synchron_order_token`) identify the customer | `BillingContext`; `SYNCHRON_API_BASE_URL` / `SYNCHRON_API_TOKEN` naming reserved; callback signing reuses `SYNCHRON_CALLBACK_SECRET` |

## Status

**Payments are NOT operational.** The catalog, cart, purchase records, audit trail, admin reconciliation, the two-step permit dialog, the webhook handler and the mock all exist and are tested, but no money can move until every item in "Waiting on Synchron" is answered, the live adapter in `src/lib/integrations/synchron-payments/live.ts` is implemented against the real contract, and the full flow (checkout → signed confirmation → `paid` → route released; card setup → CVV confirmation → permit order) has been verified end to end with Synchron (task §31).
