# TASKS — Synchron Route Purchasing, Shopping Cart, Permit Payment Readiness

Source: client task document "HEAVYHAUL AGENT — DEVELOPMENT TASK — Synchron Route Purchasing, Shopping Cart, Permit Payment Setup & Integration Preparation", dated 2026-09-29. Priority: High.
Scope: this repo (`heavyhaul-agent-workspace`) and its Supabase schema. No changes to the Python agent backend (`HEAVYHAUL-AGENT`) are required by this task.

Every task below is grounded in the codebase as of commit `b6384dd` (2026-09-29). Section 0 records what already exists so nothing is rebuilt; Section 1 lists the decisions still open; Sections 2–14 are the tasks, grouped by module.

Legend: **EXISTS** = already implemented, reuse as is · **PARTIAL** = exists but must be extended · **NEW** = not in the codebase.

---

## 0. What already exists (inspected 2026-09-29)

| Area | Status | Where | Notes |
|---|---|---|---|
| Migrations | — | `supabase/migrations/0001…0044` | Highest is **0044**. The client doc says "0040 is next"; that is stale. **Next free number is 0045.** Every table has RLS enabled with **zero policies**; all access is via the service-role client `src/lib/supabase/admin.ts` (`createAdminClient()`), with authorization in app code. New tables must follow the same pattern. |
| Purchase / cart / payment / product / Stripe tables | NEW | — | **None exist.** No Stripe package, env var, or code anywhere in `src/`. |
| Route prices | PARTIAL | `src/components/app/route-purchase.tsx:41` `PRICE_CENTS = { express: 199, extended: 1000 }`; `src/lib/demo/credits.ts:133` `PAYG`; `src/app/(marketing)/page.tsx:274` | Prices are hardcoded client-side in several places. No backend catalog. |
| Route cart | PARTIAL | `src/components/app/route-purchase.tsx` (Task 69, 2026-09-07): `useRouteCart(tripId,userId)` in **localStorage** (`hha-route-cart:${tripId}:${userId}`), `CartItem { stateCode, routeType }` unique by `stateCode`, `RoutePurchaseControl`, `CartBar` ("Review & pay" dialog). Mounted in `trip-workspace.tsx:486` and `ct-dashboard/driver-view.tsx`. | Cart is per trip per user; total computed in the browser; "pay" just POSTs one `service_requests` row per item with `paid_with:'card'`; copy says "no card is charged in this pilot build". |
| "Add to cart" button visibility | BROKEN | `trip-workspace.tsx:1296-1350` (`PermitCard` header-right slot); same order in `driver-view.tsx` `RouteDeliverables` | Branch order: fulfilled route → existing request → `p.route_processing_status` → "Route included" → `RoutePurchaseControl`. Since migration 0041 made `permits.route_processing_status` NOT NULL default `'queued'`, the third branch always wins and **the purchase control never renders**. |
| Route requests | EXISTS | table `service_requests` (`type='route_request'`, `route_type express|extended`, `paid_with credit|card`, `payer`, `vendor_order_id`, `notify`); API `POST src/app/api/trips/[id]/requests/route.ts` | Nothing in `src/` ever advances a `route_request` past `requested` or writes route links to it. |
| Prepaid route credits | EXISTS | `src/lib/data/credits.ts` `getRouteCreditBalance()` counts this month's `service_requests` with `paid_with='credit'`; allowance = `CURRENT_PLAN.routes` from `src/lib/demo/credits.ts` (`CURRENT_PLAN = PLANS[0]` = Free, 1 route) | Demo plans Free/Starter $9/Pro $29/Pro Plus $59. This is the "existing subscription/credit logic" the client says to respect. |
| Route Token Logic | EXISTS — do not touch | `permits.route_token`, `permits.route_processing_status/route_links/route_gpx_url/route_hummer_url` (0041), `permit_processing_jobs`, `src/lib/data/permit-processing.ts`, `src/lib/domain/permit-routes.ts`; backend `services/workspace_route_tokens.py` | Confirmed complete per the client. A purchase token is a different thing. |
| Synchron order token ↔ trip | EXISTS | `trips.synchron_order_token` (8 digits, partial unique), `trips.synchron_order_id` (0035); backfill 0044; `service_requests.vendor_order_id` | One trip ↔ at most one Synchron order. |
| Synchron entity tokens | EXISTS | `source_token` (16-char) on `profiles` (user), `companies` (carrier), `trip_participants`, `fleet_units` (truck/trailer), `broker_agent_leads` (0040) | **No permit token is stored on `permits`**; only `permit_profile_observations.synchron_permit_token` exists and is currently always null (the callback sends an `item_id`, see `permit-processing.ts:160`). |
| Synchron outbound | EXISTS | Email only: `sendPermitRequestToSynchron` (`src/lib/data/permit-request-email.ts:106`) to `SYNCHRON_ORDER_EMAIL`. Dead stub `createSynchronOrder()` in `src/lib/adapters/flask.ts:450`. Read-only import scripts under `scripts/synchron/` (GET only, `SYNCHRON_API_TOKEN`). | There is **no** Synchron HTTP client in the app. |
| Synchron inbound | EXISTS | `src/app/api/webhooks/synchron/route.ts`, HMAC-SHA256 via `verifySynchronSignature` (`src/lib/integrations/synchron-contract.ts:25`), secret `SYNCHRON_CALLBACK_SECRET`, events `order.created` and `permit.attached` only. | Reuse this signature scheme for future payment callbacks; no payment events exist yet. |
| Buy More Permits form | EXISTS | `BuyPermitsDialog`, local component in `src/components/app/trip-workspace/trip-workspace.tsx:1567-1808`; mounted at `:1108` and via `PermitsGate` (`:333`) | Collects states (chip picker), `notes`, files (uploaded first to `POST /api/trips/[id]/documents` kind=other), `notify` (participant ids). Submits one `POST /api/trips/[id]/requests` per state, `type:'permit_request'`. All state is React `useState`; **no draft mechanism**. `notify` is stored but never used. Copy at `:1796`: "Payment for permit processing is collected directly by Synchron Permits." |
| Auth / session | EXISTS | `getSessionUser()` `src/lib/auth.ts:34` → `SessionUser { id, email, name, role, company, internal, originId, contextKey, … }` | |
| Trip access guard | EXISTS | `requireParticipant(tripId)` `src/lib/api-guard.ts` | 401/403; admin gets a synthetic participant. |
| Per-trip permissions | EXISTS | `src/lib/domain/permissions.ts`: `canRequestServices(role)` = any participant role | |
| Company / role context | EXISTS | `membership_roles.role_type` (authoritative, 0034) `carrier_dispatcher | carrier_driver | freight_broker | pilot_company_dispatch | pilot_driver`; `loadUserContexts` / `switchContext` in `src/lib/data/modes.ts`; `SessionUser.contextKey`; `companyCapabilities(role).manageBilling` (`src/lib/domain/company.ts:53`) true for `company_owner, company_admin, billing_admin` | Use these for billing-context resolution. |
| Admin | EXISTS | Single role `admin` (`UserRole`); no Super Admin (enforced by `tests/modes.test.ts:187`). Surface registry `src/lib/domain/moderator-access.ts` (`SurfaceKey`, `SURFACES`), guards `requireSurface` / `requireSurfaceApi` (`src/lib/auth/surface-guard.ts`), pages under `src/app/admin/*`, `/admin/operations` merges all log tables. | |
| Audit / ops logging | EXISTS | `logTripEvent()` `src/lib/audit.ts` → `trip_events`; `logOperationEvent()` `src/lib/observability/operation-events.ts` → `operation_events` (0042). `area` is CHECK-constrained to `permit|integration|ai|system`; `safeOperationDetail` redacts secrets/urls. | Adding a `billing` area needs a migration. |
| Email | EXISTS | `sendPlatformEmail()` `src/lib/email/send.ts:82`; category `billing_notifications` already in `src/lib/domain/email-policy.ts` | |
| Dev-only pattern | EXISTS | `process.env.NODE_ENV !== 'development' → notFound()` (`src/app/dev-preview/…`); env flags read as `=== 'true'`; `src/lib/demo/*` | No `*_MOCK` flags or fixtures folder yet. |
| Missing-migration tolerance | EXISTS | `isMissingColumn()` `src/lib/db-compat.ts:13` | Follow for new columns on existing tables. |
| Tests | EXISTS | vitest, `tests/*.test.ts(x)` (~60 files), hoisted `vi.mock` pattern (see `tests/operation-log.test.ts`, `tests/participant-access.test.ts`) | |
| Billing page | EXISTS (preview) | `src/app/billing/page.tsx`, `billing-preview.tsx`, `src/lib/data/billing.ts` `loadRoutePurchases` | Preview only; `AddCardDialog` is a fake form. |
| UX proposal | doc | `docs/ROUTE-PURCHASE-UX-PROPOSAL.txt` (2026-09-04) | Older design proposal; the 2026-09-29 client doc supersedes it where they differ. |

