# Bad Debt Write-Off (חוב אבוד) — Implementation Plan

**Spec:** docs/specs/2026-06-01-bad-debt-writeoff.md  ·  **Slug:** bad-debt-writeoff  ·  **Wave:** 12
**Depends on:** foundation-auth-rbac, invoices-core, partial-payment-recording

## Goal
Let a tenant write off an unpaid (or partially paid) issued invoice as a bad debt (חוב אבוד) or a plain write-off, transition the invoice to the terminal `BAD_DEBT`/`WRITTEN_OFF` status, and track the Israeli VAT reclaim lifecycle (Section 24A: registered letter → ITA submission → resolution). When a written-off debtor pays later, the plan provides a recovery path that re-records payment, reverses or cancels the VAT reclaim, and re-creates the re-remittance obligation. It also delivers the `/reports/bad-debt` worklist screen plus a JSON + ExcelJS report used for annual income-tax calculation and ITA submissions.

## Architecture
This feature is a delta on top of `invoices-core` and `partial-payment-recording`. It extends the existing `invoices` table (no new status enum needed — `BAD_DEBT` and `WRITTEN_OFF` are already pinned into the `invoices_status_check` CHECK by invoices-core lines 119–127; this plan must NOT re-ALTER that constraint), adds three `bad_debt_*` columns, and introduces one new table `bad_debt_vat_reclaims`. The flagging threshold lives on the shared `tenant_settings` table (`bad_debt_threshold_days`), which is the established per-tenant settings table ALTER-ed by many sibling specs; settings-module (wave 9) owns its UI surface, so the column is available by build order at wave 12.

Data flow:
- **Write-off**: `POST /api/invoices/:id/write-off` validates eligibility (status ∈ {`TAX_ISSUED`,`PARTIALLY_PAID`}, age ≥ threshold, OWNER/ADMIN), flips status, stamps `bad_debt_at`/`bad_debt_reason`/`bad_debt_note`, and — when `log_vat_reclaim` is true and the tenant is VAT-registered — inserts a `bad_debt_vat_reclaims` row (`status='pending'`). Emits `invoice.written_off` webhook + OWNER notification, all inside one DB transaction with an audit entry.
- **Reclaim tracking**: `GET/PATCH /api/bad-debt-reclaims` drive the invoice-detail "VAT Reclaim" panel and the `/reports/bad-debt` worklist.
- **Recovery**: `POST /api/invoices/:id/record-recovery` records an `invoice_payments` row (spec 80 schema), recomputes balance/status via the partial-payment balance CTE, reverses (`reversed`) or cancels (`cancelled`) the reclaim, emits `invoice.recovered`, all transactional.

