{
  "slug": "contractor-payouts",
  "spec_file": "docs/specs/2026-05-30-contractor-payouts.md",
  "findings": [
    {
      "id": "contractor-payouts-001",
      "severity": "P0",
      "type": "security",
      "summary": "createAssignment inserts project_id without verifying the project belongs to the session tenant",
      "spec_ref": "Plan Task 6 — cross-tenant access is impossible; POST /api/contractors/:id/assignments",
      "code_ref": "packages/db/src/queries/contractors.ts:398",
      "evidence": "createAssignment only calls getContractor(db, tenantId, contractorId) then inserts { tenantId, contractorId, projectId: input.projectId } with no projects.tenant_id check. FK enforces project exists globally, not same-tenant ownership.",
      "repro": "POST /api/contractors/:id/assignments { projectId: <uuid-of-other-tenant-project> } → row created with attacker tenant_id + foreign project_id.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "contractor-payouts-002",
      "severity": "P0",
      "type": "security",
      "summary": "payout_bill_lines reads/deletes filter by bill_id only — no tenant_id predicate on line queries",
      "spec_ref": "Plan Cross-Cutting — all queries tenant-scoped",
      "code_ref": "packages/db/src/queries/contractors.ts:595",
      "evidence": "getPayoutBillWithLines selects lines with .where(eq(payoutBillLines.billId, billId)) only (:592-596). updatePayoutBill deletes lines with .where(eq(payoutBillLines.billId, billId)) only (:866). Bill header is tenant-scoped first, but line helpers violate tenant-on-every-read/write invariant.",
      "repro": "grep payoutBillLines in contractors.ts — no eq(payoutBillLines.tenantId, tenantId) on SELECT/DELETE.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "contractor-payouts-003",
      "severity": "P1",
      "type": "missing",
      "summary": "Bill generation and time reconciliation ignore contractors with Zync user accounts (user_id path)",
      "spec_ref": "Time Reconciliation — contractor has Zync user account: aggregate time_entries by user_id; Generate draft aggregates contractor_id OR user_id",
      "code_ref": "packages/db/src/queries/contractors.ts:630",
      "evidence": "generatePayoutBillDraft filters eq(timeEntries.contractorId, contractorId) only (:629-630). listContractorTimeEntries same (:464-465). contractors schema has no user_id column (packages/db/src/schema/contractors.ts) to link a Zync account.",
      "repro": "Approved billable time_entries with user_id set and contractor_id NULL are excluded from GET /api/contractors/:id/time and POST /api/contractors/:id/bills.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "contractor-payouts-004",
      "severity": "P1",
      "type": "missing",
      "summary": "GET /api/payouts/xlsx ledger Excel export not implemented",
      "spec_ref": "Payout Ledger — Export Excel; API GET /api/payouts/xlsx; Plan Task 8",
      "code_ref": "apps/zync-api/src/routes/contractors/payouts.ts:21",
      "evidence": "payouts.ts exposes only GET /. grep '/api/payouts/xlsx' and buildPayoutLedgerXlsx across repo (excl. worktrees/docs) — zero route or exporter implementation.",
      "repro": "GET /api/payouts/xlsx → 404.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "contractor-payouts-005",
      "severity": "P1",
      "type": "missing",
      "summary": "/reports/withholding (Mas 856) page not implemented",
      "spec_ref": "Annual withholding report — route withholding → /reports/withholding; Plan Task 13",
      "code_ref": "apps/zync-app/src/routes/index.tsx:104",
      "evidence": "nav-model.ts:274 links to /reports/withholding. reports/nav-map.ts:226 same. apps/zync-app/src/routes/reports/ has pnl, cashflow, vat, etc. — no withholding.tsx. moduleRoutes has no reports/withholding entry.",
      "repro": "Navigate to /reports/withholding → no matching route component.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "contractor-payouts-006",
      "severity": "P1",
      "type": "missing",
      "summary": "Contractor time reconciliation UI (/contractors/:id/time) absent",
      "spec_ref": "Hours reconciliation view /contractors/:id/time?period=YYYY-MM — table, summary, Generate bill draft",
      "code_ref": "apps/zync-app/src/routes/index.tsx:133",
      "evidence": "API GET /api/contractors/:id/time exists (router.ts:318). grep 'contractors/:id/time' and ContractorTime under apps/zync-app — no matches. Routes only contractors, contractors/:id, contractors/:id/bills.",
      "repro": "Open /contractors/:id/time?period=2026-06 → no route.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "contractor-payouts-007",
      "severity": "P1",
      "type": "missing",
      "summary": "POST /api/contractors/:id/certificate PDF upload not implemented",
      "spec_ref": "Contractor settings UI — Certificate PDF Upload; Plan Task 6 POST /api/contractors/:id/certificate",
      "code_ref": "apps/zync-api/src/routes/contractors/router.ts:488",
      "evidence": "Router implements POST /:id/withholding-certs (JSON metadata + optional r2Key) but no /:id/certificate upload route. grep '/certificate' under apps/zync-api — absent. UI stores withholdingCertificateR2Key on contractor row but no upload control ($id.tsx:41).",
      "repro": "No endpoint to upload certificate PDF to STORAGE and persist withholding_certificate_r2_key via dedicated upload flow.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "contractor-payouts-008",
      "severity": "P1",
      "type": "missing",
      "summary": "Form 857 per-contractor download not implemented",
      "spec_ref": "Annual withholding report — Each contractor receives Form 857 downloadable per row",
      "code_ref": "apps/zync-api/src/routes/contractors/router.ts:106",
      "evidence": "Only GET /withholding-report (JSON) and /withholding-report/xlsx (aggregate). grep '857' or 'form-857' under apps/ and packages/ (excl. docs/worktrees) — no API or UI.",
      "repro": "No per-contractor Form 857 artifact endpoint or UI action.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "contractor-payouts-009",
      "severity": "P1",
      "type": "missing",
      "summary": "Certificate-expiry cron route path double-segmented — scheduled job hits wrong URL",
      "spec_ref": "Cron job weekly CRON_SECRET protected at /api/cron/contractor-cert-expiry",
      "code_ref": "apps/zync-api/src/routes/contractors/withholding.ts:26",
      "evidence": "index.ts mounts withholdingCronRoute at /cron/withholding-expiry-check (:312). Handler registers GET '/withholding-expiry-check' (:26) → effective path /api/cron/withholding-expiry-check/withholding-expiry-check. cron/runner.ts:35 dispatches GET /api/cron/withholding-expiry-check (single segment).",
      "repro": "Cron dispatch to /api/cron/withholding-expiry-check returns 404; handler lives one path segment deeper.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "contractor-payouts-010",
      "severity": "P1",
      "type": "missing",
      "summary": "requireModuleEnabled('contractor_payouts') not applied to contractor/payout API routes",
      "spec_ref": "Plan Task 6/8/14 — requireModuleEnabled('payouts'); module disabled tenants must short-circuit",
      "code_ref": "apps/zync-api/src/routes/contractors/router.ts:65",
      "evidence": "contractorRoutes.use('*', authMiddleware) only (:65). payoutRoutes same (payouts.ts:17). grep requireModuleEnabled under apps/zync-api/src/routes/contractors — zero matches. Compare projects/index.ts which applies requireModuleEnabled('projects').",
      "repro": "Disable contractor_payouts for tenant → GET /api/contractors and GET /api/payouts still succeed with payouts:read.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "contractor-payouts-011",
      "severity": "P1",
      "type": "missing",
      "summary": "hasLivePayoutBill not implemented — time-entry unlock can bypass payout lock integration",
      "spec_ref": "Plan Task 3 hasLivePayoutBill; time-entry-locking — 409 payout_active when live bill references entry",
      "code_ref": "packages/db/src/queries/contractors.ts:1",
      "evidence": "grep hasLivePayoutBill across repo (excl. docs/worktrees) — only plan/spec references, no function export. time/locking.ts unlockTimeEntries clears lock without payout bill check (:103-154).",
      "repro": "POST /api/time-entries/unlock on payout-locked entries proceeds without 409 payout_active.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "contractor-payouts-012",
      "severity": "P1",
      "type": "missing",
      "summary": "E2E test file contractor-payouts.e2e.ts not present",
      "spec_ref": "Plan Task 15 — apps/zync-api/test/contractor-payouts.e2e.ts",
      "code_ref": "apps/zync-api/test/",
      "evidence": "glob contractor-payouts*.ts under apps/zync-api/test — 0 files.",
      "repro": "No automated verification of bill generation, void, withholding, or cron.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "contractor-payouts-013",
      "severity": "P1",
      "type": "missing",
      "summary": "Draft bill line editing UI and API usage absent — cannot adjust hours/rate/add fixed lines",
      "spec_ref": "Payout Bills — Edit lines while DRAFT; Plan Task 12 line editing",
      "code_ref": "apps/zync-app/src/routes/contractors/$id.bills.tsx:227",
      "evidence": "BillRow shows breakdown and status actions only. PATCH calls send { status } or payment fields — never { lines }. grep 'lines' in $id.bills.tsx — no line-edit UI.",
      "repro": "DRAFT bill cannot be line-edited from UI; fixed-fee lines cannot be added per spec.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "contractor-payouts-014",
      "severity": "P1",
      "type": "missing",
      "summary": "SENT transition does not email/notify contractor with bill summary",
      "spec_ref": "Review and approve — DRAFT→SENT contractor notified via email; Plan Task 7",
      "code_ref": "packages/db/src/queries/contractors.ts:836",
      "evidence": "updatePayoutBill sets billUpdates.status on transition (:836) with no createNotification/email call in function or router PATCH handler.",
      "repro": "PATCH bill status to SENT — no notification emitted.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "contractor-payouts-015",
      "severity": "P2",
      "type": "divergent",
      "summary": "Write permission key is payouts:manage in seed/API but spec/plan require payouts:write",
      "spec_ref": "Permissions — Manage contractors / Generate bills / Record payment → payouts:write",
      "code_ref": "packages/db/src/seed/permission-keys.ts:41",
      "evidence": "PERMISSION_KEYS lists 'payouts:read', 'payouts:manage' (:41). All write routes use requirePermission('payouts:manage') (router.ts:87+). Spec and plan Task 2 define payouts:write. settings/contractors.tsx checks payouts:write (:17) which is never seeded.",
      "repro": "grep payouts:write in packages/db — only comments/settings; no seed key. settings PATCH gated on nonexistent permission.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "contractor-payouts-016",
      "severity": "P2",
      "type": "divergent",
      "summary": "App nav gates payout ledger on contractor_payouts:read — permission not in seed catalogue",
      "spec_ref": "Permissions — View contractors + payouts → payouts:read",
      "code_ref": "apps/zync-app/src/shell/nav-model.ts:209",
      "evidence": "Payout nav item permission: 'contractor_payouts:read' (:209). permission-keys.ts has payouts:read only. Contractors nav uses contractors:read (:202) — also absent from PERMISSION_KEYS.",
      "repro": "User with seeded payouts:read may not see /payouts nav; Sidebar permission check fails on unknown key.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "contractor-payouts-017",
      "severity": "P2",
      "type": "divergent",
      "summary": "withholding-report/xlsx returns CSV not Excel; no RTL/exceljs",
      "spec_ref": "GET /api/contractors/withholding-report/xlsx — Excel Hebrew headers; Plan Task 5 buildWithholdingReportXlsx",
      "code_ref": "apps/zync-api/src/routes/contractors/router.ts:160",
      "evidence": "Handler builds comma-separated string (:160) with Content-Type text/csv (:164) and filename .csv (:165). No exceljs usage; @zync/payouts package absent.",
      "repro": "GET /api/contractors/withholding-report/xlsx → text/csv body, not .xlsx.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "contractor-payouts-018",
      "severity": "P2",
      "type": "divergent",
      "summary": "Withholding report JSON uses camelCase totals vs spec snake_case field names",
      "spec_ref": "GET /api/contractors/withholding-report — total_gross, total_withheld, gross_paid, withheld_amount",
      "code_ref": "packages/db/src/queries/contractors.ts:1125",
      "evidence": "getWithholdingReport returns { year, totalGross, totalWithheld, contractors: [{ grossPaid, withheldAmount, ... }] } (:1125-1138). Spec documents total_gross/total_withheld and snake_case contractor fields.",
      "repro": "GET /api/contractors/withholding-report?year=2026 → totalGross not total_gross.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "contractor-payouts-019",
      "severity": "P2",
      "type": "divergent",
      "summary": "Bill generation omits locked_at IS NULL guard — may include period-locked approved entries",
      "spec_ref": "Plan Task 4 — approval_status IN approved/auto_approved AND locked_at IS NULL",
      "code_ref": "packages/db/src/queries/contractors.ts:628",
      "evidence": "entryConditions include approval_status IN ('approved','auto_approved') (:634) but no locked_at IS NULL or live-bill exclusion via hasLivePayoutBill.",
      "repro": "Time entry with approval_status=approved and locked_at set (period_closed) can be included in new payout draft.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "contractor-payouts-020",
      "severity": "P2",
      "type": "divergent",
      "summary": "Payout generation sets locked_reason='invoiced' instead of spec/plan 'approved'",
      "spec_ref": "Plan Task 4 — locked_reason='approved'; Void releases locked→approved",
      "code_ref": "packages/db/src/queries/contractors.ts:770",
      "evidence": "generatePayoutBillDraft sets lockedReason: 'invoiced' (:770). Plan and contractor-payouts void spec reference locked_reason='approved' for payout billing lock.",
      "repro": "After bill generation, time_entries.locked_reason='invoiced' not 'approved'.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "contractor-payouts-021",
      "severity": "P2",
      "type": "divergent",
      "summary": "updatePayoutBill allows line replacement when status is SENT/APPROVED",
      "spec_ref": "Withholding snapshot immutable after SENT; line editing disabled once status ≥ SENT",
      "code_ref": "packages/db/src/queries/contractors.ts:864",
      "evidence": "Guard blocks PAID/VOID (:807-811). Comment at :813-816 notes withholding immutable after SENT but input.lines branch (:864-907) has no status===DRAFT check before delete/insert.",
      "repro": "PATCH /api/contractors/:id/bills/:bid { lines: [...] } on SENT bill mutates lines and recomputes amount.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "contractor-payouts-022",
      "severity": "P2",
      "type": "divergent",
      "summary": "Contractors list API/UI missing Active projects column/count",
      "spec_ref": "Contractors List — columns include Active projects; Plan Task 6 paginated list with active-project count",
      "code_ref": "packages/db/src/queries/contractors.ts:256",
      "evidence": "listContractors returns raw contractor rows only (:256-274). UI table headers ['Name','Tax ID','Billing','Rate','Status'] (index.tsx:360) — no active project count.",
      "repro": "GET /api/contractors — items lack activeProjects field.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "contractor-payouts-023",
      "severity": "P2",
      "type": "divergent",
      "summary": "Cert-expiry cron targets payouts:manage holders; spec says payouts_manager / payouts:write",
      "spec_ref": "Certificate expiry warnings — notify payouts_manager user; Plan Task 9 payouts:write holders",
      "code_ref": "apps/zync-api/src/routes/contractors/withholding.ts:63",
      "evidence": "Cron SQL filters p.key = 'payouts:manage' (:63). Spec references role payouts_manager and plan seeds payouts:write for OWNER/ADMIN.",
      "repro": "Users granted only payouts:write (if renamed) or spec-intended manager role may not receive expiry alerts.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "contractor-payouts-024",
      "severity": "P2",
      "type": "missing",
      "summary": "@zync/payouts package from plan not created — logic lives inline in @zync/db",
      "spec_ref": "Plan Architecture — packages/payouts with generatePayoutBill, resolveWithholdingRate, exporters",
      "code_ref": "packages/db/src/queries/contractors.ts:617",
      "evidence": "glob packages/payouts — 0 results. generatePayoutBillDraft, getWithholdingReport, resolveStatutoryWithholdingRate all in packages/db/src/queries/contractors.ts.",
      "repro": "No packages/payouts/package.json or src/index.ts in repo.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "contractor-payouts-025",
      "severity": "P3",
      "type": "cosmetic",
      "summary": "Payout ledger page export button downloads Mas 856 CSV instead of ledger Excel",
      "spec_ref": "Payout Ledger — Export Excel via GET /api/payouts/xlsx",
      "code_ref": "apps/zync-app/src/routes/payouts.tsx:169",
      "evidence": "Ledger page button links to /api/contractors/withholding-report/xlsx (:169). Comment at file top says CSV download for annual report (:6).",
      "repro": "On /payouts, Export triggers withholding report not ledger export.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "contractor-payouts-026",
      "severity": "P3",
      "type": "cosmetic",
      "summary": "Migration partial index references nonexistent payout status PENDING",
      "spec_ref": "payout_bills.status CHECK IN DRAFT,SENT,APPROVED,PAID,VOID",
      "code_ref": "packages/db/migrations/0006_sleepy_molten_man.sql:471",
      "evidence": "idx_payout_bills_unpaid WHERE status = 'PENDING' (:471) but status_check allows only DRAFT|SENT|APPROVED|PAID|VOID (:87). Index never matches rows.",
      "repro": "Inspect 0006_sleepy_molten_man.sql:471 vs :87.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "contractor-payouts-027",
      "severity": "P3",
      "type": "cosmetic",
      "summary": "Void 409 response body is free-text message not spec error code cannot_void_paid",
      "spec_ref": "POST void — 409 if PAID",
      "code_ref": "apps/zync-api/src/routes/contractors/router.ts:461",
      "evidence": "PayoutBillConflictError message string returned as { error: err.message } (:461). Spec documents machine code cannot_void_paid.",
      "repro": "Void PAID bill → { error: 'Cannot void a PAID bill...' } not cannot_void_paid.",
      "fix_direction": "code_to_spec"
    }
  ],
  "summary": {
    "P0": 2,
    "P1": 12,
    "P2": 10,
    "P3": 3,
    "total": 27
  }
}
