# Invoice Payment Link Generation — Implementation Plan

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

## Goal
Give tenant staff a first-class way to generate, copy, preview, and email a customer payment link from inside the invoice management UI. The link is a stateless HMAC-SHA256 token over `invoiceId + ':' + tenantId` (the exact scheme already defined by `invoice-payment-ux`), resolving to `https://zync.is/pay/{invoiceToken}`. No new table is introduced; a single `payment_link_sent_at` audit column is added to `invoices`. Two API routes expose link generation (`GET`) and email send (`POST`), and the invoice detail / list UIs surface copy, open, and compose actions gated on payable status and gateway configuration.

## Architecture
This spec is a thin feature layer on top of three upstream modules:

- **payment-gateway-adapters** owns the token signing scheme conceptually shared with `invoice-payment-ux`: `HMAC-SHA256(INVOICE_PAYMENT_LINK_KEY, invoiceId + ':' + tenantId)` base64url-encoded. It also owns `payment_gateway_configs` (one active row per tenant). The "is a gateway configured?" check reads `payment_gateway_configs WHERE tenant_id = ? AND active = true`.
- **invoice-payment-ux** owns the customer-facing `/pay/{invoiceToken}` route, the `INVOICE_PAYMENT_LINK_KEY` secret, and the token verify/resolve helper (`resolveInvoiceFromToken`). This spec **reuses** the same token-signing helper so a link generated here is consumed there unchanged. We extract/sign the token; we do not re-implement verification.
- **invoices-core** owns the `invoices` table (with `status`, `invoice_number`, `proforma_number`, `customer_id`, `total`, `currency`, `due_date`), the `InvoiceStatus` type, the `invoices:read` / `invoices:write` permissions, the `serializeInvoice` serializer + `InvoiceObject` type, and the `POST /api/invoices/:id/send` route (where the auto-include-link behavior is wired).

Data flow for link generation: route handler loads invoice (tenant-scoped) → checks `status ∈ {SENT, TAX_ISSUED, PARTIALLY_PAID}` and at least one active gateway → signs token via shared helper → returns `{ url, token, status, reason? }`. For email send: route loads invoice + primary customer email (from `customers`), interpolates the `tenant_email_templates.payment_link` template (spec 66) replacing `{{payment_link}}`, sends via `sendEmail` (upstream `@zync/notifications`), and stamps `invoices.payment_link_sent_at = now()`.

Token validity is **derived** (stateless): tokens for `PAID | VOID | REJECTED | WRITTEN_OFF | BAD_DEBT | DRAFT | APPROVED` invoices report `status: 'inactive'` with a reason; only `SENT | TAX_ISSUED | PARTIALLY_PAID` report `active`.

Upstream tables/exports consumed: `invoices`, `customers`, `payment_gateway_configs` (tables); `InvoiceStatus`, `InvoiceObject`, `serializeInvoice`, `Customer` (types/serializers); `invoices:read`, `invoices:write` (permissions); `authMiddleware`, `requirePermission`, `tenantQuery`, `buildPaginated`, `sendEmail` (functions); `ApiError` (type). New secret reused: `INVOICE_PAYMENT_LINK_KEY`.

## Tech Stack
- **API:** Hono routes in `apps/zync-api` (Cloudflare Workers runtime), Drizzle ORM against Neon Postgres via Hyperdrive. Zod validation on the send body.
- **Token signing:** Web Crypto `crypto.subtle` HMAC-SHA256 in a shared helper in `packages/payments` (co-located with the gateway adapters that own the token scheme), base64url encoding.
- **Packages:** `@zync/payments` (token helper export), `@zync/db` (schema delta + query), `@zync/types` (response types), `@zync/notifications` (`sendEmail`), `@zync/ui` (Button, Card, Input, Toast/`toast`).
- **App UI:** Vite + React in `apps/zync-app` — invoice detail payment-link panel, invoice list inline copy action, inline compose panel.
- **Bindings:** `HYPERDRIVE`/`DB` (Drizzle), secret `INVOICE_PAYMENT_LINK_KEY` (already in wrangler.toml + `.dev.vars` per invoice-payment-ux). Email via existing notifications adapter (Resend / tenant SMTP).
- **Cross-cutting:** clipboard copy uses a secure-context guard; compose panel is keyboard-accessible with `aria` labels; RTL-aware (Hebrew tenants); `prefers-reduced-motion` respected on the "Copied!" toast.

