# Contract Settings — Implementation Plan

**Spec:** docs/specs/2026-06-01-settings-contracts.md  ·  **Slug:** settings-contracts  ·  **Wave:** 13
**Depends on:** contract-renewal-amendment, contracts-esignature, foundation-auth-rbac

## Goal
Deliver a `/settings/contracts` page and its API so a tenant can configure contract-wide defaults: default contract template, default expiry term (days), e-signature workflow defaults (signing order, email verification, reminder cadence), and renewal-reminder timing/channels. These settings are scalar tenant config stored on the existing `tenant_settings` table, consumed as pre-fills by the contract-creation flow (spec 48) and by the renewal-reminder cron (spec 89). No new tables are created; this spec extends `tenant_settings` and adds one new column (`signing_order`) to `contracts`.

## Architecture
- **Storage:** All settings are scalar columns added to the shared `tenant_settings` table (the canonical per-tenant scalar-config table that sibling settings specs 125/148/151/152 also `ALTER`). One column `signing_order` is added to the upstream `contracts` table (owned by `contracts-esignature`).
- **`tenant_settings` provenance:** The base table (`id` UUID PK, unique `tenant_id` FK, timestamps) is owned by `foundation-auth-rbac` and is in every tenant's transitive closure, so it always exists before any settings migration runs. This plan only `ALTER TABLE tenant_settings ADD COLUMN IF NOT EXISTS …` for its contract-config columns; it never creates the table. (`tenant_settings` is the typed module-config table; free-form business-profile/locale prefs live in `tenants.settings` JSONB, owned by `settings-module`. `ai_tenant_settings`, created by `system-ai`, is a different table.)
- **Reconciliation with upstream (important):** The contract *term-end* date column is owned by `contract-renewal-amendment`, which already defines `contracts.expiry_date DATE` (read by the `contract-expiry-reminder` cron). The settings spec text proposed a second column `contracts.expires_at TIMESTAMPTZ` for the same concept — that is drift. This plan does NOT add `expires_at`; the contract-creation pre-fill writes the existing `contracts.expiry_date` instead. Only the genuinely-new `contracts.signing_order` column is added here.
- **Consumes upstream tables/columns:**
  - `tenant_settings` (upstream scalar-config table) — extended with contract_* columns.
  - `contract_templates(id)` (from `contracts-esignature`) — FK target for `contract_default_template_id`, `ON DELETE SET NULL`.
  - `contracts` (from `contracts-esignature`) — gains `signing_order`; pre-fill writes `template_id`, `signing_order`, and the upstream `expiry_date`.
  - `tenants(id)` — tenant scoping (already the PK referenced by `tenant_settings`).
- **Consumes upstream exports:** `authMiddleware`, `requirePermission` (foundation-auth-rbac) for `users:manage` gating; `requireTier`/`meetsMinimumTier` for the Business+ gate on the e-signature section; `tenantQuery` for tenant-scoped DB access; `require-zod-validation-in-routes` and `no-raw-drizzle-from-routes` lint conventions.
- **Data flow:** UI page → `GET /api/settings/contracts` (read row) and `PATCH /api/settings/contracts` (partial update) → repository helpers `getContractSettings` / `updateContractSettings` → Drizzle on `tenant_settings`. Contract-creation pre-fill and the renewal cron read the same columns directly via their own modules; this spec only owns the columns + settings UI/API.
- **Tier gating:** The e-signature workflow subsection is gated Business+. Renewal reminders and contract defaults are available to all tiers. Non-Business+ tenants see the e-signature section rendered as an upsell card (links to the upgrade modal), and the PATCH endpoint must reject changes to e-signature-only fields for non-Business+ tenants.

## Tech Stack
- **App:** `apps/zync-api` (Hono on Cloudflare Workers) for the two routes; `apps/zync-app` (Vite + React) for the settings page UI.
- **Packages:** `@zync/db` (Drizzle schema for `tenant_settings`/`contracts` deltas, repository helpers, migration), `@zync/types` (shared `ContractSettings` type + Zod schema), `@zync/ui` (Card, Input, Radio, Switch, Checkbox, Select, Button, Form primitives), `@zync/auth` (`requirePermission`, `requireTier`).
- **Bindings:** Cloudflare Hyperdrive → Neon Postgres (`DB`); no new bindings.
- **Validation:** Zod request bodies; React Hook Form on the client.
- **i18n/RTL:** All labels via `@zync/i18n` translation keys; layout uses logical properties so the page mirrors correctly under Hebrew RTL. Form controls keep visible focus rings (a11y) and honor `prefers-reduced-motion` for any save-state transitions.

