# Invoice Payment UX (Customer Pay Flow) — Implementation Plan

**Spec:** docs/specs/2026-05-31-invoice-payment-ux.md  ·  **Slug:** invoice-payment-ux  ·  **Wave:** 11
**Depends on:** foundation-auth-rbac, invoices-core, payment-gateway-adapters, tenant-portals

## Goal
Deliver the customer-facing UX for paying an issued invoice online. Customers reach a hosted pay flow from three entry points — the customer portal "Pay Now", a direct HMAC-signed payment link (`/pay/{invoiceToken}`, no login), and a "Pay Now" button embedded in invoice emails. All three converge on a single Astro hybrid-SSR payment page + React island that previews the invoice, creates a gateway payment session, redirects to the hosted gateway, and shows success / pending / failed return states. The webhook (owned by `payment-gateway-adapters`) remains the sole authority that marks an invoice `PAID`; this spec only reads/polls session status and sends the receipt email.

## Architecture
This spec is **UX + read-side + token plumbing only**. It introduces **no new tables**. It consumes upstream interfaces:

- From `payment-gateway-adapters`: the `invoice_payment_sessions` table (columns `id, invoice_id, tenant_id, gateway, session_id, amount, currency, status CHECK('pending','paid','failed','expired'), return_url, created_at, paid_at, webhook_received_at, webhook_payload`), the `payment_gateway_configs` table (active gateway per tenant), the `ZyncPaymentAdapter` interface / `getAdapter(gateway)` registry, and the already-defined `POST /api/invoices/:id/payment/session` endpoint (this plan reuses it, does not redefine its session-creation logic).
- From `invoices-core`: the `invoices` table (`status` enum `DRAFT|SENT|APPROVED|TAX_ISSUED|PAID|PARTIALLY_PAID|REJECTED|VOID|WRITTEN_OFF|BAD_DEBT`, `invoice_number`, `tax_issue_date`, `due_date`, `currency`, `subtotal`, `vat_amount`, `vat_rate`, `total`, `customer_id`, `paid_at`), the `invoice_lines` table, `InvoiceObject` / `InvoiceStatus` / `InvoiceLineObject` types, `serializeInvoice` / `serializeInvoiceLine`, and the existing receipt/HTML endpoints (`GET /api/invoices/:id/html`, receipt PDF).
- From `tenant-portals`: tenant white-label branding (logo, brand/primary color, custom domain, locale), `customer_portal_users`, and the portal invoice list at `/portal/{tenantSlug}/invoices`.
- From `foundation-auth-rbac`: tenant resolution, `tenants` row (incl. `default_currency`, locale), and `timingSafeEqual` for HMAC comparison.
- From `system-i18n`: `formatCurrency`, `formatDate`, `toFormattingLocale`, `LocaleProvider`, `useDirection`.
- From `system-communications-notifications` (transitively, via invoices-core): `sendEmail` for the receipt; respects tenant custom SMTP if configured.

**Data flow.** Token entry: customer opens `/pay/{invoiceToken}` → Astro SSR verifies the HMAC token (`HMAC-SHA256(INVOICE_PAYMENT_LINK_KEY, invoiceId + ':' + tenantId)`), resolves the invoice via `GET /api/pay/:invoiceToken/invoice` (no auth, token-gated), renders `PaymentPageIsland`. The island calls the upstream `POST /api/invoices/:id/payment/session` with a `returnUrl` of `https://zync.is/pay/{invoiceToken}/return`, then `window.location.href = redirectUrl`. Gateway redirects back to `/pay/{invoiceToken}/return?gateway=&session_id=&status=` → return page resolves invoice → looks up `invoice_payment_sessions` by `(gateway, session_id)` → renders success/pending/failed from **DB status** (query `status` param is untrusted). Pending state polls `GET /api/invoices/:id/payment/status?session={sessionId}` every 5s for up to 60s. The receipt email is fired by the `invoice.paid` event handler (webhook completion in `payment-gateway-adapters`); this spec implements the email template + send function and wires it to that event.

