# Customer Settings — Implementation Plan

**Spec:** docs/specs/2026-06-01-settings-customers.md  ·  **Slug:** settings-customers  ·  **Wave:** 11
**Depends on:** customer-portal-access-control, customers-module, foundation-auth-rbac, invoices-core, tenant-portals

## Goal
Add a tenant-level `/settings/customers` page that configures defaults for customer management: default payment terms (display-only here, owned by `invoices-core`), default customer currency, automatic portal-invitation behavior, and the default portal role for new invites. The page also surfaces (but does not own) the tenant-level portal-visibility defaults that live in `tenant_settings.portal_visibility` (owned by spec 82 / spec 136). When `customer_auto_invite_portal` is configured, the system auto-enqueues portal invitations on customer creation or on first invoice send, reusing the existing portal-invitation mechanism.

## Architecture
This spec adds three scalar columns to the existing `tenant_settings` table (established upstream by `tenant-portals`) and exposes a thin GET/PATCH settings API. It does **not** create any new table and does **not** own any visibility storage — the "Portal Visibility Defaults" UI section is a surfaced control bound to the existing `tenant_settings.portal_visibility` JSONB (owned by `customer-portal-access-control` / spec 136 `customer-portal-settings-ui`) and is read/written through spec 136's `/api/settings/portal` endpoint, never through `/api/settings/customers`. Portal visibility is **tenant-level only** — there is no per-customer override column anywhere in the corpus; spec 82's middleware enforces visibility tenant-wide.

The auto-invite behavior plugs into two existing flows:
- `POST /api/customers` (from `customers-module`, exported `createCustomer`) — after a successful create, when the mode is `on_creation` and the customer has at least one contact with an email, enqueue a portal invitation for the primary contact.
- The invoice-send transition to `SENT` (from `invoices-core`) — when the mode is `on_first_invoice` and the invoice's customer has no rows in `customer_portal_users`, enqueue a portal invitation for the primary contact.

Both reuse the existing portal-invitation mechanism behind `POST /api/customers/:id/contacts/:cid/invite-portal` (the `invitations` table + notifications pipeline from `foundation-auth-rbac` / `customers-module`). No new invitation table or flow is introduced.

Upstream tables/exports consumed: `tenant_settings` (ALTER target for the three own columns; reads `default_payment_terms_days` owned by `invoices-core`), `customers`, `customer_contacts`, `customer_portal_users`, `invitations`; helpers `tenantQuery`, `authMiddleware`, `requirePermission`. This page's own config is read/written through its `getCustomerSettings` / `updateCustomerSettings` helpers (Task 3, via `tenantQuery`) — NOT the AI-config `getTenantSettings` / `upsertTenantSettings` (which operate on `ai_tenant_settings`). The existing `customers:read` / `customers:write` permissions are seeded by `seedPermissions`.

## Tech Stack
- **apps/zync-api** (Hono on Cloudflare Workers): `GET`/`PATCH /api/settings/customers` routes + Zod schema; auto-invite hooks wired into the customer-create and invoice-send paths.
- **apps/zync-app** (Vite + React): `/settings/customers` route page + TanStack Query hook.
- **packages/db** (Drizzle): migration adding three columns to `tenant_settings`; Drizzle schema update.
- **packages/ui**: reuse existing `Card`, `Form`, `FormField`, `Input`, `Select`, `Radio`, `Checkbox`, `Button`, `Switch`, `toast` primitives.
- Bindings: Neon Postgres via Hyperdrive (`DB`), notifications `QUEUE` for invitation enqueue.

## Wave Plan
| Sub-wave | Tasks | Files touched | Parallelizable? |
|----------|-------|---------------|-----------------|
| 11.a | Task 1 | packages/db migration + schema | No (blocks all) |
| 11.b | Task 2, Task 3 | zync-api settings routes + Zod schema; service helpers | Task 3 after Task 1; Task 2 after Task 3 |
| 11.c | Task 4, Task 5 | zync-api auto-invite hooks (create + invoice-send) | Parallel after Task 3 |
| 11.d | Task 6 | zync-app page + hook | After Task 2 |

