{
  "spec_file": "docs/specs/2026-05-30-invoices-core.md",
  "audited_at_commit": "eec20fd9e9dec21a0b24d90f8d2864d7d89b31ab",
  "summary": {
    "p0": 2,
    "p1": 11,
    "p2": 10,
    "p3": 4
  },
  "findings": [
    {
      "id": "ic-fresh-001",
      "severity": "P0",
      "title": "R2 HTML snapshot captured before TAX_ISSUED lacks tax invoice number",
      "requirement": "On TAX_ISSUED: snapshot of rendered HTML stored in R2 as immutable record. R2 key: {tenantId}/invoices/{invoiceId}/{type}-{number}.html",
      "current_behavior": "POST /:id/issue-tax renders HTML and writes R2 while invoice is still APPROVED (invoiceNumber=null), then calls issueTaxInvoice afterward. renderInvoiceHtml titles the document from proformaNumber when invoiceNumber is absent. The stored snapshot is permanently linked via htmlSnapshotUrl and served for TAX_ISSUED+ invoices.",
      "location": "apps/zync-api/src/routes/invoices/index.ts:498-536",
      "fix_direction": "code_to_spec",
      "recommended_fix": "Move snapshot render+R2 put inside the issueTaxInvoice transaction after invoiceNumber/taxIssueDate are assigned, or re-render post-issue and store tax-invoice-{number}-{lang}.html per spec."
    },
    {
      "id": "ic-fresh-002",
      "severity": "P0",
      "title": "billedEntryIds accepted on create but time entries never marked billed",
      "requirement": "On invoice creation, each selected entry creates an invoice line … and sets time_entries.invoice_id to prevent double-billing.",
      "current_behavior": "createInvoiceSchema accepts billedEntryIds but createInvoice ignores the field (comment defers to spec 77). markTimeEntriesBilled exists in packages/db/src/queries/time-invoice.ts but is never called from POST /api/invoices. Time can be added to lines without locking entries, enabling double-billing.",
      "location": "packages/db/src/queries/invoices.ts:57-58,467-560",
      "fix_direction": "code_to_spec",
      "recommended_fix": "Within createInvoice transaction, after line insert, call markTimeEntriesBilled(tx, tenantId, { invoiceId, entryIds: billedEntryIds, billedAt: now }) when billedEntryIds is non-empty; return 409 if entries already billed."
    },
    {
      "id": "ic-fresh-003",
      "severity": "P1",
      "title": "GET /api/invoices/unbilled-time is a stub returning empty items",
      "requirement": "GET /api/invoices/unbilled-time?projectId={id} returns unbilled time entries for a project (those with invoice_id IS NULL).",
      "current_behavior": "Route always returns { items: [], note: 'Requires spec 77…' }. listUnbilledTimeEntries is implemented and wired only at GET /api/time/unbilled, not at the invoices-core path.",
      "location": "apps/zync-api/src/routes/invoices/index.ts:197-204",
      "fix_direction": "code_to_spec",
      "recommended_fix": "Call listUnbilledTimeEntries(db, { tenantId, projectId }) from this route (require invoices:read or align permission with spec) and return entries in the shape the invoice form expects."
    },
    {
      "id": "ic-fresh-004",
      "severity": "P1",
      "title": "Legacy POST /record-payment skips invoice_payments table",
      "requirement": "POST /api/invoices/:id/record-payment → TAX_ISSUED → PAID (+ payment method, amount, date)",
      "current_behavior": "Route calls recordPayment in invoices.ts which updates amountPaid/status on invoices row only; paymentMethod, paymentDate, and reference from recordPaymentSchema are discarded. recordInvoicePayment in invoice-payments.ts inserts invoice_payments rows but is only used by POST /:id/payments. InvoiceDetailPage and useRecordPayment hook call the legacy /record-payment path.",
      "location": "packages/db/src/queries/invoices.ts:1027-1080; apps/zync-api/src/routes/invoices/index.ts:565-581; apps/zync-app/src/api/invoices.ts:259-267",
      "fix_direction": "code_to_spec",
      "recommended_fix": "Delegate POST /:id/record-payment to recordInvoicePayment (map paymentMethod→source/note, paymentDate→paidAt) or deprecate the legacy endpoint and migrate UI to /:id/payments."
    },
    {
      "id": "ic-fresh-005",
      "severity": "P1",
      "title": "VAT rate stamped at SENT, not recomputed at tax_issue_date",
      "requirement": "VAT rate: looked up from vat_rates table at invoice issue date … Rate stored on invoice at issue time — immutable.",
      "current_behavior": "sendInvoice fetches VAT using proforma issueDate and writes vatRate/subtotal/vatAmount/total at DRAFT→SENT. issueTaxInvoice assigns invoiceNumber and taxIssueDate but does not re-lookup or recompute VAT/totals for tax_issue_date.",
      "location": "packages/db/src/queries/invoices.ts:757-786,952-992",
      "fix_direction": "spec_review",
      "recommended_fix": "Clarify whether VAT locks at proforma send or tax-issue date; if tax-issue date governs, recompute in issueTaxInvoice using getVatRateForDate(country, taxIssueDate)."
    },
    {
      "id": "ic-fresh-006",
      "severity": "P1",
      "title": "Invoice/proforma number prefixes from settings not applied to sequences",
      "requirement": "Returns: prefix + padded(last_number, 5) e.g. 'INV-00042'. Sequential number assigned at state transitions.",
      "current_behavior": "nextInvoiceNumber INSERT uses prefix '' on first row; ON CONFLICT only increments last_number. tenant_settings.invoice_number_prefix / proforma_number_prefix (settings-invoices.ts) are never synced into invoice_sequences.prefix.",
      "location": "packages/db/src/queries/invoices.ts:304-313; packages/db/src/queries/settings-invoices.ts:18-29",
      "fix_direction": "code_to_spec",
      "recommended_fix": "On tenant settings save (or before nextInvoiceNumber), upsert invoice_sequences.prefix from tenant invoice settings; seed prefix rows for new tenants."
    },
    {
      "id": "ic-fresh-007",
      "severity": "P1",
      "title": "POST /credit-note creates DRAFT only; spec describes immediate paired credit note",
      "requirement": "POST /api/invoices/:id/credit-note → creates paired credit note invoice. Cancellation: once TAX_ISSUED, … creates a paired negative invoice.",
      "current_behavior": "POST /:id/credit-note calls createCreditNoteDraft (status DRAFT, no number). Separate POST /:creditNoteId/issue assigns number and flips to TAX_ISSUED. Legacy createCreditNote one-shot helper exists but route does not use it.",
      "location": "apps/zync-api/src/routes/invoices/credit-notes.ts:27-50; packages/db/src/queries/invoices.ts:1227-1407",
      "fix_direction": "spec_review",
      "recommended_fix": "Either update invoices-core spec to reference invoice-credit-notes two-step flow, or wire POST /credit-note to draft+issue atomically for full cancellations."
    },
    {
      "id": "ic-fresh-008",
      "severity": "P1",
      "title": "Automated invoice generation (invoice.generate queue) not implemented",
      "requirement": "When retainer_months.hours_used >= hours_included and auto_invoice = true: Worker enqueues invoice.generate job … Configurable task-status trigger … Fixed price deposit invoice.",
      "current_behavior": "No invoice.generate consumer or queue handler in apps/zync-api. Retainer depletion may enqueue retainer.invoice in projects.ts but no invoices-core consumer creates DRAFT/SENT invoices from these events.",
      "location": "docs/specs/2026-05-30-invoices-core.md:300-319 (no implementation file)",
      "fix_direction": "code_to_spec",
      "recommended_fix": "Implement queue consumer per spec Task 11: handle retainer_depleted, task_status, fixed_deposit messages; create invoice with correct source and lines."
    },
    {
      "id": "ic-fresh-009",
      "severity": "P1",
      "title": "/invoices/new route and inbound query-param pre-fill missing",
      "requirement": "New Invoice form (/invoices/new) accepts pre-filled customer + line items from ?contract_id=, ?proposal_id=, ?project_id=&from_time=true, milestone handoffs.",
      "current_behavior": "Module routes only /, /reconcile, /:id. /invoices/new resolves to InvoiceDetailPage with id='new' → not found. InvoiceFormSheet has no query-param seeding for contract/proposal/time/milestone.",
      "location": "apps/zync-app/src/modules/invoices.tsx:20-47; apps/zync-app/src/pages/invoices/InvoiceFormSheet.tsx:33-55",
      "fix_direction": "code_to_spec",
      "recommended_fix": "Add /invoices/new route opening InvoiceFormSheet; read search params and pre-seed customer/lines/billedEntryIds per upstream handoff specs."
    },
    {
      "id": "ic-fresh-010",
      "severity": "P1",
      "title": "voidInvoice allows APPROVED/REJECTED; spec limits VOID to DRAFT or SENT",
      "requirement": "VOID is a terminal state reachable from DRAFT or SENT only. Actions: DRAFT → Void; SENT → Void.",
      "current_behavior": "voidInvoice blocks only TAX_ISSUED, PAID, PARTIALLY_PAID, WRITTEN_OFF, BAD_DEBT. APPROVED and REJECTED invoices can be voided (no status guard).",
      "location": "packages/db/src/queries/invoices.ts:1497-1516",
      "fix_direction": "code_to_spec",
      "recommended_fix": "Restrict voidInvoice to existing.status in ('DRAFT','SENT'); return 409 for APPROVED/REJECTED with guidance to use credit note or edit flow."
    },
    {
      "id": "ic-fresh-011",
      "severity": "P1",
      "title": "Add time entries control missing from invoice create/edit UI",
      "requirement": "Add time entries — pulls in unbilled time entries for selected project. New Invoice form consuming side of spec 77 handoff.",
      "current_behavior": "InvoiceFormSheet has customer/project/currency/due date/notes/lines only. No button to fetch unbilled entries or append lines. Grep in pages/invoices finds no time-entry integration.",
      "location": "apps/zync-app/src/pages/invoices/InvoiceFormSheet.tsx:1-55",
      "fix_direction": "code_to_spec",
      "recommended_fix": "Add 'Add time entries' action when projectId set; fetch unbilled entries (from wired /api/invoices/unbilled-time or /api/time/unbilled) and track billedEntryIds for submit."
    },
    {
      "id": "ic-fresh-012",
      "severity": "P1",
      "title": "default_payment_terms_days not applied when creating invoices",
      "requirement": "ALTER TABLE tenant_settings ADD COLUMN default_payment_terms_days … Read by every invoice-creation flow.",
      "current_behavior": "Column exists on tenant_settings (tenants schema + migration 0005). createInvoice sets dueDate only from explicit input; POST /api/invoices route does not read tenant default to compute due_date.",
      "location": "packages/db/src/schema/tenants.ts:102; packages/db/src/queries/invoices.ts:502; apps/zync-api/src/routes/invoices/index.ts:141-162",
      "fix_direction": "code_to_spec",
      "recommended_fix": "On create, if dueDate omitted, set due_date = today + tenant_settings.default_payment_terms_days."
    },
    {
      "id": "ic-fresh-013",
      "severity": "P1",
      "title": "unbilled-time endpoint gated on time:read instead of invoices:read",
      "requirement": "GET /api/invoices/unbilled-time listed under invoices API; invoice form data source for invoices-core consumers.",
      "current_behavior": "Stub route uses requirePermission('time:read'). Spec permission table maps invoice viewing to invoices:read; accountants with invoices:write but no time:read cannot use the invoices-core path.",
      "location": "apps/zync-api/src/routes/invoices/index.ts:197",
      "fix_direction": "spec_review",
      "recommended_fix": "When implementing the endpoint, gate on invoices:read (or invoices:write) per product intent; document if time:read is intentional cross-module gate."
    },
    {
      "id": "ic-fresh-014",
      "severity": "P2",
      "title": "DELETE draft uses invoices:write not invoices:delete",
      "requirement": "Delete draft | Required permission: invoices:delete",
      "current_behavior": "DELETE /:id requires requirePermission('invoices:write'). invoices:delete is seeded but never checked on this route.",
      "location": "apps/zync-api/src/routes/invoices/index.ts:269",
      "fix_direction": "code_to_spec",
      "recommended_fix": "Gate DELETE on invoices:delete (accept invoices:write as superset if backward compat needed)."
    },
    {
      "id": "ic-fresh-015",
      "severity": "P2",
      "title": "Invoice list missing customer, project, source filters and sort",
      "requirement": "Filter: status, customer, project, date range, source. Sort: issue date, amount, number.",
      "current_behavior": "API listInvoices supports customerId, projectId, source, dateFrom/dateTo. InvoicePage UI only exposes status + date range filters; no sort controls; list ordered by createdAt desc only.",
      "location": "apps/zync-app/src/pages/invoices/InvoicePage.tsx:155-187; packages/db/src/queries/invoices.ts:358-417",
      "fix_direction": "code_to_spec",
      "recommended_fix": "Add customer/project/source filter controls and sort query params (issueDate, total, number) to API+UI."
    },
    {
      "id": "ic-fresh-016",
      "severity": "P2",
      "title": "List and detail show customer/project UUIDs not resolved names",
      "requirement": "Table columns: Number, Customer, Project, Amount, Status, Issue date, Due date. Preview shows customer details.",
      "current_behavior": "InvoicePage and InvoiceDetailPage render invoice.customerId and invoice.projectId raw UUID strings. HTML render uses customerId as customerName placeholder.",
      "location": "apps/zync-app/src/pages/invoices/InvoicePage.tsx:342-346; apps/zync-app/src/pages/invoices/InvoiceDetailPage.tsx:572-578; apps/zync-api/src/routes/invoices/index.ts:503-506",
      "fix_direction": "code_to_spec",
      "recommended_fix": "Join or fetch customer/project names in list/detail API responses; pass real names into renderInvoiceHtml."
    },
    {
      "id": "ic-fresh-017",
      "severity": "P2",
      "title": "Status lifecycle timeline replaced by generic activity feed",
      "requirement": "Status timeline at top: DRAFT → SENT → APPROVED → TAX_ISSUED → PAID (with timestamps).",
      "current_behavior": "InvoiceDetailPage shows ActivityTimeline (operational audit) but no dedicated state-step timeline with sentAt/approvedAt/taxIssuedAt/paidAt markers.",
      "location": "apps/zync-app/src/pages/invoices/InvoiceDetailPage.tsx:698-707",
      "fix_direction": "code_to_spec",
      "recommended_fix": "Add InvoiceStatusTimeline component rendering the five-step flow with timestamps from invoice row."
    },
    {
      "id": "ic-fresh-018",
      "severity": "P2",
      "title": "R2 snapshot key/path diverges from spec (no invoices/ segment, number, or lang)",
      "requirement": "R2 key: {tenantId}/invoices/{invoiceId}/{type}-{number}.html; include lang in key on TAX_ISSUED.",
      "current_behavior": "Key is ${tenantId}/${id}/${randomUuid}/invoice.html. No tax-invoice number or locale suffix.",
      "location": "apps/zync-api/src/routes/invoices/index.ts:517-521",
      "fix_direction": "code_to_spec",
      "recommended_fix": "After ic-fresh-001 fix, write to {tenantId}/invoices/{invoiceId}/tax-invoice-{number}-{lang}.html."
    },
    {
      "id": "ic-fresh-019",
      "severity": "P2",
      "title": "HTML template uses Google Fonts CDN not R2 Heebo @font-face embed",
      "requirement": "Embed via @font-face in print CSS … Font file stored in R2 at _static/fonts/heebo-variable.woff2.",
      "current_behavior": "renderInvoiceHtml uses @import url('https://fonts.googleapis.com/css2?family=Heebo…') in inline styles.",
      "location": "packages/db/src/queries/invoices.ts:1707-1708",
      "fix_direction": "code_to_spec",
      "recommended_fix": "Serve Heebo from R2 public URL with @font-face font-display:block for print reliability."
    },
    {
      "id": "ic-fresh-020",
      "severity": "P2",
      "title": "Outbound invoice email locale hardcoded to he-IL",
      "requirement": "Invoice and reminder emails use tenant.settings.locale for subject line and body language.",
      "current_behavior": "POST /:id/send passes locale: 'he-IL' to sendEmail regardless of tenant locale; defaultSubject is English template string.",
      "location": "apps/zync-api/src/routes/invoices/index.ts:367-386",
      "fix_direction": "code_to_spec",
      "recommended_fix": "Resolve tenant locale from settings; use invoice_sent_he / invoice_sent_en templates per spec."
    },
    {
      "id": "ic-fresh-021",
      "severity": "P2",
      "title": "GET /html serves R2 snapshot only for TAX_ISSUED, not PAID/PARTIALLY_PAID",
      "requirement": "GET /api/invoices/:id/html → TAX_ISSUED serves R2 snapshot",
      "current_behavior": "Redirect to htmlSnapshotUrl only when status === 'TAX_ISSUED'. PAID and PARTIALLY_PAID invoices with snapshots fall through to live re-render.",
      "location": "apps/zync-api/src/routes/invoices/index.ts:667-670",
      "fix_direction": "code_to_spec",
      "recommended_fix": "Serve immutable snapshot for any post-issue status when htmlSnapshotUrl is set (TAX_ISSUED, PAID, PARTIALLY_PAID, WRITTEN_OFF, BAD_DEBT)."
    },
    {
      "id": "ic-fresh-022",
      "severity": "P2",
      "title": "Create form missing issue date field; no drag-to-reorder on line items",
      "requirement": "Sheet form: Issue date, Due date. Line items: … Drag to reorder.",
      "current_behavior": "InvoiceFormSheet exposes due date but not issue date (issue_date set server-side at send). LineItemEditor supports add/remove but no drag-and-drop reorder.",
      "location": "apps/zync-app/src/pages/invoices/InvoiceFormSheet.tsx:44-55; apps/zync-app/src/pages/invoices/LineItemEditor.tsx:34-58",
      "fix_direction": "code_to_spec",
      "recommended_fix": "Add issue date picker for draft editing; integrate drag handle library to update line position fields."
    },
    {
      "id": "ic-fresh-023",
      "severity": "P2",
      "title": "HTML snapshot write not transactional with TAX_ISSUED status flip",
      "requirement": "Atomicity requirement: number assignment and the status update … must be the same DB transaction.",
      "current_behavior": "R2 put happens in route handler before issueTaxInvoice DB transaction. Failure after R2 put but before commit leaves orphan snapshot; failure after commit without snapshot leaves issued invoice without immutable record.",
      "location": "apps/zync-api/src/routes/invoices/index.ts:498-536",
      "fix_direction": "code_to_spec",
      "recommended_fix": "Assign number in DB first, then write snapshot with final number, then set htmlSnapshotUrl in same transaction (or compensating delete on rollback)."
    },
    {
      "id": "ic-fresh-024",
      "severity": "P3",
      "title": "GET /html missing Content-Disposition inline filename",
      "requirement": "Served with Content-Disposition: inline; filename=\"invoice-{number}.html\" for direct open + print.",
      "current_behavior": "Live HTML response sets Content-Type and CSP only; no Content-Disposition header.",
      "location": "apps/zync-api/src/routes/invoices/index.ts:679-687",
      "fix_direction": "code_to_spec",
      "recommended_fix": "Add Content-Disposition: inline; filename=\"invoice-{invoiceNumber||proformaNumber}.html\"."
    },
    {
      "id": "ic-fresh-025",
      "severity": "P3",
      "title": "No explicit idempotency key on issue-tax; relies on status guard only",
      "requirement": "Sequential numbering … Gap or double-assignment if separate. IL law requires gap-free sequential numbering.",
      "current_behavior": "issueTaxInvoice uses SELECT FOR UPDATE and rejects non-APPROVED status. Duplicate concurrent requests: second fails with Cannot issue. No Idempotency-Key header; safe retry after success returns 409 not prior result.",
      "location": "packages/db/src/queries/invoices.ts:961-972",
      "fix_direction": "spec_review",
      "recommended_fix": "Consider returning 200 with existing invoice on idempotent retry when already TAX_ISSUED; optional Idempotency-Key for client safety."
    },
    {
      "id": "ic-fresh-026",
      "severity": "P3",
      "title": "REJECTED state has no explicit Reopen-to-DRAFT action",
      "requirement": "REJECTED: Reopen to DRAFT (staff edits and resends)",
      "current_behavior": "rejectInvoice sets status REJECTED. UI allows Edit and Send directly from REJECTED without a distinct reopen transition or status flip to DRAFT.",
      "location": "packages/db/src/queries/invoices.ts:579-580,906-914; apps/zync-app/src/pages/invoices/InvoiceDetailPage.tsx:158-159",
      "fix_direction": "spec_review",
      "recommended_fix": "Clarify whether REJECTED-is-editable satisfies 'reopen', or add explicit POST /reopen → DRAFT transition for audit trail."
    }
  ]
}