## Tech Stack
- **apps/zync-www** (Astro, `output: 'hybrid'`): two new SSR routes under `src/pages/pay/`. React islands via `@astrojs/react`. Cloudflare Workers runtime.
- **apps/zync-api** (Hono on Workers): two new read endpoints (`GET /api/pay/:invoiceToken/invoice`, `GET /api/invoices/:id/payment/status`), the token verify/sign helpers, and the receipt-email handler hooked to `invoice.paid`.
- **packages/payments** (existing, from `payment-gateway-adapters`): add `invoiceToken` sign/verify helpers here so both API and the email builder share them. Reuses `getAdapter`, `invoice_payment_sessions`.
- **packages/ui**: reuse `Button`, `Card`, `Spinner`, `Alert`, `ErrorState`. No new design tokens.
- **packages/db**: Drizzle — read-only queries against `invoice_payment_sessions`, `invoices`, `invoice_lines`, `payment_gateway_configs`, `tenants`, `customers`.
- **Bindings/secrets:** new secret `INVOICE_PAYMENT_LINK_KEY` (HMAC key). Existing: `STORAGE` (R2 receipt PDFs), `QUEUE`/`webhook.deliver`, `RATE_LIMITER_WEBHOOK`. i18n via `@zync/config` + `translations`.
- **Crypto:** `crypto.subtle` HMAC-SHA256 + base64url; constant-time compare via `timingSafeEqual`.

## Wave Plan
| Sub-wave | Tasks | Files touched | Parallelizable? |
|----------|-------|---------------|-----------------|
| 11a — token + secret | 1 | packages/payments/src/invoice-token.ts; wrangler.toml; .dev.vars.example | No (foundation for all) |
| 11b — read API | 2, 3 | apps/zync-api routes | Yes (both depend only on Task 1) |
| 11c — shared island | 4 | apps/zync-www island + UX components | After Task 2 |
| 11d — pages | 5, 6 | apps/zync-www pay pages | After Tasks 3, 4 |
| 11e — receipt email | 7 | apps/zync-api email handler | After Task 1 (parallel with 11b–11d) |
| 11f — tests | 8 | api + island tests | Last |

## Tasks

### Task 1: Invoice payment-link token (HMAC sign/verify) + secret
**Blocks:** 2, 3, 5, 6, 7  ·  **Blocked by:** —
**Files:**
- Create: `packages/payments/src/invoice-token.ts`
- Modify: `packages/payments/src/index.ts` (export new helpers)
- Modify: `apps/zync-www/wrangler.toml`, `apps/zync-api/wrangler.toml` (declare `INVOICE_PAYMENT_LINK_KEY` secret)
- Modify: `apps/zync-www/.dev.vars.example`, `apps/zync-api/.dev.vars.example` (example value)
**Steps:**
- [ ] Implement `signInvoiceToken({ invoiceId, tenantId }, key)` → `HMAC-SHA256(key, invoiceId + ':' + tenantId)` via `crypto.subtle.importKey('raw', key, {name:'HMAC',hash:'SHA-256'},...)` + `crypto.subtle.sign`, base64url-encode the digest, and append it to a base64url payload so the token is `base64url(invoiceId:tenantId) + '.' + base64url(mac)`.
- [ ] Implement `verifyInvoiceToken(token, key)` → split on `.`, decode payload to `{invoiceId, tenantId}`, recompute MAC, compare with `timingSafeEqual` (from `@zync/auth`). Return `null` on any malformed/invalid token. No DB lookup (stateless).
- [ ] Token has **no expiry** — validity is enforced downstream by checking the resolved invoice is payable (not VOID/archived).
- [ ] Add `INVOICE_PAYMENT_LINK_KEY` to both Worker wrangler secret lists and `.dev.vars.example` with a clearly-fake 32-byte hex example.
**Schema / Interfaces:**
```ts
// packages/payments/src/invoice-token.ts
export interface InvoiceTokenClaims { invoiceId: string; tenantId: string }
export function signInvoiceToken(claims: InvoiceTokenClaims, key: string): Promise<string>
export function verifyInvoiceToken(token: string, key: string): Promise<InvoiceTokenClaims | null>
```
**Acceptance:**
- [ ] `verifyInvoiceToken(await signInvoiceToken({invoiceId,tenantId}, K), K)` round-trips to the same claims.
- [ ] A tampered token (any byte flipped) returns `null`; a token signed with a different key returns `null`.
- [ ] Comparison uses `timingSafeEqual`, not `===` (lint rule `no-string-equality-for-tokens` passes).