## Wave Plan
| Sub-wave | Tasks | Files touched | Parallelizable? |
|----------|-------|---------------|-----------------|
| 12.a | 1 (schema delta) | `packages/db/src/schema/invoices.ts`, migration SQL | No — blocks all |
| 12.b | 2 (token helper), 3 (status/gateway service) | `packages/payments/src/payment-link.ts`, `apps/zync-api/src/services/payment-link.ts` | Yes (2 and 3 independent) |
| 12.c | 4 (GET route), 5 (POST send route) | `apps/zync-api/src/routes/invoices/payment-link.ts` | No — both depend on 12.b |
| 12.d | 6 (send-invoice auto-include), 7 (detail UI panel), 8 (list inline copy), 9 (compose panel) | invoices-core send handler, `apps/zync-app/src/...` | Partially — 7/8/9 parallel; 6 independent |

## Tasks

### Task 1: Schema delta — `payment_link_sent_at` on `invoices`
**Blocks:** 5, 6  ·  **Blocked by:** —
**Files:**
- Modify: `packages/db/src/schema/invoices.ts`
- Create: `packages/db/migrations/<timestamp>_invoice_payment_link_sent_at.sql`
**Steps:**
- [ ] Add the `payment_link_sent_at` column to the Drizzle `invoices` table definition as a nullable `timestamp({ withTimezone: true })`.
- [ ] Write the migration SQL (additive, no backfill — column is nullable).
- [ ] Regenerate / verify Drizzle types compile with the new column.
**Schema / Interfaces:**
```sql
ALTER TABLE invoices ADD COLUMN payment_link_sent_at TIMESTAMPTZ;
-- Timestamp of last payment link email send (audit trail, not enforcement).
```
Drizzle:
```ts
// packages/db/src/schema/invoices.ts (addition to existing invoices table)
payment_link_sent_at: timestamp('payment_link_sent_at', { withTimezone: true }),
```
**Acceptance:**
- [ ] Migration applies cleanly to Neon Postgres; `invoices.payment_link_sent_at` exists as `timestamptz`, nullable, no default.
- [ ] Existing invoices-core queries are unaffected (column omitted from inserts defaults to NULL).

### Task 2: Shared payment-link token helper
**Blocks:** 3, 4, 5  ·  **Blocked by:** —
**Files:**
- Create: `packages/payments/src/payment-link.ts`
- Modify: `packages/payments/src/index.ts` (export)
**Steps:**
- [ ] Implement `signInvoicePaymentToken(invoiceId, tenantId, key)` computing `HMAC-SHA256(key, invoiceId + ':' + tenantId)` via `crypto.subtle.importKey` + `crypto.subtle.sign`, base64url-encoded (no padding, `+`→`-`, `/`→`_`). This MUST match the scheme in `invoice-payment-ux` so the customer `/pay/{invoiceToken}` route resolves it.
- [ ] Implement `buildPaymentLinkUrl(token, baseUrl = 'https://zync.is')` returning `${baseUrl}/pay/${token}`.
- [ ] Verify it agrees with the upstream `resolveInvoiceFromToken` verifier (same separator `:`, same encoding) — if `invoice-payment-ux` already exports a signer, re-export that instead of duplicating; only implement here if no signer is exported upstream.
- [ ] Export both from the package index as `signInvoicePaymentToken`, `buildPaymentLinkUrl`.
**Schema / Interfaces:**
```ts
// packages/payments/src/payment-link.ts
export async function signInvoicePaymentToken(
  invoiceId: string,
  tenantId: string,
  key: string,
): Promise<string>  // base64url(HMAC-SHA256(key, `${invoiceId}:${tenantId}`))

export function buildPaymentLinkUrl(token: string, baseUrl?: string): string
```
**Acceptance:**
- [ ] A token signed by `signInvoicePaymentToken` resolves successfully via the `invoice-payment-ux` token verifier (round-trip test against the same key).
- [ ] Output is URL-safe base64 (no `+`, `/`, or `=`).