## Tasks

### Task 1: Extend `tenant_settings` with customer-defaults columns
**Blocks:** Task 2, Task 3, Task 4, Task 5  ·  **Blocked by:** —
**Files:**
- Create: `packages/db/migrations/<timestamp>_settings_customers_defaults.sql`
- Modify: `packages/db/src/schema/tenant-settings.ts`
**Steps:**
- [ ] Add the three new columns to `tenant_settings` via idempotent `ALTER TABLE ... ADD COLUMN IF NOT EXISTS`.
- [ ] Do NOT add `default_payment_terms_days` — it is owned by `invoices-core` (wave 6, a build dependency); this plan only reads it.
- [ ] Do NOT add any `portal_show_*` flat boolean columns and do NOT add a `portal_visibility` column here — visibility is owned by spec 82 / spec 136.
- [ ] Mirror the three columns in the Drizzle `tenantSettings` schema definition (text columns with the same defaults; encode the CHECK sets as Drizzle enums or a raw check).
**Schema / Interfaces:**
```sql
ALTER TABLE tenant_settings
  ADD COLUMN IF NOT EXISTS customer_default_currency TEXT NOT NULL DEFAULT 'ILS',
  ADD COLUMN IF NOT EXISTS customer_auto_invite_portal TEXT NOT NULL DEFAULT 'never'
    CHECK (customer_auto_invite_portal IN ('never', 'on_creation', 'on_first_invoice')),
  ADD COLUMN IF NOT EXISTS customer_default_portal_role TEXT NOT NULL DEFAULT 'customer_viewer'
    CHECK (customer_default_portal_role IN ('customer_viewer'));
```
**Acceptance:**
- [ ] Migration applies cleanly against Neon Postgres and is idempotent (re-running is a no-op).
- [ ] `tenant_settings` has exactly three new columns with the defaults and CHECK constraints above; `default_payment_terms_days` is untouched.
- [ ] Drizzle schema typechecks and reflects the three new columns.

### Task 2: `GET` + `PATCH /api/settings/customers` routes
**Blocks:** Task 6  ·  **Blocked by:** Task 1, Task 3
**Files:**
- Create: `apps/zync-api/src/server/routes/settings/customers.ts`
- Modify: `apps/zync-api/src/server/routes/settings/index.ts` (mount the router)
**Steps:**
- [ ] Implement `GET /api/settings/customers`, guarded by `authMiddleware` + `requirePermission('customers:read')`; load via the Task 3 `getCustomerSettings(db, tenantId)` helper and return exactly the four fields.
- [ ] Implement `PATCH /api/settings/customers`, guarded by `authMiddleware` + `requirePermission('customers:write')`; validate the body with `updateCustomerSettingsSchema` (Task 3); persist via the Task 3 `updateCustomerSettings` service helper; return the updated four-field object.
- [ ] All DB access goes through the service helper (`no-raw-drizzle-from-routes`); request bodies validated with Zod (`require-zod-validation-in-routes`).
- [ ] Do NOT add visibility fields to either endpoint; the visibility checkboxes bind to spec 136's `/api/settings/portal`.
**Schema / Interfaces:**
```ts
// GET /api/settings/customers  (requires customers:read)
interface CustomerSettings {
  default_payment_terms_days: number;          // read-only mirror, owned by invoices-core
  customer_default_currency: string;           // default 'ILS'
  customer_auto_invite_portal: 'never' | 'on_creation' | 'on_first_invoice';
  customer_default_portal_role: 'customer_viewer';
}

// PATCH /api/settings/customers  (requires customers:write)
// body: Partial of the settable fields below (default_payment_terms_days is NOT settable here)
```
**Acceptance:**
- [ ] `GET` returns the four fields for the caller's tenant; 403 without `customers:read`.
- [ ] `PATCH` updates only the settable fields and persists across requests; 403 without `customers:write`; invalid enum/currency values return 400 from Zod.
- [ ] No raw Drizzle calls in the route file.

