# Contractor Settings — Implementation Plan

**Spec:** docs/specs/2026-05-31-contractor-settings.md  ·  **Slug:** contractor-settings  ·  **Wave:** 11
**Depends on:** contractor-payouts, contractor-portal, foundation-auth-rbac, time-approval-workflow

## Goal
Deliver the `/settings/contractors` page that lets tenant managers configure contractor defaults in one place: portal access rules, the contractor time-approval policy, and payout defaults (period, currency, withholding-tax note). All settings are per-tenant scalar config: five additive columns this plan owns on the existing `tenant_settings` singleton, plus the consumed `contractor_require_time_approval` flag (owned by `time-management`, wave 5) — no new table. The feature exposes a read/write API gated by the `payouts:read` / `payouts:write` permissions and surfaces a Business+ upgrade prompt when the contractor portal is enabled on a sub-Business+ tenant.

## Architecture
This spec fills the configuration gap left by three upstream specs. It does not own any of those features — it only writes the flags they read:

- **contractor-payouts** (spec 21) owns `contractors`, `contractor_assignments`, `payout_bills`, `payout_bill_lines`. Its payout-generation flow reads `tenant_settings.contractor_payout_period` and `contractor_withholding_note` (default tax note printed on payout bills). The `payouts:read` / `payouts:write` permissions used here are defined there.
- **time-management** (wave 5) owns `tenant_settings.contractor_require_time_approval`; **time-approval-workflow** (spec 52) reads it in `createTimeEntry` to decide whether CONTRACTOR time entries enter `pending` vs `auto_approved`. This page consumes that column and is the only editing UI that sets the flag. The settings page also surfaces the explanatory copy "Time entries logged by contractors will require manager approval before being included in payouts." when the flag is `true`, and the approval-deadline value `contractor_approval_deadline_days`.
- **contractor-portal** (spec 87) serves the portal at `/contractor-portal/`. It reads `tenant_settings.contractor_portal_enabled` (master on/off) and `contractor_portal_show_billing` (whether project billing amounts are visible to contractors). Portal access is gated at the portal auth layer in spec 87, **not** here; this page only writes the flags and shows the Business+ note.

