# Payment Gateway Adapters (Tenant Invoice Payment Collection) — Implementation Plan

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

## Goal
Enable a tenant's customers to pay invoices online through hosted payment pages (redirect, never embedded — PCI-safe). Three gateway adapters ship: Payplus and Cardcom (Israeli, primary) and Stripe (international/multi-currency). A single `PaymentGatewayAdapter` interface plus a registry keeps invoice and portal code gateway-agnostic. This spec covers **outbound** collection (tenant's customers → tenant), not Zync's own subscription billing.

## Architecture
- New package `@zync/payments` (`packages/payments`) exporting the adapter interface, three adapter implementations, the registry (`getAdapter`), and AES-256-GCM config crypto (`encryptConfig` / `decryptConfig`).
- New tables: `payment_gateway_configs` (one config per tenant per gateway; credentials AES-256-GCM encrypted in `config_encrypted`) and `invoice_payment_sessions` (one row per "Pay Now" attempt). A `payment_webhook_token` column is added to the upstream `tenants` table for per-tenant webhook routing.
- Consumes upstream: `tenants(id)`, `invoices(id, status, total, currency, customer_id, paid_at, tenant_id)` and `InvoiceStatus` from `@zync/types` (statuses `TAX_ISSUED`, `PARTIALLY_PAID`, `PAID`), `customer_portal_users` (portal auth from tenant-portals), `requirePermission`/`authMiddleware`/`tenantQuery`/`systemQuery` from `@zync/auth` + `@zync/db`, `RATE_LIMITER_WEBHOOK` binding, `sendEmail` (`@zync/notifications`), and the existing `webhook.deliver` outbound queue for the `invoice.paid` event.
- Customer flow: portal "Pay Now" → `POST /api/invoices/:id/payment/session` (loads active gateway config → `adapter.createPaymentSession`) → store session row → return `redirectUrl` → customer pays on hosted page → gateway POSTs `POST /webhooks/payment/:gateway/:tenantWebhookToken` → `adapter.verifyWebhook` → mark session `paid`, invoice `PAID`, fire `invoice.paid` outbound event + receipt email → customer redirected to `returnUrl`.
- Settings UI at `/settings/integrations/payments` (an integration card inside the settings-module integrations hub) manages config, test connection, and the per-tenant webhook URL.

## Tech Stack
- **Package:** `packages/payments` (`@zync/payments`) — pure TS, Workers-compatible (uses `crypto.subtle`, `fetch`; no Node crypto).
- **API:** Hono routes in `apps/zync-api` — settings routes, portal payment routes, public webhook route.
- **App UI:** Vite+React in `apps/zync-app` — `/settings/integrations/payments` page + invoice-detail payment section; portal "Pay Now" button surface lives in the portal app/route (tenant-portals).
- **ORM:** Drizzle (`@zync/db`). **DB:** Neon Postgres via Hyperdrive.
- **Bindings:** `RATE_LIMITER_WEBHOOK` (existing), env secret `PAYMENT_CONFIG_ENCRYPTION_KEY`, `webhook.deliver` queue (existing), R2 `STORAGE` for receipt PDF.
- **Stripe:** use `stripe` SDK's `constructEventAsync` (Workers/`crypto.subtle` variant) for webhook verification.

## Wave Plan
| Sub-wave | Tasks | Files touched | Parallelizable? |
|----------|-------|---------------|-----------------|
| 10a | 1 (migrations + tenants delta) | `packages/db/src/schema/payments.ts`, migration SQL | No (blocks all) |
| 10b | 2 (package scaffold + interface + crypto + registry) | `packages/payments/*` | After 1 |
| 10c | 3, 4, 5 (Payplus, Cardcom, Stripe adapters) | `packages/payments/src/adapters/*` | Yes (parallel after 2) |
| 10d | 6 (config service), 7 (session service) | `apps/zync-api/src/services/payments/*` | Yes (after 2) |
| 10e | 8 (settings routes), 9 (portal payment routes), 10 (webhook route) | `apps/zync-api/src/routes/*` | Partly (after 6/7) |
| 10f | 11 (settings UI), 12 (invoice + portal UI surfaces) | `apps/zync-app/src/**` | Yes (after 8/9) |
| 10g | 13 (tenant webhook-token generation), 14 (tests) | hooks + test files | After above |

## Tasks