### Task 2: `GET /api/pay/:invoiceToken/invoice` — token-gated invoice resolver
**Blocks:** 4, 5, 6  ·  **Blocked by:** 1
**Files:**
- Create: `apps/zync-api/src/routes/pay.ts`
- Modify: `apps/zync-api/src/index.ts` (mount `pay` router, no auth middleware on this route)
**Steps:**
- [ ] Add `GET /api/pay/:invoiceToken/invoice` with **no auth middleware** — authorization is the HMAC token itself.
- [ ] `verifyInvoiceToken(invoiceToken, env.INVOICE_PAYMENT_LINK_KEY)` → `null` ⇒ 404 `{ error: 'invalid_token' }` (do not distinguish "not found" from "bad signature").
- [ ] Load the invoice scoped to `{ id: claims.invoiceId, tenant_id: claims.tenantId }`; if absent ⇒ 404 `invalid_token`.
- [ ] Load `invoice_lines` (ordered by `position`), the tenant branding (logo URL, primary/brand color, custom domain flag, locale, name), and whether an **active** `payment_gateway_configs` row exists for the tenant (`gateway` slug for the "Pay securely via {gateway}" label; do **not** return credentials).
- [ ] Resolve `customerEmail` from the invoice's `customer_id` (for the receipt-sent message only; never expose other customer PII).
- [ ] Return the serialized invoice using `serializeInvoice` / `serializeInvoiceLine` plus a `tenantBranding` block and a `gateway` field (`null` when no active gateway). Set `Cache-Control: no-store`.
- [ ] Apply `RATE_LIMITER_WEBHOOK`-style rate limiting (reuse an existing limiter binding) keyed by IP to prevent token brute-forcing; CSP/security headers per cross-cutting spec.
**Schema / Interfaces:**
```ts
// Response shape
interface PayInvoiceResponse {
  invoice: {
    id: string; number: string | null; status: InvoiceStatus;
    lineItems: InvoiceLineObject[]; subtotal: string; vatAmount: string;
    vatRate: string | null; total: string; currency: string;
    taxIssueDate: string | null; dueDate: string | null;
  };
  tenantBranding: {
    tenantSlug: string | null; tenantName: string; logoUrl: string | null;
    primaryColor: string | null; locale: 'he' | 'en'; hideZyncBranding: boolean;
  };
  gateway: { slug: 'payplus' | 'cardcom' | 'stripe' } | null;
  customerEmail: string | null;
  isPayable: boolean; // status IN ('TAX_ISSUED','PARTIALLY_PAID') AND gateway != null
}
```
**Acceptance:**
- [ ] Valid token returns invoice + branding; lines ordered by `position`.
- [ ] `isPayable` is true only for `TAX_ISSUED` / `PARTIALLY_PAID` with an active gateway; false for `SENT`/`DRAFT`/`APPROVED`/`PAID`/`VOID` or no gateway.
- [ ] Invalid/tampered token and unknown invoice both return identical 404 `invalid_token` (no enumeration leak). No credentials ever in the response.