## Wave Plan
| Sub-wave | Tasks | Files touched | Parallelizable? |
|----------|-------|---------------|-----------------|
| A — schema | 1 | `packages/db/src/schema/*.ts`, migration SQL | No (root dependency) |
| B — types + repo | 2, 3 | `packages/types`, `packages/db/src/repositories` | Yes (2 and 3 independent after Task 1) |
| C — API | 4 | `apps/zync-api/src/routes/settings/contracts.ts` | No (needs 2, 3) |
| D — UI | 5, 6 | `apps/zync-app/src/pages/settings/contracts/*` | 6 after 5; 5 after 4 |
| E — verification | 7 | tests | No (last) |

## Tasks

### Task 1: Schema delta — extend `tenant_settings`, add `contracts.signing_order`
**Blocks:** 2, 3, 4  ·  **Blocked by:** —
**Files:**
- Modify: `packages/db/src/schema/tenant-settings.ts` (Drizzle table for upstream `tenant_settings`)
- Modify: `packages/db/src/schema/contracts.ts` (Drizzle table for upstream `contracts`)
- Create: `packages/db/migrations/0153_settings_contracts.sql`
**Steps:**
- [ ] Do NOT create `tenant_settings` here; the base table is owned by `foundation-auth-rbac`. This migration only `ALTER ... ADD COLUMN IF NOT EXISTS` the contract-config columns.
- [ ] Add the contract_* columns to the `tenant_settings` Drizzle table definition, matching the DDL below exactly (defaults, NOT NULL, CHECK constraints).
- [ ] Add `signing_order` to the `contracts` Drizzle table with default `'parallel'` and the CHECK constraint.
- [ ] Do NOT add `expires_at` to `contracts`; the term-end date is the upstream `expiry_date DATE` column owned by `contract-renewal-amendment`. Confirm `contracts.expiry_date` already exists in the schema before writing the migration.
- [ ] Write the migration SQL using `ADD COLUMN IF NOT EXISTS` so it is idempotent against the upstream-created tables.
- [ ] Register the `contract_default_template_id` FK to `contract_templates(id)` with `ON DELETE SET NULL`.
**Schema / Interfaces:**
```sql
-- tenant_settings base table (id + unique tenant_id FK + timestamps) is owned by
-- foundation-auth-rbac; this migration only ALTERs it to add contract-config columns.

-- contracts: new column only (expiry_date/effective_date/renewed_from_id/amended_from_id
-- already exist from contract-renewal-amendment; do NOT redefine them).
ALTER TABLE contracts
  ADD COLUMN IF NOT EXISTS signing_order TEXT NOT NULL DEFAULT 'parallel'
    CHECK (signing_order IN ('parallel', 'ordered'));

-- tenant_settings: contract configuration columns
ALTER TABLE tenant_settings
  ADD COLUMN IF NOT EXISTS contract_default_template_id UUID
    REFERENCES contract_templates(id) ON DELETE SET NULL,
  ADD COLUMN IF NOT EXISTS contract_default_expiry_days INTEGER NOT NULL DEFAULT 365
    CHECK (contract_default_expiry_days >= 0),
  ADD COLUMN IF NOT EXISTS contract_default_signing_order TEXT NOT NULL DEFAULT 'parallel'
    CHECK (contract_default_signing_order IN ('parallel', 'ordered')),
  ADD COLUMN IF NOT EXISTS contract_require_email_verification BOOLEAN NOT NULL DEFAULT true,
  ADD COLUMN IF NOT EXISTS contract_reminder_first_days INTEGER NOT NULL DEFAULT 3
    CHECK (contract_reminder_first_days >= 0),
  ADD COLUMN IF NOT EXISTS contract_reminder_followup_days INTEGER NOT NULL DEFAULT 7
    CHECK (contract_reminder_followup_days >= 0),
  ADD COLUMN IF NOT EXISTS contract_reminder_max_count INTEGER NOT NULL DEFAULT 3
    CHECK (contract_reminder_max_count >= 0),
  ADD COLUMN IF NOT EXISTS contract_renewal_remind_30d BOOLEAN NOT NULL DEFAULT true,
  ADD COLUMN IF NOT EXISTS contract_renewal_remind_7d BOOLEAN NOT NULL DEFAULT true,
  ADD COLUMN IF NOT EXISTS contract_renewal_remind_1d BOOLEAN NOT NULL DEFAULT false,
  ADD COLUMN IF NOT EXISTS contract_renewal_notify_email BOOLEAN NOT NULL DEFAULT true,
  ADD COLUMN IF NOT EXISTS contract_renewal_notify_in_app BOOLEAN NOT NULL DEFAULT true;
```
**Acceptance:**
- [ ] Migration applies cleanly whether or not `tenant_settings` already exists (defensive `CREATE TABLE IF NOT EXISTS` runs first); `contracts` and `contract_templates` are assumed present from upstream; re-running is a no-op (IF NOT EXISTS on table and columns).
- [ ] `contracts` has `signing_order` but NOT a new `expires_at` column.
- [ ] All CHECK constraints and defaults match the DDL above; FK is `UUID → contract_templates(id)` with `ON DELETE SET NULL`.

