# Invoices: Core

**Date:** 2026-05-30  
**Status:** Draft  
**Depends on:** `foundation-auth-rbac`, `customers-module`, `projects-module`, `system-i18n`, `time-management`  
**Referenced by:** `invoices-adapters`, `billing-module`, `contractor-payouts`, `reports-analytics`, `tenant-portals`, `invoice-receipt-document`, `uniform-format-export`, `customer-statement`

---

## Overview

Invoice lifecycle compliant with Israeli tax law. Two-stage flow: חשבונית עסקה (proforma / business invoice) → customer approval → חשבונית מס (tax invoice). VAT calculated against point-in-time rate table. Supports manual creation and automated generation from project billing triggers.

---

## Israeli Invoice Law Compliance

Israeli law requires:
1. **חשבונית עסקה** (business invoice / proforma): issued first, sent to customer for approval
2. **חשבונית מס** (tax invoice): issued after approval, legally binding VAT document
3. Tax invoice must include: business name, business ID (ח.פ./ע.מ.), customer details, issue date, sequential invoice number, line items with VAT, total inc. VAT, VAT rate
4. Sequential numbering: no gaps allowed. Numbers assigned at issue time, never at draft time.

VAT rate: looked up from `vat_rates` table at **TAX-ISSUE date** (`taxIssueDate`) and stored immutably on the invoice at `TAX_ISSUED`. The proforma (`SENT`) may show an indicative rate from `issue_date`, but the legal/stored rate is the tax-issue-date rate. *(Israeli tax point = tax invoice issuance.)*

---

## Invoice States

```
DRAFT → SENT → APPROVED → TAX_ISSUED → PAID
                    └──→ REJECTED → (back to DRAFT, edit, re-send)

TAX_ISSUED | PARTIALLY_PAID → WRITTEN_OFF   (owner explicitly writes off; spec 168)
TAX_ISSUED | PARTIALLY_PAID → BAD_DEBT      (uncollectable after threshold; spec 168; requires OWNER)
WRITTEN_OFF | BAD_DEBT → PARTIALLY_PAID | PAID   (debtor pays after write-off; recovery, spec 168; re-remits reclaimed VAT)
```

| State | Description |
|-------|-------------|
| `DRAFT` | Not yet sent. Editable. No invoice number assigned. |
| `SENT` | חשבונית עסקה sent to customer. Awaiting approval. Read-only. |
| `APPROVED` | Customer approved חשבונית עסקה. Ready to issue tax invoice. |
| `REJECTED` | Customer rejected. Goes back to draft (editable, new send flow). |
| `TAX_ISSUED` | חשבונית מס issued. Sequential number assigned. Immutable. |
| `PAID` | Payment received and recorded. |
| `WRITTEN_OFF` | Collection outcome: invoice explicitly written off by the owner (spec 168 `bad-debt-writeoff`). Reversible only via recovery if the debtor later pays. |
| `BAD_DEBT` | Collection outcome: invoice marked uncollectable after the overdue threshold (spec 168). Transition requires OWNER. Reversible only via recovery (→ PARTIALLY_PAID/PAID), which re-remits any reclaimed VAT. |

Cancellation: once `TAX_ISSUED`, a cancellation requires issuing a חשבונית זיכוי (credit note). Credit-note issuance follows the two-step draft→issue flow defined in `invoice-credit-notes` spec (`POST /:id/credit-note` creates a `DRAFT` credit note; `POST /:creditNoteId/issue` assigns the number and flips to `TAX_ISSUED`). Never delete or edit a tax-issued invoice. *(Two-step flow keeps gap-free CN numbering and staff review before issuance.)*

`WRITTEN_OFF` and `BAD_DEBT` are collection-outcome statuses reachable only from `TAX_ISSUED` or `PARTIALLY_PAID`. They are terminal unless the debtor later pays: spec 168's recovery flow transitions them back to `PARTIALLY_PAID`/`PAID` and re-remits any VAT that was reclaimed. See spec 168 for the write-off / bad-debt flow, recovery, and the VAT reclaim handling.

---

## Data Model