### Task 3: Payment-link status + gateway-availability service
**Blocks:** 4, 5  ·  **Blocked by:** 2
**Files:**
- Create: `apps/zync-api/src/services/payment-link.ts`
**Steps:**
- [ ] Implement `paymentLinkStatusForInvoice(status: InvoiceStatus): { state: 'active' | 'inactive'; reason?: string }` mapping the spec status table: `SENT | TAX_ISSUED | PARTIALLY_PAID` → active; `PAID` → inactive "Invoice paid — payment link expired"; `VOID` → inactive "Invoice void"; `REJECTED` → inactive "Invoice rejected"; `DRAFT | APPROVED` → inactive "Invoice not yet sent"; `WRITTEN_OFF | BAD_DEBT` → inactive "Invoice closed".
- [ ] Implement `tenantHasActiveGateway(db, tenantId): Promise<boolean>` querying `payment_gateway_configs WHERE tenant_id = ? AND active = true LIMIT 1` (uses upstream `payment_gateway_configs` table + partial index `idx_payment_gateway_configs_tenant`).
- [ ] Implement `resolveCustomerPrimaryEmail(db, tenantId, customerId): Promise<string | null>` reading the primary email from the upstream `customers` table.
**Schema / Interfaces:**
```ts
export type PaymentLinkState = 'active' | 'inactive'
export function paymentLinkStatusForInvoice(
  status: InvoiceStatus,
): { state: PaymentLinkState; reason?: string }
export function tenantHasActiveGateway(db: Db, tenantId: string): Promise<boolean>
export function resolveCustomerPrimaryEmail(
  db: Db, tenantId: string, customerId: string,
): Promise<string | null>
```
**Acceptance:**
- [ ] Every value of `InvoiceStatus` returns a defined `{ state, reason? }` (exhaustive switch; no `undefined`).
- [ ] `tenantHasActiveGateway` returns `false` when no row or all rows have `active = false`.

### Task 4: `GET /api/invoices/:id/payment-link` route
**Blocks:** 7, 8  ·  **Blocked by:** 2, 3
**Files:**
- Create: `apps/zync-api/src/routes/invoices/payment-link.ts`
- Modify: `apps/zync-api/src/routes/invoices/index.ts` (mount route)
**Steps:**
- [ ] Add Hono handler guarded by `authMiddleware` + `requirePermission('invoices:read')`.
- [ ] Load the invoice tenant-scoped via `tenantQuery` (404 `ApiError` if not found).
- [ ] Compute `state = paymentLinkStatusForInvoice(invoice.status)`. If no active gateway (`tenantHasActiveGateway` false), force `state = 'inactive'`, `reason = 'No payment gateway configured'`.
- [ ] Sign token with `signInvoicePaymentToken(invoice.id, tenantId, env.INVOICE_PAYMENT_LINK_KEY)`; build URL with `buildPaymentLinkUrl`.
- [ ] Return `{ url, token, status, reason? }` where `status` is the `'active' | 'inactive'` string.
**Schema / Interfaces:**
```
GET /api/invoices/:id/payment-link
  Auth: authMiddleware + requirePermission('invoices:read')
  Returns: { url: string; token: string; status: 'active' | 'inactive'; reason?: string }
```
```ts
interface PaymentLinkResponse {
  url: string
  token: string
  status: 'active' | 'inactive'
  reason?: string
}
```
**Acceptance:**
- [ ] For a `SENT` invoice with a configured gateway, returns `status: 'active'` and a URL resolving on `/pay/{token}`.
- [ ] For a `PAID` invoice, returns `status: 'inactive'`, `reason: 'Invoice paid — payment link expired'`.
- [ ] With no gateway configured, returns `status: 'inactive'`, `reason: 'No payment gateway configured'`.
- [ ] Cross-tenant invoice id returns 404 (tenant-scoped query).