**Shared `tenant_settings` table provenance:** `tenant_settings` is a per-tenant singleton typed-config table whose base (`id` UUID PK, `tenant_id` UUID UNIQUE → `tenants(id)`, timestamps) is owned by `foundation-auth-rbac` (in every tenant's closure), extended additively by many settings specs. It is NOT created by this plan (`ai_tenant_settings`, owned by `system-ai`, is a different AI-scoped table). This plan only runs an idempotent `ALTER TABLE tenant_settings ADD COLUMN IF NOT EXISTS ...` migration and extends the Drizzle table definition with the six new columns, so whatever generic accessor reads the row, the columns are present.

**Data flow:** React settings page → `useContractorSettings` hook → `GET /api/settings/contractors` (returns the six fields) → on save → `PATCH /api/settings/contractors` (zod-validated partial body) → DB-layer accessor reads/writes the `tenant_settings` row for the current tenant via the tenant-scoped query helper.

## Tech Stack
- **App:** `zync-app` (Vite + React) for the settings page UI; `zync-api` (Hono on Cloudflare Workers) for the two routes.
- **Packages:** `@zync/db` (Drizzle table extension + accessor), `@zync/ui` (Card, Switch, Radio, Select, Input, Textarea, Button, Form, FormField, FormLabel, Alert), `@zync/auth` (`requirePermission`, `authMiddleware`), `@zync/types` (shared settings type).
- **DB:** Neon Postgres via Cloudflare Hyperdrive, Drizzle ORM. Migration is an additive `ALTER TABLE`.
- **Tier gating:** `meetsMinimumTier` (server) + `useUpgradeModal` / `useTierGate` (client) for the Business+ portal note.
- **Validation:** zod schema for the PATCH body (`require-zod-validation-in-routes`).
- **i18n / RTL:** Hebrew default withholding note; form must be RTL-aware and use aria roles for radio groups and the upgrade alert.

## Wave Plan
| Sub-wave | Tasks | Files touched | Parallelizable? |
|----------|-------|---------------|-----------------|
| A — schema | 1 | migration SQL, `@zync/db` schema | No (root) |
| B — db + types | 2, 3 | `@zync/db` accessor, `@zync/types` | After A; 2 and 3 parallel |
| C — api | 4 | `zync-api` routes | After 2, 3 |
| D — client data | 5 | `zync-app` hook | After 4 |
| E — ui | 6 | `zync-app` page | After 5 |
| F — wiring | 7 | nav/route registration | After 6 |

## Tasks

### Task 1: Schema migration — extend `tenant_settings`
**Blocks:** 2, 3  ·  **Blocked by:** —
**Files:**
- Create: `packages/db/migrations/0119_contractor_settings.sql` (use the repo's next sequential migration prefix)
- Modify: `packages/db/src/schema/tenant-settings.ts` (extend existing Drizzle table definition)
**Steps:**
- [ ] Add the five owned additive columns to `tenant_settings` via idempotent `ALTER TABLE ... ADD COLUMN IF NOT EXISTS`. Do NOT add `contractor_require_time_approval` — it is owned by `time-management` (wave 5, a build dependency); consumed/edited here only.
- [ ] Extend the existing Drizzle `tenantSettings` table object with the five owned columns (do not redefine the table). `contractorRequireTimeApproval` is already on the Drizzle definition (owned by `time-management`); do NOT redeclare it.
- [ ] Seed/default the Hebrew withholding note as a column DEFAULT exactly as the spec writes it.
- [ ] Do NOT create a new table; `tenant_settings` already exists upstream.
**Schema / Interfaces:**
```sql
-- contractor_require_time_approval is NOT added here — it is owned by time-management
-- (wave 5, a build dependency of this plan). This page
-- consumes/edits it on the shared row but never ALTERs it.
ALTER TABLE tenant_settings
  ADD COLUMN IF NOT EXISTS contractor_portal_enabled BOOLEAN NOT NULL DEFAULT true,
  ADD COLUMN IF NOT EXISTS contractor_portal_show_billing BOOLEAN NOT NULL DEFAULT false,
  ADD COLUMN IF NOT EXISTS contractor_approval_deadline_days INTEGER NOT NULL DEFAULT 7,
  ADD COLUMN IF NOT EXISTS contractor_payout_period TEXT NOT NULL DEFAULT 'monthly'
    CHECK (contractor_payout_period IN ('weekly', 'biweekly', 'monthly')),
  ADD COLUMN IF NOT EXISTS contractor_withholding_note TEXT;
```
Drizzle additions (canonical, append to existing `tenantSettings` definition):
```ts
contractorPortalEnabled: boolean('contractor_portal_enabled').notNull().default(true),
contractorPortalShowBilling: boolean('contractor_portal_show_billing').notNull().default(false),
// contractorRequireTimeApproval — Drizzle column defined by time-approval-workflow (spec 52, owner); do NOT redeclare here.
contractorApprovalDeadlineDays: integer('contractor_approval_deadline_days').notNull().default(7),
contractorPayoutPeriod: text('contractor_payout_period').notNull().default('monthly'),
  // CHECK (contractor_payout_period IN ('weekly','biweekly','monthly')) — enforced in SQL migration
contractorWithholdingNote: text('contractor_withholding_note'),
```
**Acceptance:**
- [ ] Migration runs idempotently against an existing `tenant_settings` table (re-run is a no-op).
- [ ] `contractor_payout_period` rejects values outside `{weekly, biweekly, monthly}`.
- [ ] `contractor_portal_show_billing` defaults to `false` (opt-in for sensitive billing exposure).

### Task 2: DB accessor for contractor settings
**Blocks:** 4  ·  **Blocked by:** 1
**Files:**
- Modify: `packages/db/src/accessors/tenant-settings.ts`
**Steps:**
- [ ] Implement `getContractorSettings(db, tenantId)` reading the six columns from the tenant's `tenant_settings` row via the tenant-scoped query helper (`tenantQuery`).
- [ ] Implement `updateContractorSettings(db, tenantId, patch)` performing an additive update of only the supplied fields on the existing row.
- [ ] If the tenant row is missing, fall back to inserting a defaults row via `tenantQuery` (`INSERT INTO tenant_settings (tenant_id) ... ON CONFLICT (tenant_id) DO NOTHING`, relying on column defaults) so a read never 500s — never the AI-config `upsertTenantSettings` accessor (it targets `ai_tenant_settings`).
- [ ] Never use raw Drizzle from routes (`no-raw-drizzle-from-routes`) — all access goes through these accessors.
**Schema / Interfaces:**
```ts
export interface ContractorSettings {
  contractor_portal_enabled: boolean;
  contractor_portal_show_billing: boolean;
  contractor_require_time_approval: boolean;
  contractor_approval_deadline_days: number;
  contractor_payout_period: 'weekly' | 'biweekly' | 'monthly';
  contractor_withholding_note: string | null;
}
export function getContractorSettings(db: Db, tenantId: string): Promise<ContractorSettings>;
export function updateContractorSettings(
  db: Db, tenantId: string, patch: Partial<ContractorSettings>,
): Promise<ContractorSettings>;
```
**Acceptance:**
- [ ] `getContractorSettings` returns all six fields for the current tenant.
- [ ] `updateContractorSettings` updates only the keys present in `patch`, leaving others unchanged.

### Task 3: Shared type + zod validation schema
**Blocks:** 4  ·  **Blocked by:** 1
**Files:**
- Modify: `packages/types/src/contractor-settings.ts` (create within package)
- Modify: `packages/types/src/index.ts` (export)
**Steps:**
- [ ] Export the `ContractorSettings` type (shape in Task 2).
- [ ] Export `updateContractorSettingsSchema` — a zod object with all six fields optional, validating: booleans for the three flags, `contractor_approval_deadline_days` integer `>= 0` (0 = no deadline), `contractor_payout_period` enum `['weekly','biweekly','monthly']`, `contractor_withholding_note` nullable string (trim, max 500 chars).
**Schema / Interfaces:**
```ts
import { z } from 'zod';
export const updateContractorSettingsSchema = z.object({
  contractor_portal_enabled: z.boolean().optional(),
  contractor_portal_show_billing: z.boolean().optional(),
  contractor_require_time_approval: z.boolean().optional(),
  contractor_approval_deadline_days: z.number().int().min(0).optional(),
  contractor_payout_period: z.enum(['weekly', 'biweekly', 'monthly']).optional(),
  contractor_withholding_note: z.string().trim().max(500).nullable().optional(),
}).strict();
export type UpdateContractorSettings = z.infer<typeof updateContractorSettingsSchema>;
```
**Acceptance:**
- [ ] `updateContractorSettingsSchema` rejects unknown keys (`.strict()`) and out-of-range deadline values.
- [ ] Type and schema exported from `@zync/types`.

### Task 4: API routes — GET / PATCH `/api/settings/contractors`
**Blocks:** 5  ·  **Blocked by:** 2, 3
**Files:**
- Create: `apps/zync-api/src/routes/settings/contractors.ts`
- Modify: `apps/zync-api/src/routes/settings/index.ts` (mount)
**Steps:**
- [ ] `GET /api/settings/contractors`: `authMiddleware` → `requirePermission('payouts:read')` → `getContractorSettings(db, tenantId)` → return the six fields as JSON.
- [ ] `PATCH /api/settings/contractors`: `authMiddleware` → `requirePermission('payouts:write')` → validate body with `updateContractorSettingsSchema` → `updateContractorSettings(db, tenantId, patch)` → return the updated full settings object.
- [ ] Use tenant id from session payload; never trust a client-supplied tenant id.
- [ ] Apply zod validation in the route (`require-zod-validation-in-routes`); return 400 on validation failure with field errors.
**Schema / Interfaces:**
```
GET   /api/settings/contractors   → 200 ContractorSettings            (requires payouts:read)
PATCH /api/settings/contractors   → 200 ContractorSettings            (requires payouts:write)
        body: UpdateContractorSettings (partial; .strict zod)
        400 on invalid body · 403 on missing permission
```
**Acceptance:**
- [ ] GET returns 403 without `payouts:read`; PATCH returns 403 without `payouts:write`.
- [ ] PATCH with `{ contractor_payout_period: 'daily' }` returns 400.
- [ ] PATCH returns the merged full settings object, not just the patched keys.

### Task 5: Client data hook
**Blocks:** 6  ·  **Blocked by:** 4
**Files:**
- Create: `apps/zync-app/src/features/settings/contractors/useContractorSettings.ts`
**Steps:**
- [ ] Implement `useContractorSettings()` — react-query query against `GET /api/settings/contractors`.
- [ ] Implement the mutation calling `PATCH /api/settings/contractors`, invalidating the query on success and firing a success `toast`.
- [ ] Surface server validation errors back to the form.
**Schema / Interfaces:**
```ts
export function useContractorSettings(): {
  data: ContractorSettings | undefined;
  isLoading: boolean;
  save: (patch: UpdateContractorSettings) => Promise<void>;
  isSaving: boolean;
};
```
**Acceptance:**
- [ ] Hook loads current settings and persists changes via PATCH, invalidating cache on success.

### Task 6: Settings page UI — `/settings/contractors`
**Blocks:** 7  ·  **Blocked by:** 5
**Files:**
- Create: `apps/zync-app/src/features/settings/contractors/ContractorSettingsPage.tsx`
**Steps:**
- [ ] Build three `Card` sections matching the spec layout: Portal Access, Time Entry Approval, Payout Defaults.
- [ ] **Portal Access card:** `Switch`/`Radio` for `contractor_portal_enabled` (Enabled/Disabled); a "Contractors can see" checklist where "Their time entries", "Their payout bills", "Assigned projects (name + status only)" are informational/always-on, and "Project billing amounts" is the editable `contractor_portal_show_billing` checkbox (default off). Caption: "(Business+: portal at /contractor-portal/)".
- [ ] **Business+ gate note:** when `contractor_portal_enabled === true` and the tenant tier does not meet Business+ (client check via `useTierGate` / `meetsMinimumTier` echoed from session), render an `Alert` (role="status"/aria-live) with the warning copy "Contractor portal requires Business+. Contractors will see a \"coming soon\" message until you upgrade." plus an "Upgrade to Business+" button wired to `useUpgradeModal`. Do not block saving — gating is enforced at the portal auth layer (spec 87).
- [ ] **Time Entry Approval card:** `Radio` group for `contractor_require_time_approval` ("All contractor time requires approval" = true / "Auto-approve (no review required)" = false). When true, show helper copy "Time entries logged by contractors will require manager approval before being included in payouts." `Input` (number) for `contractor_approval_deadline_days` with hint "(0 = no deadline)".
- [ ] **Payout Defaults card:** `Select` for `contractor_payout_period` (Weekly · Bi-weekly · Monthly); `Select` for default rate currency rendered **read-only**, bound to the existing `tenants.default_currency` (the spec defines no currency column among the six; editing tenant currency is owned by general settings-module, not this page); `Radio` "Include withholding tax note on payout bills" (Yes/No) controlling visibility of the `Textarea` bound to `contractor_withholding_note`, pre-filled with the Hebrew default `לפי ס' 164 לפקודת מס הכנסה, ינוכה מס במקור` and editable.
- [ ] Single `[Save changes]` button submitting the whole form via the Task 5 `save` mutation.
- [ ] No raw HTML in pages (`no-raw-html-in-pages`) — use `@zync/ui` primitives only; no hardcoded colors/spacing/radius.
- [ ] RTL-aware layout and proper aria roles on radio groups, the upgrade alert, and form labels (`FormLabel`).
**Acceptance:**
- [ ] All three cards render and bind to the six settings fields.
- [ ] Withholding-note textarea pre-fills the exact Hebrew default and is editable.
- [ ] Sub-Business+ tenant with portal enabled sees the upgrade alert; saving still succeeds.
- [ ] Form is keyboard-navigable, RTL-correct, and uses only design-system tokens.

### Task 7: Route + nav registration
**Blocks:** —  ·  **Blocked by:** 6
**Files:**
- Modify: `apps/zync-app/src/router.tsx` (or settings route tree)
- Modify: settings nav config (settings sidebar list)
**Steps:**
- [ ] Register the `/settings/contractors` route rendering `ContractorSettingsPage`, guarded so only users with `payouts:write` can open it (spec: "`/settings/contractors` — requires `payouts:write`").
- [ ] Add "Contractors" to the Settings nav, visible only when the current user has `payouts:write`.
**Acceptance:**
- [ ] `/settings/contractors` is reachable from Settings nav for `payouts:write` users and hidden otherwise.