### Task 3: `GET /api/invoices/:id/payment/status` — poll session status
**Blocks:** 6  ·  **Blocked by:** 1
**Files:**
- Modify: `apps/zync-api/src/routes/invoices.ts` (add status-poll route)
**Steps:**
- [ ] Add `GET /api/invoices/:id/payment/status?session={gatewaySessionId}`.
- [ ] Auth: accept **either** portal customer auth (logged-in portal user owning the invoice) **or** a valid `invoiceToken` passed as a query/header param verified via `verifyInvoiceToken` whose `invoiceId` matches `:id`. Reject otherwise with 403.
- [ ] Look up `invoice_payment_sessions` by `(invoice_id = :id, session_id = :session)`; 404 if no row.
- [ ] Normalize the stored status to the customer-facing set: map `'expired'` ⇒ `'failed'`; return `{ status: 'pending' | 'paid' | 'failed' }`. Source of truth is the DB row (never a query param). `Cache-Control: no-store`.
- [ ] This endpoint **must not** mutate invoice or session state (read-only; webhook owns transitions).
**Schema / Interfaces:**
```ts
// GET /api/invoices/:id/payment/status?session=<gatewaySessionId>
//   -> 200 { status: 'pending' | 'paid' | 'failed' }
//   -> 403 invalid auth ; 404 session not found
```
**Acceptance:**
- [ ] Returns DB session status, normalizing `expired`→`failed`.
- [ ] Works with both portal auth and invoiceToken auth; rejects mismatched token/invoice with 403.
- [ ] No write occurs (verified: invoice/session unchanged after a poll).

### Task 4: `PaymentPageIsland` + UX state components
**Blocks:** 5  ·  **Blocked by:** 2
**Files:**
- Create: `apps/zync-www/src/components/payment/PaymentPageIsland.tsx`
- Create: `apps/zync-www/src/components/payment/InvoicePreview.tsx`
- Create: `apps/zync-www/src/components/payment/NotPayableNotice.tsx`
- Create: `apps/zync-www/src/components/payment/payment-i18n.ts`
**Steps:**
- [ ] `InvoicePreview` renders the summary card: tenant logo, "Powered by Zync" (hidden when `tenantBranding.hideZyncBranding`), `Invoice #{number}`, `{tenantName} · Issued {formatDate(taxIssueDate, locale, 'long')}`, line items, Subtotal, `VAT ({vatRate%})`, Total — all amounts via `formatCurrency(amount, currency, toFormattingLocale(locale))`. "Pay securely via {gateway}" label + gateway logo.
- [ ] Primary "Pay {total} →" button styled with `tenantBranding.primaryColor` (fallback to design-system primary). On click: POST `/api/invoices/:id/payment/session` with `{ returnUrl: "https://zync.is/pay/{invoiceToken}/return" }`; show `Spinner`, disable button to block re-click. On success: `window.location.href = redirectUrl`. On error: inline `Alert` "Could not initiate payment. Please try again or contact {tenantName}."
- [ ] `NotPayableNotice` renders the not-payable matrix by `status` / gateway: `PAID` ⇒ "This invoice has been paid. Thank you!" + receipt download (`GET /api/invoices/:id/receipt/pdf`); `DRAFT`/`APPROVED` ⇒ "…has not been issued yet. Contact {tenantName}."; `VOID` ⇒ "…has been voided. Contact {tenantName}."; no gateway ⇒ "Online payment is not available… Contact {tenantName}…"; not found handled at the page level ⇒ "This link is invalid or has expired."
- [ ] Island decides preview vs. notice from `isPayable`. All copy from `payment-i18n.ts` (he + en); RTL honored via `dir` from page. Respect `prefers-reduced-motion` on the spinner. ARIA: button `aria-busy` while loading, error `Alert` has `role="alert"`.
**Schema / Interfaces:**
```tsx
interface PaymentPageIslandProps {
  invoiceToken: string;
  data: PayInvoiceResponse; // from Task 2
  apiBaseUrl: string;
}
export function PaymentPageIsland(props: PaymentPageIslandProps): JSX.Element
```
**Acceptance:**
- [ ] Payable invoice shows preview + working Pay button (spinner, disabled-on-click, redirect on success).
- [ ] Each non-payable state shows its exact message; PAID shows receipt download.
- [ ] Hebrew locale renders RTL with `formatCurrency`/`formatDate` output; "Powered by Zync" hidden on custom domain.