### Task 5: `POST /api/invoices/:id/payment-link/send` route
**Blocks:** 9  ·  **Blocked by:** 1, 2, 3
**Files:**
- Modify: `apps/zync-api/src/routes/invoices/payment-link.ts`
- Create: `apps/zync-api/src/routes/invoices/payment-link.schema.ts` (Zod)
**Steps:**
- [ ] Add Hono handler guarded by `authMiddleware` + `requirePermission('invoices:write')`.
- [ ] Validate body with Zod: `to: string[]` (each a valid email, min 1), optional `subject: string`, optional `message: string`.
- [ ] Load invoice tenant-scoped; reject with 409 `ApiError` if `paymentLinkStatusForInvoice(invoice.status).state !== 'active'` (cannot send an inactive link) and if no active gateway.
- [ ] Default `to` to `resolveCustomerPrimaryEmail(...)` result when body `to` is empty/omitted; if still null, 422 `ApiError`.
- [ ] Sign token + build URL; load the `tenant_email_templates.payment_link` template (spec 66) for `subject`/`message` defaults when not provided; interpolate `{{payment_link}}` (and `{business_name}`) at send time.
- [ ] Send via `sendEmail` (`@zync/notifications`) to each recipient (respects tenant custom SMTP per spec 51 if configured, else Resend default).
- [ ] In the same DB write, set `invoices.payment_link_sent_at = now()`.
- [ ] Return `{ sent: true }`.
**Schema / Interfaces:**
```
POST /api/invoices/:id/payment-link/send
  Auth: authMiddleware + requirePermission('invoices:write')
  Body: { to: string[]; subject?: string; message?: string }
  Returns: { sent: true }
```
```ts
import { z } from 'zod'
export const sendPaymentLinkSchema = z.object({
  to: z.array(z.string().email()).min(1).optional(),
  subject: z.string().max(255).optional(),
  message: z.string().max(5000).optional(),
})
```
**Acceptance:**
- [ ] Sends an email containing the resolved payment URL (`{{payment_link}}` interpolated) to each recipient.
- [ ] Defaults `to` to the customer's primary email when body omits it.
- [ ] Sets `invoices.payment_link_sent_at` to the send time.
- [ ] Returns 409 when the invoice is not in a payable status; 422 when no recipient can be resolved.
- [ ] Requires `invoices:write` (403 otherwise).

