# Invoice Settings Page — Implementation Plan

**Spec:** docs/specs/2026-05-31-invoice-settings-page.md  ·  **Slug:** invoice-settings-page  ·  **Wave:** 10
**Depends on:** foundation-auth-rbac, invoices-core, settings-module

## Goal
Deliver `/settings/invoicing` — the admin-only tenant settings page that configures all invoice-related defaults (payment terms, VAT/tax rate, currency, invoice/proforma number prefixes, IL tax-invoice compliance flags, late-payment fees, and invoice appearance). These values back invoice creation across every flow (number prefixing, pre-filled payment terms and tax rate, pay-now link inclusion in sent emails). All settings persist in the shared `tenant_settings` table via additive, idempotent column deltas; the page exposes one card per concern, each with an independent **Save** button.

## Architecture
- **Data store:** the shared one-row-per-tenant `tenant_settings` table (base: `id` UUID PK, `tenant_id` UUID UNIQUE → `tenants(id)`, timestamps), owned by `foundation-auth-rbac` and extended by settings specs via `ALTER TABLE ... ADD COLUMN IF NOT EXISTS`. This plan adds the invoice-settings columns the same idempotent way. `default_currency` is NOT a `tenant_settings` column — it is the `tenants.default_currency` scalar (owned by `system-i18n`); the Invoice Defaults card surfaces it read/write by reading/updating `tenants.default_currency` via `tenantQuery`. This spec is the **sole owner** of `default_tax_rate NUMERIC(5,4) DEFAULT 0.18`. `default_payment_terms_days` is owned by `invoices-core` (the invoice domain owner, wave 6, a build dependency of this spec) — this page only consumes/edits it via the shared `tenant_settings` row, never ALTERing it. `proposal-to-invoice-direct` (spec 107) depends on this spec and only consumes `default_tax_rate` — it does not add it.
- **Business identity fields are read-only here.** `business_type` (עוסק מורשה / חברה בע"מ / עוסק פטור), Business ID (ח.פ.), VAT registration #, and the company Logo live in `tenants.settings JSONB`, owned by `settings-module`'s `/settings/business` page. The IL-Compliance and Invoice-Appearance cards DISPLAY those values read-only with a cross-link to `/settings/business`; they are NOT mutated by this page. The only IL-compliance values this page WRITES are `issue_tax_invoices` and `proforma_number_prefix` (both in `tenant_settings`). Logo on the Appearance card is a read-only thumbnail + "Change" link to `/settings/business`; this page writes only `invoice_footer_text` and `invoice_show_payment_link`.
- **API:** two Hono routes under `apps/zync-api/src/routes/settings/invoicing.ts` — `GET`/`PATCH /api/settings/invoicing` — guarded by `requirePermission('settings:write')` for PATCH and `requirePermission('settings:read')` for GET, consistent with the rest of `settings-module`. New tenant-scoped query helpers `getInvoiceSettings` / `updateInvoiceSettings` live in `@zync/db` (NOT the AI package's `getTenantSettings`/`upsertTenantSettings`, which operate on `ai_tenant_settings` — reusing those would be a name collision).
- **UI:** a new page `apps/zync-app/src/features/settings/pages/InvoicingPage.tsx` rendered inside the existing `SettingsShell` (from `settings-module`), registered in the settings nav and router under `/settings/invoicing`. Four independent cards, each with its own form state, validation, and Save button using `@zync/ui` primitives.
- **Upstream consumed:** tables `tenant_settings`, `tenants`; exports `requirePermission`, `authMiddleware`, `tenantQuery`, `buildPaginated` (n/a), `Db`, `createDb` (`@zync/db`/`@zync/auth`); UI `Card`, `Form`, `FormField`, `FormLabel`, `FormError`, `Input`, `Select`, `Switch`, `Radio`, `Button`, `Stack`, `Divider`, `toast` (`@zync/ui`); `useDirection` for RTL; `SettingsShell` + `SETTINGS_NAV` from `settings-module`.

## Tech Stack
- **API:** `apps/zync-api` (Hono on Cloudflare Workers), Drizzle ORM over Neon Postgres via Hyperdrive. Zod validation. Bindings already provisioned: `DB` (Hyperdrive→Neon).
- **DB:** `packages/db` (Drizzle schema for `tenant_settings`, query helpers), raw SQL migration in `apps/zync-api/migrations`.
- **Types:** `packages/types` (shared `InvoiceSettings` type + Zod-derivable shape).
- **UI:** `apps/zync-app` (Vite + React), `@zync/ui` primitives, `@tanstack/react-query` for data fetching/mutations, `react-hook-form` + `zod` resolver for per-card forms.
- **Turborepo + pnpm** workspace.

## Wave Plan
| Sub-wave | Tasks | Files touched | Parallelizable? |
|----------|-------|---------------|-----------------|
| 10a — schema + types | 1 (migration + Drizzle delta), 2 (shared types + Zod schema) | `apps/zync-api/migrations`, `packages/db/src/schema/tenant-settings.ts`, `packages/types/src/invoice-settings.ts` | No (2 after 1) |
| 10b — query + API | 3 (query helpers), 4 (GET/PATCH routes) | `packages/db/src/queries/invoice-settings.ts`, `apps/zync-api/src/routes/settings/invoicing.ts` | No (4 after 3) |
| 10c — UI | 5 (data hook), 6 (page + 4 cards), 7 (nav + router wiring) | `apps/zync-app/src/features/settings/pages/InvoicingPage.tsx`, hooks, `settingsNav.ts`, `router.tsx` | 5 then 6 then 7 |
| 10d — invoice-creation wiring | 8 (apply defaults on invoice create + email pay-link) | `packages/db`/`apps/zync-api` invoice create path | After 3 |

## Tasks

### Task 1: `tenant_settings` invoice-columns migration + Drizzle delta
**Blocks:** 2, 3, 8  ·  **Blocked by:** —
**Files:**
- Create: `apps/zync-api/migrations/0XXX_tenant_settings_invoice_defaults.sql`
- Modify: `packages/db/src/schema/tenant-settings.ts` (add columns to the existing `tenantSettings` Drizzle table object)
**Steps:**
- [ ] Add the invoice-settings columns to `tenant_settings` using `ADD COLUMN IF NOT EXISTS` for every column. The base table is owned by `foundation-auth-rbac`. This spec is sole owner of `default_tax_rate`; `default_payment_terms_days` is owned by `invoices-core` and only consumed/edited here.
- [ ] Do NOT add `default_currency` to `tenant_settings` — it is the `tenants.default_currency` scalar (owned by `system-i18n`). The Invoice Defaults card surfaces it by reading/updating `tenants.default_currency` via `tenantQuery`, not a `tenant_settings` column.
- [ ] Own `default_tax_rate NUMERIC(5,4) DEFAULT 0.18` here as sole owner. Do NOT add `default_payment_terms_days` — it is owned by `invoices-core` (wave 6, a build dependency of this spec); read/write it via the shared `tenant_settings` row, never ALTER it here. `proposal-to-invoice-direct` (spec 107) depends on this spec and only consumes `default_tax_rate`.
- [ ] Add matching Drizzle column builders to the `tenantSettings` table object: `integer`/`numeric`/`boolean`/`text` with `.notNull()` + `.default(...)` where the SQL has `NOT NULL`/`DEFAULT`; declare CHECK constraints via the table's third-argument `check()` callback so generated DDL matches.
- [ ] Apply to a Neon branch and confirm `\d tenant_settings` shows all new columns + the `late_fee_type` CHECK.
**Schema / Interfaces:**
```sql
-- tenant_settings base table is OWNED by foundation-auth-rbac (id + UNIQUE tenant_id + timestamps,
-- one row per tenant seeded at signup). Do NOT CREATE here. Expense-config columns
-- (filing_cadence, tax_basis, business_category, per_diem_rates, ...) are added by expenses-module.
-- default_currency is NOT on tenant_settings — it is the tenants.default_currency scalar (system-i18n).

-- default_payment_terms_days is NOT added here — it is owned by invoices-core (wave 6, the
-- invoice domain owner and a build dependency of this spec). This page reads/edits it on the
-- shared tenant_settings row but never ALTERs it.
ALTER TABLE tenant_settings
  -- Invoice-default column this spec solely owns (proposal-to-invoice-direct depends on
  -- this spec and only consumes default_tax_rate):
  ADD COLUMN IF NOT EXISTS default_tax_rate NUMERIC(5,4) NOT NULL DEFAULT 0.18,
  -- Invoice-settings delta (this spec):
  ADD COLUMN IF NOT EXISTS invoice_number_prefix TEXT NOT NULL DEFAULT 'INV-',
  ADD COLUMN IF NOT EXISTS issue_tax_invoices BOOLEAN NOT NULL DEFAULT true,
  ADD COLUMN IF NOT EXISTS proforma_number_prefix TEXT NOT NULL DEFAULT 'PROFORMA-',
  ADD COLUMN IF NOT EXISTS late_fee_type TEXT NOT NULL DEFAULT 'none'
    CHECK (late_fee_type IN ('none', 'flat', 'percentage')),
  ADD COLUMN IF NOT EXISTS late_fee_amount NUMERIC(10,2),
  ADD COLUMN IF NOT EXISTS late_fee_threshold_days INTEGER DEFAULT 30,
  ADD COLUMN IF NOT EXISTS invoice_footer_text TEXT,
  ADD COLUMN IF NOT EXISTS invoice_show_payment_link BOOLEAN NOT NULL DEFAULT true;
```
**Acceptance:**
- [ ] Migration applies cleanly and is idempotent (`IF NOT EXISTS` → re-run is a no-op with no error).
- [ ] `default_currency` is NOT in the `tenant_settings` ALTER — it is read/written on `tenants.default_currency` (system-i18n scalar).
- [ ] `late_fee_amount` is `NUMERIC(10,2)`; `default_tax_rate` is `NUMERIC(5,4)`; booleans are `BOOLEAN` (never INTEGER); `late_fee_type` is an inline TEXT CHECK over `('none','flat','percentage')`.
- [ ] Drizzle `tenantSettings` selects/inserts every new column without type errors.

### Task 2: Shared `InvoiceSettings` type + Zod validation schema
**Blocks:** 3, 4, 5, 6  ·  **Blocked by:** 1
**Files:**
- Create: `packages/types/src/invoice-settings.ts`
- Modify: `packages/types/src/index.ts` (re-export)
**Steps:**
- [ ] Define the `InvoiceSettings` TS type covering exactly the 11 mutable fields plus echoed read-only business-identity context.
- [ ] Define `LateFeeType` union and `updateInvoiceSettingsSchema` (Zod) matching the PATCH body — all fields optional (partial), each independently validated. Currency restricted to `'ILS' | 'USD' | 'EUR'`. Cross-field rule: when `late_fee_type` is `'flat'` or `'percentage'`, `late_fee_amount` must be a positive number; when `'none'`, `late_fee_amount` may be null/omitted.
- [ ] Export `INVOICE_SETTINGS_DEFAULTS` mirroring the DDL defaults so the UI can render sane values before first save.
**Schema / Interfaces:**
```typescript
export type LateFeeType = 'none' | 'flat' | 'percentage';

export interface InvoiceSettings {
  default_payment_terms_days: number;     // default 30
  default_tax_rate: number;               // NUMERIC(5,4), e.g. 0.18
  default_currency: 'ILS' | 'USD' | 'EUR';
  invoice_number_prefix: string;          // default 'INV-'
  issue_tax_invoices: boolean;            // default true
  proforma_number_prefix: string;         // default 'PROFORMA-'
  late_fee_type: LateFeeType;             // default 'none'
  late_fee_amount: number | null;         // NUMERIC(10,2)
  late_fee_threshold_days: number | null; // default 30
  invoice_footer_text: string | null;
  invoice_show_payment_link: boolean;     // default true
  // read-only context echoed from tenants.settings JSONB for display only:
  readonly business_type: string | null;
  readonly business_tax_id: string | null;   // ח.פ. / ע.מ.
  readonly vat_registration_number: string | null;
  readonly logo_url: string | null;
}

export const updateInvoiceSettingsSchema = z.object({
  default_payment_terms_days: z.number().int().min(0).max(365).optional(),
  default_tax_rate: z.number().min(0).max(1).optional(),
  default_currency: z.enum(['ILS', 'USD', 'EUR']).optional(),
  invoice_number_prefix: z.string().min(1).max(16).optional(),
  issue_tax_invoices: z.boolean().optional(),
  proforma_number_prefix: z.string().min(1).max(16).optional(),
  late_fee_type: z.enum(['none', 'flat', 'percentage']).optional(),
  late_fee_amount: z.number().nonnegative().nullable().optional(),
  late_fee_threshold_days: z.number().int().min(0).max(365).nullable().optional(),
  invoice_footer_text: z.string().max(1000).nullable().optional(),
  invoice_show_payment_link: z.boolean().optional(),
}).refine(
  (v) => v.late_fee_type === undefined || v.late_fee_type === 'none' ||
         (typeof v.late_fee_amount === 'number' && v.late_fee_amount > 0),
  { message: 'late_fee_amount required and > 0 when late_fee_type is flat or percentage', path: ['late_fee_amount'] }
);

export const INVOICE_SETTINGS_DEFAULTS = {
  default_payment_terms_days: 30, default_tax_rate: 0.18, default_currency: 'ILS',
  invoice_number_prefix: 'INV-', issue_tax_invoices: true, proforma_number_prefix: 'PROFORMA-',
  late_fee_type: 'none', late_fee_amount: null, late_fee_threshold_days: 30,
  invoice_footer_text: null, invoice_show_payment_link: true,
} as const;
```
**Acceptance:**
- [ ] `updateInvoiceSettingsSchema` rejects a `flat` fee with no/zero `late_fee_amount` and accepts `none` with null amount.
- [ ] Type re-exported from `@zync/types` index; no AI-package type names reused.

### Task 3: `getInvoiceSettings` / `updateInvoiceSettings` query helpers
**Blocks:** 4, 8  ·  **Blocked by:** 1, 2
**Files:**
- Create: `packages/db/src/queries/invoice-settings.ts`
- Modify: `packages/db/src/index.ts` (export both helpers)
**Steps:**
- [ ] `getInvoiceSettings(db, tenantId)`: select the invoice-relevant columns from `tenant_settings` for the tenant; if no row exists yet, return `INVOICE_SETTINGS_DEFAULTS` merged with read-only context. Also read `tenants.default_currency` (the canonical currency scalar, system-i18n) to populate the `default_currency` field, and `tenants.settings JSONB` for the read-only `business_type`, `business_tax_id`, `vat_registration_number`, `logo_url` fields.
- [ ] `updateInvoiceSettings(db, tenantId, patch)`: upsert (`INSERT ... ON CONFLICT (tenant_id) DO UPDATE`) only the `tenant_settings` columns present in `patch`; set `updated_at = now()`. When `patch.default_currency` is present, write it to `tenants.default_currency` (NOT `tenant_settings`). Never write the read-only business-identity fields. Return the fresh merged `InvoiceSettings`.
- [ ] Use `tenantQuery` scoping so the write is constrained to the caller's tenant; do NOT reuse the AI package's `getTenantSettings`/`upsertTenantSettings`.
**Schema / Interfaces:**
```typescript
export function getInvoiceSettings(db: Db, tenantId: string): Promise<InvoiceSettings>;
export function updateInvoiceSettings(
  db: Db, tenantId: string, patch: Partial<Omit<InvoiceSettings,
    'business_type' | 'business_tax_id' | 'vat_registration_number' | 'logo_url'>>,
): Promise<InvoiceSettings>;
```
**Acceptance:**
- [ ] First-ever PATCH for a tenant inserts the `tenant_settings` row (ON CONFLICT path verified); subsequent PATCH updates in place and bumps `updated_at`.
- [ ] Read-only business-identity fields are never written by `updateInvoiceSettings`.
- [ ] No import of `getTenantSettings`/`upsertTenantSettings` from the AI package.

### Task 4: `GET` / `PATCH /api/settings/invoicing` routes
**Blocks:** 5  ·  **Blocked by:** 3
**Files:**
- Create: `apps/zync-api/src/routes/settings/invoicing.ts`
- Modify: `apps/zync-api/src/routes/index.ts` (mount under `/api/settings/invoicing`)
**Steps:**
- [ ] `GET /api/settings/invoicing`: `requirePermission('settings:read')`; return `getInvoiceSettings(db, tenantId)`.
- [ ] `PATCH /api/settings/invoicing`: `requirePermission('settings:write')`; validate body with `updateInvoiceSettingsSchema` (return 422 on failure with field errors); call `updateInvoiceSettings`; return the updated `InvoiceSettings`.
- [ ] Mount behind `authMiddleware`; tenant id derived from the session, never from the body.
- [ ] Preserve cross-cutting requirements: rely on the app-wide CSP/security headers middleware (do not weaken it); validate ALL input via Zod (require-zod-validation-in-routes); no raw Drizzle from the route — go through the Task 3 helpers (no-raw-drizzle-from-routes).
**Schema / Interfaces:**
```
GET   /api/settings/invoicing  → 200 InvoiceSettings                 (requires settings:read)
PATCH /api/settings/invoicing  → 200 InvoiceSettings | 422 {errors}  (requires settings:write)
  body: Partial<{ default_payment_terms_days, default_tax_rate, default_currency,
                  invoice_number_prefix, issue_tax_invoices, proforma_number_prefix,
                  late_fee_type, late_fee_amount, late_fee_threshold_days,
                  invoice_footer_text, invoice_show_payment_link }>
```
**Acceptance:**
- [ ] Non-admin (lacking `settings:write`) receives 403 on PATCH; lacking `settings:read` receives 403 on GET.
- [ ] Invalid `late_fee_type`/missing amount yields 422 with a field-level error; valid partial body updates only named fields.
- [ ] Route uses query helpers, not inline Drizzle; body is Zod-validated.

### Task 5: `useInvoiceSettings` data hook
**Blocks:** 6  ·  **Blocked by:** 4
**Files:**
- Create: `apps/zync-app/src/features/settings/hooks/useInvoiceSettings.ts`
**Steps:**
- [ ] `useInvoiceSettings()`: react-query `useQuery(['settings','invoicing'])` → `GET /api/settings/invoicing`.
- [ ] `useUpdateInvoiceSettings()`: `useMutation` → `PATCH /api/settings/invoicing` with a `Partial<InvoiceSettings>` payload; on success invalidate the query and `toast.success`; on 422 surface field errors to the calling card's form; on other errors `toast.error`.
- [ ] Accept a partial patch so each card submits only its own fields.
**Acceptance:**
- [ ] Mutation invalidates the settings query and the page reflects saved values without a full reload.
- [ ] A 422 maps server field errors back onto the originating card's form fields.

### Task 6: `InvoicingPage` with four independent-save cards
**Blocks:** 7  ·  **Blocked by:** 5
**Files:**
- Create: `apps/zync-app/src/features/settings/pages/InvoicingPage.tsx`
**Steps:**
- [ ] Render inside `SettingsShell` with breadcrumb "Settings > Invoicing". Each card owns its own `react-hook-form` instance + Save button; submitting a card calls `useUpdateInvoiceSettings()` with ONLY that card's fields (partial save — a validation error in one card never blocks another).
- [ ] **Invoice Defaults card:** `default_payment_terms_days` (Input, number, "days after issue date"), `default_tax_rate` (Input, render as % with helper "0.18 = 18%"), `default_currency` (Select: ILS/USD/EUR), `invoice_number_prefix` (Input, prefixed display `INV-`).
- [ ] **IL Compliance card:** read-only display of `business_type`, Business ID (ח.פ.), VAT registration # echoed from `tenants.settings` with a cross-link "Edit in Business settings" → `/settings/business` (this page does NOT mutate them). Writable here: `issue_tax_invoices` (Radio Yes/No, labelled "Issue as tax invoices / חשבונית מס") and `proforma_number_prefix` (Input). Hebrew labels preserved.
- [ ] **Late Payments card:** `late_fee_type` (Radio: None / Flat amount / Percentage), `late_fee_amount` (Input, ₪ for flat / % for percentage — disabled when type is None), `late_fee_threshold_days` (Input "days overdue"). Client-side mirror of the Zod cross-field rule before submit.
- [ ] **Invoice Appearance card:** read-only logo thumbnail + "Change" link → `/settings/business`; `invoice_footer_text` (Textarea, helper "Appears at bottom of PDF invoices"); `invoice_show_payment_link` (Switch/Checkbox "Include pay-now link in sent emails").
- [ ] A11y + i18n: every field has an associated `<FormLabel htmlFor>`; Radio groups use `role="radiogroup"`; validation errors announced via `aria-describedby`/`FormError`; respect `prefers-reduced-motion` for any card save spinner; layout is RTL-aware via `useDirection` so Hebrew renders right-to-left.
- [ ] Use only `@zync/ui` primitives (Card, Form, FormField, FormLabel, FormError, Input, Textarea, Select, Radio, Switch, Button, Stack, Divider) — no raw HTML form controls (no-raw-html-in-pages); no hardcoded colors/spacing (use tokens).
**Acceptance:**
- [ ] Each card's Save persists only its own fields; an invalid Late Payments card does not block saving Invoice Defaults.
- [ ] Business identity fields and logo are read-only with working cross-links to `/settings/business`; they are never sent in any PATCH body.
- [ ] Page renders correctly RTL with Hebrew labels; all controls are keyboard-operable and labelled (axe: no violations).

### Task 7: Register `/settings/invoicing` in nav + router
**Blocks:** —  ·  **Blocked by:** 6
**Files:**
- Modify: `apps/zync-app/src/features/settings/settingsNav.ts` (add Invoicing entry)
- Modify: `apps/zync-app/src/router.tsx` (register route `/settings/invoicing` → `InvoicingPage`, admin-guarded)
**Steps:**
- [ ] Add `{ path: '/settings/invoicing', labelKey: 'settings.invoicing', requiresPermission: 'settings:write' }` to `SETTINGS_NAV` so it appears in the SettingsShell sidebar (matching the canonical settings route table, spec 25 row "spec 125").
- [ ] Register the route lazily in `router.tsx` guarded so non-admins are redirected/forbidden, consistent with other `/settings/*` admin pages.
- [ ] Provide Hebrew + English translation keys for the nav label and card titles.
**Acceptance:**
- [ ] `/settings/invoicing` appears in the Settings sidebar for admins and is hidden/forbidden for users lacking `settings:write`.
- [ ] Direct navigation to the route renders `InvoicingPage` inside `SettingsShell`.

### Task 8: Apply invoice-settings defaults on invoice creation + pay-link email
**Blocks:** —  ·  **Blocked by:** 3
**Files:**
- Modify: invoice-creation path in `apps/zync-api`/`packages/db` (the `createInvoice`/issue flow from `invoices-core`)
**Steps:**
- [ ] On new-invoice creation (any flow), read `getInvoiceSettings(db, tenantId)` and pre-fill: invoice `payment_terms_days` ← `default_payment_terms_days`; default line `tax_rate` ← `default_tax_rate`.
- [ ] Generate the invoice number using `invoice_number_prefix` + the existing gap-free sequence from `invoices-core` (`issueTaxInvoice` numbering). Proforma/draft numbering uses `proforma_number_prefix` where applicable.
- [ ] When a sent-invoice email is dispatched and `invoice_show_payment_link = true`, include the pay-now button (defer the link generation itself to spec 53; here only gate inclusion on this flag).
- [ ] Do not alter `invoices-core`'s sequence/transaction guarantees — read settings, then feed values into the existing create/issue logic.
**Acceptance:**
- [ ] A newly created invoice has `payment_terms_days` and default `tax_rate` matching the tenant's saved settings.
- [ ] Issued invoice numbers carry the configured `invoice_number_prefix`.
- [ ] Sent-invoice email includes the pay-now button only when `invoice_show_payment_link` is true.