---

## 1. Client decisions (confirmed 2026-09-29)

| # | Decision |
|---|---|
| D1 | **Prepaid credits stay exactly as they are.** If the user has route credits, one click uses a credit and the route is granted; no card, no cart. The cart is only for routes when no credit is available. Keep `RoutePurchaseControl`'s credit path, `paid_with='credit'`, and `getRouteCreditBalance` unchanged. |
| D2 | **When no credit is left, the button reads "Purchase route"** (not "Add to cart"). Clicking it adds the route to the trip's cart; the user then checks out from the cart. Visibility rule (client did not specify; default retained): show the control whenever the permit has no fulfilled route and no open route request, regardless of `route_processing_status`; render the processing chip beside it, not instead of it. |
| D3 | **The cart is stored in the database, one cart per trip** (real users are the target). Items a client adds for a trip live in that trip's cart. localStorage is no longer the source of truth (Task 4.2). |
| D4 | **Buy More Permits becomes a two-step dialog.** Step 1 is the existing form (states, comments, files, notify). Its button becomes **"Continue"**. Step 2 is payment: HHA asks Synchron for the card on file; if the customer has cards on file, they choose which one to use; the customer must re-enter the CVV each time as an anti-abuse check (see constraint below). Only then is the permit order sent. The request/email is not released until step 2 succeeds. |
| D5 | **Unchanged.** Any permit processed by Synchron Permits includes its route in the price; Synchron produces the route and pushes it to HHA. No route purchase is ever offered for a Synchron-processed permit ("Route included" stays). |
| D6 | **Carrier Dispatch (CD) only** for this phase: `src/app/cd-dashboard`, `src/app/cd-trip-workspace/[ref]`. Other profiles (FB, driver, pilot) are reviewed after the CD version is seen. Shared components may keep compiling for other views but no cart/checkout UI work is done for them now. |