### Task 2: Shared `ContractSettings` type + Zod validation schema
**Blocks:** 4  ·  **Blocked by:** 1
**Files:**
- Create: `packages/types/src/contract-settings.ts`
- Modify: `packages/types/src/index.ts` (export)
**Steps:**
- [ ] Define the `ContractSettings` TypeScript interface mirroring the read-payload fields.
- [ ] Define `updateContractSettingsSchema` (Zod) as a partial object — every field optional, so PATCH accepts any subset.
- [ ] Enforce numeric bounds in Zod (`>= 0` for day/count fields) and enum membership for `contract_default_signing_order`.
- [ ] Export both from the package index so the API route and UI can import them.
**Schema / Interfaces:**
```ts
export type SigningOrder = 'parallel' | 'ordered';

export interface ContractSettings {
  contract_default_template_id: string | null;   // UUID
  contract_default_expiry_days: number;           // >= 0; 0 = no expiry
  contract_default_signing_order: SigningOrder;
  contract_require_email_verification: boolean;
  contract_reminder_first_days: number;           // >= 0
  contract_reminder_followup_days: number;        // >= 0
  contract_reminder_max_count: number;            // >= 0
  contract_renewal_remind_30d: boolean;
  contract_renewal_remind_7d: boolean;
  contract_renewal_remind_1d: boolean;
  contract_renewal_notify_email: boolean;
  contract_renewal_notify_in_app: boolean;
}

import { z } from 'zod';
export const updateContractSettingsSchema = z.object({
  contract_default_template_id: z.string().uuid().nullable(),
  contract_default_expiry_days: z.number().int().min(0),
  contract_default_signing_order: z.enum(['parallel', 'ordered']),
  contract_require_email_verification: z.boolean(),
  contract_reminder_first_days: z.number().int().min(0),
  contract_reminder_followup_days: z.number().int().min(0),
  contract_reminder_max_count: z.number().int().min(0),
  contract_renewal_remind_30d: z.boolean(),
  contract_renewal_remind_7d: z.boolean(),
  contract_renewal_remind_1d: z.boolean(),
  contract_renewal_notify_email: z.boolean(),
  contract_renewal_notify_in_app: z.boolean(),
}).partial();
export type UpdateContractSettingsInput = z.infer<typeof updateContractSettingsSchema>;
```
**Acceptance:**
- [ ] `ContractSettings` and `updateContractSettingsSchema` are exported from `@zync/types`.
- [ ] `updateContractSettingsSchema.parse({})` succeeds (all fields optional); out-of-range numbers and unknown signing-order values are rejected.

