# Invoice Payment UX (Customer Pay Flow)

**Date:** 2026-05-31  
**Status:** Draft  
**Tier:** All tiers with a payment gateway configured (Payplus, Cardcom, or Stripe)  
**Depends on:** `invoices-core`, `payment-gateway-adapters`, `tenant-portals`, `foundation-auth-rbac`  
**Referenced by:** `payment-gateway-adapters`, `invoices-core`

---

## Locale & Direction

The payment page is customer-facing. `<html dir lang>` must be set from the invoice's tenant locale:

```astro
<!-- apps/zync-www/src/pages/pay/[invoiceToken].astro -->
const { invoice, tenantBranding } = await resolveInvoiceFromToken(invoiceToken)
const locale = tenantBranding.locale ?? 'he'
const dir = locale === 'he' ? 'rtl' : 'ltr'
---
<html dir={dir} lang={locale}>
```

Invoice amounts on the page: `formatCurrency(invoice.total, invoice.currency, toFormattingLocale(locale))`. Dates: `formatDate(invoice.taxIssueDate, locale, 'long')`.

---

## Overview

Specifies the customer-facing UX for paying an invoice. Three entry points:

1. **From the portal** — customer clicks "Pay Now" in `/portal/{tenantSlug}/invoices`
2. **From a payment link email** — tenant emails a direct payment link to the customer
3. **From a direct invoice link** — `/pay/{invoiceToken}` (no portal account needed)

All three converge at the same hosted payment flow:
→ `POST /api/invoices/:id/payment/session` → redirect to gateway → return to Zync return URL.

Spec 49 (`payment-gateway-adapters`) owns the `ZyncPaymentAdapter` interface, gateway implementations, session schema, and webhook verification. This spec owns the customer-facing pages and UX states.

---

## Entry Points

### A. Portal — My Invoices

Invoice list at `/portal/{tenantSlug}/invoices` (spec 30 — tenant-portals). For each invoice in status `TAX_ISSUED` (issued, unpaid):

> **Payable states:** payment is valid only after `TAX_ISSUED` (חשבונית מס issued, IL law requirement) — plus `PARTIALLY_PAID` for the remaining balance. A `SENT` proforma (חשבונית עסקה) is never payable. `payment-gateway-adapters` (spec 49) gates the Pay Now button on these states.
- Show "Pay Now" button if tenant has an active payment gateway
- Button state: primary CTA, disabled while session is being created

### B. Direct Payment Link (`/pay/{invoiceToken}`)

Tenant can share a direct link to pay a specific invoice without requiring portal login.

**invoiceToken**: URL-safe signed token: `HMAC-SHA256(INVOICE_PAYMENT_LINK_KEY, invoiceId + ':' + tenantId)` base64url-encoded. Token has no expiry (payment link remains valid until invoice is paid or archived).

**New route:** `zync.is/pay/[invoiceToken]` (zync-www, hybrid SSR). Resolves invoice from token → verifies invoice is payable → shows the Payment Page (see below) without portal auth.

**New secret:** `INVOICE_PAYMENT_LINK_KEY` — HMAC key for invoice token signing. Added to wrangler.toml secrets.

### C. Payment Link Sent with Invoice Email

When staff sends an invoice via email (invoices-core), if tenant has a gateway configured:
- Email includes a "Pay Now" button linking to `zync.is/pay/{invoiceToken}`
- No portal account required for the customer to pay

---

## Payment Page (`/pay/{invoiceToken}` and portal Pay Now flow)

Both converge on the same React island component `PaymentPageIsland`.

### Invoice preview

Before redirect to gateway, customer sees a summary:

```
┌────────────────────────────────────────────────┐
│  [Tenant logo]               Powered by Zync   │
├────────────────────────────────────────────────┤
│                                                │
│  Invoice #{number}                             │
│  {tenantName}  ·  Issued {date}                │
│                                                │
│  ┌──────────────────────────────────────────┐  │
│  │  Item 1                     ₪1,000       │  │
│  │  Item 2                     ₪  500       │  │
│  │  ─────────────────────────────────���───── │  │
│  │  Subtotal                   ₪1,500       │  │
│  │  VAT (17%)                  ₪  255       │  │
│  │  ─────────────────────────────────────── │  │
│  │  Total                      ₪1,755       │  │
│  └──────────────────────────────────────────┘  │
│                                                │
│  Pay securely via {gateway name + logo}        │
│                                                │
│  [Pay ₪1,755 →]                               │
│                                                │
│  �� Secure payment · No card data stored here  │
└────────────────────────────────────────────────┘
```

White-label branding: tenant logo, `primaryColor` on Pay button, custom domain → hide "Powered by Zync".

"Pay now" button:
1. Fires `POST /api/invoices/:id/payment/session` with `{ returnUrl: "https://zync.is/pay/{invoiceToken}/return" }`
2. Button shows spinner while session is created (disables re-click)
3. On success: `window.location.href = redirectUrl` (external gateway page)
4. On error: inline error "Could not initiate payment. Please try again or contact {tenantName}."

### Invoice states (not payable)

| Condition | Message |
|-----------|---------|
| `status === 'PAID'` | "This invoice has been paid. Thank you!" + receipt download |
| `status === 'DRAFT'` or `'APPROVED'` | "This invoice has not been issued yet. Contact {tenantName}." |
| `status === 'VOID'` | "This invoice has been voided. Contact {tenantName}." |
| No gateway configured | "Online payment is not available for this invoice. Contact {tenantName} to arrange payment." |
| Invoice not found | "This link is invalid or has expired." |

---

## Return URL Page (`/pay/{invoiceToken}/return`)

After payment (success or failure), the gateway redirects to:
`https://zync.is/pay/{invoiceToken}/return?gateway={slug}&session_id={gatewaySessionId}&status={success|failure|pending}`

The return page is a Astro hybrid SSR page (`apps/zync-www/src/pages/pay/[invoiceToken]/return.astro`).

On load:
1. Resolve invoice from `invoiceToken`
2. Look up `invoice_payment_sessions` by `gateway` + `session_id`
3. Check `session.status`

The gateway `status` query param is **untrusted** — always check DB session status (race between webhook and redirect).

> **Payment recording authority:** the gateway webhook (spec 49) is the sole authority for marking gateway-paid invoices `PAID`. The return page only polls session status — it does **not** call `record-payment`. Spec 30 (`tenant-portals`) defers to the webhook for gateway payments; `POST /api/invoices/:id/record-payment` remains only for manual/offline payment recording by staff.

### Return States

#### Success — `session.status === 'paid'`

```
┌────────────────────────────────────────────────┐
│  [Tenant logo]                                 │
│                                                │
│  ✓ Payment successful                          │
│                                                │
│  Invoice #{number} · ₪1,755                   │
│  Paid on {date}                                │
│                                                │
│  A receipt has been sent to {customerEmail}    │
│                                                │
│  [Download receipt]    [Back to portal]        │
└────────────────────────────────────────────────┘
```

"Back to portal" only shown if `tenantSlug` resolvable (portal exists). "Download receipt" → `GET /api/invoices/:id/receipt/pdf` (invoices-core: existing PDF endpoint).

#### Pending — `session.status === 'pending'`

Some gateways process async (Cardcom bank transfer):

```
┌────────────────────────────────────────────────┐
│  ⏳ Payment is being processed                  │
│                                                │
│  Invoice #{number} · ₪1,755                   │
│                                                │
│  Your payment is pending confirmation.         │
│  You will receive an email once confirmed.     │
│                                                │
│  Reference: {gatewaySessionId}                 │
└────────────────────────────────────────────────┘
```

Page polls `GET /api/invoices/:id/payment/status?session={sessionId}` every 5 seconds for up to 60 seconds. On session status changing to `paid`: transitions to Success state. After 60s timeout: shows "This may take a few minutes. You will receive a confirmation email." ��� stops polling.