### Task 5: Pay page `/pay/[invoiceToken].astro` (hybrid SSR)
**Blocks:** —  ·  **Blocked by:** 2, 4
**Files:**
- Create: `apps/zync-www/src/pages/pay/[invoiceToken].astro`
- Modify: `apps/zync-www/astro.config.mjs` (ensure `output: 'hybrid'`; owned by spec 22, assert here)
**Steps:**
- [ ] SSR: call `GET /api/pay/:invoiceToken/invoice`. On 404 ⇒ render the standalone "This link is invalid or has expired." page (no island).
- [ ] Compute `const locale = tenantBranding.locale ?? 'he'; const dir = locale === 'he' ? 'rtl' : 'ltr'` and set `<html dir={dir} lang={locale}>`.
- [ ] Inject tenant brand color as a CSS variable on the page root; mount `<PaymentPageIsland client:load invoiceToken={...} data={...} apiBaseUrl={...} />`.
- [ ] Emit the CSP + security headers required by the cross-cutting spec (no inline-script except hashed island bootstrap; `frame-ancestors 'none'`).
**Acceptance:**
- [ ] `/pay/{validToken}` renders the payment page with correct `dir`/`lang`.
- [ ] `/pay/{badToken}` renders the invalid-link page, HTTP 404, no island, no leak.
- [ ] Page is SSR (hybrid), not statically prerendered.

### Task 6: Return page `/pay/[invoiceToken]/return.astro` (hybrid SSR) + poller island
**Blocks:** —  ·  **Blocked by:** 3, 4
**Files:**
- Create: `apps/zync-www/src/pages/pay/[invoiceToken]/return.astro`
- Create: `apps/zync-www/src/components/payment/ReturnStateIsland.tsx`
**Steps:**
- [ ] SSR: verify token + resolve invoice (reuse `GET /api/pay/:invoiceToken/invoice`); read query params `gateway`, `session_id`, `status`. The `status` param is **untrusted** — never used to render success directly.
- [ ] Look up the session via `GET /api/invoices/:id/payment/status?session={session_id}` (passing the invoiceToken for auth) to get the **DB** status. Set `<html dir lang>` from tenant locale as in Task 5.
- [ ] `ReturnStateIsland` renders by DB status:
  - **paid** ⇒ "✓ Payment successful", `Invoice #{number} · {formatCurrency(total)}`, `Paid on {formatDate(paid_at,…)}`, "A receipt has been sent to {customerEmail}", `[Download receipt]` (`GET /api/invoices/:id/receipt/pdf`) + `[Back to portal]` (only when `tenantSlug` resolvable).
  - **pending** ⇒ "⏳ Payment is being processed", invoice line, "pending confirmation… email once confirmed", `Reference: {gatewaySessionId}`. Poll `GET /api/invoices/:id/payment/status?session={sessionId}` every **5s up to 60s**; on `paid` transition to success; after 60s timeout stop polling and show "This may take a few minutes. You will receive a confirmation email."
  - **failed** (DB `failed`, or `expired`→`failed`, or query `status=failure`) ⇒ "✕ Payment failed", invoice line, "could not be processed… check card details", `[Try again]` (→ back to `/pay/{invoiceToken}`) + `[Contact {tenantName}]`.