### Task 1: Database schema — payment tables + tenants delta
**Blocks:** 2,6,7,8,9,10,13  ·  **Blocked by:** —
**Files:**
- Create: `packages/db/src/schema/payments.ts`
- Modify: `packages/db/src/schema/index.ts` (export new tables)
- Modify: `packages/db/src/schema/tenants.ts` (add `payment_webhook_token` column)
- Create: `packages/db/migrations/<ts>_payment_gateway_adapters.sql`
**Steps:**
- [ ] Write the canonical Postgres DDL below as a Drizzle migration (Neon, not SQLite).
- [ ] Add Drizzle table definitions mirroring the DDL in `payments.ts`; export `paymentGatewayConfigs`, `invoicePaymentSessions`.
- [ ] Add `payment_webhook_token TEXT` to the existing `tenants` table (nullable; populated by Task 13).
- [ ] Add a `test_mode` boolean to `invoice_payment_sessions` (spec §Test mode: sessions tagged `test_mode = true`).
**Schema / Interfaces:**
```sql
-- Gateway configuration per tenant (one active config per gateway)
CREATE TABLE payment_gateway_configs (
  id                UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id         UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
  gateway           TEXT NOT NULL CHECK (gateway IN ('payplus', 'cardcom', 'stripe')),
  config_encrypted  TEXT NOT NULL,  -- AES-256-GCM encrypted JSON of credentials (EncryptedConfig blob)
  test_mode         BOOLEAN NOT NULL DEFAULT false,
  active            BOOLEAN NOT NULL DEFAULT true,
  created_at        TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at        TIMESTAMPTZ NOT NULL DEFAULT now(),
  UNIQUE (tenant_id, gateway)
);

CREATE INDEX idx_payment_gateway_configs_tenant ON payment_gateway_configs(tenant_id)
  WHERE active = true;

-- Payment sessions created when a customer clicks "Pay Now"
CREATE TABLE invoice_payment_sessions (
  id                  UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  invoice_id          UUID NOT NULL REFERENCES invoices(id) ON DELETE CASCADE,
  tenant_id           UUID NOT NULL REFERENCES tenants(id),
  gateway             TEXT NOT NULL CHECK (gateway IN ('payplus', 'cardcom', 'stripe')),
  session_id          TEXT NOT NULL,            -- gateway-provided session/page ID
  amount              INTEGER NOT NULL,         -- agorot (ILS) or cents
  currency            TEXT NOT NULL DEFAULT 'ILS',
  status              TEXT NOT NULL DEFAULT 'pending'
                        CHECK (status IN ('pending', 'paid', 'failed', 'expired')),
  test_mode           BOOLEAN NOT NULL DEFAULT false,
  return_url          TEXT NOT NULL,
  created_at          TIMESTAMPTZ NOT NULL DEFAULT now(),
  paid_at             TIMESTAMPTZ,
  webhook_received_at TIMESTAMPTZ,
  webhook_payload     TEXT,                     -- raw webhook body for audit
  UNIQUE (gateway, session_id)
);

CREATE INDEX idx_payment_sessions_invoice ON invoice_payment_sessions(invoice_id);
CREATE INDEX idx_payment_sessions_tenant_status ON invoice_payment_sessions(tenant_id, status);

-- Per-tenant webhook routing token (hashed; only routes, security is HMAC)
ALTER TABLE tenants ADD COLUMN payment_webhook_token TEXT; -- 32-byte hex
```
**Acceptance:**
- [ ] `pnpm --filter @zync/db migrate` applies cleanly on a Neon branch.
- [ ] Both tables and the `tenants.payment_webhook_token` column exist; all FKs are UUID→UUID; all enums are `CHECK` constraints matching the spec verbatim.

