# Expense Settings UI (`/settings/expenses`) — Implementation Plan

**Spec:** docs/specs/2026-05-31-expense-settings-ui.md  ·  **Slug:** expense-settings-ui  ·  **Wave:** 10
**Depends on:** expenses-module, foundation-auth-rbac, settings-module

## Goal
Deliver the tenant-facing configuration page at `/settings/expenses` that lets an admin set how the Expenses module behaves: default AI fallback category, auto-approve threshold, VAT filing cadence, receipt-reminder cadence, per-diem rates, and (Business+ only) an approval threshold + approver role. All settings persist as **typed `tenant_settings` columns** (owned by `expenses-module`) via a single atomic `PATCH /api/settings/expenses`. These are the same columns read by the expenses processing pipeline (auto-approve), the VAT report (`filing_cadence`), the per-diem entry form (`per_diem_rates`), the receipt-reminder cron, and the approval workflow (`expense_approval_threshold_ils`/`expense_approver_role`, spec 61) — writer and readers share one store.

## Architecture
This spec adds **one React route only** (`/settings/expenses`) rendered inside the settings shell owned by `settings-module`. There is **no new API route, no new table, no new accessor, and no new type**: the `GET`/`PATCH /api/settings/expenses` route, the `getExpenseSettings`/`updateExpenseSettings` accessor on the `tenant_settings` columns, and the `ExpenseSettings` type + `EXPENSE_SETTINGS_DEFAULTS` + `expenseSettingsSchema` + `mergeExpenseSettings` are all **owned by `expenses-module`** (the data-owning module). This page is a pure consumer: it calls the existing route, imports the existing type/schema/defaults from `@zync/types`, and does NOT redeclare any of them (a second declaration would be a duplicate-identifier collision / double-mounted route — a hard build break). It does NOT touch `tenants.settings` JSONB. The page consumes:
- Upstream exports: `authMiddleware`, `requirePermission` (`settings:write`), `requireTier` / `useTierGate('business')` (foundation-auth-rbac); `Card`, `Button`, `Input`, `Select`, `Radio`, `Switch`, `Form`, `FormField`, `FormLabel`, `FormError`, `toast`, `useDirection` (foundation-design-system / system-i18n); the `GET`/`PATCH /api/settings/expenses` route + the `ExpenseSettings` / `ExpenseSettingsPatch` types, `EXPENSE_SETTINGS_DEFAULTS`, `expenseSettingsSchema`, `mergeExpenseSettings` (from `expenses-module` / `@zync/types`).
- Upstream constants: the 8 expense categories from `packages/types/src/expense-categories.ts` (`office, marketing, professional, vehicle, equipment, finance, welfare, exceptional`) defined by `expenses-module` (via `EXPENSE_CATEGORY_IDS`), plus the per-diem rate defaults from `EXPENSE_SETTINGS_DEFAULTS`.
- Client tenant context (from the app session/workspace): `slug` (to derive the read-only forwarding address `expenses@{slug}.zync.is`) and `tier` (to gate the Business+ approval block) — both already available client-side, so the route does not return them.
- The settings sidebar manifest entry `/settings/expenses` (already listed in `settings-module`'s Settings Navigation Manifest, Tier "All") — this plan wires the page component to that route, it does not redefine the manifest.

Data flow: page mount → `GET /api/settings/expenses` (expenses-module route → `getExpenseSettings` → `tenant_settings` columns) → form hydrates; `forwardingAddress` derived client-side from tenant-context `slug`, Business+ gate from tenant-context `tier` → user edits → "Save Changes" → `PATCH /api/settings/expenses` (full page-editable object, validated upstream by `expenseSettingsSchema`, audited) → toast.

## Tech Stack
- **API:** none added. The `GET`/`PATCH /api/settings/expenses` route is owned by `expenses-module`; this spec only calls it.
- **App:** Vite + React route in `apps/zync-app` under the settings shell; React Query for fetch/mutation; `@zync/ui` components; `@zync/types` for the shared `ExpenseSettings`/`ExpenseSettingsPatch` types, `EXPENSE_SETTINGS_DEFAULTS`, `expenseSettingsSchema`, `mergeExpenseSettings`, and category constants (all authored by `expenses-module`).
- **Package:** none added (no new types — consumes `@zync/types`).
- **Bindings:** none new. No queue, no R2, no KV.

## Wave Plan
| Sub-wave | Tasks | Files touched | Parallelizable? |
|----------|-------|---------------|-----------------|
| 10a | Task 1 | `apps/zync-app/src/features/settings/expenses/useExpenseSettings.ts` | Yes (no deps within wave) |
| 10b | Task 2, Task 3 | `apps/zync-app/src/features/settings/expenses/*`, settings route table | Task 3 after Task 2; both after Task 1 |
| 10c | Task 4 | test files | After Tasks 1–3 |

## Tasks

### Task 1: React data layer — query + mutation hooks
**Blocks:** Task 2, Task 3  ·  **Blocked by:** —
**Files:**
- Create: `apps/zync-app/src/features/settings/expenses/useExpenseSettings.ts`
**Steps:**
- [ ] `useExpenseSettings()`: React Query `useQuery` against `GET /api/settings/expenses` (the **expenses-module-owned** route — not declared here). The route returns the full `ExpenseSettings`; coalesce through `mergeExpenseSettings` defensively. Derive `forwardingAddress = expenses@{slug}.zync.is` and `tier` from the **client tenant context** (session/workspace) — the route does not return them. Return `{ settings, forwardingAddress, tier }`.
- [ ] `useUpdateExpenseSettings()`: `useMutation` to `PATCH /api/settings/expenses` with the page-editable object (validated upstream by `expenseSettingsSchema`); on success invalidate the query and `toast('Expense settings saved')`; on error toast the validation message.
- [ ] Type both hooks with the shared `@zync/types` `ExpenseSettings` / `ExpenseSettingsPatch`; import `mergeExpenseSettings` + `EXPENSE_SETTINGS_DEFAULTS` from `@zync/types`. Do NOT redefine the type, schema, defaults, route, or accessor — all are owned by `expenses-module`.
**Schema / Interfaces:**
```ts
import { useTenant } from '@/lib/tenant'; // client tenant context: { slug, tier }
import { mergeExpenseSettings, type ExpenseSettings, type ExpenseSettingsPatch } from '@zync/types';

export function useExpenseSettings(): {
  data?: { settings: ExpenseSettings; forwardingAddress: string; tier: string };
  isLoading: boolean;
};
export function useUpdateExpenseSettings(): {
  mutate: (next: ExpenseSettingsPatch) => void;
  isPending: boolean;
};
```
**Acceptance:**
- [ ] Hooks compile against the upstream API contract; success path fires `toast('Expense settings saved')` and refetches.
- [ ] `forwardingAddress` and `tier` resolve from tenant context (no extra route field); fresh-tenant GET hydrates to `EXPENSE_SETTINGS_DEFAULTS`.

### Task 2: `/settings/expenses` page + section components
**Blocks:** Task 3  ·  **Blocked by:** Task 1
**Files:**
- Create: `apps/zync-app/src/features/settings/expenses/ExpenseSettingsPage.tsx`
- Create: `apps/zync-app/src/features/settings/expenses/sections/GeneralSection.tsx`
- Create: `apps/zync-app/src/features/settings/expenses/sections/VatReportingSection.tsx`
- Create: `apps/zync-app/src/features/settings/expenses/sections/ReceiptIntakeSection.tsx`
- Create: `apps/zync-app/src/features/settings/expenses/sections/PerDiemRatesSection.tsx`
- Create: `apps/zync-app/src/features/settings/expenses/sections/ApprovalSection.tsx`
- Modify: `apps/zync-app/src/routes/settings.tsx` (register `/settings/expenses` → `ExpenseSettingsPage` inside the settings shell)
**Steps:**
- [ ] Build a single `Form` holding local state seeded from `useExpenseSettings()`; one `Button` "Save Changes" submits the full object via `useUpdateExpenseSettings().mutate` (spec: single atomic PATCH, not field-by-field).
- [ ] **General section:** `Default Category` `Select` listing the 8 categories from `packages/types/src/expense-categories.ts` (localized label via `useDirection`/i18n; Hebrew label shown in RTL) plus the `other`/"Other business expenses" sentinel; helper text "Applied when AI cannot determine category". `Auto-approve threshold` `Input type=number min=0` prefixed `₪` with helper "AI evaluations below this amount are auto-accepted" and hint "(0 = manual review for all)".
- [ ] **VAT Reporting section:** `Radio` group `Monthly` / `Bimonthly` bound to `filing_cadence`; helper "Determines PCN874 grouping in expense reports".
- [ ] **Receipt Intake section:** read-only `Input` showing `forwardingAddress` with a `Copy` button (writes to clipboard, toasts "Copied"); caption "(read-only — generated from your workspace slug)". `Receipt Reminders` `Radio`/`Switch` Enabled/Disabled bound to `expense_receipt_reminder_enabled`; when enabled show "Remind after: [N] days without a receipt attached" numeric input bound to `expense_receipt_reminder_days` (min 1, max 365).
- [ ] **Per-Diem Rates section:** four `Input type=number min=0` fields for `domestic_full_day`, `domestic_half_day`, `international_full_day`, `international_half_day` (₪/day), bound to `per_diem_rates`; caption that the Log-per-diem form uses these rates.
- [ ] **Approval section (Business+):** gate with `useTierGate('business')`. When `allowed`, render `Require approval for expenses above` `Input min=0` (₪, hint "(0 = approval required for all)") bound to `expense_approval_threshold_ils`, and `Approver` `Select` with `Any Admin` (`any_admin`) plus admin users of the tenant (value = `user_id`) bound to `expense_approver_role`. When not allowed, render the same controls **disabled** with an `ⓘ Available on Business+ plan` badge whose click calls `upgrade('Expense approval workflow')` (opens the upgrade modal).
- [ ] Register the route in the settings shell route table so the sidebar manifest entry `/settings/expenses` resolves here.
**Acceptance:**
- [ ] Visiting `/settings/expenses` with `settings:write` shows all five sections hydrated from the API; the forwarding-address field is read-only and copy works.
- [ ] On a Freelancer tenant, the Approval section controls are disabled and show the Business+ upgrade affordance; on Business/Enterprise they are editable.
- [ ] "Save Changes" sends one PATCH with the full object and toasts "Expense settings saved".

### Task 3: Accessibility, RTL, and reduced-motion conformance
**Blocks:** —  ·  **Blocked by:** Task 2
**Files:**
- Modify: `apps/zync-app/src/features/settings/expenses/ExpenseSettingsPage.tsx` and section components
**Steps:**
- [ ] Each section is a `<section>` with an `<h2>`/`aria-labelledby` heading ("General", "VAT Reporting", "Receipt Intake", "Per-Diem Rates", "Approval"); the page has a single `<h1>` "Settings / Expenses".
- [ ] Every input has an associated `<label>` (via `FormLabel`/`FormField`); helper texts linked with `aria-describedby`; the `₪` prefix is decorative (`aria-hidden`) and the currency is conveyed in the label.
- [ ] Radio groups use `role="radiogroup"` with a group label; the Approver `Select` is keyboard-operable; the Copy button has `aria-label="Copy forwarding address"` and announces success via a `role="status"` live region.
- [ ] The read-only forwarding field has `readonly` + `aria-readonly="true"`.
- [ ] Save button shows a busy state (`aria-busy` while `isPending`); validation errors render in `FormError` with `role="alert"` and move focus to the first invalid field.
- [ ] Honor `prefers-reduced-motion` for any expand/collapse of the conditional "Remind after" row and the disabled-section reveal (no animation when reduced-motion is set).
- [ ] All spacing uses design tokens (no hardcoded px — `no-hardcoded-spacing`); colors via tokens (`no-hardcoded-colors`); full RTL: the page mirrors under Hebrew via `useDirection`, `₪` and numeric inputs render correctly in RTL.
**Acceptance:**
- [ ] Keyboard-only user can reach and operate every control; axe/pa11y reports no violations on the page.
- [ ] Under Hebrew locale the layout mirrors (RTL) and category labels show Hebrew names.
- [ ] With `prefers-reduced-motion: reduce`, no transition animations fire.

### Task 4: Tests
**Blocks:** —  ·  **Blocked by:** Task 1, Task 2, Task 3
**Files:**
- Create: `apps/zync-app/src/features/settings/expenses/ExpenseSettingsPage.test.tsx`
**Steps:**
- [ ] UI: assert all five sections render from mocked API (`GET /api/settings/expenses`); a fresh-tenant mock hydrates to `EXPENSE_SETTINGS_DEFAULTS`; Approval section disabled + upgrade affordance on Freelancer (tenant-context tier), editable on Business; `forwardingAddress` derives from tenant-context slug and the Copy button copies it; Save sends the page-editable object via PATCH and shows the success toast.
- [ ] Route-contract assertions (GET/PATCH behavior, Zod rejection, tier-neutralization, audit row) live with the route owner (`expenses-module`), not here — this spec does not own the route.
**Acceptance:**
- [ ] `pnpm test` green for the page test file.