### Task 3: `updateCustomerSettingsSchema` + settings service helpers
**Blocks:** Task 2, Task 4, Task 5  ·  **Blocked by:** Task 1
**Files:**
- Create: `apps/zync-api/src/server/services/customer-settings.ts`
- Modify: `apps/zync-api/src/server/schemas/settings.ts`
**Steps:**
- [ ] Define `updateCustomerSettingsSchema` (Zod) with optional `customer_default_currency` (3-letter ISO currency string), `customer_auto_invite_portal` (enum `never|on_creation|on_first_invoice`), `customer_default_portal_role` (enum `customer_viewer`). Exclude `default_payment_terms_days`.
- [ ] Implement `getCustomerSettings(db, tenantId)` returning the four-field shape (reads `default_payment_terms_days` + the three own columns from the tenant's `tenant_settings` row via `tenantQuery`; if the row is absent, return the column defaults).
- [ ] Implement `updateCustomerSettings(db, tenantId, patch)` that validates against the schema and writes only the three own columns via `tenantQuery` (`UPDATE tenant_settings ... WHERE tenant_id = ?`; never `default_payment_terms_days`), returning the refreshed four-field shape.
- [ ] Add a `resolveAutoInviteMode(db, tenantId)` helper returning `customer_auto_invite_portal` for reuse by the hooks (Tasks 4/5).
**Schema / Interfaces:**
```ts
import { z } from 'zod';

export const updateCustomerSettingsSchema = z.object({
  customer_default_currency: z.string().length(3).optional(),
  customer_auto_invite_portal: z.enum(['never', 'on_creation', 'on_first_invoice']).optional(),
  customer_default_portal_role: z.enum(['customer_viewer']).optional(),
}).strict();

export type UpdateCustomerSettings = z.infer<typeof updateCustomerSettingsSchema>;

export function getCustomerSettings(db: DB, tenantId: string): Promise<CustomerSettings>;
export function updateCustomerSettings(db: DB, tenantId: string, patch: UpdateCustomerSettings): Promise<CustomerSettings>;
export function resolveAutoInviteMode(db: DB, tenantId: string): Promise<'never' | 'on_creation' | 'on_first_invoice'>;
```
**Acceptance:**
- [ ] `updateCustomerSettingsSchema` rejects unknown keys (`.strict()`), bad enum values, and non-3-char currencies.
- [ ] `getCustomerSettings` / `updateCustomerSettings` round-trip the three columns and surface `default_payment_terms_days` read-only.

### Task 4: Auto-invite hook on customer creation (`on_creation`)
**Blocks:** —  ·  **Blocked by:** Task 1, Task 3
**Files:**
- Modify: `apps/zync-api/src/server/routes/customers.ts` (the `POST /api/customers` handler / `createCustomer` call site)
**Steps:**
- [ ] After `POST /api/customers` succeeds, call `resolveAutoInviteMode(db, tenantId)`.
- [ ] If mode is `on_creation` AND the new customer has at least one contact with a non-empty email, select the primary contact (the one with `is_primary = true`, else the first contact) and enqueue a portal invitation for it.
- [ ] Reuse the existing portal-invitation mechanism — the same code path behind `POST /api/customers/:id/contacts/:cid/invite-portal` (creates an `invitations` row with the customer's `customer_default_portal_role` and dispatches the standard portal invite email via the notifications pipeline). Do NOT create a new invitation table or flow.
- [ ] Make the enqueue best-effort: a failure to enqueue must not fail the customer-create response (log + continue).
**Acceptance:**
- [ ] With mode `on_creation` and a customer created with an emailed primary contact, exactly one portal invitation is enqueued for that contact.
- [ ] With mode `never` or a customer with no emailed contact, no invitation is enqueued.
- [ ] Customer creation still returns 201 even if invitation enqueue fails.

### Task 5: Auto-invite hook on first invoice sent (`on_first_invoice`)
**Blocks:** —  ·  **Blocked by:** Task 1, Task 3
**Files:**
- Modify: `apps/zync-api/src/server/routes/invoices.ts` (the invoice-send handler that transitions an invoice to `SENT`)
**Steps:**
- [ ] On the transition of an invoice to status `SENT`, call `resolveAutoInviteMode(db, invoice.tenant_id)`.
- [ ] If mode is `on_first_invoice`, check whether the invoice's customer has any rows in `customer_portal_users`. If none exist, select the customer's primary contact (as in Task 4) and enqueue a portal invitation for it via the same reused mechanism.
- [ ] Best-effort: invitation enqueue failure must not block the invoice-send response.
- [ ] Guard against duplicate invites: only enqueue when `customer_portal_users` is empty for that customer (the "first invoice" gate).
**Acceptance:**
- [ ] With mode `on_first_invoice` and a customer that has zero `customer_portal_users`, sending an invoice enqueues one portal invitation.
- [ ] If the customer already has any `customer_portal_users` row, no invitation is enqueued.
- [ ] With mode `never` / `on_creation`, sending an invoice enqueues nothing from this hook.

### Task 6: `/settings/customers` page + `useCustomerSettings` hook
**Blocks:** —  ·  **Blocked by:** Task 2
**Files:**
- Create: `apps/zync-app/src/pages/settings/CustomerSettingsPage.tsx`
- Create: `apps/zync-app/src/hooks/useCustomerSettings.ts`
- Modify: `apps/zync-app/src/router.tsx` (register `/settings/customers`, guarded by `customers:write`)
**Steps:**
- [ ] `useCustomerSettings`: TanStack Query hook wrapping `GET /api/settings/customers` (query) and `PATCH /api/settings/customers` (mutation with cache invalidation + `toast` on success/error).
- [ ] Render three `Card` sections matching the spec layout:
  - **Customer Defaults:** `default_payment_terms_days` as an `Input` (number, days) with helper text "pre-fills new invoice payment terms / also editable in Settings > Invoices"; `customer_default_currency` as a `Select` (default ILS).
  - **Portal Access Defaults:** `customer_auto_invite_portal` as a 3-option `Radio` group (`Never` / `On customer creation (invite primary contact)` / `On first invoice sent`); `customer_default_portal_role` shown as a read-only `customer_viewer` label ("portal access is read-only").
  - **Portal Visibility Defaults:** checkboxes bound to the canonical `PortalVisibility` keys (`@zync/types`) — `Invoices`→`show_invoices`, `Projects (name + status only)`→`show_projects`, `Support tickets`→`show_tickets`, `Contracts`→`show_contracts`, `Proposals`→`show_proposals`, `Shared files`→`show_files`, `Time summary`→`show_time_summary` — written to spec 82's `tenant_settings.portal_visibility` through spec 136's `/api/settings/portal` route (NOT `/api/settings/customers`). Read/write via the spec-136 portal-settings hook; this page only surfaces the control. Visibility is tenant-level — there is no per-customer override.
- [ ] `[Save changes]` `Button` triggers the PATCH mutation for the Customer Defaults + Portal Access Defaults fields.
- [ ] Restrict the route to `customers:write`; show a permission-denied / empty state otherwise.
- [ ] Honor cross-cutting requirements: proper `aria` roles/labels on the radio group and checkboxes, label-for associations on inputs, full RTL/Hebrew support (logical CSS properties, translated strings via `translations`), and `prefers-reduced-motion` on any transitions. No hardcoded colors/spacing/radii (use design tokens).
**Acceptance:**
- [ ] Page loads current settings, edits persist via PATCH, and a success `toast` fires.
- [ ] Visibility checkboxes read/write `tenant_settings.portal_visibility` through `/api/settings/portal`, not `/api/settings/customers`.
- [ ] Visibility checkboxes use only the canonical 7 `show_*` `PortalVisibility` keys — no `project_billing` or other non-canonical keys (spec-136's `.strict()` schema rejects unknown keys).
- [ ] Route is gated by `customers:write`; RTL layout and a11y roles verified; no hardcoded color/spacing/radius values.