### Task 2: `@zync/payments` package — adapter interface, registry, config crypto
**Blocks:** 3,4,5,6,7  ·  **Blocked by:** 1
**Files:**
- Create: `packages/payments/package.json`, `packages/payments/tsconfig.json`, `packages/payments/src/index.ts`
- Create: `packages/payments/src/adapter.ts`
- Create: `packages/payments/src/registry.ts`
- Create: `packages/payments/src/config-crypto.ts`
**Steps:**
- [ ] Scaffold `@zync/payments` workspace package (Turborepo): `package.json` with name `@zync/payments`, type module, peer on `@zync/types`.
- [ ] Define the adapter interface and supporting types in `adapter.ts` exactly as the spec dictates (see below).
- [ ] Implement `config-crypto.ts` using `crypto.subtle` AES-GCM with `PAYMENT_CONFIG_ENCRYPTION_KEY` (base64 32-byte key imported via `crypto.subtle.importKey`). 12-byte random IV per encrypt; output `EncryptedConfig` JSON serialized to the `config_encrypted` TEXT column.
- [ ] Implement `registry.ts` with `getAdapter(gateway)` over the three adapters.
- [ ] Re-export the public surface from `index.ts`.
**Schema / Interfaces:**
```ts
// packages/payments/src/adapter.ts
import type { InvoiceObject as Invoice } from '@zync/types'

export type GatewaySlug = 'payplus' | 'cardcom' | 'stripe'
export type PaymentStatus = 'pending' | 'paid' | 'failed' | 'refunded'

export interface GatewayConfig {
  gateway: GatewaySlug
  testMode: boolean
  credentials: Record<string, string> // decrypted at call time
}

export interface WebhookEvent {
  type: 'payment.succeeded' | 'payment.failed' | 'payment.pending'
  sessionId: string
  amount: number
  currency: string
  metadata: Record<string, unknown>
}

export interface PaymentGatewayAdapter {
  readonly gateway: GatewaySlug
  createPaymentSession(
    invoice: Invoice, config: GatewayConfig, returnUrl: string, webhookUrl: string
  ): Promise<{ sessionId: string; redirectUrl: string }>
  verifyWebhook(payload: string, signature: string, config: GatewayConfig): Promise<WebhookEvent>
  getPaymentStatus(sessionId: string, config: GatewayConfig): Promise<PaymentStatus>
  testConnection(config: GatewayConfig): Promise<{ ok: boolean; error?: string }>
}

// packages/payments/src/config-crypto.ts
export interface EncryptedConfig { iv: string; tag: string; data: string } // all base64
export function encryptConfig(plain: Record<string, string>, keyB64: string): Promise<string>  // returns serialized EncryptedConfig
export function decryptConfig(blob: string, keyB64: string): Promise<Record<string, string>>

// packages/payments/src/registry.ts
export function getAdapter(gateway: GatewaySlug): PaymentGatewayAdapter
```
**Acceptance:**
- [ ] `encryptConfig` → `decryptConfig` round-trips identical credential objects; IV differs per call; tampering with `data` causes GCM auth failure on decrypt.
- [ ] `getAdapter('payplus'|'cardcom'|'stripe')` returns the correct instance; unknown slug throws.

### Task 3: Payplus adapter
**Blocks:** 8,9,10  ·  **Blocked by:** 2
**Files:**
- Create: `packages/payments/src/adapters/payplus.ts`
**Steps:**
- [ ] Implement `PayplusAdapter implements PaymentGatewayAdapter` with `gateway = 'payplus'`.
- [ ] Base URL: live `https://restapi.payplus.co.il/api/v1.0/`, sandbox `https://sandboxapi.payplus.co.il/` when `config.testMode`.
- [ ] Auth header: `Authorization: {apiKey}:{secretKey}`.
- [ ] `createPaymentSession`: POST `/PaymentPages/generateLink` with invoice amount (agorot), currency, `more_info` metadata carrying the session/invoice id, `refURL_success`/`refURL_failure` = `returnUrl`, `refURL_callback` = `webhookUrl`; parse `payment_page_link` as `redirectUrl` and the page UID as `sessionId`.
- [ ] `verifyWebhook`: compute HMAC-SHA256 of raw `payload` with `secretKey` via `crypto.subtle`; compare against `signature` (header `x-ppplus-signature`) using timing-safe equality; throw on mismatch. Map status → `WebhookEvent.type`.
- [ ] `getPaymentStatus`: query Payplus transaction status by `sessionId`; map to `PaymentStatus`.
- [ ] `testConnection`: lightweight authenticated call (e.g. terminal info) → `{ ok }`.
- [ ] Credential fields read from `config.credentials`: `apiKey`, `secretKey`, `terminalNumber`.
**Acceptance:**
- [ ] Webhook signature verification rejects a tampered body; uses constant-time comparison (no early-exit string equality).
- [ ] `createPaymentSession` returns a non-empty `redirectUrl` and `sessionId` against the sandbox.