```sql
invoices (
  id UUID PRIMARY KEY,
  tenant_id UUID NOT NULL,
  customer_id UUID NOT NULL,
  project_id UUID,                  -- nullable (not all invoices tied to a project)
  invoice_number TEXT,              -- NULL until TAX_ISSUED; sequential, no gaps
  proforma_number TEXT,             -- sequential proforma number (assigned at SENT)
  status TEXT DEFAULT 'DRAFT',        -- enum values: DRAFT | SENT | APPROVED | TAX_ISSUED | PAID | PARTIALLY_PAID | REJECTED | VOID | WRITTEN_OFF | BAD_DEBT; sourced from packages/types InvoiceStatus
  currency TEXT DEFAULT 'ILS',
  issue_date DATE,                  -- date חשבונית עסקה was sent
  tax_issue_date DATE,              -- date חשבונית מס was issued
  due_date DATE,
  vat_rate NUMERIC(5,4),            -- stored at issue time from vat_rates table
  subtotal NUMERIC(12,2) NOT NULL DEFAULT 0,
  vat_amount NUMERIC(12,2) NOT NULL DEFAULT 0,
  total NUMERIC(12,2) NOT NULL DEFAULT 0,
  notes TEXT,
  source TEXT DEFAULT 'manual',     -- 'manual' | 'retainer' | 'hourly_auto' | 'fixed_deposit'
  sent_at TIMESTAMPTZ,
  approved_at TIMESTAMPTZ,
  tax_issued_at TIMESTAMPTZ,
  paid_at TIMESTAMPTZ,
  external_id TEXT,                 -- ID in adapter system (Morning, iCount, etc.)
  external_provider TEXT,           -- 'morning' | 'icount' | 'rivhit' | ...
  created_by UUID NOT NULL,
  created_at TIMESTAMPTZ DEFAULT now(),
  updated_at TIMESTAMPTZ DEFAULT now()
)

invoice_lines (
  id UUID PRIMARY KEY,
  invoice_id UUID NOT NULL,
  tenant_id UUID NOT NULL,
  description TEXT NOT NULL,
  quantity NUMERIC(10,3) NOT NULL DEFAULT 1,
  unit_price NUMERIC(12,2) NOT NULL,
  discount_pct NUMERIC(5,2) DEFAULT 0,
  line_total NUMERIC(12,2) NOT NULL,  -- quantity * unit_price * (1 - discount_pct/100)
  taxable BOOLEAN DEFAULT true,
  position INTEGER NOT NULL           -- display order
)

invoice_sequences (
  tenant_id UUID NOT NULL,
  type TEXT NOT NULL,                  -- 'invoice' | 'proforma'
  last_number INTEGER NOT NULL DEFAULT 0,
  prefix TEXT DEFAULT '',              -- e.g. 'INV-' or '2025-'
  PRIMARY KEY (tenant_id, type)
)

-- Tenant invoice-creation default, OWNED HERE (invoice domain owner, wave 6). Base tenant_settings
-- table (id + tenant_id UNIQUE FK + timestamps) owned by foundation-auth-rbac; additive idempotent
-- ALTER. Read by every invoice-creation flow (bulk-invoice-generation, proposal-to-invoice-direct,
-- ar-aging-report, customer-statement); invoice-settings-page is the editor, not the owner.
ALTER TABLE tenant_settings
  ADD COLUMN IF NOT EXISTS default_payment_terms_days INTEGER NOT NULL DEFAULT 30;

-- Credit notes (חשבונית זיכוי) are invoices with source = 'credit_note' and negative totals
-- linked via parent_invoice_id:
ALTER TABLE invoices ADD COLUMN parent_invoice_id UUID;
-- VOID support:
ALTER TABLE invoices ADD COLUMN void_reason TEXT;       -- populated when status = 'VOID'
ALTER TABLE invoices ADD COLUMN voided_at TIMESTAMPTZ;  -- timestamp of void transition
ALTER TABLE invoices ADD COLUMN voided_by UUID REFERENCES users(id);
-- Payment denormalization, OWNED HERE (invoice domain owner, wave 6). partial-payment-recording
-- (spec 80) reads/writes it and sets PARTIALLY_PAID when amount_paid > 0 AND amount_paid < total.
ALTER TABLE invoices ADD COLUMN amount_paid NUMERIC(12,2) NOT NULL DEFAULT 0;

-- Status CHECK constraint (includes the spec 168 collection-outcome terminals):
ALTER TABLE invoices ADD CONSTRAINT invoices_status_check CHECK (
  status IN (
    'DRAFT', 'SENT', 'APPROVED', 'REJECTED', 'TAX_ISSUED',
    'PAID', 'PARTIALLY_PAID', 'VOID', 'WRITTEN_OFF', 'BAD_DEBT'
  )
);
-- WRITTEN_OFF / BAD_DEBT added by spec 168 (bad-debt-writeoff); reachable only from
-- TAX_ISSUED or PARTIALLY_PAID. BAD_DEBT transition requires the OWNER role.
```