#### Failed — `session.status === 'failed'` or `status` param is `failure`

```
┌────────────────────────────────────────────────┐
│  ✕ Payment failed                              │
│                                                │
│  Invoice #{number} · ₪1,755                   │
│                                                │
│  The payment could not be processed.           │
│  Please check your card details and try again. │
│                                                │
│  [Try again]    [Contact {tenantName}]         │
└────────────────────────────────────────────────┘
```

"Try again" → back to Payment Page (re-initiates a new session).

#### Race condition: status pending in DB but success in query param

Gateway redirect may arrive before webhook. If `session.status` is still `pending` but `status=success` in URL, show Pending state + begin polling. Most gateways deliver webhook within seconds of redirect.

---

## API Endpoints

```
POST /api/invoices/:id/payment/session
     → create payment session (portal auth OR invoiceToken-based auth)
        body: { returnUrl: string }
        returns: { sessionId, redirectUrl }
        Errors: 404 no gateway, 409 already paid, 403 invoice not owned by customer

GET  /api/invoices/:id/payment/status
     → poll session status (portal auth OR invoiceToken-based auth)
        query: session={gatewaySessionId}
        returns: { status: 'pending'|'paid'|'failed' }

GET  /api/pay/:invoiceToken/invoice
     → resolve invoice for payment page (no auth — token-based)
        returns: { invoice: { id, number, status, lineItems, total, currency },
                   tenantBranding: { ... } }
```

`invoiceToken` auth: verify HMAC signature; resolve invoice ID + tenant ID.

---

## Receipt Email

Triggered by `invoice.paid` event (spec 49 completion flow). Sent via Resend to `invoices.customer_email`:

Subject: "Receipt for Invoice #{number} — {tenantName}"  
Body: Invoice summary + "Paid on {date}" + "Download PDF" button (signed URL).

If tenant has custom SMTP configured (spec 51), send via tenant's SMTP. Otherwise Resend default.

---

## Foundation Deltas

**New secret:** `INVOICE_PAYMENT_LINK_KEY` — HMAC key for payment link tokens. Add to wrangler.toml secrets + `.dev.vars` example.

**New zync-www routes:**
- `apps/zync-www/src/pages/pay/[invoiceToken].astro` (hybrid SSR)
- `apps/zync-www/src/pages/pay/[invoiceToken]/return.astro` (hybrid SSR)

Requires `output: 'hybrid'` in `astro.config.mjs` (change owned by spec 22; referenced here for clarity).

**New API endpoint on invoices-core (spec 15):**
- `GET /api/invoices/:id/payment/status` — poll session status
- `GET /api/pay/:invoiceToken/invoice` — resolve invoice from token (no auth)

---

## Architecture Decisions

| Decision | Choice | Reason |
|----------|--------|--------|
| `invoiceToken` is HMAC-signed, not opaque ID | HMAC of invoiceId + tenantId | Stateless token — no DB lookup needed to verify authenticity; tenant and invoice embedded; no separate table |
| Return URL is on `zync.is/pay/…` (not gateway-specific) | Single return URL pattern | Uniform across Payplus/Cardcom/Stripe; gateway-specific redirect params handled in query string |
| Return page checks DB, not gateway status param | DB is source of truth | `status` query param from gateway is untrusted (can be spoofed); webhook + DB status is the authoritative record |
| Polling for pending status | Client polls every 5s, max 60s | Async gateways (Cardcom bank transfer) may take seconds; no WebSocket needed for one-time payment flow |
| Payment link no-auth (HMAC-gated) | HMAC token, no portal account needed | Customers without portal accounts should be able to pay; reducing friction increases payment conversion |
| "Pay Now" in invoice emails | Default when gateway configured | Fastest path to payment; reduces outstanding balance time; tenant can disable in invoice settings |