### Task 4: Cardcom adapter
**Blocks:** 8,9,10  ·  **Blocked by:** 2
**Files:**
- Create: `packages/payments/src/adapters/cardcom.ts`
**Steps:**
- [ ] Implement `CardcomAdapter implements PaymentGatewayAdapter` with `gateway = 'cardcom'`.
- [ ] Base URL `https://secure.cardcom.solutions/api/v11/`; use the test terminal credentials when `config.testMode`.
- [ ] Auth via request body: `terminalNumber`, `apiName`, `apiPassword` from `config.credentials`.
- [ ] `createPaymentSession`: POST `/BillGold/` (LowProfile create) with invoice amount, currency, success/error/indicator (webhook) URLs; receive `LowProfileCode`; construct redirect URL from it; `sessionId = LowProfileCode`.
- [ ] `verifyWebhook`: Cardcom POSTs to the configured notification URL; verify by matching `TerminalNumber` against config and `ReturnValue` against the stored session reference; throw if mismatch. Map `OperationResponse`/deal status → `WebhookEvent.type`.
- [ ] `getPaymentStatus`: call Cardcom transaction lookup by `LowProfileCode`; map → `PaymentStatus`.
- [ ] `testConnection`: lightweight authenticated probe → `{ ok }`.
**Acceptance:**
- [ ] `verifyWebhook` throws when `TerminalNumber` does not match the config terminal.
- [ ] `createPaymentSession` returns a redirect URL containing the `LowProfileCode`.

### Task 5: Stripe adapter
**Blocks:** 8,9,10  ·  **Blocked by:** 2
**Files:**
- Create: `packages/payments/src/adapters/stripe.ts`
**Steps:**
- [ ] Implement `StripeAdapter implements PaymentGatewayAdapter` with `gateway = 'stripe'`.
- [ ] Use Stripe Checkout Sessions API; instantiate the Stripe client with `config.credentials.secretKey` and the Workers `fetch` HTTP client.
- [ ] `createPaymentSession`: create a Checkout Session with one line item matching the invoice total (amount in the smallest currency unit), `currency` from invoice, `success_url = returnUrl`, `cancel_url = returnUrl`, `metadata` carrying the payment-session id; return `{ sessionId: session.id, redirectUrl: session.url }`.
- [ ] `verifyWebhook`: `await stripe.webhooks.constructEventAsync(payload, signature, config.credentials.webhookSecret, undefined, Stripe.createSubtleCryptoProvider())`; map `checkout.session.completed`/`async_payment_failed` → `WebhookEvent.type`.
- [ ] `getPaymentStatus`: retrieve the Checkout Session; map `payment_status` → `PaymentStatus`.
- [ ] `testConnection`: `stripe.balance.retrieve()` → `{ ok }`; validate `secretKey` `sk_` prefix and `webhookSecret` `whsec_` prefix.
- [ ] Multi-currency: pass invoice `currency` straight through (ILS or other).
**Acceptance:**
- [ ] Webhook verification uses `constructEventAsync` with the SubtleCrypto provider (Workers-compatible); a bad signature throws.
- [ ] Checkout session is created with the correct currency and amount.

### Task 6: Config service — load/save/decrypt active gateway config
**Blocks:** 8,9,10  ·  **Blocked by:** 2
**Files:**
- Create: `apps/zync-api/src/services/payments/config-service.ts`
**Steps:**
- [ ] `saveGatewayConfig(db, tenantId, gateway, credentials, testMode)`: `encryptConfig` then upsert into `payment_gateway_configs` (`ON CONFLICT (tenant_id, gateway)`), set `active = true`, others for tenant `active = false` (single active config). Use `tenantQuery`.
- [ ] `loadActiveGatewayConfig(db, tenantId)`: select the active row; `decryptConfig`; return `GatewayConfig` (gateway, testMode, credentials).
- [ ] `getRedactedConfig(db, tenantId)`: return `{ gateway, testMode, fields: { <field>: { configured: true } } }` — never plaintext (spec §"Never shown in plaintext after first save").
- [ ] `removeGatewayConfig(db, tenantId)`: delete the tenant's config rows.
- [ ] `testGatewayConnection(gateway, credentials, testMode)`: `getAdapter(gateway).testConnection(...)`.
**Schema / Interfaces:**
```ts
export function saveGatewayConfig(db: DB, tenantId: string, gateway: GatewaySlug, credentials: Record<string,string>, testMode: boolean): Promise<void>
export function loadActiveGatewayConfig(db: DB, tenantId: string): Promise<GatewayConfig | null>
export function getRedactedConfig(db: DB, tenantId: string): Promise<{ gateway: GatewaySlug; testMode: boolean; fields: Record<string, { configured: boolean }> } | null>
export function removeGatewayConfig(db: DB, tenantId: string): Promise<void>
```
**Acceptance:**
- [ ] Saving a second gateway deactivates the previous active config (only one active row per tenant).
- [ ] `getRedactedConfig` never returns credential plaintext.