| D7 | **Routes and permits are separate flows, never one checkout.** Route purchase = pay before you get it (cart → Synchron checkout → paid → route released). Permit order = card-on-file process (two-step dialog → Synchron bills later). A permit request is never a cart item. The trip's purchase history (Task 7.2) may list both kinds side by side. |

**Constraint on D4 (CVV):** doc §20 forbids collecting or displaying raw card data inside HHA, and Synchron owns the Stripe account. Therefore the card-selection + CVV step must be a **Synchron-hosted (Stripe-hosted) page or element** that HHA opens; HHA passes the request and receives only a result token/status. HHA never renders a CVV field itself. Whether Synchron can provide such a page is added to the "Waiting on Synchron" list (Task 14.1). Until then the mock adapter simulates the step.

---

## 2. Module: Database & Migrations

### Task 2.1 — Migration `0045_routing_products.sql` (NEW)
Create table `routing_products`, RLS enabled, no policies (repo convention).
Columns (from doc §5): `id uuid pk default gen_random_uuid()`, `product_code text not null unique`, `product_name text not null`, `description text`, `selling_price_cents integer not null check (>= 0)`, `currency text not null default 'USD'`, `active boolean not null default true`, `product_version integer not null default 1`, `created_at`, `updated_at` (reuse the `touch_updated_at()` trigger pattern from 0019).
Seed rows: `express_route` / "Express Route" / 199 · `extended_route` / "Extended Route" / 1000.
Do **not** use Synchron's own flat routing price anywhere (doc §5).

### Task 2.2 — Migration `0046_purchases.sql` (NEW)
Three tables, RLS enabled, no policies. Field names follow doc §9; FK targets are the real tables.

