# ITA E-Invoice Registration (חשבונית ממוחשבת) — Implementation Plan

**Spec:** docs/specs/2026-06-01-ita-einvoice.md  ·  **Slug:** ita-einvoice  ·  **Wave:** 10
**Depends on:** foundation-auth-rbac, invoices-core, settings-module

## Goal
Israeli Tax Authority (ITA / רשות המסים) requires VAT-registered businesses above an annual revenue threshold to register each tax invoice (חשבונית מס) with the ITA "Shaba" (שב"א) central registry before delivering it, receiving an 8-digit confirmation number (מספר אישור) that must be printed on the invoice. This spec extends the existing `POST /api/invoices/:id/issue-tax` lifecycle to synchronously call the ITA Shaba API at the `APPROVED → TAX_ISSUED` transition for qualifying invoices, store the returned confirmation number, block issuance on ITA failure (legal mandate — no async retry), render the confirmation number on the invoice document, and expose per-tenant ITA settings (enable flag, VAT number, mTLS certificate) plus an admin-configurable threshold.

## Architecture
The feature is a delivery-format/compliance extension layered onto `invoices-core`. It consumes:
- Upstream table **`invoices`** (cols `id, tenant_id, status, source, total, vat_amount, invoice_number, issue_date, currency, customer_id` and the `invoices_status_check` constraint) — extended here with `ita_confirmation_number` and `ita_registered_at`.
- Upstream table **`invoice_lines`** (cols `description, quantity, unit_price, line_total, position`) — read to build the ITA payload `lineItems`.
- Upstream table **`tenants`** — extended here with `ita_registration_enabled` and `ita_vat_number` (scalar config columns, same pattern as `tenants.country_code` / `tenants.default_currency`). The `issueInvoice` handler reads these directly off the tenant object so no new join path is invented.
- Upstream table **`adapter_credentials`** (cols `id, tenant_id, adapter_id, credentials BYTEA, UNIQUE(tenant_id, adapter_id)`) — stores the mTLS certificate + password under `adapter_id = 'ita_shaba'`, AES-256-GCM encrypted via the upstream `saveAdapterCredential` / `loadAdapterCredential` helpers (same pattern as Gmail/Telegram/SMTP secrets).
- Upstream auth: `authMiddleware`, `requirePermission('settings:write')`, `tenantQuery`.
- Upstream `customers` table read for `customerVatNumber` (the customer's VAT number, if VAT-registered).

New owned objects:
- **`system_config`** — a global key/value KV table (admin-configurable, no UUID PK — `key` is the PK so the seed insert and lookups work). Seeded with `ita_threshold_ils = '20000'`. Also consumed downstream by `bituach-leumi` for NII rates, so the shape is a generic KV store.
- **`packages/country-il/ita-shaba`** adapter module: `registerWithITA`, `testITAConnection`, `ITARegistrationError`, `getITAThreshold`, payload builders.
- Settings routes `GET /api/settings/ita/test`, `PATCH /api/settings/ita`.

Data flow at issue-tax:
`POST /api/invoices/:id/issue-tax` → load invoice + tenant → compute `requiresITA = total >= getITAThreshold() && tenant.ita_vat_number != null && tenant.ita_registration_enabled` → if required, `registerWithITA()` (mTLS POST to Shaba) → on success persist `ita_confirmation_number` + `ita_registered_at` in the SAME transaction that flips status to `TAX_ISSUED`; on failure throw `ITARegistrationError` mapped to 503/400/409, invoice stays `APPROVED` for manual retry → render confirmation number on `/api/invoices/:id/html`.

## Tech Stack
- **apps/zync-api** (Hono on Cloudflare Workers): extend `src/routes/invoices/issue-tax.ts`; new `src/routes/settings/ita.ts`.
- **packages/country-il** (or `packages/public-api`-adjacent country adapter package; reuse the `CountryAdapter` pattern): new `ita-shaba/` client using `fetch` with an mTLS client certificate. Cloudflare Workers mTLS is provided via a configured **mTLS certificate binding** (`env.ITA_MTLS_CERT`) OR per-tenant cert material decrypted from `adapter_credentials` and passed to a `fetch` with `cf: { mtls: { certId } }` — implement via the per-tenant decrypted cert (each business registers its own cert at ITA).
- **packages/db** (Drizzle): schema deltas for `invoices`, `tenants`; new `system_config` table + Drizzle model.
- **apps/zync-app** (Vite + React): `/settings/invoicing` ITA section component.
- **packages/types**: `ITAConfig`, `ITARegisterRequest`, `ITARegisterResponse`, `ITATestResult` types.
- Bindings: Hyperdrive (Postgres), `adapter_credentials` encryption key (existing secret used by `encryptCredential`/`decryptCredential`).
- Cross-cutting: timing-safe handling of cert password (never logged); CSP unchanged; settings UI a11y (labelled inputs, `aria-live` for test-connection result); RTL/Hebrew bilingual labels (מספר אישור) on the invoice document and settings; `prefers-reduced-motion` respected on any spinner during the synchronous ITA call.

## Wave Plan
| Sub-wave | Tasks | Files touched | Parallelizable? |
|----------|-------|---------------|-----------------|
| 10a | 1, 2 | packages/db schema + migration, system_config seed | Yes (schema) |
| 10b | 3, 4 | packages/types, packages/country-il/ita-shaba adapter | Task 4 after 1–3 |
| 10c | 5 | apps/zync-api issue-tax handler | After 1,3,4 |
| 10d | 6, 7 | apps/zync-api settings/ita routes | After 4; parallel with 5 |
| 10e | 8 | apps/zync-api invoice HTML template | After 1 |
| 10f | 9 | apps/zync-app settings UI | After 6,7 |

## Tasks

### Task 1: Schema delta — `invoices` and `tenants` columns
**Blocks:** 3, 5, 8  ·  **Blocked by:** —
**Files:**
- Create: `packages/db/migrations/<timestamp>_ita_einvoice.sql`
- Modify: `packages/db/src/schema/invoices.ts`
- Modify: `packages/db/src/schema/tenants.ts`
**Steps:**
- [ ] Add `ita_confirmation_number` and `ita_registered_at` to `invoices` (the invoice document and issue handler read these).
- [ ] Add `ita_registration_enabled` and `ita_vat_number` to `tenants` (scalar config columns, same pattern as `tenants.country_code`). These are tenant **tax-registration identity** (read together in `issueInvoice`), so they live on `tenants` alongside `country_code`/`default_currency`, NOT on `tenant_settings` (which holds module behavior config). The spec's `ALTER TABLE tenant_settings` is reconciled to `tenants` for this identity pair.
- [ ] The handler checks `tenant.ita_vat_number != null` (single source of truth — do not reference a `tenants.vat_number` column, which is not canonical).
- [ ] Mirror all columns in the Drizzle schema models with matching types.
**Schema / Interfaces:**
```sql
ALTER TABLE invoices ADD COLUMN ita_confirmation_number TEXT;
  -- Populated when ITA registration succeeds; NULL for exempt or pre-feature invoices.
ALTER TABLE invoices ADD COLUMN ita_registered_at TIMESTAMPTZ;
  -- Timestamp of successful ITA registration.

ALTER TABLE tenants ADD COLUMN ita_registration_enabled BOOLEAN NOT NULL DEFAULT false;
  -- Tenant must explicitly opt in; some tenants are exempt by size.
ALTER TABLE tenants ADD COLUMN ita_vat_number TEXT;
  -- Tenant's 9-digit VAT registration number (עוסק מורשה / ח.פ.).
```
**Acceptance:**
- [ ] Migration applies on Neon Postgres; `invoices` has both ITA columns nullable; `tenants` has both ITA columns with the boolean defaulting to `false`.
- [ ] Drizzle types compile; the ITA identity columns are on `tenants`, not duplicated onto `tenant_settings`.

### Task 2: `system_config` KV table + threshold seed
**Blocks:** 4  ·  **Blocked by:** —
**Files:**
- Create: `packages/db/src/schema/system-config.ts`
- Modify: `packages/db/migrations/<timestamp>_ita_einvoice.sql` (same migration as Task 1)
**Steps:**
- [ ] Create the `system_config` global key/value table — `key` is the primary key (NOT a UUID id), so the seed insert and key lookups work and downstream `bituach-leumi` can reuse it for NII rates.
- [ ] Seed `ita_threshold_ils = '20000'` idempotently (`ON CONFLICT (key) DO NOTHING`).
- [ ] Tighten `updated_at` to `TIMESTAMPTZ NOT NULL DEFAULT now()`.
**Schema / Interfaces:**
```sql
CREATE TABLE IF NOT EXISTS system_config (
  key        TEXT PRIMARY KEY,
  value      TEXT NOT NULL,
  updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

INSERT INTO system_config (key, value) VALUES
  ('ita_threshold_ils', '20000')
ON CONFLICT (key) DO NOTHING;
```
**Acceptance:**
- [ ] Table exists with `key` as PK (a row insert with a duplicate key is rejected by the PK, not by a separate unique).
- [ ] `SELECT value FROM system_config WHERE key = 'ita_threshold_ils'` returns `'20000'` after migration.

### Task 3: Types — ITA payload, response, config, errors
**Blocks:** 4, 5, 6, 7, 9  ·  **Blocked by:** 1
**Files:**
- Create: `packages/types/src/ita.ts`
- Modify: `packages/types/src/index.ts` (export the new types)
**Steps:**
- [ ] Define request/response/config/test types matching the Shaba payload exactly (amounts in smallest currency unit — agorot — i.e. ILS × 100).
- [ ] Define the discriminated `ITAErrorCode` union used by the error map (Task 5).
**Schema / Interfaces:**
```ts
export interface ITARegisterRequest {
  taxableEntityID: string        // tenant's 9-digit VAT number (ita_vat_number)
  invoiceReferenceNumber: string // invoices.invoice_number
  customerVatNumber?: string     // customer's VAT number if VAT-registered
  invoiceDate: string            // YYYY-MM-DD (invoices.issue_date)
  invoiceType: 305 | 320         // 305 tax invoice; 320 credit note
  currencyCode: string           // 'ILS'
  invoiceTotal: number           // smallest unit: round(total * 100)
  vatTotal: number               // smallest unit: round(vat_amount * 100)
  lineItems: ITALineItem[]
}
export interface ITALineItem {
  lineSequenceNumber: number     // invoice_lines.position (1-based)
  description: string
  quantity: number
  unitPrice: number              // smallest unit
  lineTotal: number              // smallest unit
}
export interface ITARegisterResponse {
  confirmationNumber: string     // up to 8 digits
  status: string                 // 'OK' on success
}
export type ITAErrorCode =
  | 'ita_auth_error'             // cert expired/invalid → 503
  | 'ita_service_unavailable'    // ITA 5xx → 503
  | 'ita_validation_error'       // ITA 4xx invalid data → 400
  | 'ita_duplicate'              // duplicate invoice number → 409
export interface ITAConfig {
  ita_registration_enabled: boolean
  ita_vat_number: string | null
  certificate_uploaded: boolean
  threshold_ils: number
}
export interface ITATestResult {
  success: boolean
  error?: string
  endpoint: string
}
```
**Acceptance:**
- [ ] Types are exported from `@zync/types` and importable by api/app/country packages.

### Task 4: ITA Shaba adapter — `registerWithITA`, `testITAConnection`, `getITAThreshold`, `ITARegistrationError`
**Blocks:** 5, 6  ·  **Blocked by:** 2, 3
**Files:**
- Create: `packages/country-il/src/ita-shaba/client.ts`
- Create: `packages/country-il/src/ita-shaba/errors.ts`
- Create: `packages/country-il/src/ita-shaba/threshold.ts`
- Modify: `packages/country-il/src/index.ts` (export the adapter surface)
**Steps:**
- [ ] Implement `getITAThreshold(db)` — read `system_config.ita_threshold_ils`, parse to a number; fall back to a sane default only if the row is missing (it is seeded in Task 2).
- [ ] Implement `buildITAPayload(invoice, lines, tenant, customer, invoiceType)` — convert NUMERIC ILS amounts to smallest unit via `Math.round(value * 100)`; map `invoice_lines.position` → `lineSequenceNumber`; set `customerVatNumber` only when the customer has a VAT number.
- [ ] Implement `registerWithITA(invoice, tenant, env, invoiceType)` — load the tenant's mTLS cert from `adapter_credentials` (`adapter_id = 'ita_shaba'`) via `loadAdapterCredential` + `decryptCredential`; POST the payload to the Shaba endpoint over mTLS; parse the response. Endpoint base from `env.ITA_SHABA_BASE_URL` (sandbox `https://openapi.taxes.gov.il/shaam/tsandbox`, live `https://openapi.taxes.gov.il/shaam/openapi`). Never log the cert password.
- [ ] Map HTTP outcomes to `ITARegistrationError` with the correct `ITAErrorCode`: cert/TLS auth failure → `ita_auth_error`; response 5xx → `ita_service_unavailable`; 4xx invalid data → `ita_validation_error` (carry the ITA error message); explicit duplicate → `ita_duplicate`.
- [ ] Implement `testITAConnection(tenant, env)` — perform a lightweight authenticated reachability call with the stored cert; return `{ success, error?, endpoint }`.
- [ ] `ITARegistrationError extends Error` carries `{ code: ITAErrorCode, details: string }`.
**Schema / Interfaces:**
```ts
export class ITARegistrationError extends Error {
  code: ITAErrorCode
  details: string
  constructor(code: ITAErrorCode, details: string)
}
export function getITAThreshold(db: Db): Promise<number>
export function buildITAPayload(
  invoice: InvoiceRow, lines: InvoiceLineRow[], tenant: TenantRow,
  customer: CustomerRow | null, invoiceType: 305 | 320,
): ITARegisterRequest
export function registerWithITA(
  invoice: InvoiceRow, tenant: TenantRow, env: Env, invoiceType: 305 | 320,
): Promise<{ success: true; confirmationNumber: string } | { success: false; error: string; code: ITAErrorCode }>
export function testITAConnection(tenant: TenantRow, env: Env): Promise<ITATestResult>
```
**Acceptance:**
- [ ] `getITAThreshold` returns `20000` against the seeded config.
- [ ] `buildITAPayload` produces `invoiceTotal` / `vatTotal` / `unitPrice` / `lineTotal` in agorot (×100, rounded) and sets `invoiceType` per the credit-note branch.
- [ ] A simulated cert failure yields `ITARegistrationError` with `code = 'ita_auth_error'`; the cert password never appears in any thrown message or log.

### Task 5: Extend `issue-tax` handler with ITA registration
**Blocks:** —  ·  **Blocked by:** 1, 3, 4
**Files:**
- Modify: `apps/zync-api/src/routes/invoices/issue-tax.ts`
**Steps:**
- [ ] After loading the invoice and tenant (and before flipping to `TAX_ISSUED`), compute `requiresITA = invoice.total >= await getITAThreshold(db) && tenant.ita_vat_number != null && tenant.ita_registration_enabled`.
- [ ] Determine `itaInvoiceType = invoice.source === 'credit_note' ? 320 : 305` (read `invoices.source`).
- [ ] If `requiresITA`, call `registerWithITA(invoice, tenant, env, itaInvoiceType)`. On failure throw `ITARegistrationError`; do NOT transition status — invoice stays `APPROVED` for manual retry.
- [ ] On success, within the SAME transaction that performs the `TAX_ISSUED` transition and assigns the invoice number, set `ita_confirmation_number = :confirmationNumber, ita_registered_at = now()`. (Honor invoices-core atomicity requirement: number assignment + status update + confirmation number are one DB transaction.)
- [ ] If `requiresITA` is false, proceed with the unmodified `TAX_ISSUED` transition (confirmation number stays NULL).
- [ ] Map `ITARegistrationError` to HTTP at the route boundary per the error table below; response body `{ error: <code>, details }`.
- [ ] Validate input with the existing zod pattern (`require-zod-validation-in-routes`); never bypass `authMiddleware` / `requirePermission('invoices:write')`.
**Schema / Interfaces:**
```ts
// Error → HTTP mapping (route boundary):
// ita_auth_error          → 503  { error: 'ita_auth_error', details }
// ita_service_unavailable → 503  { error: 'ita_service_unavailable', details }
// ita_validation_error    → 400  { error: 'ita_validation_error', details }
// ita_duplicate           → 409  { error: 'ita_duplicate', details }
export async function issueInvoice(invoiceId: string, tenantId: string, env: Env): Promise<void>
```
**Acceptance:**
- [ ] Qualifying invoice (total ≥ threshold, tenant enabled + has VAT number): on ITA success the invoice becomes `TAX_ISSUED` with `ita_confirmation_number` and `ita_registered_at` set in one transaction.
- [ ] On ITA `ita_service_unavailable`, the endpoint returns 503, the invoice remains `APPROVED`, and no invoice number is consumed.
- [ ] `source = 'credit_note'` sends `invoiceType = 320`; all other sources send `305`.
- [ ] Below-threshold or disabled tenant issues normally with `ita_confirmation_number = NULL`.

### Task 6: `GET /api/settings/ita/test` route
**Blocks:** 9  ·  **Blocked by:** 4
**Files:**
- Create: `apps/zync-api/src/routes/settings/ita.ts`
- Modify: `apps/zync-api/src/routes/settings/index.ts` (mount)
**Steps:**
- [ ] Guard with `authMiddleware` + `requirePermission('settings:write')`.
- [ ] Load the current tenant, call `testITAConnection(tenant, env)`, return `{ success, error?, endpoint }`.
**Schema / Interfaces:**
```
GET /api/settings/ita/test
  → { success: boolean, error?: string, endpoint: string }
  Requires: settings:write
```
**Acceptance:**
- [ ] With a valid stored cert, returns `{ success: true, endpoint }`; with an invalid/expired cert, returns `{ success: false, error, endpoint }`.
- [ ] Without `settings:write`, returns 403.

### Task 7: `PATCH /api/settings/ita` route (config + cert upload)
**Blocks:** 9  ·  **Blocked by:** 4
**Files:**
- Modify: `apps/zync-api/src/routes/settings/ita.ts`
**Steps:**
- [ ] Guard with `authMiddleware` + `requirePermission('settings:write')`; zod-validate the body.
- [ ] Update `tenants.ita_vat_number` and/or `tenants.ita_registration_enabled` per the supplied fields (partial update).
- [ ] When a certificate file (.p12/.pfx) + password are supplied, encrypt the cert material via `encryptCredential` and upsert into `adapter_credentials` (`adapter_id = 'ita_shaba'`, `UNIQUE(tenant_id, adapter_id)`) via `saveAdapterCredential`. Cert password is included in the encrypted blob, never stored plaintext, never logged.
- [ ] Return the resulting `ITAConfig` (with `certificate_uploaded` derived from presence of an `ita_shaba` credential and `threshold_ils` from `getITAThreshold`).
**Schema / Interfaces:**
```
PATCH /api/settings/ita
  body: { vat_number?: string, ita_registration_enabled?: boolean,
          certificate?: <base64 .p12/.pfx>, certificate_password?: string }
  → ITAConfig
  Requires: settings:write
```
**Acceptance:**
- [ ] Setting `ita_registration_enabled = true` persists on `tenants`; `vat_number` persists to `tenants.ita_vat_number`.
- [ ] Uploading a cert creates/updates the `ita_shaba` row in `adapter_credentials` with encrypted bytes; `GET /api/settings/ita/test` then succeeds.
- [ ] Cert password never appears in DB plaintext, logs, or the response body.

### Task 8: Invoice document — render ITA confirmation number
**Blocks:** —  ·  **Blocked by:** 1
**Files:**
- Modify: `apps/zync-api/src/routes/invoices/html.ts` (invoice HTML template)
**Steps:**
- [ ] When `invoice.ita_confirmation_number IS NOT NULL`, render the bilingual confirmation block below the totals section and above notes.
- [ ] Hebrew label `מספר אישור:` and English `ITA Confirmation:` both shown; ensure correct RTL direction for the Hebrew line.
- [ ] For `TAX_ISSUED` invoices that serve the immutable R2 HTML snapshot, ensure the confirmation number is included in the snapshot captured at issue time (it is persisted before the transition completes in Task 5).
**Schema / Interfaces:**
```html
<div class="ita-confirmation">
  <span dir="rtl">מספר אישור: {{ ita_confirmation_number }}</span>
  <span>ITA Confirmation: {{ ita_confirmation_number }}</span>
</div>
```
**Acceptance:**
- [ ] An issued invoice with a confirmation number shows the block (positioned below totals, above notes) in both Hebrew and English; an invoice without one omits the block entirely.
- [ ] The R2 snapshot for a registered `TAX_ISSUED` invoice contains the confirmation number.

### Task 9: Settings UI — ITA E-Invoice Registration section
**Blocks:** —  ·  **Blocked by:** 3, 6, 7
**Files:**
- Create: `apps/zync-app/src/pages/settings/invoicing/ITASettingsSection.tsx`
- Modify: `apps/zync-app/src/pages/settings/invoicing/index.tsx` (mount section)
**Steps:**
- [ ] Render the section in `/settings/invoicing` only when `tenant.tier !== 'freelancer'` (Freelancer businesses are typically below threshold).
- [ ] Controls: enable checkbox (`ita_registration_enabled`), VAT-number input (`עוסק מורשה`), certificate upload (.p12/.pfx) with uploaded indicator, certificate-password input (masked), "Test connection" button, and a read-only threshold line showing the current `threshold_ils` (e.g. "Invoices ≥ ₪20,000 (total) require registration").
- [ ] Use the design-system primitives (`Checkbox`, `Input`, `Button`, `Switch` as appropriate); no raw HTML controls (`no-raw-html-in-pages`); no hardcoded colors/spacing.
- [ ] Wire enable/VAT/cert changes to `PATCH /api/settings/ita`; wire the button to `GET /api/settings/ita/test` and surface the result in an `aria-live="polite"` region (`✓ ITA Shaba API reachable` / error text). Respect `prefers-reduced-motion` on any pending spinner.
- [ ] All labels bilingual / RTL-aware (Hebrew `חשבונית ממוחשבת`, `עוסק מורשה`).
**Acceptance:**
- [ ] Section is hidden for `freelancer` tier, visible otherwise.
- [ ] Toggling enable, saving a VAT number, and uploading a cert all round-trip via `PATCH /api/settings/ita`; "Test connection" shows a live success/error message in an accessible region.
- [ ] All inputs are keyboard-reachable and labelled; no accessibility violations on the section.