### Task 7: Session service — create session, status, webhook settlement
**Blocks:** 9,10  ·  **Blocked by:** 2
**Files:**
- Create: `apps/zync-api/src/services/payments/session-service.ts`
**Steps:**
- [ ] `createPaymentSession(db, env, tenantId, invoiceId)`: load invoice; reject unless status ∈ {`TAX_ISSUED`, `PARTIALLY_PAID`} (IL law — only payable after חשבונית מס; never on `SENT` proforma). Load active config; compute `amount` in minor units from invoice `total`, `currency`; build `returnUrl = https://{portalSlug}.zync.is/portal/invoices/{invoiceId}?payment=success` and per-tenant `webhookUrl`; call `adapter.createPaymentSession`; insert `invoice_payment_sessions` row with `test_mode` from config; return `{ redirectUrl }`.
- [ ] `getSessionStatus(db, tenantId, invoiceId)`: return latest session status for the invoice (DB-authoritative).
- [ ] `settleWebhook(db, env, tenantId, gateway, event, rawPayload)`: find session by `(gateway, session_id)`; **idempotency** — if already `paid`, return without re-processing. On `payment.succeeded`: in one transaction set session `status='paid', paid_at=now(), webhook_received_at=now(), webhook_payload=rawPayload`, set `invoices.status='PAID', paid_at=now()`; enqueue `invoice.paid` to `webhook.deliver`; enqueue receipt email via `sendEmail`. On `payment.failed`: set session `status='failed'`. Store raw payload regardless (audit).
**Schema / Interfaces:**
```ts
export function createPaymentSession(db: DB, env: Env, tenantId: string, invoiceId: string): Promise<{ redirectUrl: string }>
export function getSessionStatus(db: DB, tenantId: string, invoiceId: string): Promise<{ status: 'pending'|'paid'|'failed'|'expired' } | null>
export function settleWebhook(db: DB, env: Env, tenantId: string, gateway: GatewaySlug, event: WebhookEvent, rawPayload: string): Promise<void>

// invoice.paid outbound event payload (webhook.deliver queue):
// { event: 'invoice.paid', invoice_id, amount, currency, gateway, paid_at }
```
**Acceptance:**
- [ ] Creating a session for a `SENT`/`DRAFT` invoice is rejected (only `TAX_ISSUED`/`PARTIALLY_PAID` allowed).
- [ ] Re-delivering an already-`paid` webhook does not double-update the invoice or re-enqueue (idempotency on `session_id` + current `status`).

### Task 8: Settings API routes — `/api/settings/integrations/payments`
**Blocks:** 11  ·  **Blocked by:** 3,4,5,6
**Files:**
- Create: `apps/zync-api/src/routes/settings/payments.ts`
- Modify: `apps/zync-api/src/routes/index.ts` (mount)
**Steps:**
- [ ] `GET /api/settings/integrations/payments` — `requirePermission('settings:read')`; return `getRedactedConfig` + the per-tenant webhook URL (`https://api.zync.is/webhooks/payment/{gateway}/{tenantWebhookToken}`).
- [ ] `PUT /api/settings/integrations/payments` — `requirePermission('settings:write')`; Zod-validate body (`gateway`, `credentials` per-gateway field set, `testMode`); validate Stripe key prefixes (`pk_`/`sk_`/`whsec_`); `saveGatewayConfig`.
- [ ] `POST /api/settings/integrations/payments/test` — `requirePermission('settings:write')`; `testGatewayConnection`; return `{ ok, error? }`.
- [ ] `DELETE /api/settings/integrations/payments` — `requirePermission('settings:write')`; `removeGatewayConfig`.
- [ ] All routes go through `authMiddleware` + `tenantQuery`; never log decrypted credentials.
**Acceptance:**
- [ ] `GET` returns `{ configured: true }`-style redacted fields, never plaintext.
- [ ] `PUT` with a Stripe secret key missing the `sk_` prefix is rejected by Zod validation.

