# Contractor Payouts

**Date:** 2026-05-30  
**Status:** Draft  
**Depends on:** `foundation-auth-rbac`, `projects-module`, `time-management`, `invoices-core`  
**Referenced by:** `reports-analytics`, `app-shell`, `vendors-suppliers`

---

## Overview

Ledger for tracking what the tenant owes to contractors (sub-providers, freelancers). Contractors log hours on projects; the tenant reviews time, generates draft payout bills, and records outgoing payments. The payout bill is the tenant's expense (contractor's invoice to the tenant) — not Zync's invoice system, which handles the tenant's outgoing invoices to customers.

### OS route titles

OS and mobile frames MUST title `/payouts` "Payout ledger", `/contractors` "Contractors", contractor detail routes "Contractor {id}", bill routes "Contractor bills", and reconciliation routes "Time reconciliation".

Rationale: preserve route-specific wayfinding without changing payout workflows.

---

## Data Model

```sql
contractors (
  id UUID PRIMARY KEY,
  tenant_id UUID NOT NULL,
  name TEXT NOT NULL,
  email TEXT,
  phone TEXT,
  tax_id TEXT,                          -- ח.פ. / ע.מ. / ת.ז.
  billing_type TEXT DEFAULT 'hourly',   -- 'hourly' | 'fixed' | 'retainer'
  hourly_rate NUMERIC(10,2),
  currency TEXT DEFAULT 'ILS',
  notes TEXT,
  created_at TIMESTAMPTZ DEFAULT now(),
  updated_at TIMESTAMPTZ DEFAULT now()
)

contractor_assignments (
  id UUID PRIMARY KEY,
  tenant_id UUID NOT NULL,
  contractor_id UUID NOT NULL,
  project_id UUID NOT NULL,
  role TEXT,                            -- e.g. "Backend developer", "Designer"
  rate_override NUMERIC(10,2),          -- override contractor default rate for this project
  created_at TIMESTAMPTZ DEFAULT now()
)

payout_bills (
  id UUID PRIMARY KEY,
  tenant_id UUID NOT NULL,
  contractor_id UUID NOT NULL,
  period_start DATE NOT NULL,
  period_end DATE NOT NULL,
  status TEXT DEFAULT 'DRAFT',          -- 'DRAFT' | 'SENT' | 'APPROVED' | 'PAID' | 'VOID'
  total_hours NUMERIC(8,2),             -- sum of approved time entries
  amount NUMERIC(12,2) NOT NULL,
  currency TEXT DEFAULT 'ILS',
  notes TEXT,
  paid_at TIMESTAMPTZ,
  payment_method TEXT,                  -- 'bank_transfer' | 'check' | 'other'
  payment_reference TEXT,               -- bank transfer ref or check number
  voided_at TIMESTAMPTZ,                 -- set when bill is cancelled (status = 'VOID')
  voided_by UUID,                        -- user who voided
  void_reason TEXT,                      -- required free-text reason for the void
  created_by UUID NOT NULL,
  created_at TIMESTAMPTZ DEFAULT now(),
  updated_at TIMESTAMPTZ DEFAULT now()
)

payout_bill_lines (
  id UUID PRIMARY KEY,
  bill_id UUID NOT NULL,
  tenant_id UUID NOT NULL,
  time_entry_id UUID,                   -- FK to time_entries (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
)
```

---

## Features

### Contractors List (`/contractors`)

Table: Name, Tax ID, Billing type, Rate, Active projects, Status.

"New contractor" → sheet form: name, email, phone, tax ID, billing type + rate.

### Contractor Detail (`/contractors/:id`)