### Sequential numbering

```ts
// atomic: select-for-update invoice_sequences, increment, return
async function nextInvoiceNumber(db, tenantId, type): Promise<string> {
  // transaction: UPDATE invoice_sequences SET last_number = last_number + 1
  //              WHERE tenant_id = ? AND type = ?
  //              RETURNING prefix, last_number
  // Returns: prefix + padded(last_number, 5) e.g. 'INV-00042'
}
```

Sequential number assigned at state transitions (`SENT` → proforma number, `TAX_ISSUED` → invoice number), never in draft.

> **Atomicity requirement:** number assignment and the status update to `SENT` (or `TAX_ISSUED`) must be the same DB transaction. Gap or double-assignment if separate. IL law requires gap-free sequential numbering.

---

## Features

### Invoice list (`/invoices`)

Table: `DataTable` with columns: Number, Customer, Project, Amount, Status, Issue date, Due date, Actions.

Status badges: color-coded per state. Filter: status, customer, project, date range, source. Sort: issue date, amount, number.

"Create invoice" button → sheet form.

### List Performance

Invoice list uses **cursor-based pagination** (not offset):

```ts
// API: GET /api/invoices?cursor={encodedCursor}&limit=50
interface InvoiceListResponse {
  items: Invoice[]
  nextCursor: string | null  // null = last page
  total: number              // total count for "Showing X of N"
}
// Cursor = base64(JSON({ id, createdAt })) — stable under concurrent inserts
```

**Virtual scroll (TanStack Virtual):** when list > 200 rows, activate virtual row rendering. Row height: 56px fixed. Overscan: 5 rows.

```tsx
const rowVirtualizer = useVirtualizer({
  count: invoices.length,
  getScrollElement: () => tableContainerRef.current,
  estimateSize: () => 56,
  overscan: 5,
})
```

**Invariant:** the invoice list API must never return more than 100 rows per request. Clients that need export-all use the `/api/invoices/export` endpoint (spec 71), not the list endpoint.

### Invoice detail (`/invoices/:id`)

Preview panel (PDF-like): shows formatted invoice with logo, business details, customer details, line items, VAT breakdown, totals.

Status timeline at top: DRAFT → SENT → APPROVED → TAX_ISSUED → PAID (with timestamps). VOID is a terminal state reachable from DRAFT or SENT only.

Actions vary by state:
- `DRAFT`: Edit, Send (→ SENT), Delete, Void (→ VOID)
- `SENT`: Approve (staff on behalf of customer), Reject, Resend, Preview, Void (→ VOID)
- `APPROVED`: Issue Tax Invoice (→ TAX_ISSUED)
  Tracked product lines with a non-null `products.stock_item_id` decrement inventory inside the same DB transaction, one stock commit per invoice line, before any async adapter sync is enqueued.
- `TAX_ISSUED`: Record payment (→ PAID / PARTIALLY_PAID), Issue Credit Note, Open/Print Invoice
- `PARTIALLY_PAID`: Record additional payment (→ PAID or remains PARTIALLY_PAID), Issue Credit Note
- `PAID`: Open/Print Invoice, Issue Credit Note
- `VOID`: Read-only. Shows void reason. Cannot be reopened.
  Voiding a DRAFT or SENT invoice also runs the host inventory rollback for any tracked lines in the same transaction before the status flips to `VOID`.
- `REJECTED`: Reopen to DRAFT (staff edits and resends)

### Create / edit invoice

Sheet form:
- Customer (required), Project (optional)
- Issue date, Due date
- Line items: description, quantity, unit price, discount %, line total (computed). Drag to reorder.
- Notes (free text, shown on invoice)
- "Add time entries" — pulls in unbilled time entries for selected project

**Inbound pre-fill (consuming side of upstream handoffs):** the New Invoice form (`/invoices/new`) accepts a pre-filled customer + line items from callers, populating the same sheet a manual create uses:
- `?contract_id=` — from contract → invoice (spec 76)
- `?proposal_id=` — from accepted proposal (spec 107)
- `?project_id=&from_time=true` — from time entries (spec 77; seeds `billedEntryIds[]`)
- milestone → invoice (spec 132): seeds customer + a line for the milestone name at its amount
- `source = 'retainer'` auto-gen lines (see Automated Invoice Generation below)

All callers land on the standard create sheet with fields pre-seeded; staff can edit before saving.

### Invoice HTML generation

Hono route `GET /api/invoices/:id/html` → renders HTML invoice template server-side → returns `text/html` with print CSS.