**`purchases`**
`id uuid pk`, `hha_user_id uuid not null → profiles`, `company_id uuid → companies` (nullable: some users have no company), `hha_trip_id uuid not null → trips`, `synchron_order_token text` (copy of `trips.synchron_order_token` at creation; nullable), `synchron_user_token text` (copy of the buyer's `profiles.source_token`; nullable), `purchase_type text not null check in ('route')` (leave room for future types), `subtotal_cents integer not null`, `total_amount_cents integer not null`, `currency text not null default 'USD'`, `purchase_status text not null` (values in Task 5.1), `purchase_reference text not null unique` (HHA purchase token sent to Synchron, doc §15), `idempotency_key text not null unique`, `created_at`, `updated_at`.
**No unique constraint on `synchron_order_token`** — many purchases per order (doc §7). Index on `(hha_trip_id, created_at desc)`.

**`purchase_items`**
`id uuid pk`, `purchase_id uuid not null → purchases on delete cascade`, `hha_permit_id uuid → permits`, `synchron_permit_token text` (nullable — not currently known, see §0), `hha_route_request_token text` (nullable), `service_request_id uuid → service_requests` (link to the `route_request` row created on release; nullable), `product_code text not null`, `route_type text not null check in ('express','extended')`, `unit_price_cents integer not null`, `quantity integer not null default 1 check (> 0)`, `line_total_cents integer not null`, `currency text not null`, `product_version integer not null`, `fulfillment_status text not null` (values in Task 5.1), `created_at`, `updated_at`.
Price columns are a **snapshot** and are never updated from the catalog (doc §5, §8, Test D).

**`payment_references`**
`id uuid pk`, `purchase_id uuid not null → purchases on delete cascade`, `synchron_purchase_token text`, `synchron_order_token text`, `stripe_checkout_session_id text`, `stripe_payment_intent_id text`, `stripe_charge_id text`, `payment_status text`, `payment_confirmed_at timestamptz`, `source_system text not null default 'synchron'`, `checkout_url text`, `last_synced_at timestamptz`, `created_at`, `updated_at`. All external ids nullable until Synchron supplies them (doc §9); never fabricated in code.

### Task 2.3 — Migration `0047_purchase_events_and_permit_drafts.sql` (NEW)
- **`purchase_events`**: `id`, `purchase_id → purchases`, `purchase_item_id → purchase_items` (nullable), `event text not null`, `actor_user_id uuid`, `actor_label text`, `source_system text` (`hha|synchron|mock`), `synchron_order_token text`, `purchase_reference text`, `external_event_id text` (for duplicate-callback detection; partial unique where not null, doc §16/Test L), `detail jsonb not null default '{}'`, `created_at`. Event names in Task 12.1.
- **`permit_request_drafts`**: `id`, `trip_id → trips`, `user_id → profiles`, `state_codes text[] not null`, `notes text`, `document_ids uuid[]` (the already-uploaded `documents.id`s), `notify jsonb`, `readiness_status text`, `status text not null check in ('pending','resumed','submitted','abandoned')`, `created_at`, `updated_at`; **unique `(trip_id, user_id)` where status = 'pending'** so a draft can never fan out into multiple Synchron orders (doc §19).
- **`trip_carts`** (D3): `id`, `trip_id → trips not null`, `user_id → profiles not null`, `status text not null check in ('open','checked_out','abandoned') default 'open'`, `created_at`, `updated_at`; unique `(trip_id, user_id)` where status = 'open'.
- **`trip_cart_items`**: `id`, `cart_id → trip_carts on delete cascade`, `permit_id → permits not null`, `product_code text not null`, `quantity integer not null default 1`, `added_at`; unique `(cart_id, permit_id)`. **No price columns** — prices are always resolved from `routing_products` when the cart is read (Task 4.1).
- Extend `operation_events.area` CHECK to add `'billing'` (0042 constraint).

### Task 2.4 — Types and verify script (PARTIAL)
- Add `RoutingProduct`, `Purchase`, `PurchaseItem`, `PaymentReference`, `PurchaseEvent`, `PermitRequestDraft`, `TripCart`, `TripCartItem` to `src/types/db.ts` (hand-written file).
- Add `scripts/verify-migration-0045.mjs` … `0047` following `scripts/verify-migration-0042.mjs`.

---

## 3. Module: Routing Product Catalog & Pricing

### Task 3.1 — Backend product resolver (NEW)
`src/lib/data/routing-products.ts`: `listActiveRoutingProducts()`, `resolveRoutingProduct(product_code)` → `{ product_code, product_name, selling_price_cents, currency, product_version }` or an error if inactive/unknown. Reads `routing_products` via `createAdminClient()`. Prices stay in integer cents (doc §5).
Domain mapping `src/lib/domain/routing-products.ts`: `product_code ↔ route_type` (`express_route ↔ express`, `extended_route ↔ extended`) so existing `service_requests.route_type` and the `RouteType` union keep working.

### Task 3.2 — Remove client-side authoritative prices (PARTIAL)
- `src/components/app/route-purchase.tsx`: delete `PRICE_CENTS`; the UI receives prices from the server (Task 4.1). `PRICE_LABEL` becomes derived from server data.
- `src/lib/demo/credits.ts` `PAYG` and `src/lib/demo/route-types.ts` `ROUTE_PRICES`: leave for the demo trip `HH-48843549` and marketing copy only; they must not feed any purchase calculation.
- `src/app/(marketing)/page.tsx:274` "Order route · $1.99": marketing copy, unchanged.
- The browser submits **only `product_code`** per item (doc §5).

### Task 3.3 — Public read endpoint (NEW)
`GET /api/routing-products` (any signed-in user): returns active products for rendering the cart. No admin price-editing UI is requested; prices are "configurable in the backend" = editable in the table.

---

## 4. Module: Route Shopping Cart

### Task 4.1 — Server-side cart API (NEW, replaces the localStorage cart — D3)
All under `requireParticipant` + `canRequestServices`, CD context only (D6):
- `GET /api/trips/[id]/cart` — returns the caller's open `trip_carts` row for this trip with every item **priced live** from `routing_products`: `product_name`, `unit_price_cents`, `line_total_cents`, `subtotal_cents`, `total_cents`, `currency`. Creates the open cart on first read.
- `POST /api/trips/[id]/cart/items` body `{ permit_id, product_code, quantity? }` — validates: product active; permit belongs to the trip; permit is not Synchron-processed (D5); returns a per-line **warning** (not an error) when the permit already has a fulfilled route or an open `route_request`, so an intentional repurchase stays possible (doc §16).
- `DELETE /api/trips/[id]/cart/items/[itemId]`.
The client never sends a price. `CartBar` and the review dialog display **only** numbers returned by `GET …/cart` (doc §6).

### Task 4.2 — Replace `useRouteCart` storage (PARTIAL)
`useRouteCart(tripId, userId)` in `src/components/app/route-purchase.tsx` keeps its public shape (`items`, `add`, `remove`, `clear`, `totalCents`) but is backed by the Task 4.1 endpoints instead of localStorage (fetch on mount, optimistic update, refetch after each mutation). Remove the `hha-route-cart:*` localStorage key and the `hha-route-cart` window event. `CartItem` becomes `{ id, permitId, stateCode, productCode, productName, unitPriceCents }`, unique by `permitId` (today it is by `stateCode`, which cannot represent two permits for the same state). A cart may mix Express and Extended items (doc §6). Selections survive navigation because they are in the DB.

### Task 4.3 — Cart UI (PARTIAL: extend `CartBar` / review dialog)
Per doc §6 the review view shows: permit/state, route product, individual price, quantity where applicable, total, **Remove item**, **Continue shopping** (closes the dialog, returns to the trip), **Complete Purchase** (Task 5.2). Replace the current "Review & pay" wording and the "no card is charged in this pilot build" copy. The list format matches the doc example: "Texas Permit — Express Route — $1.99 … Total: $13.98". On successful purchase creation the cart row becomes `checked_out` and a fresh open cart is created on the next add.

### Task 4.4 — "Purchase route" control on permit cards (BROKEN → fix; D1, D2)
In `trip-workspace.tsx` `PermitCard` (as rendered by `cd-trip-workspace`), adjust the header-right branches so `RoutePurchaseControl` renders when the permit has no fulfilled route and no open route request, with the `route_processing_status` chip shown beside it. Behaviour:
- Credits remaining → existing "Use 1 Express Route credit · N left" inline confirm, unchanged (D1).
- No credits → button label **"Purchase route"** (replace "Add to cart · $…"). Click lets the user pick Express or Extended (product code, prices from Task 3.3) and calls `POST …/cart/items`; the `CartBar` then shows the cart and the checkout path.
- Synchron-processed permits (`trip.permit_policy === 'synchron_required'`) keep "Route included" and never show the control (D5).
Do not touch `driver-view.tsx` in this phase (D6).
Cart scope stays one trip / one Synchron order; no cross-trip or cross-company checkout (doc §6).

---

## 5. Module: Purchases, Checkout Preparation & Idempotency

### Task 5.1 — Status vocabularies (NEW)
`src/lib/domain/purchases.ts`:
- `PurchaseStatus`: `draft | pending_checkout | pending_payment | paid | payment_failed | expired | cancelled | refund_pending | partially_refunded | refunded`
- `FulfillmentStatus`: `awaiting_payment | ready_for_processing | submitted_to_synchron | processing | needs_review | completed | failed | cancelled`
- `PaymentReadiness` (permits): `checking | card_on_file | card_required | setup_pending | setup_failed | unavailable`
- Allowed transition tables + `assertTransition()`; `paid` may **only** be entered via Task 6.4 (verified confirmation), never from a UI route.
Two separate fields, never merged (doc §10).

### Task 5.2 — Create purchase (NEW)
`POST /api/trips/[id]/purchases` guarded as in 4.1. Body: `{ items: [{ permit_id, product_code, quantity }], idempotency_key }` where the client generates `idempotency_key` once per cart submission attempt and re-sends the same key on retry.
Server: re-quote (Task 4.1) → insert `purchases` (`purchase_status='pending_checkout'`, `purchase_reference` = new HHA purchase token, `synchron_order_token` from the trip, `synchron_user_token` from the resolved billing profile — Task 8.1) → insert `purchase_items` (`fulfillment_status='awaiting_payment'`, price snapshot) → log events (Task 12.1). If `idempotency_key` already exists, return the existing purchase (200) and create nothing (doc §16, Test E). Use a DB transaction (RPC) or insert order that cannot leave a purchase without items.
Do **not** create a new trip or a new Synchron order (doc §7).

### Task 5.3 — Request checkout (NEW)
`POST /api/purchases/[purchaseId]/checkout`: owner or admin only. Calls `requestRouteCheckout(purchase)` on the adapter (Task 6.1). On success stores `checkout_url` and any returned ids in `payment_references`, sets `pending_payment`, returns `{ checkout_url }`; the client redirects (doc §12 steps 5–7). If a `payment_references` row with a `checkout_url` already exists and the purchase is still `pending_payment`, return the same URL instead of requesting a new one (doc §26 "response is lost"). If the adapter throws, keep the purchase in `pending_checkout` with an error event; never fabricate a checkout (doc §26).

### Task 5.4 — Status read endpoints (NEW)
`GET /api/purchases/[purchaseId]` (purchase + items + payment reference, owner/company/admin per Task 8.2) and `GET /api/trips/[id]/purchases` (history, Task 7.1).

### Task 5.5 — Release to routing after payment (NEW)
When a purchase becomes `paid` (Task 6.4 only): for each item create the `service_requests` row (`type:'route_request'`, `route_type`, `paid_with:'card'`, `vendor_order_id = synchron_order_token`, `requested_by`), link it via `purchase_items.service_request_id`, set `fulfillment_status='ready_for_processing'`, then `submitted_to_synchron` once handed off. Existing `logTripEvent('service_requested')` and `syncTripToHha` calls in `requests/route.ts` are reused by extracting the insert into a shared helper rather than duplicating it. Paid ≠ completed (doc §10).

---

## 6. Module: Synchron Payment Integration Adapter & Mocks

### Task 6.1 — Adapter interface (NEW)
`src/lib/integrations/synchron-payments/index.ts` exporting a `SynchronPaymentsAdapter` interface with the operations from doc §11 (internal names, not endpoint names):
`createRoutePurchase`, `requestRouteCheckout`, `getRoutePurchaseStatus`, `getRoutePurchaseItems`, `getCustomerPaymentStatus` (returns the list of cards on file), `requestCardSetup`, `confirmCardUse` (D4: Synchron-hosted card selection + CVV confirmation, returns a `payment_confirmation_token`), `getCardSetupStatus`, `submitPermitOrder`, `reconcilePurchaseStatus`.
All inputs/outputs typed with the Synchron **tokens** (user/order/permit/carrier/truck/trailer `source_token`s, `purchase_reference`) plus HHA ids; no Stripe secret key, no card data. `getAdapter()` picks the implementation from env (Task 6.3). No React component or page imports Synchron specifics directly (doc §11).

### Task 6.2 — Live adapter stub (NEW, intentionally unconnected)
`live.ts`: every method throws `SynchronApiNotConfiguredError` referencing the pending item in `docs/SYNCHRON-PAYMENTS-INTEGRATION.md` (Task 14.1). No invented URLs (doc §30). Reuse `SYNCHRON_API_BASE_URL`/`SYNCHRON_API_TOKEN` naming when the real contract arrives; do not add `STRIPE_*` vars.

### Task 6.3 — Mock adapter (NEW)
`mock.ts`, selected only when `SYNCHRON_PAYMENTS_MODE=mock` **and** `NODE_ENV !== 'production'`; production always resolves to `live`. Scenario is chosen per call via a `x-hha-mock-scenario` header or `?mock=` in development, covering doc §27: `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`. Mock checkout URLs point at a local dev page (`/dev-preview/synchron-checkout/[purchaseRef]`, `notFound()` outside development) that lets the tester pick the return outcome. Mock confirmations carry `source_system='mock'` and are refused by Task 6.4 unless mock mode is active (doc §11: a mock must never mark a real transaction paid).

### Task 6.4 — Verified payment confirmation (NEW)
Single function `applyVerifiedPaymentEvent(event)` in `src/lib/data/purchases.ts`, the **only** code path that sets `purchase_status='paid'`. Inputs come from either (a) a signed Synchron callback or (b) `reconcilePurchaseStatus` polling. It: verifies `source_system` is trusted for the current mode; de-duplicates by `external_event_id` (Test L); updates `payment_references`; transitions status; triggers Task 5.5; logs events. Duplicate events are no-ops.
Callback endpoint: extend `src/app/api/webhooks/synchron/route.ts` with new event kinds (`purchase.payment_confirmed`, `purchase.payment_failed`, `purchase.refunded`, `card_setup.completed`, `card_setup.failed`) behind the existing `verifySynchronSignature`. Payload shapes are provisional and are listed in Task 14.1 as waiting on Synchron.

### Task 6.5 — Reconciliation job (NEW)
`GET/POST /api/internal/purchase-reconciliation` (Bearer `CRON_SECRET`, same pattern as `api/internal/permit-processing`): for purchases in `pending_payment` older than N minutes, call `reconcilePurchaseStatus` and feed the result into Task 6.4. Covers doc §26 "callback delayed".

---

## 7. Module: Checkout Return & Purchase History UI

### Task 7.1 — Return page (NEW)
`/purchases/[purchaseRef]/return` (protected; add to `PROTECTED_PREFIXES` in `src/proxy.ts`). Reads the purchase (Task 5.4) and renders one of the five states with the exact copy from doc §13:
- Pending: "We're checking your payment status with Synchron Permits." (polls Task 5.4 every few seconds, then calls reconcile)
- Confirmed: "Your payment was confirmed. Your routing request is being processed."
- Failed: "Your payment was not completed. Please try again." (button re-opens Task 5.3)
- Cancelled: "Your purchase was not completed. Your route selections have been preserved." (cart in localStorage is left intact; purchase → `cancelled`)
- Verification delayed: "Your purchase is awaiting payment confirmation. Please check again shortly."
Visiting this page never changes payment status (doc §13, Test F).

### Task 7.2 — Route purchase history under the trip (NEW)
Section "Routing Purchases" in the trip workspace overview (next to the existing "Requests to Synchron Permits" list, `trip-workspace.tsx:1082-1103`), fed by `GET /api/trips/[id]/purchases`. Each purchase shows: date, items (state + product), amount (snapshot), payment status, fulfillment status per item — matching the doc §14 example. Copy must not imply the whole Synchron order is paid. Reuse `src/lib/data/billing.ts` `loadRoutePurchases` if it fits, otherwise replace it to read `purchases`.

---

## 8. Module: Billing Context & Permissions

### Task 8.1 — Billing-context resolver (NEW)
`resolveBillingContext(user, trip)` in `src/lib/data/billing-context.ts`: from `SessionUser.contextKey` / `membership_roles` (active) and the trip's `carrier_company_id` / broker page company, determine the company and the Synchron user token (`profiles.source_token`) that this purchase bills under. Fails with 403 when the active role/company is not a participant on the trip's order. The frontend never sends a Synchron user token (doc §22).

### Task 8.2 — Enforcement (PARTIAL)
- Every purchase endpoint: `requireParticipant` + `canRequestServices` + Task 8.1.
- Read of a purchase: owner, approved member of the purchase's `company_id` with `companyCapabilities().manageBilling`, or admin. Knowing a trip id / Synchron token grants nothing (doc §23).
- No dashboard merging; the shared components stay mounted per role interface (doc §23, D6).

---

## 9. Module: Buy More Permits — Payment Readiness, Drafts, Card on File

### Task 9.1 — Readiness check endpoint (NEW)
`GET /api/trips/[id]/permit-payment-readiness` → adapter `getCustomerPaymentStatus(billingContext)`; returns `{ status: PaymentReadiness, card?: { brand, last4, exp_month, exp_year, available } }`. Always asks Synchron (or the mock); no locally cached card status is authoritative (doc §17, §26 "user closes the browser"). Only safe card fields are ever returned/stored (doc §20).

### Task 9.2 — Two-step `BuyPermitsDialog` (PARTIAL; D4)
**Step 1** is the existing form unchanged (states, special comments, files, notify). Its submit button is renamed **"Continue"**. Clicking it uploads the files (existing behaviour), saves the draft (Task 9.4) and moves to step 2 without creating any request.
**Step 2 — Payment** calls Task 9.1 and renders per doc §18:
- `checking`: spinner.
- `card_on_file`: list the cards Synchron returns (brand, last4, exp — safe fields only); the user selects the card to use. Button **"Send permit order"**. Because the client requires a CVV re-entry every time, this step hands off to the Synchron-hosted card-confirmation page/element (adapter `requestCardSetup`/`confirmCardUse`, see §1 constraint) and continues only on a confirmed result. HHA renders no CVV field.
- `card_required`: text "Add a payment method to your Synchron Permits billing profile to continue." + button **"Add Card Securely"** → Task 9.4.
- `setup_pending`: "Waiting for Synchron to confirm your payment method." + re-check.
- `setup_failed`: retry button.
- `unavailable`: sending disabled, explain Synchron is unreachable; draft preserved.
On confirmed result, the existing per-state `POST /api/trips/[id]/requests` loop runs (Task 9.3 passes the confirmation token) and the draft is marked `submitted`. Do not redesign step 1. No card fields inside HHA (doc §20). Both mount points (`:1108` and `PermitsGate`) get the same two-step dialog; CD workspace only (D6).

### Task 9.3 — Server-side gate (NEW, D4)
`POST /api/trips/[id]/requests` for `type:'permit_request'` requires a `payment_confirmation_token` (returned by the adapter after the step-2 card selection/CVV confirmation) and re-validates it server-side via `getCardSetupStatus`; if missing/invalid or readiness is not `card_on_file`, returns 409 `{ error, readiness }` and inserts/emails nothing. Store the selected card's safe reference (adapter-provided id, brand, last4 — never PAN/CVV) on the `service_requests` row via a new nullable `payment_method_ref text` column (add to migration 0047, tolerate with `isMissingColumn`). HHA never estimates or collects the permit amount (doc §21).

### Task 9.4 — Draft preservation across card setup (NEW)
Before redirecting to card setup: upload files as today, then `POST /api/trips/[id]/permit-request-drafts` saving `state_codes, notes, document_ids, notify` into `permit_request_drafts` (one pending draft per trip+user, Task 2.3). Call adapter `requestCardSetup(billingContext, returnUrl)` and redirect to the returned secure URL (generated by Synchron; HHA never builds a card form).
Return URL `/trips/[ref]/permits/resume?draft=…` → reopens `BuyPermitsDialog` prefilled from the draft (files listed by name, already uploaded), re-runs 9.1, and lets the user submit. On submit the draft is marked `submitted`; the existing per-state POST loop runs once. A resumed draft cannot be submitted twice (unique pending constraint + status).

### Task 9.5 — Card-on-file display (PARTIAL)
Replace the fake `AddCardDialog` in `src/app/billing/billing-preview.tsx` with a read-only card summary from Task 9.1 and the "Add Card Securely" action (Task 9.4). Never store full PAN/CVV (doc §20).

---

## 10. Module: Duplicate-Purchase Protection

### Task 10.1 — Client and server idempotency (NEW)
- Client: "Complete Purchase" is disabled after the first click; the server derives the `idempotency_key` from the open `trip_carts.id` (one open cart → one purchase attempt), so a retry after connection loss maps to the same purchase.
- Server: unique `purchases.idempotency_key` (Task 2.2); checkout URL reuse (Task 5.3); `external_event_id` dedupe (Task 6.4).
- Repurchase of a permit that already has a paid/fulfilled route is allowed after an explicit per-line warning in the quote (Task 4.1) — distinguishing accidental duplicates from intentional new purchases (doc §16).

---

## 11. Module: Admin — Purchase Reconciliation

### Task 11.1 — Admin surface `purchases` (NEW)
Register `SurfaceKey 'purchases'` in `src/lib/domain/moderator-access.ts` with `adminOnly: true`; page `src/app/admin/purchases/page.tsx` via `requireSurface('purchases')`, `AppShell` + `BackLink`, following `admin/operations/page.tsx`. Columns per doc §24: HHA purchase reference, customer, company, trip ref, Synchron order token, items with snapshot prices, purchase status, Synchron payment reference (session/intent ids), last sync time, fulfillment status per item, latest integration error (from `purchase_events`). Filters: status, trip, date. Detail drawer shows the event timeline.
Admin actions are limited to **"Re-run reconciliation"** (Task 6.5 for one purchase) and **"Flag for reconciliation"** (sets a `needs_reconciliation` boolean + event). **No "mark as paid"** (doc §24). Automatically flag purchases where the local status and the last reconcile response disagree.
Only the existing single `admin` role; no Super Admin (doc §24).

---

## 12. Module: Logging & Audit Trail

### Task 12.1 — Purchase events (NEW)
`logPurchaseEvent()` in `src/lib/data/purchase-events.ts` writing `purchase_events`, and mirroring a summary to `logOperationEvent({ area: 'billing', … })` so it appears in `/admin/operations`. Event names (doc §25): `cart_created`, `item_added`, `item_removed`, `purchase_submitted`, `checkout_requested`, `checkout_url_received`, `customer_redirected`, `payment_pending`, `synchron_payment_verified`, `purchase_marked_paid`, `routing_item_released`, `routing_item_completed`, `payment_failed`, `refund_reported`, `card_setup_requested`, `card_setup_confirmed`, `card_use_confirmed`, `permit_request_submitted`, `synchron_api_error`. Each carries timestamp, actor, `purchase_reference`, `synchron_order_token`, and any external ids. Cart events are written by the Task 4.1 endpoints server-side (`purchase_id` null, `cart_id` in `detail`). Use `safeOperationDetail` — never log secrets or card data.

---

## 13. Module: Automated Tests (vitest, `tests/`)

Use the hoisted `vi.mock` + in-memory Supabase builder patterns from `tests/operation-log.test.ts` and `tests/participant-access.test.ts`. One file per letter is fine (`tests/purchases-*.test.ts`).

| Test | Asserts (doc §28) |
|---|---|
| A | One `express_route` → one purchase, one item, `unit_price_cents=199`, status `pending_checkout`. |
| B | 2× express + 1× extended → total 1398, three items under one purchase. |
| C | Two purchases on the same `synchron_order_token` keep separate `payment_references` rows. |
| D | After updating `routing_products.selling_price_cents`, an existing item still reads its snapshot. |
| E | Same `idempotency_key` twice → one purchase, adapter `requestRouteCheckout` called once. |
| F | Rendering/hitting the return page does not change `purchase_status`. |
| G | Only `applyVerifiedPaymentEvent` with a trusted source sets `paid`; an unsigned or mock-in-production event is rejected. |
| H | Readiness `card_on_file` → permit request POST proceeds and email helper is called. |
| I | Readiness `card_required` → POST returns 409, draft row exists with states/notes/document ids/notify, no email. |
| J | User with `carrier_dispatcher` (Company A) and `freight_broker` (Company B) cannot create or read a purchase under the other company's context. |
| K | Adapter throws `api_unavailable` → cart entry untouched, uploaded documents remain, purchase stays `pending_checkout` with an error event. |
| L | The same `external_event_id` delivered twice → one status transition, one `service_requests` row per item. |

Also keep `tests/modes.test.ts:187` (no Super Admin) green and run the full suite (`npm test`), `npm run lint`, `npm run build`.

---

## 14. Module: Documentation

### Task 14.1 — `docs/SYNCHRON-PAYMENTS-INTEGRATION.md` (NEW)
Follow the `docs/PERMIT-COST.md` / `docs/MODERATOR-ACCESS.md` layout (rules, "where things live" table, migrations, verify scripts). Must contain the explicit **"Waiting on Synchron"** list from doc §30, mapped to adapter methods: checkout creation endpoint (`requestRouteCheckout`), card-setup endpoint (`requestCardSetup`), a Synchron-hosted card-selection + CVV confirmation page (`confirmCardUse`, required by D4), Checkout Session response shape, purchase/order-item persistence contract (`createRoutePurchase`), payment webhook payload and confirmation mechanism (Task 6.4 events), card-on-file endpoint (`getCustomerPaymentStatus`/`getCardSetupStatus`), permit pre-authorization behaviour (`submitPermitOrder`), route fulfillment contract (`getRoutePurchaseItems`), partial-refund/reconciliation capability (`reconcilePurchaseStatus`), HHA↔Synchron authentication. State clearly that payments are **not** operational until these are connected and verified end to end (doc §31).

### Task 14.2 — Final summary for the client (NEW)
On completion, produce the summary the client requires (doc §31): what was implemented, migrations introduced (0045–0047), existing components reused (`route-purchase.tsx`, `BuyPermitsDialog`, `requireParticipant`, `verifySynchronSignature`, `logOperationEvent`, admin surface registry, `sendPlatformEmail`), tests and results, API interfaces prepared, remaining Synchron dependencies. Also update `README.md` "Environment variables" with `SYNCHRON_PAYMENTS_MODE`.

---

## 15. Explicitly out of scope (do not build)

- Real Stripe integration on either side; any `STRIPE_*` env var in HHA (doc §12).
- Estimating or collecting permit-processing amounts (doc §21).
- Rebuilding Route Token Logic or the permit processing queue (doc §15).
- New Trip or Synchron order creation on route purchase (doc §7).
- Cross-trip / cross-company checkout (doc §6).
- Merging role dashboards or trip views (doc §23).
- Cart/checkout/two-step permit UI for FB, driver (`ct-`), pilot (`pd-`/`pc-`) views — CD only this phase (D6).
- A CVV or card-number field rendered by HHA (doc §20); card confirmation is Synchron-hosted.
- Super Admin (doc §24).
- Admin "mark as paid" (doc §24).
- Changing HHA subscription plans (Category A stays separate, doc §2).
- Custom card-entry form in HHA (doc §20).

---

## 16. Acceptance checklist (doc §31)

- [ ] Open a Trip, select multiple permits, add Express and Extended routes, see server-quoted prices, create one pending purchase with all items.
- [ ] Second and third purchases on the same Synchron order token succeed and keep separate payment references.
- [ ] Changing a catalog price does not alter historical purchase items.
- [ ] Full payment lifecycle demonstrable with the mock adapter (pending → confirmed / failed / cancelled / delayed).
- [ ] Buy More Permits detects `card_required`, preserves the draft through the card-setup redirect, and resumes.
- [ ] Mock cannot mark anything paid in production; unverified return visits never set `paid`.
- [ ] Records use Synchron tokens externally and HHA uuids internally.
- [ ] Subscriptions, routing logic, memberships, historical claim flow and role dashboards unchanged (`npm test`, `npm run lint`, `npm run build` green).
