# Contractor Payouts — Implementation Plan

**Spec:** docs/specs/2026-05-30-contractor-payouts.md  ·  **Slug:** contractor-payouts  ·  **Wave:** 7
**Depends on:** foundation-auth-rbac, invoices-core, projects-module, time-management

## Goal
Deliver a ledger that tracks what the tenant owes external contractors (sub-providers, freelancers). Contractors log hours on projects (as Zync users or as external `contractor_id` time entries); staff reconcile approved hours, generate draft payout bills, calculate Israeli withholding tax (ניכוי מס במקור), record outgoing payments, and produce the annual Form 856 / Form 857 withholding report. A payout bill is the tenant's *expense* (the contractor's bill to the tenant) — it is deliberately separate from `invoices` (the tenant's outgoing invoices to customers) because the lifecycle and direction differ.

## Architecture
A new `@zync/payouts` package holds the data access layer, withholding resolver, bill-generation engine, and Excel export. New Hono routes mount under `/api/contractors` and `/api/payouts` in the API worker, guarded by `authMiddleware`, `requireModuleEnabled('payouts')`, and `requirePermission('payouts:read'|'payouts:write')`. New React pages live in the app worker under `/contractors`, `/payouts`, and `/reports/withholding`.

Upstream consumption (exact names):
- **time-management** — `time_entries` table. Payout generation aggregates `time_entries` for a contractor in a period (matched by `contractor_id`, or by `user_id` for contractors who hold a Zync account) where `billable = true`. Reads `duration_seconds`, `project_id`, `task_id`, `description`. The `user_or_contractor` CHECK already permits `user_id = NULL` external entries.
- **time-approval-workflow / time-entry-locking** — `time_entries.approval_status` (enum `auto_approved|pending|approved|rejected|locked`), `time_entries.locked_at`, `time_entries.locked_reason` (`invoiced|period_closed|approved`). Generation only bills entries in `approval_status IN ('approved','auto_approved')`; finalizing a bill sets `approval_status='locked'` + `locked_at=now()` + `locked_reason='approved'`. Voiding releases them back to `approved` and clears the lock. These columns are **base columns on `time_entries`, owned by `time-management` (wave 5)** and already present at this wave (7); this plan only reads/writes them via `tenantQuery` and never declares them.
- **projects-module** — `projects` table (FK target for `contractor_assignments.project_id`, `payout_bill_lines.project_id`).
- **foundation-auth-rbac** — `tenants` (`country_code`, `default_currency`), `users` (FK for `created_by`, `voided_by`), `permissions`/`role_permissions` seeding, `requirePermission`, `tenantQuery`, `systemQuery`, `createDb`, `buildPaginated`, `requireModuleEnabled`.
- **invoices-core / tax-rates-seed-data** — `tax_rates` (`country_code, tax_type, rate, effective_from`). The withholding fallback resolves the latest `tax_rates` row with `tax_type='withholding_default'`, `country_code='IL'`, `effective_from <= bill_date` (currently `0.3000`). No constant is hard-coded.
- **system-communications-notifications** — `createNotification` for certificate-expiry alerts (type `'expense_submitted'`, reused for operational alerts per spec).
- **STORAGE** (R2 binding) — stores uploaded withholding certificate PDFs.

Data flow for bill generation: select contractor + period → query approved billable `time_entries` → per line compute rate via `COALESCE(contractor_assignments.rate_override, contractors.hourly_rate)` → sum → resolve withholding rate (snapshot) → insert `payout_bills` + `payout_bill_lines` in one transaction → lock the source entries.

## Tech Stack
- **Package:** `packages/payouts` (`@zync/payouts`) — Drizzle schema, queries, `generatePayoutBill`, `resolveWithholdingRate`, `buildWithholdingReport`, `buildPayoutLedgerXlsx`, `buildWithholdingReportXlsx`.
- **API:** `apps/zync-api` (Hono on Cloudflare Workers) — route modules `routes/contractors.ts`, `routes/payouts.ts`, `routes/cron/contractor-cert-expiry.ts`.
- **App:** `apps/zync-app` (Vite + React) — pages + `@tanstack/react-query` hooks + `@zync/ui` components.
- **Libraries:** Drizzle ORM, Zod (request validation), `exceljs` (Excel export, already used by expense reports), `@zync/ui`, `@zync/db` (`createDb`, `tenantQuery`, `systemQuery`), `@zync/auth` (`requirePermission`, `requireModuleEnabled`, `authMiddleware`), `@zync/notifications` (`createNotification`).
- **Bindings:** Hyperdrive (Neon Postgres), `STORAGE` (R2), `CRON_SECRET` (env) for the weekly cert-expiry cron.

## Wave Plan
| Sub-wave | Tasks | Files touched | Parallelizable? |
|----------|-------|---------------|-----------------|
| 7a — schema & permissions | 1, 2 | `packages/db`, `packages/payouts/schema` | Task 1 then 2 |
| 7b — package core | 3, 4, 5 | `packages/payouts/src` | 3 first; 4,5 parallel after |
| 7c — API routes | 6, 7, 8, 9 | `apps/zync-api/src/routes` | parallel after 7b |
| 7d — UI | 10, 11, 12, 13 | `apps/zync-app/src` | parallel after 7c |
| 7e — module registration & verification | 14, 15 | manifest, e2e | after all |

## Tasks

### Task 1: Database migration — payout tables & schema deltas
**Blocks:** 2, 3, 6  ·  **Blocked by:** —
**Files:**
- Create: `packages/db/migrations/0xxx_contractor_payouts.sql`
- Modify: `packages/db/src/schema/index.ts` (export new schema barrel)
**Steps:**
- [ ] Author the migration with the four new tables and the `contractors` / `payout_bills` withholding deltas in canonical Neon Postgres dialect (DDL below).
- [ ] Do NOT add `time_entries.approval_status`/`locked_at`/`locked_reason` — they are base columns owned by `time-management` (wave 5) and already exist at this wave; read/write them via `tenantQuery`.
- [ ] Add supporting indexes for tenant-scoped lookups and bill generation.
- [ ] Register the migration in the Drizzle journal.
**Schema / Interfaces:**
```sql
CREATE TABLE contractors (
  id                              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id                       UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
  name                            TEXT NOT NULL,
  email                           TEXT,
  phone                           TEXT,
  tax_id                          TEXT,                       -- ח.פ. / ע.מ. / ת.ז.
  billing_type                    TEXT NOT NULL DEFAULT 'hourly'
                                    CHECK (billing_type IN ('hourly','fixed','retainer')),
  hourly_rate                     NUMERIC(10,2),
  currency                        TEXT NOT NULL DEFAULT 'ILS',
  user_id                         UUID REFERENCES users(id) ON DELETE SET NULL, -- linked Zync account (optional)
  is_active                       BOOLEAN NOT NULL DEFAULT true,                -- DELETE = deactivate
  withholding_tax_rate            NUMERIC(5,4),               -- NULL = use statutory default; 0.0000 = full exemption
  withholding_certificate_number  TEXT,                       -- ITA certificate number, e.g. "456/2026"
  withholding_certificate_expiry  DATE,
  withholding_certificate_r2_key  TEXT,                       -- R2 key for uploaded certificate PDF
  notes                           TEXT,
  created_at                      TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at                      TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_contractors_tenant ON contractors(tenant_id, is_active);
CREATE INDEX idx_contractors_cert_expiry ON contractors(withholding_certificate_expiry)
  WHERE withholding_certificate_expiry IS NOT NULL;

CREATE TABLE contractor_assignments (
  id             UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id      UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
  contractor_id  UUID NOT NULL REFERENCES contractors(id) ON DELETE CASCADE,
  project_id     UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
  role           TEXT,                       -- e.g. "Backend developer", "Designer"
  rate_override  NUMERIC(10,2),              -- overrides contractor.hourly_rate for this project
  created_at     TIMESTAMPTZ NOT NULL DEFAULT now(),
  CONSTRAINT uq_contractor_assignment UNIQUE (contractor_id, project_id)
);
CREATE INDEX idx_contractor_assignments_tenant ON contractor_assignments(tenant_id);
CREATE INDEX idx_contractor_assignments_project ON contractor_assignments(project_id);

CREATE TABLE payout_bills (
  id                 UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id          UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
  contractor_id      UUID NOT NULL REFERENCES contractors(id) ON DELETE RESTRICT,
  period_start       DATE NOT NULL,
  period_end         DATE NOT NULL,
  status             TEXT NOT NULL DEFAULT 'DRAFT'
                       CHECK (status IN ('DRAFT','SENT','APPROVED','PAID','VOID')),
  total_hours        NUMERIC(8,2),                  -- sum of approved time-entry hours
  amount             NUMERIC(12,2) NOT NULL,        -- gross
  currency           TEXT NOT NULL DEFAULT 'ILS',
  withholding_rate   NUMERIC(5,4) NOT NULL DEFAULT 0,   -- snapshot at generation (immutable after SENT)
  withholding_amount NUMERIC(12,2) NOT NULL DEFAULT 0,  -- = ROUND(amount * withholding_rate, 2)
  net_amount         NUMERIC(12,2),                     -- = amount - withholding_amount
  notes              TEXT,
  paid_at            TIMESTAMPTZ,
  payment_method     TEXT CHECK (payment_method IN ('bank_transfer','check','other')),
  payment_reference  TEXT,
  voided_at          TIMESTAMPTZ,
  voided_by          UUID REFERENCES users(id),
  void_reason        TEXT,
  created_by         UUID NOT NULL REFERENCES users(id),
  created_at         TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at         TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_payout_bills_tenant_status ON payout_bills(tenant_id, status);
CREATE INDEX idx_payout_bills_contractor ON payout_bills(contractor_id, period_start, period_end);

CREATE TABLE payout_bill_lines (
  id             UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  bill_id        UUID NOT NULL REFERENCES payout_bills(id) ON DELETE CASCADE,
  tenant_id      UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
  time_entry_id  UUID REFERENCES time_entries(id) ON DELETE SET NULL, -- nullable for fixed/retainer lines
  description    TEXT NOT NULL,
  hours          NUMERIC(8,2),
  rate           NUMERIC(10,2),
  line_total     NUMERIC(12,2) NOT NULL,
  project_id     UUID REFERENCES projects(id) ON DELETE SET NULL,
  created_at     TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_payout_bill_lines_bill ON payout_bill_lines(bill_id);
CREATE INDEX idx_payout_bill_lines_time_entry ON payout_bill_lines(time_entry_id)
  WHERE time_entry_id IS NOT NULL;

-- time_entries.approval_status / locked_at / locked_reason are base columns owned by
-- time-management (wave 5, the time_entries owner) and already exist at this wave (7).
-- This plan reads/writes them via tenantQuery; it does NOT declare them.
```
**Acceptance:**
- [ ] Migration applies cleanly on a fresh Neon branch; all FKs are UUID→UUID.
- [ ] All five status values and three billing/payment-method enums enforce via CHECK.
- [ ] Re-running the migration (idempotent `ADD COLUMN IF NOT EXISTS`) is a no-op when columns exist.

### Task 2: Seed `payouts:read` / `payouts:write` permissions
**Blocks:** 6, 7, 8, 9  ·  **Blocked by:** 1
**Files:**
- Modify: `packages/auth/src/seed/permissions.ts` (the `seedPermissions` source list)
- Modify: `packages/auth/src/seed/roles.ts` (`seedSystemRoles` role→permission grants)
**Steps:**
- [ ] Add `payouts:read` and `payouts:write` to the canonical permission catalogue consumed by `seedPermissions`.
- [ ] Grant both to `OWNER` and `ADMIN`; grant `payouts:read` to `MANAGER`; leave `MEMBER` without payout permissions by default.
- [ ] Ensure seeding is idempotent (upsert on permission key).
**Schema / Interfaces:**
```ts
// permission keys (string identifiers used by requirePermission)
'payouts:read'   // View contractors + payouts + withholding report
'payouts:write'  // Manage contractors, generate/edit/void bills, record payment, upload certificates
```
**Acceptance:**
- [ ] After `seedPermissions` + `seedSystemRoles`, OWNER/ADMIN hold both permissions; MANAGER holds only `payouts:read`.
- [ ] `requirePermission('payouts:write')` rejects a MEMBER with 403.

### Task 3: `@zync/payouts` package — schema, queries, withholding resolver
**Blocks:** 4, 5, 6, 7, 8, 9  ·  **Blocked by:** 1
**Files:**
- Create: `packages/payouts/package.json`, `packages/payouts/tsconfig.json`, `packages/payouts/src/index.ts`
- Create: `packages/payouts/src/schema.ts` (Drizzle table definitions mirroring Task 1)
- Create: `packages/payouts/src/queries.ts`
- Create: `packages/payouts/src/withholding.ts`
**Steps:**
- [ ] Define Drizzle table objects `contractors`, `contractorAssignments`, `payoutBills`, `payoutBillLines` matching Task 1 DDL exactly (column names, types, CHECKs).
- [ ] Implement tenant-scoped CRUD queries: `listContractors`, `getContractor`, `createContractor`, `updateContractor`, `deactivateContractor`, `listAssignments`, `createAssignment`, `deleteAssignment`, `listPayoutBills`, `getPayoutBill` (with lines), `listPayoutLedger`. All accept a `tenantQuery`-bound db handle and filter by `tenant_id`.
- [ ] Implement `resolveWithholdingRate(db, { countryCode, billDate })`: select the latest `tax_rates` row where `tax_type='withholding_default'` and `country_code=countryCode` and `effective_from <= billDate`, ordered `effective_from DESC`; return its `rate`. Falls back here only when `contractor.withholding_tax_rate IS NULL`.
- [ ] Implement `computeWithholding({ amount, rate })` → `{ withholdingAmount: round2(amount*rate), netAmount: amount - withholdingAmount }`.
- [ ] Add `hasLivePayoutBill(db, timeEntryId)` — returns true if any non-VOID `payout_bills` references the entry via `payout_bill_lines`. Exported for `time-entry-locking`'s unlock handler.
- [ ] Export everything from `src/index.ts`.
**Schema / Interfaces:**
```ts
export interface WithholdingResolution { rate: number; source: 'certificate' | 'statutory_default'; }
export function resolveWithholdingRate(
  db: Db, args: { countryCode: string; billDate: string },
): Promise<number>;                                  // statutory default (e.g. 0.30)
export function computeWithholding(
  args: { amount: number; rate: number },
): { withholdingAmount: number; netAmount: number };
// true if any non-VOID payout_bills references the entry via payout_bill_lines.
// Imported by time-entry-locking's POST /api/time/:id/unlock handler (409 'payout_active' when true).
export function hasLivePayoutBill(db: Db, timeEntryId: string): Promise<boolean>;
// Row types are 1:1 with the Task 1 DDL columns (camelCase fields). Drizzle infers them
// via typeof contractors.$inferSelect etc.; exported as ContractorRow, PayoutBillRow, PayoutBillLineRow.
export type ContractorRow = typeof contractors.$inferSelect;
export type PayoutBillRow = typeof payoutBills.$inferSelect;
export type PayoutBillLineRow = typeof payoutBillLines.$inferSelect;
```
**Acceptance:**
- [ ] `resolveWithholdingRate` returns `0.30` for an `IL` bill dated 2026 against the seeded `tax_rates`, and `0.20` for a 2010 bill date.
- [ ] `computeWithholding({ amount: 12000, rate: 0.10 })` → `{ withholdingAmount: 1200, netAmount: 10800 }`.
- [ ] All queries are tenant-scoped; cross-tenant rows are never returned.

### Task 4: Bill generation engine
**Blocks:** 7  ·  **Blocked by:** 3
**Files:**
- Create: `packages/payouts/src/generate.ts`
- Modify: `packages/payouts/src/index.ts`
**Steps:**
- [ ] Implement `generatePayoutBill(db, { tenantId, contractorId, periodStart, periodEnd, createdBy })` inside a single DB transaction.
- [ ] Query billable, approved source entries: `time_entries` where `tenant_id=$tenant` AND `(contractor_id=$contractorId OR user_id=$contractorUserId)` AND `started_at::date BETWEEN periodStart AND periodEnd` AND `billable = true` AND `approval_status IN ('approved','auto_approved')` AND `locked_at IS NULL` (not already in another live bill).
- [ ] For each entry compute `hours = duration_seconds / 3600` (round 2dp) and `rate = COALESCE(assignment.rate_override, contractor.hourly_rate)` resolved per the entry's `project_id` via `contractor_assignments`; `line_total = round2(hours * rate)`.
- [ ] Build one `payout_bill_lines` row per time entry (carry `time_entry_id`, `description`, `project_id`).
- [ ] Sum lines → `amount` and `total_hours`. Resolve `withholding_rate` = `COALESCE(contractor.withholding_tax_rate, resolveWithholdingRate(db, { countryCode, billDate }))` snapshotted onto the bill; compute `withholding_amount`, `net_amount` via `computeWithholding`.
- [ ] Insert `payout_bills` (status `DRAFT`) + lines. **Lock the billed source entries in the same transaction** (per time-entry-locking "payout generation locks contractor entries"): set `approval_status='locked'`, `locked_at=now()`, `locked_reason='approved'` on every entry referenced by a line. This is what makes `locked_at IS NULL` in the source query correctly exclude entries already on a live (non-voided) draft — preventing two concurrent drafts from double-billing the same hours.
- [ ] Return `{ billId, amount, withholdingRate, withholdingAmount, netAmount, lineCount, usedStatutoryDefault }` so the API can surface the "no certificate" warning.
**Schema / Interfaces:**
```ts
export function generatePayoutBill(db: Db, args: {
  tenantId: string; contractorId: string;
  periodStart: string; periodEnd: string; createdBy: string;
}): Promise<{
  billId: string; amount: number; totalHours: number;
  withholdingRate: number; withholdingAmount: number; netAmount: number;
  lineCount: number; usedStatutoryDefault: boolean;
}>;
```
**Acceptance:**
- [ ] Only `billable`, `approved`/`auto_approved`, unlocked entries inside the period are included.
- [ ] Rate falls back to `contractors.hourly_rate` when no `rate_override` exists for the entry's project; uses the override when present.
- [ ] `usedStatutoryDefault` is true exactly when `contractor.withholding_tax_rate IS NULL`, and the snapshotted `withholding_rate` equals the resolved statutory value.
- [ ] Generation locks the billed entries (`approval_status='locked'`, `locked_at` set) so a second concurrent draft for the same period cannot re-bill them.

### Task 5: Excel exporters (ledger + withholding report)
**Blocks:** 8, 9  ·  **Blocked by:** 3
**Files:**
- Create: `packages/payouts/src/export.ts`
- Modify: `packages/payouts/src/index.ts`
**Steps:**
- [ ] Implement `buildPayoutLedgerXlsx(rows, { locale })` using `exceljs`; columns: Contractor, Period, Gross, Withholding, Net, Status, Paid date. Honour RTL + Hebrew (set worksheet `views[0].rightToLeft = true` and Hebrew headers when `locale === 'he'`) — same RTL/Hebrew support as expense reports.
- [ ] Implement `buildWithholdingReportXlsx(report, year)` (Form 856): Hebrew column headers for ITA submission — שם הספק (name), מספר עוסק/ת.ז. (tax_id), סכום ברוטו (gross), שיעור ניכוי (rate), סכום שנוכה (withheld), מספר אישור (certificate_number), תוקף אישור (expiry); set `rightToLeft = true`.
- [ ] Implement `buildWithholdingReport(db, { tenantId, year })` aggregating per-contractor gross + withheld across all `PAID` (non-VOID) bills. Withholding is realised at payment, so key the report by the **`paid_at` year** (`EXTRACT(YEAR FROM paid_at) = year`), not the bill period — December work paid in January belongs to the January tax year.
- [ ] Return `ArrayBuffer`/`Uint8Array` suitable for a Workers `Response` with `Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet`.
**Schema / Interfaces:**
```ts
export interface WithholdingReport {
  year: number; total_gross: number; total_withheld: number;
  contractors: Array<{
    contractor_id: string; name: string; tax_id: string | null;
    gross_paid: number; withholding_rate: number; withheld_amount: number;
    certificate_number: string | null; certificate_expiry: string | null;
  }>;
}
export function buildWithholdingReport(db: Db, args: { tenantId: string; year: number }): Promise<WithholdingReport>;
export function buildPayoutLedgerXlsx(rows: PayoutLedgerRow[], opts: { locale: string }): Promise<Uint8Array>;
export function buildWithholdingReportXlsx(report: WithholdingReport, year: number): Promise<Uint8Array>;
```
**Acceptance:**
- [ ] VOID bills are excluded from both gross and withheld totals.
- [ ] Hebrew locale produces RTL worksheets with Hebrew headers.

### Task 6: Contractors & assignments API routes
**Blocks:** 10, 11  ·  **Blocked by:** 2, 3
**Files:**
- Create: `apps/zync-api/src/routes/contractors.ts`
- Modify: `apps/zync-api/src/index.ts` (mount router)
**Steps:**
- [ ] Mount all routes behind `authMiddleware` + `requireModuleEnabled('payouts')`.
- [ ] Implement endpoints with Zod-validated bodies and `requirePermission` guards:
  - `GET  /api/contractors` (`payouts:read`) → paginated list via `buildPaginated`; columns include active-project count.
  - `POST /api/contractors` (`payouts:write`) → create.
  - `GET  /api/contractors/:id` (`payouts:read`) → detail incl. assignments + withholding fields.
  - `PATCH /api/contractors/:id` (`payouts:write`) → update info + withholding (number, rate 0–1, expiry).
  - `DELETE /api/contractors/:id` (`payouts:write`) → soft deactivate (`is_active = false`).
  - `GET  /api/contractors/:id/assignments` (`payouts:read`).
  - `POST /api/contractors/:id/assignments` (`payouts:write`) → assign to project (`{ projectId, role?, rateOverride? }`).
  - `DELETE /api/contractors/:id/assignments/:aid` (`payouts:write`).
  - `GET  /api/contractors/:id/time` (`payouts:read`) → reconciliation entries filterable by `period=YYYY-MM`, joining `time_entries` for the contractor (by `contractor_id` or linked `user_id`).
  - `POST /api/contractors/:id/certificate` (`payouts:write`) → upload withholding certificate PDF to `STORAGE` (R2); store key in `withholding_certificate_r2_key`.
- [ ] Validate `withholding_tax_rate` in `[0,1]`; reject otherwise (422).
**Schema / Interfaces:**
```ts
const createContractorSchema = z.object({
  name: z.string().min(1), email: z.string().email().optional(),
  phone: z.string().optional(), taxId: z.string().optional(),
  billingType: z.enum(['hourly','fixed','retainer']).default('hourly'),
  hourlyRate: z.number().nonnegative().optional(),
  currency: z.string().default('ILS'), userId: z.string().uuid().optional(),
});
const assignmentSchema = z.object({
  projectId: z.string().uuid(), role: z.string().optional(),
  rateOverride: z.number().nonnegative().optional(),
});
const withholdingSchema = z.object({
  withholdingCertificateNumber: z.string().optional(),
  withholdingTaxRate: z.number().min(0).max(1).optional(),
  withholdingCertificateExpiry: z.string().date().optional(),
});
```
**Acceptance:**
- [ ] Every route enforces module-enabled + correct permission; cross-tenant access is impossible.
- [ ] `DELETE /api/contractors/:id` sets `is_active=false` rather than hard-deleting.
- [ ] Certificate upload stores under `STORAGE` and round-trips the R2 key.

### Task 7: Payout bills API routes (generate, update, void)
**Blocks:** 12  ·  **Blocked by:** 2, 3, 4
**Files:**
- Modify: `apps/zync-api/src/routes/contractors.ts`
**Steps:**
- [ ] `GET /api/contractors/:id/bills` (`payouts:read`) → list bills for the contractor.
- [ ] `POST /api/contractors/:id/bills` (`payouts:write`) → body `{ periodStart, periodEnd }`; call `generatePayoutBill`; if `usedStatutoryDefault` include warning `"No withholding certificate on file — applying statutory default rate of 30%."` in response; return bill + lines.
- [ ] `PATCH /api/contractors/:id/bills/:bid` (`payouts:write`) → edit lines (hours/rate/add fixed-fee lines) while `DRAFT`; transition status `DRAFT→SENT→APPROVED→PAID`. Source entries are already locked at generation (Task 4) — this handler does not re-lock. On `→SENT`: snapshot becomes immutable (reject withholding/line edits after SENT), email contractor a bill summary via `createNotification`/email adapter. On `→PAID`: require `{ paidAt, paymentMethod, paymentReference }`, set `status='PAID'`, `paid_at`; record the **net** amount as paid.
- [ ] `POST /api/contractors/:id/bills/:bid/void` (`payouts:write`) → body `{ reason }` (required, non-empty); allowed only when status ∈ `DRAFT|SENT|APPROVED`; return 409 `cannot_void_paid` if `PAID`. Set `status='VOID'`, `voided_at=now()`, `voided_by`, `void_reason`. Release each linked `time_entry`: clear `locked_at`/`locked_reason` and reset `approval_status` `'locked'→'approved'`. Run in one transaction.
**Schema / Interfaces:**
```ts
const generateBillSchema = z.object({ periodStart: z.string().date(), periodEnd: z.string().date() });
const updateBillSchema = z.object({
  status: z.enum(['DRAFT','SENT','APPROVED','PAID']).optional(),
  notes: z.string().optional(),
  lines: z.array(z.object({
    id: z.string().uuid().optional(), description: z.string().min(1),
    hours: z.number().nonnegative().nullable(), rate: z.number().nonnegative().nullable(),
    lineTotal: z.number(), projectId: z.string().uuid().nullable().optional(),
    timeEntryId: z.string().uuid().nullable().optional(),
  })).optional(),
  paidAt: z.string().datetime().optional(),
  paymentMethod: z.enum(['bank_transfer','check','other']).optional(),
  paymentReference: z.string().optional(),
});
const voidBillSchema = z.object({ reason: z.string().min(1) });
```
**Acceptance:**
- [ ] Generating with no certificate returns the statutory-default warning string.
- [ ] Voiding a `DRAFT`/`SENT`/`APPROVED` bill unlocks its source entries and resets `approval_status` from `locked` back to `approved`.
- [ ] Voiding a `PAID` bill returns 409; `VOID` is terminal (no further transitions).
- [ ] Withholding snapshot (`withholding_rate`) cannot change after `SENT`.

### Task 8: Payout ledger API
**Blocks:** 13  ·  **Blocked by:** 2, 3, 5
**Files:**
- Create: `apps/zync-api/src/routes/payouts.ts`
- Modify: `apps/zync-api/src/index.ts`
**Steps:**
- [ ] `GET /api/payouts` (`payouts:read`) → ledger across all contractors; filter by `period`, `status`, `contractor`; include "total due" (unpaid, non-void) summary; paginate via `buildPaginated`.
- [ ] `GET /api/payouts/xlsx` (`payouts:read`) → stream `buildPayoutLedgerXlsx` honouring the request locale; `Content-Disposition: attachment`.
- [ ] Mount behind `authMiddleware` + `requireModuleEnabled('payouts')`.
**Acceptance:**
- [ ] "Total due" sums only unpaid, non-VOID bills.
- [ ] Excel download returns a valid `.xlsx` with RTL/Hebrew when locale is `he`.

### Task 9: Withholding report API + certificate-expiry cron
**Blocks:** 13  ·  **Blocked by:** 2, 3, 5
**Files:**
- Create: `apps/zync-api/src/routes/cron/contractor-cert-expiry.ts`
- Modify: `apps/zync-api/src/routes/contractors.ts` (report endpoints), `apps/zync-api/wrangler.toml` (cron trigger)
**Steps:**
- [ ] `GET /api/contractors/withholding-report?year=2026` (`payouts:read`) → JSON `WithholdingReport` from `buildWithholdingReport`.
- [ ] `GET /api/contractors/withholding-report/xlsx?year=2026` (`payouts:read`) → `buildWithholdingReportXlsx` (Form 856, Hebrew headers).
- [ ] Implement weekly cron handler at `/api/cron/contractor-cert-expiry`, `CRON_SECRET`-guarded (timing-safe comparison via `timingSafeEqual`). Select `contractors WHERE withholding_certificate_expiry IS NOT NULL AND withholding_certificate_expiry < NOW() + INTERVAL '30 days'`; for each, call `createNotification` (type `'expense_submitted'`) to every user in the contractor's tenant holding `payouts:write` (resolved via `role_permissions`/`tenant_memberships`) with body `"Contractor {name}'s withholding certificate expires on {date}. Upload a new one."`.
- [ ] Register the weekly cron schedule in `wrangler.toml`.
**Acceptance:**
- [ ] Report totals match summed per-contractor figures and exclude VOID bills.
- [ ] Cron rejects requests without a valid `CRON_SECRET` (timing-safe).
- [ ] A contractor whose certificate expires in <30 days triggers exactly one notification per run.

### Task 10: Contractors list page (`/contractors`)
**Blocks:** 14  ·  **Blocked by:** 6
**Files:**
- Create: `apps/zync-app/src/pages/contractors/ContractorsListPage.tsx`
- Create: `apps/zync-app/src/hooks/useContractors.ts`
- Modify: `apps/zync-app/src/router.tsx`
**Steps:**
- [ ] `useContractors` query hook (react-query) hitting `GET /api/contractors`.
- [ ] `DataTable` with columns: Name, Tax ID, Billing type, Rate, Active projects, Status.
- [ ] "New contractor" → `Sheet` form (name, email, phone, tax ID, billing type + rate) → `POST /api/contractors`.
- [ ] Gate the page behind `payouts:read`; gate the create action behind `payouts:write` (hide button otherwise).
- [ ] Use `@zync/ui` primitives only — no hardcoded colors/spacing/radius; logical RTL-safe properties throughout.
**Acceptance:**
- [ ] List renders, paginates, and respects RTL in Hebrew locale.
- [ ] A `payouts:read`-only user sees the list but not the create button.

### Task 11: Contractor detail + time reconciliation (`/contractors/:id`)
**Blocks:** 14  ·  **Blocked by:** 6
**Files:**
- Create: `apps/zync-app/src/pages/contractors/ContractorDetailPage.tsx`
- Create: `apps/zync-app/src/pages/contractors/ContractorTimePage.tsx`
- Create: `apps/zync-app/src/hooks/useContractor.ts`, `apps/zync-app/src/hooks/useContractorTime.ts`
- Modify: `apps/zync-app/src/router.tsx`
**Steps:**
- [ ] Detail page: editable Info panel incl. a "Payments" section with the withholding block (certificate number, rate 0–100%, expiry date, certificate PDF upload via `POST /api/contractors/:id/certificate`).
- [ ] Assigned projects list with add/remove (assignments API).
- [ ] Payout history table linking to bills.
- [ ] Reconciliation view `/contractors/:id/time?period=YYYY-MM`: table Date, Project, Task, Hours, Billable toggle, Notes; summary `total hours × rate = due`; "Generate bill draft" button → `POST /api/contractors/:id/bills` then navigate to the new bill.
- [ ] Surface a warning badge when `withholding_certificate_expiry` is within 30 days or past.
**Acceptance:**
- [ ] Editing withholding fields persists; an out-of-range rate is rejected client + server side.
- [ ] "Generate bill draft" creates a DRAFT and routes to it; if no certificate, the statutory-default warning is shown.

### Task 12: Payout bill detail page
**Blocks:** 14  ·  **Blocked by:** 7
**Files:**
- Create: `apps/zync-app/src/pages/contractors/PayoutBillPage.tsx`
- Create: `apps/zync-app/src/hooks/usePayoutBill.ts`
- Modify: `apps/zync-app/src/router.tsx`
**Steps:**
- [ ] Render bill lines (editable while `DRAFT`), gross total, and the withholding breakdown block:
  `Bill total → Withholding tax (R%) → Net payment`.
- [ ] Status actions: Send (`→SENT`, with confirm that lines lock), Approve (`→APPROVED`), Record payment (`→PAID` dialog: date, method, reference), Void (`→VOID` dialog requiring a reason). Each action calls the matching API and respects allowed transitions; disable Void on `PAID`.
- [ ] Respect `prefers-reduced-motion` on any status-transition animation; status changes also announced via a `role="status"` polite region.
- [ ] Gate edit/transition actions behind `payouts:write`.
**Acceptance:**
- [ ] Withholding breakdown numbers equal the stored snapshot (`amount`, `withholding_amount`, `net_amount`).
- [ ] Void requires a non-empty reason; PAID bills cannot be voided from the UI.
- [ ] Line editing is disabled once status ≥ SENT.

### Task 13: Payout ledger (`/payouts`) + withholding report (`/reports/withholding`)
**Blocks:** 14  ·  **Blocked by:** 8, 9
**Files:**
- Create: `apps/zync-app/src/pages/payouts/PayoutLedgerPage.tsx`
- Create: `apps/zync-app/src/pages/reports/WithholdingReportPage.tsx`
- Create: `apps/zync-app/src/hooks/usePayoutLedger.ts`, `apps/zync-app/src/hooks/useWithholdingReport.ts`
- Modify: `apps/zync-app/src/router.tsx`, app nav config
**Steps:**
- [ ] Ledger page: table Contractor, Period, Amount, Status, Paid date; filters period/status/contractor; "Total due" summary header; "Export Excel" → `GET /api/payouts/xlsx`.
- [ ] Withholding report page (route `withholding`, nav label "Mas 856"): year selector; totals (gross, withheld); per-contractor rows; per-row "Download Form 857" and a top-level "Download Form 856 (Excel)" → `GET /api/contractors/withholding-report/xlsx?year=`.
- [ ] Both pages gated behind `payouts:read`; full RTL/Hebrew support.
**Acceptance:**
- [ ] Filters and the "Total due" summary reflect server data.
- [ ] Excel downloads (856 + ledger) succeed and open with Hebrew headers in `he` locale.

### Task 14: Module manifest registration
**Blocks:** 15  ·  **Blocked by:** 10, 11, 12, 13
**Files:**
- Modify: `packages/config/src/modules/manifest.ts` (`MODULE_MANIFEST`, `MODULE_BY_ID`, `MODULE_CARD_ORDER`)
- Modify: app nav/route registry
**Steps:**
- [ ] Add a `payouts` module entry to `MODULE_MANIFEST` (id `'payouts'`, label "Contractor Payouts", routes `/contractors`, `/payouts`, `/reports/withholding`, permissions `payouts:read`/`payouts:write`, dependency on `time-management` + `projects` per `ModuleDependency`).
- [ ] Add to `TOGGLEABLE_MODULE_IDS` so it can be enabled/disabled per tenant via `tenant_modules`.
- [ ] Wire nav entries (gated by `useModuleEnabled('payouts')` + `payouts:read`).
**Acceptance:**
- [ ] `requireModuleEnabled('payouts')` short-circuits routes when the module is disabled for a tenant.
- [ ] Nav items appear only when the module is enabled and the user holds `payouts:read`.

### Task 15: End-to-end verification
**Blocks:** —  ·  **Blocked by:** 14
**Files:**
- Create: `apps/zync-api/test/contractor-payouts.e2e.ts`
**Steps:**
- [ ] Seed a tenant, a project, a contractor with `hourly_rate`, an assignment with `rate_override`, and approved billable `time_entries` (mix of `contractor_id` and linked `user_id`).
- [ ] Generate a bill; assert lines use the override, `amount`/`total_hours` correct, `withholding_rate` = statutory `0.30` (no certificate), `net_amount = amount - withholding_amount`.
- [ ] Set a `withholding_tax_rate=0.10` certificate; regenerate; assert snapshot `0.10`.
- [ ] Generate a draft; assert source entries are locked at generation (`approval_status='locked'`, `locked_at` set). Generate a second draft for the same contractor/period; assert it picks up zero already-locked entries (no double-billing). Void the first draft; assert its entries reset to `approved` and unlocked.
- [ ] Assert voiding a `PAID` bill returns 409; assert VOID bills excluded from ledger "total due" and from the withholding report.
- [ ] Hit the cert-expiry cron with a near-expiry contractor; assert one notification created; assert wrong `CRON_SECRET` rejected.
**Acceptance:**
- [ ] All e2e assertions pass against a Neon test branch.

## Cross-Cutting Compliance
- **Security:** every route behind `authMiddleware` + `requireModuleEnabled` + `requirePermission`; all bodies Zod-validated; cron guarded with `timingSafeEqual` against `CRON_SECRET`; void/payment actions audit-logged; all queries tenant-scoped.
- **A11y:** status transitions announced via `role="status"` polite region; bill/timer-style animations respect `prefers-reduced-motion`; form controls labelled.
- **i18n/RTL:** all pages and both Excel exports support Hebrew + RTL (worksheet `rightToLeft`, Hebrew Form 856/857 headers); no hardcoded LTR layout.
- **Performance:** ledger and lists paginate via `buildPaginated`; bill generation runs in a single transaction; indexes back tenant + period + status lookups.