Upstream tables consumed: `invoices`, `invoice_lines`, `invoice_payments` (spec 80), `customers`, `tenants`, `tenant_settings`, `users`. Upstream exports consumed: `tenantQuery`, `requirePermission`, `serializeInvoice`, `createNotification`, `buildPaginated`, `clampLimit`, `Customer`, `InvoiceObject`, `InvoiceStatus`. The reclaimable VAT for a `PARTIALLY_PAID` invoice is NOT the full `invoices.vat_amount`; it is the VAT portion of the **unpaid** balance: `vat_amount * (total - amount_paid) / total` (the modal's ₪1,932 example is the zero-paid case). Webhook events ride the existing tenant-public-api outbound delivery (`webhook.deliver` queue) by `event_type` string, matching `invoice.issued`/`invoice.paid`.

## Tech Stack
- **API**: `apps/zync-api` (Hono on Cloudflare Workers). New routes mounted under the existing invoices + reports routers. Zod validation (`require-zod-validation-in-routes`), `tenantQuery` for all DB access (`no-raw-drizzle-from-routes`), audit-in-transaction (`require-audit-in-transaction`).
- **DB**: Neon Postgres via Hyperdrive, Drizzle ORM. New table `bad_debt_vat_reclaims`; column adds on `invoices` and `tenant_settings`.
- **Types**: `@zync/types` — `BadDebtReason`, `VatReclaimStatus`, `BadDebtVatReclaim`, `BadDebtReportData` plus request/response DTOs.
- **App UI**: `apps/zync-app` (Vite + React) — write-off confirmation modal, VAT-reclaim panel, recovery modal, `/reports/bad-debt` screen. Uses `@zync/ui` (`Dialog`, `Button`, `DataTable`, `Badge`, `Form`, `Input`, `Select`, `Checkbox`, `StatCard`). React Query hooks.
- **xlsx**: ExcelJS (established Workers-compatible choice — sibling tax reports use `worksheet.views[0].rightToLeft = true`).
- **Bindings**: Hyperdrive (DB), `QUEUE` (webhook.deliver), notifications adapter via `createNotification`.

## Wave Plan
| Sub-wave | Tasks | Files touched | Parallelizable? |
|----------|-------|---------------|-----------------|
| 12.a | Task 1 (schema), Task 2 (types) | `packages/db/src/schema/*`, migration, `packages/types/src/*` | Task 1 ‖ Task 2 |
| 12.b | Task 3 (write-off service+route), Task 4 (reclaim list/patch), Task 5 (recovery), Task 6 (report+xlsx) | `apps/zync-api/src/routes/invoices/*`, `apps/zync-api/src/routes/reports/*`, `apps/zync-api/src/services/bad-debt/*` | After 12.a; Tasks 3–6 mostly ‖ (3 blocks 5) |
| 12.c | Task 7 (write-off modal), Task 8 (VAT-reclaim panel), Task 9 (recovery modal), Task 10 (/reports/bad-debt screen) | `apps/zync-app/src/features/invoices/*`, `apps/zync-app/src/features/reports/bad-debt/*` | After 12.b; UI tasks ‖ |
| 12.d | Task 11 (settings wiring), Task 12 (i18n/RTL/a11y pass) | `apps/zync-app/src/features/settings/*`, locale files | After 12.c |

## Tasks

### Task 1: Schema — invoice columns, threshold column, `bad_debt_vat_reclaims` table
**Blocks:** 3, 4, 5, 6  ·  **Blocked by:** —
**Files:**
- Modify: `packages/db/src/schema/invoices.ts` (add bad-debt columns)
- Modify: `packages/db/src/schema/tenant-settings.ts` (add `bad_debt_threshold_days`)
- Create: `packages/db/src/schema/bad-debt-vat-reclaims.ts`
- Modify: `packages/db/src/schema/index.ts` (export new table)
- Create: `packages/db/migrations/<timestamp>_bad_debt_writeoff.sql`
**Steps:**
- [ ] Add the three bad-debt columns to the `invoices` Drizzle table and migration. Do NOT touch `invoices_status_check` — `BAD_DEBT`/`WRITTEN_OFF` are already in it.
- [ ] Add a CHECK on `bad_debt_reason` constraining it to the three reason values.
- [ ] Add `bad_debt_threshold_days` to `tenant_settings` (NOT NULL DEFAULT 90).
- [ ] Create the `bad_debt_vat_reclaims` table with the 6-value status CHECK and the tenant index.
- [ ] Define the Drizzle relations (`bad_debt_vat_reclaims.invoice_id` → `invoices.id`, `tenant_id` → `tenants.id`).
- [ ] Export `badDebtVatReclaims` from the schema barrel.
**Schema / Interfaces:**
```sql
-- invoices delta (status enum already pinned upstream — DO NOT re-ALTER the CHECK)
ALTER TABLE invoices ADD COLUMN bad_debt_at     TIMESTAMPTZ;
ALTER TABLE invoices ADD COLUMN bad_debt_reason TEXT
  CHECK (bad_debt_reason IS NULL OR bad_debt_reason IN ('bankruptcy', 'collection_failed', 'other'));
ALTER TABLE invoices ADD COLUMN bad_debt_note   TEXT;

-- bad-debt flagging threshold (days overdue before an invoice is surfaced for write-off)
ALTER TABLE tenant_settings ADD COLUMN bad_debt_threshold_days INTEGER NOT NULL DEFAULT 90;

-- VAT reclaim tracking
CREATE TABLE bad_debt_vat_reclaims (
  id                         UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id                  UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
  invoice_id                 UUID NOT NULL REFERENCES invoices(id) ON DELETE CASCADE,
  vat_amount                 NUMERIC(12,2) NOT NULL,                  -- = vat_amount * (total - amount_paid) / total
  status                     TEXT NOT NULL DEFAULT 'pending'
    CHECK (status IN ('pending', 'submitted', 'approved', 'rejected', 'reversed', 'cancelled')),
  registered_letter_sent_at  DATE,
  ita_submission_date        DATE,
  ita_reference              TEXT,
  resolved_at                TIMESTAMPTZ,
  reversed_at                TIMESTAMPTZ,
  notes                      TEXT,
  created_at                 TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_bad_debt_vat_tenant ON bad_debt_vat_reclaims(tenant_id, created_at DESC);
CREATE INDEX idx_bad_debt_vat_invoice ON bad_debt_vat_reclaims(invoice_id);
```
**Acceptance:**
- [ ] Migration applies cleanly against Neon; `bad_debt_vat_reclaims` exists with both CHECK constraints and both indexes.
- [ ] `invoices_status_check` is unchanged (still the 10-value upstream constraint).
- [ ] `pnpm --filter @zync/db build` passes; `badDebtVatReclaims` is importable.

### Task 2: Types — enums and DTOs in `@zync/types`
**Blocks:** 3, 4, 5, 6, 7, 8, 9, 10  ·  **Blocked by:** —
**Files:**
- Create: `packages/types/src/bad-debt.ts`
- Modify: `packages/types/src/index.ts`
**Steps:**
- [ ] Define `BadDebtReason`, `VatReclaimStatus`, the row type `BadDebtVatReclaim`, the write-off request/response DTOs, the reclaim patch DTO, and the report payload type.
- [ ] Re-export all from the package barrel.
**Schema / Interfaces:**
```ts
export type BadDebtReason = 'bankruptcy' | 'collection_failed' | 'other';
export type VatReclaimStatus =
  | 'pending' | 'submitted' | 'approved' | 'rejected' | 'reversed' | 'cancelled';

export interface BadDebtVatReclaim {
  id: string;
  tenantId: string;
  invoiceId: string;
  vatAmount: number;
  status: VatReclaimStatus;
  registeredLetterSentAt: string | null; // ISO date
  itaSubmissionDate: string | null;       // ISO date
  itaReference: string | null;
  resolvedAt: string | null;              // ISO datetime
  reversedAt: string | null;              // ISO datetime
  notes: string | null;
  createdAt: string;                      // ISO datetime
}

export interface WriteOffRequest {
  reason: BadDebtReason;
  note?: string;
  log_vat_reclaim: boolean;
  write_off_date?: string;                // ISO date; defaults to today
}
export interface WriteOffResponse { invoiceId: string; vatReclaimId?: string; }

export interface ReclaimPatchRequest {
  registered_letter_sent_at?: string;
  ita_submission_date?: string;
  ita_reference?: string;
  status?: VatReclaimStatus;
  notes?: string;
}

export interface RecordRecoveryRequest {
  amount: number;
  paidAt: string;                         // ISO date
  source?: 'manual' | 'bank_transfer' | 'gateway' | 'other';
  reference?: string;
  note?: string;
}

export interface BadDebtReportInvoice {
  invoice_number: string;
  customer_name: string;
  amount: number;
  vat_amount: number;
  bad_debt_at: string;
  vat_reclaim_status: VatReclaimStatus | null;
  registered_letter_sent: boolean;
}
export interface BadDebtReportData {
  year: number;
  total_written_off: number;
  total_vat_reclaimed: number;
  by_reason: Record<BadDebtReason, number>;
  invoices: BadDebtReportInvoice[];
}
```
**Acceptance:**
- [ ] `pnpm --filter @zync/types build` passes; all names importable from `@zync/types`.

### Task 3: Write-off service + `POST /api/invoices/:id/write-off`
**Blocks:** 5, 7  ·  **Blocked by:** 1, 2
**Files:**
- Create: `apps/zync-api/src/services/bad-debt/write-off.ts`
- Create: `apps/zync-api/src/routes/invoices/write-off.ts`
- Modify: `apps/zync-api/src/routes/invoices/index.ts` (mount route)
**Steps:**
- [ ] Zod schema for the body: `reason` ∈ reason enum, `note` optional, `log_vat_reclaim` boolean, `write_off_date` optional ISO date.
- [ ] Guard: `requirePermission('invoices:write')` AND role ∈ {OWNER, ADMIN} (else 403). Write-off is OWNER/ADMIN-gated per spec.
- [ ] Load invoice via `tenantQuery`; reject (422) if `status NOT IN ('TAX_ISSUED','PARTIALLY_PAID')`.
- [ ] Eligibility: compute days overdue from `due_date`; reject (422) if `< tenant_settings.bad_debt_threshold_days`.
- [ ] If `log_vat_reclaim` is true, require the tenant to be VAT-registered (check tenant VAT registration); if not registered, reject the reclaim (422 with a clear message) — the plain write-off path does not require this.
- [ ] In ONE transaction: set `status = log_vat_reclaim ? 'BAD_DEBT' : 'WRITTEN_OFF'`, `bad_debt_at = write_off_date ?? now()`, `bad_debt_reason`, `bad_debt_note`. If `log_vat_reclaim`, insert a `bad_debt_vat_reclaims` row with `status='pending'` and `vat_amount = round(vat_amount * (total - amount_paid) / total, 2)`. Write an audit entry (action `invoice.write_off`, captures reason + reclaim flag + reclaimable VAT) in the same transaction.
- [ ] After commit: `createNotification` to OWNER ("Invoice {number} written off as bad debt."); enqueue `invoice.written_off` webhook via the `webhook.deliver` queue.
- [ ] Return `{ invoiceId, vatReclaimId? }`.
**Schema / Interfaces:**
```ts
// POST /api/invoices/:id/write-off  -> WriteOffResponse
// Reclaimable VAT (do not use full vat_amount for partially-paid invoices):
const reclaimableVat = round2(invoice.vatAmount * (invoice.total - invoice.amountPaid) / invoice.total);
```
**Acceptance:**
- [ ] Writing off a `TAX_ISSUED` invoice with `log_vat_reclaim:true` yields `BAD_DEBT` + a `pending` reclaim row; `false` yields `WRITTEN_OFF` and NO reclaim row.
- [ ] A `PARTIALLY_PAID` invoice produces a reclaim `vat_amount` equal to the VAT on the unpaid balance, not the full `vat_amount`.
- [ ] Non-OWNER/ADMIN gets 403; invoice in `DRAFT`/`SENT`/`APPROVED`/`PAID`/`VOID` gets 422; under-threshold gets 422.
- [ ] An audit row is written in the same transaction; `invoice.written_off` webhook is enqueued; OWNER notification created.

### Task 4: Reclaim list + patch — `GET/PATCH /api/bad-debt-reclaims`
**Blocks:** 8, 10  ·  **Blocked by:** 1, 2
**Files:**
- Create: `apps/zync-api/src/services/bad-debt/reclaims.ts`
- Create: `apps/zync-api/src/routes/bad-debt-reclaims.ts`
- Modify: `apps/zync-api/src/index.ts` (mount router)
**Steps:**
- [ ] `GET /api/bad-debt-reclaims` — `requirePermission('invoices:read')`. Query params: `status` (comma list, e.g. `pending,submitted`), `year`. Join `invoices` + `customers` for display fields. Paginate with `buildPaginated`/`clampLimit`.
- [ ] `PATCH /api/bad-debt-reclaims/:id` — `requirePermission('invoices:write')`. Zod body = `ReclaimPatchRequest`. Update provided fields; when `status` moves to `approved`/`rejected`, set `resolved_at = now()`. Audit entry in the same transaction.
- [ ] All access through `tenantQuery`; serialize dates to ISO.
**Schema / Interfaces:**
```ts
// GET /api/bad-debt-reclaims?status=pending,submitted&year=2026 -> PaginatedResponse<BadDebtVatReclaim & { invoiceNumber, customerName }>
// PATCH /api/bad-debt-reclaims/:id  body: ReclaimPatchRequest -> BadDebtVatReclaim
```
**Acceptance:**
- [ ] `GET ?status=pending,submitted` returns only those statuses, tenant-scoped, paginated.
- [ ] `PATCH` updating `status` to `approved` stamps `resolved_at`; writes an audit row.
- [ ] `invoices:read` required for GET, `invoices:write` for PATCH.

### Task 5: Recovery service + `POST /api/invoices/:id/record-recovery`
**Blocks:** 9  ·  **Blocked by:** 1, 2, 3
**Files:**
- Create: `apps/zync-api/src/services/bad-debt/recovery.ts`
- Create: `apps/zync-api/src/routes/invoices/record-recovery.ts`
- Modify: `apps/zync-api/src/routes/invoices/index.ts` (mount route)
**Steps:**
- [ ] Zod body = `RecordRecoveryRequest`. Guard: `requirePermission('invoices:write')` AND role ∈ {OWNER, ADMIN}.
- [ ] Reject (422) unless invoice `status IN ('BAD_DEBT','WRITTEN_OFF')`.
- [ ] In ONE transaction: insert an `invoice_payments` row (spec 80) for the recovered amount (`source` from body, default `manual`, `recorded_by = userId`). Recompute `amount_paid` + status using the partial-payment balance CTE so the invoice flips to `PARTIALLY_PAID` (partial) or `PAID` (full). Retain `bad_debt_at`/`bad_debt_reason`/`bad_debt_note` for audit.
- [ ] Reclaim reversal logic: if a `bad_debt_vat_reclaims` row exists and `status='approved'` → set `status='reversed'`, `reversed_at=now()` (re-remittance obligation; surfaced in next VAT report period as output VAT owed back). If reclaim `status IN ('pending','submitted')` → set `status='cancelled'`. (`rejected` reclaims are left untouched.)
- [ ] Write an audit entry (`invoice.recovery`) in the same transaction capturing recovered amount + reclaim transition.
- [ ] After commit: enqueue `invoice.recovered` webhook; `createNotification` to OWNER.
**Schema / Interfaces:**
```ts
// POST /api/invoices/:id/record-recovery  body: RecordRecoveryRequest
//   -> { invoiceId, status: InvoiceStatus, reclaimStatus?: VatReclaimStatus }
// Balance recompute uses the spec-80 CTE:
//   WITH new_amount AS (SELECT COALESCE(SUM(amount),0) AS total_paid
//     FROM invoice_payments WHERE invoice_id = :invoiceId)
//   UPDATE invoices SET amount_paid = (SELECT total_paid FROM new_amount),
//     status = CASE WHEN (SELECT total_paid FROM new_amount) >= total THEN 'PAID'
//                   WHEN (SELECT total_paid FROM new_amount) > 0 THEN 'PARTIALLY_PAID'
//                   ELSE status END,
//     paid_at = CASE WHEN (SELECT total_paid FROM new_amount) >= total THEN now() ELSE NULL END
//   WHERE id = :invoiceId;
```
**Acceptance:**
- [ ] Recovery on a `BAD_DEBT` invoice inserts a payment, flips status to `PARTIALLY_PAID`/`PAID`, and `bad_debt_*` columns are retained.
- [ ] An `approved` reclaim becomes `reversed` with `reversed_at` set; a `pending`/`submitted` reclaim becomes `cancelled`.
- [ ] `invoice.recovered` webhook enqueued; OWNER notified; audit row written in the same transaction.
- [ ] Recovery is rejected (422) on a non-written-off invoice and 403 for non-OWNER/ADMIN.

### Task 6: Bad-debt report (JSON + xlsx) — `GET /api/reports/bad-debt[/xlsx]`
**Blocks:** 10  ·  **Blocked by:** 1, 2
**Files:**
- Create: `apps/zync-api/src/services/bad-debt/report.ts`
- Create: `apps/zync-api/src/routes/reports/bad-debt.ts`
- Modify: `apps/zync-api/src/routes/reports/index.ts` (mount route)
**Steps:**
- [ ] `GET /api/reports/bad-debt?year=YYYY` — `requirePermission('reports:read')`. Aggregate all `BAD_DEBT` + `WRITTEN_OFF` invoices with `bad_debt_at` in the year: `total_written_off` (sum of `total`), `total_vat_reclaimed` (sum of `vat_amount` from reclaims with `status='approved'`), `by_reason` counts, and the per-invoice list joined to `customers` + reclaim status.
- [ ] `GET /api/reports/bad-debt/xlsx?year=YYYY` — `requirePermission('reports:export')`. Build the workbook with ExcelJS; set `worksheet.views[0].rightToLeft = true` and a Hebrew-capable font for RTL/Hebrew parity with sibling tax reports. Stream as `application/vnd.openxmlformats-officedocument.spreadsheetml.sheet` with a `Content-Disposition` filename `bad-debt-{year}.xlsx`.
- [ ] All reads via `tenantQuery`.
**Schema / Interfaces:**
```ts
// GET /api/reports/bad-debt?year=2026 -> BadDebtReportData
// GET /api/reports/bad-debt/xlsx?year=2026 -> binary xlsx (RTL worksheet)
```
**Acceptance:**
- [ ] JSON response matches `BadDebtReportData`: totals, `by_reason` counts, per-invoice rows with `vat_reclaim_status` + `registered_letter_sent`.
- [ ] xlsx downloads with the correct content-type, RTL worksheet, and Hebrew headers; gated by `reports:export`.
- [ ] JSON gated by `reports:read`.

### Task 7: Write-off confirmation modal (invoice detail)
**Blocks:** —  ·  **Blocked by:** 2, 3
**Files:**
- Create: `apps/zync-app/src/features/invoices/components/WriteOffModal.tsx`
- Create: `apps/zync-app/src/features/invoices/hooks/useWriteOff.ts`
- Modify: `apps/zync-app/src/features/invoices/InvoiceDetail.tsx` (add `[Write off as bad debt]` button)
**Steps:**
- [ ] Show `[Write off as bad debt]` on invoice detail only when `status ∈ {TAX_ISSUED, PARTIALLY_PAID}` AND current user is OWNER/ADMIN AND has `invoices:write`.
- [ ] Build the modal with `Dialog`: customer, invoice number, issue date, amount due (`total - amount_paid`), `write_off_date` (default today), `reason` select (Bankruptcy / Collection failed / Other), optional `note`, and a "Log VAT reclaim request" checkbox showing the reclaimable VAT (`vat_amount * (total - amount_paid) / total`) with the registered-letter confirmation note.
- [ ] Hide/disable the VAT-reclaim checkbox when the tenant is not VAT-registered.
- [ ] On confirm, call `useWriteOff` → `POST /api/invoices/:id/write-off`; invalidate invoice + reclaim queries; toast success.
- [ ] a11y: focus-trap inside the dialog, `aria-labelledby`/`aria-describedby`, ESC + `[Cancel]` close, return focus to trigger. Honor `prefers-reduced-motion` for the dialog transition.
**Acceptance:**
- [ ] Button only visible to OWNER/ADMIN on eligible invoices.
- [ ] Checked checkbox → `BAD_DEBT`; unchecked → `WRITTEN_OFF`. Displayed reclaimable VAT equals the unpaid-balance VAT.
- [ ] Modal is keyboard-navigable with a working focus trap; transitions respect reduced-motion.

### Task 8: VAT-reclaim status panel (invoice detail)
**Blocks:** —  ·  **Blocked by:** 2, 4
**Files:**
- Create: `apps/zync-app/src/features/invoices/components/VatReclaimPanel.tsx`
- Create: `apps/zync-app/src/features/invoices/hooks/useReclaim.ts`
**Steps:**
- [ ] Render on `BAD_DEBT` invoice detail: amount to reclaim, three progress steps (registered letter sent / submitted to ITA / resolved), current status pill, and an `[Update reclaim status]` editor.
- [ ] Editor fields → `PATCH /api/bad-debt-reclaims/:id`: `registered_letter_sent_at` (date), `ita_submission_date`, `ita_reference`, `status`, `notes`. Status updates require `invoices:write`.
- [ ] a11y: step list uses `aria-current`/`role="list"`; status pill has an accessible text label, not color alone.
**Acceptance:**
- [ ] Marking the letter sent persists `registered_letter_sent_at` and reflects in the progress steps.
- [ ] Setting status `approved` shows resolved state; editor gated by `invoices:write`.

### Task 9: Recovery payment modal (invoice detail)
**Blocks:** —  ·  **Blocked by:** 2, 5
**Files:**
- Create: `apps/zync-app/src/features/invoices/components/RecordRecoveryModal.tsx`
- Create: `apps/zync-app/src/features/invoices/hooks/useRecordRecovery.ts`
- Modify: `apps/zync-app/src/features/invoices/InvoiceDetail.tsx` (add `[Record recovery payment]`)
**Steps:**
- [ ] On `BAD_DEBT`/`WRITTEN_OFF` invoice detail, show `[Record recovery payment]` to OWNER/ADMIN with `invoices:write`.
- [ ] Modal fields: amount (default outstanding balance), paid date, source select, reference, note → `POST /api/invoices/:id/record-recovery`.
- [ ] On success: invalidate invoice, payments, and reclaim queries; toast noting the reclaim transition (reversed re-remit / cancelled) when present.
- [ ] a11y + reduced-motion identical to Task 7.
**Acceptance:**
- [ ] Recording a full recovery flips the invoice to `PAID` and the UI shows the reclaim as reversed/cancelled.
- [ ] Partial recovery flips to `PARTIALLY_PAID`; balance updates.

### Task 10: `/reports/bad-debt` worklist screen
**Blocks:** —  ·  **Blocked by:** 4, 6
**Files:**
- Create: `apps/zync-app/src/features/reports/bad-debt/BadDebtReportPage.tsx`
- Create: `apps/zync-app/src/features/reports/bad-debt/hooks.ts`
- Modify: `apps/zync-app/src/router.tsx` (route `/reports/bad-debt`)
- Modify: `apps/zync-app/src/features/reports/ReportsHub.tsx` (link from reports hub + Tax & Compliance nav)
**Steps:**
- [ ] Header: year selector + `[⬇ xlsx]` (hits `GET /api/reports/bad-debt/xlsx`, gated by `reports:export`).
- [ ] Summary line via `StatCard`s: "Written off this year" + "VAT reclaimed".
- [ ] Section 1 — Open VAT reclaims: `GET /api/bad-debt-reclaims?status=pending,submitted` in a `DataTable` (Invoice, Customer, VAT, Stage, `[Update]`). `[Update]` opens the same `VatReclaimPanel` editor (`PATCH /api/bad-debt-reclaims/:id`) without leaving the page.
- [ ] Section 2 — All written-off invoices: from `GET /api/reports/bad-debt` (both `BAD_DEBT` + `WRITTEN_OFF`); row click → invoice detail.
- [ ] Page requires `reports:read`; reclaim updates require `invoices:write`.
- [ ] a11y: tables have captions/`scope` headers; status stages convey state via text + icon, not color alone.
**Acceptance:**
- [ ] Open-reclaims table shows only `pending`/`submitted` and updates inline via the editor.
- [ ] Written-off table lists both terminal statuses; year selector re-queries; xlsx export downloads.

### Task 11: Settings wiring — `bad_debt_threshold_days`
**Blocks:** —  ·  **Blocked by:** 1
**Files:**
- Modify: `apps/zync-app/src/features/settings/invoicing/InvoicingSettings.tsx`
- Modify: `apps/zync-api/src/routes/settings/invoicing.ts` (include the field in get/patch)
**Steps:**
- [ ] Add an "Overdue & Collections" sub-section field: "Bad debt threshold: number of days overdue before an invoice is flagged for write-off (default: 90)." bound to `tenant_settings.bad_debt_threshold_days`.
- [ ] Validate integer ≥ 0; persist via the existing invoicing settings patch (Zod).
**Acceptance:**
- [ ] Saving a new threshold updates `tenant_settings.bad_debt_threshold_days` and changes write-off eligibility accordingly.

### Task 12: i18n / RTL / a11y completion pass
**Blocks:** —  ·  **Blocked by:** 7, 8, 9, 10, 11
**Files:**
- Modify: `packages/i18n/src/locales/he/bad-debt.json`
- Modify: `packages/i18n/src/locales/en/bad-debt.json`
- Modify: relevant component files for `useDirection`/translation keys
**Steps:**
- [ ] Externalize all strings (modal, panel, recovery, report, settings) into `en` + `he` locale namespaces, including the term חוב אבוד and reason labels.
- [ ] Ensure RTL layout via `useDirection` in all new surfaces; mirror icons/badges.
- [ ] Verify focus-trap, reduced-motion, and color-independent status indicators across all new components.
**Acceptance:**
- [ ] Switching locale to `he` renders all new UI in Hebrew with correct RTL layout; no hardcoded strings remain.
- [ ] Axe/lint passes for the new components (no hardcoded colors/spacing, color-independent statuses, working focus traps).