**Implementation**: Handlebars/string template rendered in Worker (no puppeteer, no browser binding). Client opens in new tab → browser print dialog → save as PDF. No Cloudflare Browser Rendering binding required.

On `TAX_ISSUED`: snapshot of rendered HTML stored in R2 as immutable record.

R2 key: `{tenantId}/invoices/{invoiceId}/{type}-{number}.html`

Served with `Content-Disposition: inline; filename="invoice-{number}.html"` for direct open + print.

### PDF Locale (Hebrew / RTL Support)

Invoice HTML generation must produce locale-correct output for Hebrew tenants. The `GET /api/invoices/:id/html` route checks `tenants.locale`:

**HTML template `<html>` element:**

```html
<!-- Hebrew tenant -->
<html dir="rtl" lang="he">

<!-- English tenant -->
<html dir="ltr" lang="en">
```

**Hebrew font embed:** System fonts in browser print may not include Heebo. Embed via `@font-face` in the print CSS:

```css
@font-face {
  font-family: 'Heebo';
  src: url('https://{R2_PUBLIC_URL}/fonts/heebo-variable.woff2') format('woff2');
  font-display: block; /* block in print — no FOIT during PDF generation */
}

body { font-family: 'Heebo', Arial, sans-serif; }
```

Font file stored in R2 at `_static/fonts/heebo-variable.woff2`. The Worker fetches it once at cold start (cached in memory). Font size: ~120KB — acceptable for a print document.

**Number and date formatting in template:**

```ts
const locale = tenant.locale === 'he' ? 'he-IL' : 'en-US'
const amount = new Intl.NumberFormat(locale, { style: 'currency', currency: 'ILS' }).format(invoice.total)
const date = new Intl.DateTimeFormat(locale, { dateStyle: 'long' }).format(invoice.taxIssueDate)
```

**RTL table layout:** Use `dir="rtl"` on `<table>` for Hebrew; columns render right-to-left (Number | Amount | Description vs. Description | Amount | Number). Column order in the HTML should be logical (description first), `dir` handles visual reversal.

**R2 snapshot locale flag:** When storing the HTML snapshot on `TAX_ISSUED`, include `lang` in the R2 key:
`{tenantId}/invoices/{invoiceId}/tax-invoice-{number}-{lang}.html`

### Outbound Email Locale

Invoice and reminder emails use `tenant.settings.locale` for subject line and body language.

```ts
// Locale-aware invoice email subject:
const subject = locale === 'he-IL'
  ? `חשבונית מס ${invoice.taxInvoiceNumber} מ-${tenant.businessName}`
  : `Invoice ${invoice.taxInvoiceNumber} from ${tenant.businessName}`

// Email body: locale-keyed template
// Hebrew: template key 'invoice_sent_he', root element <html dir="rtl" lang="he">
// English: template key 'invoice_sent_en', root element <html lang="en">
// Both templates defined in spec 66 (email-template-editor)
```

Payment reminder emails (spec 79) follow the same pattern — locale from `tenant.settings.locale`. Default when locale is unset: `'he-IL'` (Israel-first market).

---

## Automated Invoice Generation

### Retainer: hour bank depleted

When `retainer_months.hours_used >= retainer_months.hours_included` and `auto_invoice = true` (from `billing_config`):

1. Worker enqueues `invoice.generate` job
2. Consumer creates invoice with `source = 'retainer'` and pre-filled lines:
   - "Retainer: {project.name} — {month}" at `monthly_amount`
   - If `overflow_action = 'invoice'`: second line for overtime hours × hourly rate
3. Status: `DRAFT` (staff reviews before sending) or `SENT` directly (configurable per project)

### Task status trigger

Configurable in `/settings/integrations/invoicing`: "Generate invoice when task reaches status X".  
E.g. task marked `DONE` in a fixed-price project → auto-generate deposit or final invoice.

### Fixed price: deposit invoice

At project creation, if `billing_config.deposit_pct > 0`: option to auto-create a deposit invoice for `total_amount × deposit_pct / 100`. Stored as draft, staff sends manually.

---

## Time Entry Billing

`GET /api/invoices/unbilled-time?projectId={id}` returns unbilled time entries for a project (those with `invoice_id IS NULL`). This is the data source; the full selection UX (grouping options, the `/invoices/new?project_id={id}&from_time=true` entry, and the `billedEntryIds[]` field on `POST /api/invoices`) is **owned by spec 77 (`time-to-invoice`)** — invoices-core does not redefine it. The New Invoice form's "Add time entries" control and inbound `from_time` query-param are the consuming side of spec 77's handoff.