### Task 9: Portal payment API routes
**Blocks:** 12  ·  **Blocked by:** 7
**Files:**
- Create: `apps/zync-api/src/routes/portal/payments.ts`
- Modify: `apps/zync-api/src/routes/index.ts` (mount under portal auth)
**Steps:**
- [ ] `POST /api/invoices/:id/payment/session` — portal auth (authenticated `customer_portal_users`, customer must own the invoice's `customer_id`); call `createPaymentSession`; return `{ redirectUrl }`.
- [ ] `GET /api/invoices/:id/payment/status` — portal auth; return DB-authoritative `getSessionStatus`.
- [ ] Enforce ownership: invoice `tenant_id`/`customer_id` must match the portal session's customer.
**Acceptance:**
- [ ] A portal customer cannot create a session for an invoice they do not own (403).
- [ ] Status endpoint reflects DB state, not the `?payment=success` query param.

### Task 10: Public webhook route — `/webhooks/payment/:gateway/:token`
**Blocks:** —  ·  **Blocked by:** 3,4,5,7
**Files:**
- Create: `apps/zync-api/src/routes/webhooks/payment.ts`
- Modify: `apps/zync-api/src/routes/index.ts` (mount, unauthenticated)
**Steps:**
- [ ] `POST /webhooks/payment/:gateway/:token` — unauthenticated; apply `RATE_LIMITER_WEBHOOK`.
- [ ] Read the **raw** request body (needed for HMAC); resolve tenant by hashed `:token` against `tenants.payment_webhook_token` (constant-time compare on the hash).
- [ ] Load that tenant's active gateway config (`loadActiveGatewayConfig`); call `getAdapter(gateway).verifyWebhook(rawBody, signature, config)`.
- [ ] On signature failure → return **200** (do not leak verification failure to attacker; spec §Webhook endpoint security #2). Log internally.
- [ ] On success → `settleWebhook(...)`; return 200.
- [ ] Idempotency handled inside `settleWebhook`.
**Acceptance:**
- [ ] Invalid signature → HTTP 200 and no state change.
- [ ] Unknown/forged `:token` → 200 with no processing; never reveals tenant existence.
- [ ] Rate limiter applied; raw body persisted to `webhook_payload`.

### Task 11: Settings UI — `/settings/integrations/payments`
**Blocks:** —  ·  **Blocked by:** 8
**Files:**
- Create: `apps/zync-app/src/pages/settings/integrations/PaymentsPage.tsx`
- Create: `apps/zync-app/src/features/payments/components/GatewayForm.tsx`
- Modify: `apps/zync-app/src/pages/settings/integrations/index.tsx` (add card)
**Steps:**
- [ ] Render Connected state (gateway name + ✓, Switch Gateway / Test Connection / Remove buttons, Test-mode checkbox, copyable webhook URL) and Setup state (gateway selector → credential fields) per spec layout.
- [ ] Per-gateway credential forms: Payplus (`apiKey`, `secretKey`, `terminalNumber`), Cardcom (`terminalNumber`, `apiName`, `apiPassword`), Stripe (`publishableKey` `pk_`, `secretKey` `sk_`, `webhookSecret` `whsec_`). All secret fields use a password input with a show/hide toggle; saved fields render as configured placeholders (never plaintext).
- [ ] Wire to `GET/PUT/POST .../test/DELETE` endpoints; show test-connection result toast.
- [ ] Webhook URL "Copy" button.
- [ ] **A11y:** label every input (`<FormLabel>`), the show/hide toggle has `aria-label` and `aria-pressed`; toast announced via `aria-live`. **i18n/RTL:** all strings via `translations`/`useDirection`; layout direction-agnostic (logical CSS). **prefers-reduced-motion:** any transition respects the reduced-motion guard. Use `@zync/ui` primitives (`Card`, `Button`, `Input`, `Switch`, `Form`, `Select`) — no hardcoded colors/spacing.
**Acceptance:**
- [ ] Saving credentials then reloading shows them as configured placeholders, never plaintext.
- [ ] Test Connection surfaces `{ ok }`/`{ error }` to the user; Copy copies the per-tenant webhook URL.
- [ ] Page passes keyboard-only navigation and has correct labels (axe clean).

### Task 12: Invoice + portal payment UI surfaces
**Blocks:** —  ·  **Blocked by:** 9
**Files:**
- Modify: `apps/zync-app/src/features/invoices/InvoiceDetailPage.tsx` (tenant view)
- Create: `apps/zync-app/src/features/invoices/components/PaymentSessionsSection.tsx`
- Modify: portal invoice page (tenant-portals route, e.g. `apps/zync-app/src/portal/invoices/InvoicePage.tsx`)
**Steps:**
- [ ] Tenant invoice detail: show a **Payment gateway status** badge when a gateway is configured, and a collapsible **Payment sessions** section listing attempts (gateway, amount, status, created_at).
- [ ] Portal invoice page: render a **Pay Now** button only when invoice status ∈ {`TAX_ISSUED`, `PARTIALLY_PAID`} and a gateway is configured; clicking calls `POST /api/invoices/:id/payment/session` and redirects to `redirectUrl`. Show a **test mode** badge on the button when config is in test mode.
- [ ] On return, read `?payment=success`, then **verify status from DB** (`GET .../payment/status`) before showing the success banner (prevent false positives). After payment: **Paid** badge + receipt download link.
- [ ] **A11y:** Pay Now is a real `<button>` with discernible text; success banner uses `role="status"`/`aria-live="polite"`. **i18n/RTL** via `translations`/`useDirection`. **reduced-motion** respected.
**Acceptance:**
- [ ] Pay Now is hidden for `DRAFT`/`SENT` invoices and when no gateway is configured.
- [ ] Success banner shows only after DB confirms `paid`, not solely on the query param.
- [ ] Test-mode badge appears when the active config has `test_mode = true`.

### Task 13: Tenant webhook-token generation
**Blocks:** —  ·  **Blocked by:** 1
**Files:**
- Modify: tenant creation service (foundation-auth-rbac tenant provisioning, e.g. `apps/zync-api/src/services/tenants/create.ts`)
- Create: `apps/zync-api/src/services/payments/webhook-token.ts`
**Steps:**
- [ ] On tenant creation, generate `payment_webhook_token` = hex of `crypto.getRandomValues(new Uint8Array(32))`; store the **hashed** token in `tenants.payment_webhook_token` (spec §"stored (hashed) in the tenant row").
- [ ] Provide `rotatePaymentWebhookToken(db, tenantId)` returning the new plaintext token once (for display in settings).
- [ ] Token is routing-only; HMAC signature is the real security boundary.
- [ ] Backfill existing tenants with a generated token in the migration/seed.
**Schema / Interfaces:**
```ts
export function generatePaymentWebhookToken(): { token: string; hash: string }
export function rotatePaymentWebhookToken(db: DB, tenantId: string): Promise<{ token: string }>
export function resolveTenantByWebhookToken(db: DB, token: string): Promise<{ tenantId: string } | null> // constant-time hash compare
```
**Acceptance:**
- [ ] New tenants get a token; the stored value is a hash, not the plaintext.
- [ ] `resolveTenantByWebhookToken` matches via constant-time comparison and returns null for unknown tokens.

### Task 14: Tests (spec-mandated behaviors)
**Blocks:** —  ·  **Blocked by:** 2,3,4,5,6,7,10
**Files:**
- Create: `packages/payments/src/__tests__/config-crypto.test.ts`
- Create: `packages/payments/src/__tests__/adapters.test.ts`
- Create: `apps/zync-api/src/routes/webhooks/__tests__/payment.test.ts`
**Steps:**
- [ ] config-crypto: round-trip; IV uniqueness; tamper → auth failure.
- [ ] Adapters: each `verifyWebhook` rejects tampered payloads (Payplus HMAC, Cardcom terminal match, Stripe signature); `createPaymentSession` shape.
- [ ] Webhook route: invalid signature → 200 + no state change; idempotent re-delivery; rate limiter invoked; raw body stored.
- [ ] Session service: rejects non-`TAX_ISSUED`/`PARTIALLY_PAID` invoices; sets invoice `PAID` and enqueues `invoice.paid` + receipt email on success.
**Acceptance:**
- [ ] All tests pass under `pnpm test`.