- Info panel (editable)
- Assigned projects list
- Time entries by period (read from `time_entries` WHERE contractor's user_id — requires contractor to have a user account, or via manual entry)
- Payout history table

### Time Reconciliation

Contractors may or may not have staff accounts in Zync. Two paths:

**Contractor has a Zync user account:** Time entries logged directly in the time module. Payout bill generated from their `time_entries` in the billing period.

**Contractor without account (external):** Time entries entered manually by staff: `POST /api/time` with `contractor_id` set and `user_id` omitted/null. The `time_entries` table allows `user_id = NULL` when `contractor_id` is set (CHECK constraint `user_or_contractor` enforced in spec 13 schema). Manual entries appear in payout bill generation same as staff entries.

Hours reconciliation view (`/contractors/:id/time?period=YYYY-MM`):
- Table: Date, Project, Task, Hours, Billable toggle, Notes
- Summary: total hours × rate = due amount
- "Generate bill draft" button

### Payout Bills (`/contractors/:id/bills`)

Table: Period, Hours, Amount, Status, Paid date.

**Generate draft:**
1. Select contractor + period (date range)
2. System aggregates approved time entries for that period
3. Rate per line: `COALESCE(contractor_assignments.rate_override, contractors.hourly_rate)` — project-specific override wins, falls back to contractor default
4. Creates `payout_bills` record + `payout_bill_lines` (one line per time entry or per project)
5. Status: `DRAFT`

**Review and approve:**
- Edit lines (adjust hours, rate, add fixed-fee lines)
- Status: `DRAFT → SENT` (contractor notified via email with bill summary) → `APPROVED` → `PAID`

**Record payment:**
- Mark as PAID: enter payment date, method, reference
- Status → `PAID`; `paid_at` set

**Void / cancel:**
- A bill in `DRAFT`, `SENT`, or `APPROVED` can be voided (cancelled before payment) via `[Void bill]`, which requires a free-text reason.
- Status → `VOID`; `voided_at`, `voided_by`, `void_reason` set. A voided bill is read-only and excluded from "Total due" and from the annual Form 856 withholding totals.
- Voiding releases the bill's time entries: any `time_entries` locked to this bill are unlocked and their `approval_status` reset `locked → approved` (per `time-entry-locking`), so they can be re-billed on a new draft.
- A `PAID` bill cannot be voided directly (a payment exists); it must first have its payment reversed. `VOID` is terminal.

### Payout Ledger (`/payouts`)

Across all contractors. Table: Contractor, Period, Amount, Status, Paid date. Filter by period, status, contractor. Total due (unpaid) summary at top.

Export: Excel (for accounting — same RTL + Hebrew support as expense reports).

---

## Permissions

| Action | Required permission |
|--------|-------------------|
| View contractors + payouts | `payouts:read` |
| Manage contractors | `payouts:write` |
| Generate / edit bills | `payouts:write` |
| Record payment | `payouts:write` |

---

## API Endpoints

```
GET    /api/contractors                         → list
POST   /api/contractors                         → create
GET    /api/contractors/:id                     → detail
PATCH  /api/contractors/:id                     → update
DELETE /api/contractors/:id                     → deactivate

GET    /api/contractors/:id/assignments         → projects assigned
POST   /api/contractors/:id/assignments         → assign to project
DELETE /api/contractors/:id/assignments/:aid    → unassign

GET    /api/contractors/:id/time                → time entries (filterable by period)
GET    /api/contractors/:id/bills               → payout bills
POST   /api/contractors/:id/bills               → generate draft from period
PATCH  /api/contractors/:id/bills/:bid          → update bill (status, lines)
POST   /api/contractors/:id/bills/:bid/void      → void/cancel bill (body: { reason }); DRAFT|SENT|APPROVED only; 409 if PAID

GET    /api/payouts                             → ledger (all contractors)
```

---

## Withholding Tax Certificates (ניכוי מס במקור)

Under Israeli tax law, businesses paying contractors may be required to withhold a percentage of payment and remit it to the Israeli Tax Authority (ITA) on behalf of the contractor. Contractors obtain a "Withholding Tax Exemption Certificate" (אישור ניכוי מס במקור) from the ITA, which states the permitted withholding rate (0%–50%).

### Data model

```sql
ALTER TABLE contractors ADD COLUMN withholding_tax_rate  NUMERIC(5,4);
  -- NULL = not configured (withhold at the statutory default from tax_rates.withholding_default; currently 0.30 = 30%)
  -- 0.0000 = zero-rate certificate (full exemption)
  -- e.g. 0.1000 = 10% withholding

ALTER TABLE contractors ADD COLUMN withholding_certificate_number TEXT;
  -- ITA certificate number, e.g. "456/2026"

ALTER TABLE contractors ADD COLUMN withholding_certificate_expiry DATE;
  -- Certificate validity date; system warns when approaching expiry

ALTER TABLE contractors ADD COLUMN withholding_certificate_r2_key TEXT;
  -- R2 key for uploaded certificate PDF (optional; for record-keeping)
```

### Payout bill withholding calculation

When a payout bill is generated, the withholding is calculated and stored on `payout_bills`:

```sql
ALTER TABLE payout_bills ADD COLUMN withholding_rate    NUMERIC(5,4) DEFAULT 0;
  -- Snapshot of contractor.withholding_tax_rate at bill generation time (immutable after SENT)
ALTER TABLE payout_bills ADD COLUMN withholding_amount  NUMERIC(12,2) DEFAULT 0;
  -- = amount × withholding_rate (computed and stored; not dynamic)
ALTER TABLE payout_bills ADD COLUMN net_amount          NUMERIC(12,2);
  -- = amount - withholding_amount (what contractor actually receives)
```

Formula:
- `withholding_amount = ROUND(amount × withholding_rate, 2)`
- `net_amount = amount - withholding_amount`

If `contractor.withholding_tax_rate IS NULL`: use the statutory default resolved from `tax_rates` — the `withholding_default` row for `country_code = 'IL'` with the latest `effective_from <= bill_date` (currently `0.3000` = 30%, Income Tax Ordinance §164). Snapshot that resolved value into `payout_bills.withholding_rate`. Display warning: "No withholding certificate on file — applying statutory default rate of 30%."

### Payout bill UI extension

On the payout bill detail view, show withholding breakdown:

```
Bill total:            ₪12,000.00
Withholding tax (10%): ₪ 1,200.00
──────────────────────────────────
Net payment:           ₪10,800.00
```

Payment recorded (`status = 'PAID'`) records the **net** amount paid to contractor. The withheld amount is tracked separately for annual Form 856 reporting.

### Annual withholding report (Form 856 / טופס 856)

A year-end consolidated report of all withholding amounts per contractor, filed to the ITA (route `withholding` → `/reports/withholding`, nav label "Mas 856"). Each contractor also receives a **Form 857** annual certificate (per-recipient artifact, downloadable per row).

```
GET /api/contractors/withholding-report?year=2026
    → {
        year: 2026,
        total_gross: number,
        total_withheld: number,
        contractors: [{
          contractor_id, name, tax_id,
          gross_paid, withholding_rate, withheld_amount,
          certificate_number, certificate_expiry
        }]
      }
      Requires: payouts:read
```

CSV/Excel download available: `GET /api/contractors/withholding-report/xlsx?year=2026`. Column headers in Hebrew (for submission to ITA).

### Certificate expiry warnings

Cron job (weekly, `CRON_SECRET` protected): check all `contractors WHERE withholding_certificate_expiry IS NOT NULL AND withholding_certificate_expiry < NOW() + INTERVAL '30 days'`. For each expiring contractor: send `payouts_manager` user a notification of type `'expense_submitted'` (reused for operational alerts) with body "Contractor {name}'s withholding certificate expires on {date}. Upload a new one."

### Contractor settings UI extension

On `/contractors/:id` → Info panel → Payments section:

```
Withholding tax
Certificate number:   [456/2026__________]
Rate:                 [10%___] (0% – 100%)
Expiry date:          [2026-12-31_]
Certificate PDF:      [Upload] [certificate-2026.pdf ✓]
```

---

## Architecture Decisions

| Decision | Choice | Reason |
|----------|--------|--------|
| Payout bill ≠ invoice | Separate `payout_bills` table | Payout is tenant's expense (inbound from contractor), not tenant's outgoing invoice; different lifecycle |
| External contractor time entries | `contractor_id` on time_entries | Allows time tracking for contractors without Zync accounts; same aggregation query |
| Generate bill from time entries | Not real-time sync | Staff reviews/approves hours before billing; reduces disputes |
| Rate = `COALESCE(assignment.rate_override, contractor.hourly_rate)` | Not flat contractor rate | Project-specific rates common (e.g. contractor charges more for certain clients); override on assignment, fallback to default |
| Draft → Sent → Approved → Paid | 4-state flow | Mirrors IL B2B practice: issue proforma → approval → payment |