On invoice creation, each selected entry creates an invoice line (description = entry description, quantity = hours, unit_price = project hourly rate) and sets `time_entries.invoice_id` to prevent double-billing.

> **Schema:** `time_entries.invoice_id` and `time_entries.billed_at` are owned by spec 77 (`ALTER TABLE time_entries ADD COLUMN invoice_id UUID REFERENCES invoices(id) ON DELETE SET NULL`, `billed_at TIMESTAMPTZ`) — see the migration-order note in `00-index.md`. invoices-core does **not** add its own `invoice_id` column.

---

## Permissions

| Action | Required permission |
|--------|-------------------|
| View invoices | `invoices:read` |
| Create / edit draft | `invoices:write` |
| Send / approve / issue tax invoice | `invoices:write` |
| Record payment | `invoices:write` |
| Issue credit note | `invoices:write` |
| Delete draft | `invoices:delete` |

---

## API Endpoints

```
GET    /api/invoices                        → list (paginated, filterable)
POST   /api/invoices                        → create draft
GET    /api/invoices/:id                    → detail + lines
PATCH  /api/invoices/:id                    → edit draft
DELETE /api/invoices/:id                    → delete draft only
POST   /api/invoices/:id/send               → DRAFT → SENT (assigns proforma number)
POST   /api/invoices/:id/approve            → SENT → APPROVED
POST   /api/invoices/:id/reject             → SENT → REJECTED (+ reason)
POST   /api/invoices/:id/issue-tax          → APPROVED → TAX_ISSUED (assigns invoice number)
                                              Also posts tracked inventory movements inline, per invoice line, in the same transaction; adapter push remains a later queue step.
POST   /api/invoices/:id/record-payment     → TAX_ISSUED → PAID (+ payment method, amount, date)
POST   /api/invoices/:id/credit-note        → creates DRAFT credit note (see invoice-credit-notes spec; issue via POST /:creditNoteId/issue)
POST   /api/invoices/:id/void               → DRAFT or SENT → VOID; body: { reason: string } (required)
                                              Reverses tracked inventory movements inline in the same transaction before the invoice is marked VOID.
                                              Returns 409 if invoice is TAX_ISSUED or later (cannot void issued invoice — use credit note)
                                              Requires: invoices:write; OWNER or ADMIN role (voiding is a privileged action)
GET    /api/invoices/:id/html               → render invoice HTML (print-to-PDF in browser); TAX_ISSUED serves R2 snapshot
GET    /api/invoices/unbilled-time          → unbilled time entries for project
POST   /api/invoices/auto-issue             → internal endpoint for billing module: creates DRAFT + assigns invoice number + transitions to TAX_ISSUED in one transaction. Source = 'auto_charge'. Body: { customerId, projectId?, lines[], paymentMethodId }. Returns { invoiceId, invoiceNumber }. Skips SENT/APPROVED steps (payment plan is customer's pre-approval).
```

---

## Architecture Decisions

| Decision | Choice | Reason |
|----------|--------|--------|
| Two-stage invoice flow | חשבונית עסקה → חשבונית מס | IL legal requirement; proforma is pre-tax, tax invoice is binding |
| Sequential numbers on state transition | Assigned at SENT / TAX_ISSUED | Numbers must be gap-free; can't assign at draft time (drafts may be discarded) |
| Credit notes as invoices | `source = 'credit_note'`, negative totals | Reuse invoice data model; simpler query for ledger |
| HTML not PDF | Server renders HTML; browser prints to PDF | Workers have no native PDF generation; CF Browser Rendering binding is beta/costly; HTML+print CSS achieves same result with zero extra bindings |
| HTML snapshot in R2 on TAX_ISSUED | Store rendered HTML, not PDF | Immutable record; client can still print; no browser binding needed |

Rationale (2026-07-05): tracked inventory posting now runs inline with `APPROVED → TAX_ISSUED`, and void rollback runs inline with `DRAFT|SENT → VOID`, so document-state changes and stock-state changes cannot diverge on queue timing or `waitUntil` behavior.
| VAT rate immutable on invoice | Stored at TAX-ISSUE date | Israeli tax point is tax-invoice issuance; rate locked at `taxIssueDate` |
| `auto-issue` endpoint skips SENT/APPROVED | Payment plan IS the approval | Recurring billing: customer pre-approved charge when they signed up for plan; skipping the 2-step approval is legally valid when documented consent exists |
| Time entry link | `invoice_id` on `time_entries` | Prevents double-billing; simple unbilled query |