### Task 3: Repository helpers `getContractSettings` / `updateContractSettings`
**Blocks:** 4  ·  **Blocked by:** 1
**Files:**
- Create: `packages/db/src/repositories/contract-settings.ts`
- Modify: `packages/db/src/repositories/index.ts` (export)
**Steps:**
- [ ] Implement `getContractSettings(db, tenantId)` — select the contract_* columns from `tenant_settings` for the tenant via `tenantQuery`; if no row exists yet, return the column defaults (defensive — `tenant_settings` is created elsewhere on tenant provisioning).
- [ ] Implement `updateContractSettings(db, tenantId, patch)` — apply only the provided keys to the tenant's `tenant_settings` row and return the full updated `ContractSettings`.
- [ ] Use Drizzle column expressions only (respect `no-raw-drizzle-from-routes`); both helpers are tenant-scoped.
- [ ] Validate `contract_default_template_id`, when non-null, belongs to the same tenant before persisting (reject cross-tenant template ids).
**Schema / Interfaces:**
```ts
export function getContractSettings(
  db: Db, tenantId: string
): Promise<ContractSettings>;

export function updateContractSettings(
  db: Db, tenantId: string, patch: UpdateContractSettingsInput
): Promise<ContractSettings>;
```
**Acceptance:**
- [ ] `getContractSettings` returns defaults for a tenant with no explicit settings row and persisted values otherwise.
- [ ] `updateContractSettings` performs a partial update (unspecified fields unchanged) and rejects a `contract_default_template_id` that belongs to another tenant.

### Task 4: API routes `GET` / `PATCH /api/settings/contracts`
**Blocks:** 5  ·  **Blocked by:** 2, 3
**Files:**
- Create: `apps/zync-api/src/routes/settings/contracts.ts`
- Modify: `apps/zync-api/src/routes/settings/index.ts` (mount router)
**Steps:**
- [ ] Mount both routes behind `authMiddleware` and `requirePermission('users:manage')`.
- [ ] `GET /api/settings/contracts` → call `getContractSettings(db, tenantId)`; return the `ContractSettings` JSON payload.
- [ ] `PATCH /api/settings/contracts` → validate body with `updateContractSettingsSchema` (via `require-zod-validation-in-routes`), then call `updateContractSettings`.
- [ ] Enforce Business+ gate on e-signature-only fields: if the tenant is not Business+ and the PATCH body contains any of `contract_default_signing_order`, `contract_require_email_verification`, `contract_reminder_first_days`, `contract_reminder_followup_days`, `contract_reminder_max_count`, return `403` with an upgrade-required error. Contract-defaults and renewal-reminder fields are always allowed.
- [ ] Return the updated settings on success.
**Schema / Interfaces:**
```
GET   /api/settings/contracts   (requires users:manage)
      → 200 ContractSettings
PATCH /api/settings/contracts   (requires users:manage)
      body: Partial<ContractSettings>  (updateContractSettingsSchema)
      → 200 ContractSettings
      → 403 if non-Business+ tenant sends e-signature-only fields
```
**Acceptance:**
- [ ] Both routes reject callers lacking `users:manage` with `403`.
- [ ] `PATCH` with an invalid body returns `400` from the Zod validator.
- [ ] Non-Business+ tenant patching `contract_require_email_verification` (or any e-sig field) gets `403`; patching `contract_renewal_remind_30d` succeeds.
- [ ] `GET` returns the full payload with all 12 fields.