### Task 6: Auto-include payment link in `POST /api/invoices/:id/send`
**Blocks:** —  ·  **Blocked by:** 1, 2, 3
**Files:**
- Modify: invoices-core send handler (`apps/zync-api/src/routes/invoices/send.ts`)
**Steps:**
- [ ] After the invoice transitions to `SENT`, check `tenant_settings.invoice_show_payment_link` (spec 125) — default behavior includes the link.
- [ ] When true AND a gateway is configured (`tenantHasActiveGateway`), sign the token and inject the payment URL into the outbound invoice email (a "Pay Now" link/button), per `invoice-payment-ux` entry point C.
- [ ] Do not block the send if no gateway is configured — simply omit the link.
- [ ] Do not stamp `payment_link_sent_at` here (that column tracks explicit "send payment link" emails, not the invoice-send email — keep semantics distinct per the spec's audit-decision note).
**Acceptance:**
- [ ] Sending an invoice with `invoice_show_payment_link = true` and a configured gateway produces an email containing the `/pay/{token}` link.
- [ ] With `invoice_show_payment_link = false`, the invoice email contains no payment link.
- [ ] With no gateway configured, the invoice still sends (no link, no error).

### Task 7: Invoice detail — payment link panel
**Blocks:** —  ·  **Blocked by:** 4
**Files:**
- Create: `apps/zync-app/src/features/invoices/components/PaymentLinkPanel.tsx`
- Modify: `apps/zync-app/src/features/invoices/pages/InvoiceDetailPage.tsx` (mount panel)
- Create: `apps/zync-app/src/features/invoices/hooks/usePaymentLink.ts`
**Steps:**
- [ ] `usePaymentLink(invoiceId)` fetches `GET /api/invoices/:id/payment-link` (TanStack Query), returning `{ url, token, status, reason }`.
- [ ] Render the panel only for invoices in `SENT | TAX_ISSUED | PARTIALLY_PAID`; for `PAID | VOID | REJECTED`, render the inactive label using `reason` ("Invoice paid — payment link expired" / "Invoice void" / "Invoice rejected"); render nothing for `DRAFT | APPROVED` (no link shown).
- [ ] Active state: show the URL (truncated), `[Copy]`, `[Open]`, `[📧]` buttons. `[Copy]` writes to clipboard (secure-context guarded) and shows a "Copied!" `toast`. `[Open]` opens `/pay/{token}` in a new tab (`rel="noopener noreferrer"`). `[📧]` opens the compose panel (Task 9).
- [ ] No-gateway state (`reason === 'No payment gateway configured'`): show "Payment link — inactive · Set up a payment gateway in Settings → Payment to enable" with a `[Go to Payment Settings →]` link to `/settings/integrations/payments`.
- [ ] Use `@zync/ui` `Card`, `Button`, `toast`; caption "Link is active until invoice is paid or voided."
**Cross-cutting:**
- [ ] All buttons have `aria-label`s; copy feedback is announced via an `aria-live="polite"` region (not toast-only).
- [ ] Layout is logical-property based (`margin-inline` etc.) so it mirrors correctly under `dir="rtl"` for Hebrew tenants.
- [ ] "Copied!" toast animation respects `prefers-reduced-motion`.
**Acceptance:**
- [ ] Panel shows copy/open/email actions for a `SENT` invoice with a gateway.
- [ ] `[Copy]` places the exact `url` on the clipboard and shows "Copied!".
- [ ] `PAID` invoice shows the inactive label; `DRAFT` shows no panel.
- [ ] No-gateway invoice shows the settings CTA linking to `/settings/integrations/payments`.

### Task 8: Invoice list — inline copy-link action
**Blocks:** —  ·  **Blocked by:** 4
**Files:**
- Modify: `apps/zync-app/src/features/invoices/components/InvoiceListRow.tsx`
**Steps:**
- [ ] For rows whose invoice status is `SENT | TAX_ISSUED | PARTIALLY_PAID`, add a copy-link icon button to the row's hover action group.
- [ ] On click, fetch the link via `usePaymentLink` (or a lighter on-demand fetch of `GET /api/invoices/:id/payment-link`), copy `url` to clipboard, show "Copied!" `toast`.
- [ ] Hide the icon for non-payable statuses.
**Cross-cutting:**
- [ ] Icon button has an `aria-label` ("Copy payment link"); the action group is keyboard-focusable, not hover-only (focus reveals it for keyboard users).
**Acceptance:**
- [ ] Copy-link icon appears on hover/focus for payable invoices and copies the correct URL.
- [ ] Icon is absent for `DRAFT`, `PAID`, `VOID`, `REJECTED`.

### Task 9: Send-payment-link inline compose panel
**Blocks:** —  ·  **Blocked by:** 5, 7
**Files:**
- Create: `apps/zync-app/src/features/invoices/components/SendPaymentLinkCompose.tsx`
- Create: `apps/zync-app/src/features/invoices/hooks/useSendPaymentLink.ts`
**Steps:**
- [ ] Inline panel (not a route) opened from the `[📧]` button on the detail panel.
- [ ] Pre-populate `To` with the customer's primary email; allow `[+ Add recipient]` for multiple recipients.
- [ ] Pre-populate `Subject` and `Message` from the `tenant_email_templates.payment_link` template (spec 66); show `{{payment_link}}` as a non-editable interpolated placeholder in the message body.
- [ ] `[Send now]` calls `useSendPaymentLink` → `POST /api/invoices/:id/payment-link/send` with `{ to, subject, message }`; on success show a success `toast` and close the panel; on error show an inline error.
- [ ] `[Cancel]` closes without sending.
**Cross-cutting:**
- [ ] Compose panel is a focus-trapped dialog region with `role="dialog"` + `aria-label`; first field receives focus on open; Escape closes.
- [ ] RTL-aware field layout for Hebrew tenants; inputs use logical properties.
- [ ] Recipient inputs validate email format client-side mirroring the server Zod schema.
**Acceptance:**
- [ ] Opening compose pre-fills `To` with the customer's primary email and the template subject/message.
- [ ] Adding a recipient and sending issues a `POST` with all recipients and shows a success toast.
- [ ] Server-side `payment_link_sent_at` is updated (verified via re-fetch / detail panel).
- [ ] Escape and `[Cancel]` close the panel without sending.

## Exported Names (for downstream specs)
- Routes: `GET /api/invoices/:id/payment-link`, `POST /api/invoices/:id/payment-link/send`
- Functions: `signInvoicePaymentToken`, `buildPaymentLinkUrl`, `paymentLinkStatusForInvoice`, `tenantHasActiveGateway`, `resolveCustomerPrimaryEmail`
- Types: `PaymentLinkResponse`, `PaymentLinkState`
- Schema: `sendPaymentLinkSchema`
- Components/hooks: `PaymentLinkPanel`, `SendPaymentLinkCompose`, `usePaymentLink`, `useSendPaymentLink`
- Column: `invoices.payment_link_sent_at`