- [ ] **Race handling:** if DB status is `pending` but query `status=success`, render Pending + start polling (do not show success until DB confirms).
- [ ] Polling respects `prefers-reduced-motion`; clears interval on unmount; `role="status"`/`aria-live="polite"` on the state region.
**Schema / Interfaces:**
```tsx
interface ReturnStateIslandProps {
  invoiceId: string; invoiceToken: string; invoiceNumber: string | null;
  total: string; currency: string; locale: 'he' | 'en';
  customerEmail: string | null; tenantName: string; tenantSlug: string | null;
  gatewaySessionId: string; initialStatus: 'pending' | 'paid' | 'failed';
  apiBaseUrl: string;
}
export function ReturnStateIsland(props: ReturnStateIslandProps): JSX.Element
```
**Acceptance:**
- [ ] Success/pending/failed states render per the spec mockups from **DB** status, ignoring a spoofed `status` query param.
- [ ] Pending polls every 5s, max 60s, auto-transitions to success on confirmation, then stops.
- [ ] `status=success` query + DB `pending` shows Pending (no false success). "Back to portal" hidden when no portal/slug.

### Task 7: Payment receipt email (on `invoice.paid`)
**Blocks:** —  ·  **Blocked by:** 1
**Files:**
- Create: `apps/zync-api/src/email/payment-receipt.ts`
- Modify: `apps/zync-api/src/events/invoice-paid.ts` (the `invoice.paid` handler from payment-gateway-adapters; wire the receipt send)
**Steps:**
- [ ] Build a `sendPaymentReceiptEmail({ invoiceId, tenantId })` that loads the invoice + customer email + tenant branding, regenerates/loads the invoice PDF from R2 (reuse invoices-core HTML→PDF; receipt PDF endpoint), and composes the email.
- [ ] Subject: `Receipt for Invoice #{number} — {tenantName}`. Body: invoice summary + `Paid on {formatDate(paid_at, locale, 'long')}` + a "Download PDF" button using a signed URL (token via `signInvoiceToken` / existing signed-URL helper). Localize (he/en) from tenant locale.
- [ ] Send via `sendEmail`: if the tenant has custom SMTP configured (spec 51), use it; otherwise the Resend default adapter. Send to the invoice's customer email.
- [ ] Invoke from the `invoice.paid` event handler **after** the webhook marks the invoice `PAID` — idempotent (skip if a receipt was already sent for this paid session).
**Schema / Interfaces:**
```ts
export function sendPaymentReceiptEmail(
  env: Env, args: { invoiceId: string; tenantId: string }
): Promise<DeliveryResult>
```
**Acceptance:**
- [ ] On `invoice.paid`, a receipt email is sent to the customer email with subject `Receipt for Invoice #{number} — {tenantName}` and a working signed Download-PDF link.
- [ ] Tenant-custom-SMTP tenants send via their SMTP; others via Resend default.
- [ ] Re-delivery of the same `invoice.paid` event does not send a duplicate receipt.

### Task 8: Tests
**Blocks:** —  ·  **Blocked by:** 2, 3, 4, 5, 6, 7
**Files:**
- Create: `packages/payments/src/invoice-token.test.ts`
- Create: `apps/zync-api/src/routes/pay.test.ts`
- Create: `apps/zync-api/test/payment-status.test.ts`
- Create: `apps/zync-www/src/components/payment/ReturnStateIsland.test.tsx`
**Steps:**
- [ ] Token: round-trip, tamper rejection, wrong-key rejection, constant-time compare path.
- [ ] `GET /api/pay/:invoiceToken/invoice`: payable vs non-payable `isPayable`, identical 404 for bad token and unknown invoice, no credentials in response.
- [ ] `GET /api/invoices/:id/payment/status`: DB-status read, `expired→failed` normalization, portal-auth and token-auth both accepted, mismatched token 403, no mutation.
- [ ] `ReturnStateIsland`: DB-status authority over spoofed `status` param; pending poll transitions to success; 60s timeout stops polling; reduced-motion honored.
**Acceptance:**
- [ ] All tests pass under the repo test runner; security assertions (no enumeration, no credential leak, DB-is-truth) are explicitly covered.