### Task 5: Settings page `/settings/contracts` — data + form
**Blocks:** 6  ·  **Blocked by:** 4
**Files:**
- Create: `apps/zync-app/src/pages/settings/contracts/ContractSettingsPage.tsx`
- Create: `apps/zync-app/src/pages/settings/contracts/useContractSettings.ts`
- Modify: `apps/zync-app/src/routes.tsx` (register `/settings/contracts`, guard `users:manage`)
**Steps:**
- [ ] Create `useContractSettings` hook: react-query `useQuery` against `GET /api/settings/contracts` and a `useMutation` against `PATCH /api/settings/contracts` with cache invalidation on success.
- [ ] Render three `Card` sections: **Contract Defaults**, **E-Signature Workflow** (Business+), **Renewal Reminders**.
- [ ] Contract Defaults: a `Select` of tenant `contract_templates` (load via the templates list endpoint from `contracts-esignature`; include a "None (blank)" option mapping to `null`) bound to `contract_default_template_id`; an `Input number` for `contract_default_expiry_days` with helper text "0 = no expiry".
- [ ] E-Signature Workflow: `Radio` group for `contract_default_signing_order` (`parallel` / `ordered`); `Radio` group for `contract_require_email_verification` (Yes/No); three `Input number` fields for `contract_reminder_first_days`, `contract_reminder_followup_days`, `contract_reminder_max_count`.
- [ ] Renewal Reminders: three `Checkbox`+`Input` pairs for `contract_renewal_remind_30d/_7d/_1d` (checkbox toggles whether that interval fires); two `Checkbox`/`Switch` controls for `contract_renewal_notify_in_app` and `contract_renewal_notify_email`.
- [ ] A single `[Save changes]` `Button` submits the whole form as a PATCH; show success `toast` and disable the button while saving.
- [ ] Use translation keys for every label (i18n) and logical-property layout (RTL-safe); ensure all inputs have associated `<label>`/`aria-label` (a11y) and honor `prefers-reduced-motion` on any save-state animation.
**Acceptance:**
- [ ] Page loads current settings, edits persist via PATCH, and a success toast appears.
- [ ] Template select offers a "None (blank)" option that saves `null`; expiry input accepts `0`.
- [ ] All form labels resolve through i18n and the layout mirrors correctly under Hebrew RTL.

### Task 6: Business+ gating UI + pre-fill wiring
**Blocks:** —  ·  **Blocked by:** 5
**Files:**
- Modify: `apps/zync-app/src/pages/settings/contracts/ContractSettingsPage.tsx`
- Modify: `apps/zync-app/src/pages/contracts/NewContractPage.tsx` (or the existing contract-creation form from `contracts-esignature`)
**Steps:**
- [ ] In the settings page, when the tenant is not Business+ (use `useTierGate`/`meetsMinimumTier`), render the E-Signature Workflow section as a disabled/upsell `Card` with the copy "E-Signature — Business+ feature / Set signing order, require email verification, and configure reminder cadence." and an `[Upgrade to Business+]` button wired to `useUpgradeModal`. Do not submit e-sig fields for non-Business+ tenants.
- [ ] Keep the Contract Defaults and Renewal Reminders sections fully functional for all tiers.
- [ ] Wire contract-creation pre-fill: on opening the new-contract form, read settings — pre-select `template_id` from `contract_default_template_id`; pre-fill the term-end as `now() + contract_default_expiry_days` days into the upstream `contracts.expiry_date` field (only when `contract_default_expiry_days > 0`); pre-select `signing_order` from `contract_default_signing_order`. All pre-fills remain user-overridable.
**Acceptance:**
- [ ] Non-Business+ tenant sees the e-signature upsell card with a working upgrade CTA and cannot edit e-sig fields.
- [ ] Business+ tenant sees the full editable e-signature section.
- [ ] New-contract form pre-fills template, `expiry_date` (= now + default days when default > 0), and `signing_order` from settings, all overridable.

### Task 7: Tests for repository, API gating, and pre-fill
**Blocks:** —  ·  **Blocked by:** 4, 6
**Files:**
- Create: `apps/zync-api/src/routes/settings/contracts.test.ts`
- Create: `packages/db/src/repositories/contract-settings.test.ts`
**Steps:**
- [ ] Repository: assert defaults are returned for a tenant with no settings row, partial updates leave other fields intact, and a cross-tenant `contract_default_template_id` is rejected.
- [ ] API: assert `users:manage` enforcement (403 without), Zod 400 on invalid body, Business+ gate (403 for non-Business+ on e-sig fields, 200 on renewal fields), and the GET payload shape (all 12 fields present).
- [ ] Pre-fill: assert the new-contract pre-fill computes `expiry_date = now + contract_default_expiry_days` and skips pre-fill when the default is `0`.
**Acceptance:**
- [ ] All tests pass against a Neon Postgres test branch with the migration applied.
- [ ] Tier-gating and permission tests fail closed (deny) when the guard is removed.
