# Accounts Receivable Aging Report — Implementation Plan

**Spec:** docs/specs/2026-05-31-ar-aging-report.md  ·  **Slug:** ar-aging-report  ·  **Wave:** 11
**Depends on:** customers-module, foundation-auth-rbac, invoices-core, reports-analytics

## Goal
Deliver a dedicated Accounts Receivable (AR) aging report at `/reports/ar-aging` that breaks every outstanding invoice into aging buckets (Current, 1–30, 31–60, 61–90, 90+ days) summarized per customer and in aggregate, computed for any `as_of` date (not only today). Operators use it to see who owes what, for how long, and total exposure per bucket, then drill into invoice-level detail, send payment reminders, and bulk-send account statements to overdue customers. This is a pure read/reporting projection over `invoices` and `invoice_payments` plus a CSV/PDF export and a bulk statement mailer; it defines **no new tables**.

## Architecture
- **Read-only projection.** The report derives entirely from existing upstream tables: `invoices` (status, `total`, `due_date`, `sent_at`, `customer_id`, `tenant_id`, `currency` — from `invoices-core`), `invoice_payments` (`amount`, `paid_at`, `invoice_id` — from `partial-payment-recording`, spec 80), `customers` (`name`, `email` — from `customers-module`), and `tenant_settings.default_payment_terms_days` (owned by `invoices-core`, default 30) for the null-`due_date` fallback. No new table, no migration.
- **Canonical bucket logic.** This spec (140) **owns** the bucket-assignment function. Spec 183 (`customer-statement`) and spec 20 (`reports-analytics`) reuse it — so the bucketing is exported once from `@zync/db` and not re-derived elsewhere.
- **Historical-accurate balance.** For partially-paid invoices the outstanding balance is `total − SUM(invoice_payments.amount WHERE paid_at <= as_of)`, NOT `invoices.amount_paid`. `amount_paid` is the *current* maintained value; bounding the payment sum by `as_of` is what makes historical snapshots correct (Architecture Decision #1).
- **API surface.** Two Hono routes on the tenant API worker: `GET /api/reports/ar-aging` (report data) and `POST /api/reports/ar-aging/statements` (bulk statement send). Export endpoints for CSV and PDF.
- **Bulk statement send.** Spec 183 (`customer-statement`, wave 16) is the canonical owner of the single-customer statement document, but it builds AFTER this report. Therefore this plan ships a **self-contained** statement renderer + emailer (HTML-to-PDF Worker pattern, RTL/Hebrew, reusing the email adapter via `sendEmail`) keyed to the same data shape so spec 183 can later supersede it without changing the route contract `POST /api/customers/:id/statement/send`. The bulk endpoint fans out one statement email per selected overdue customer.
- **Data flow:** route → `tenantQuery`-scoped Drizzle read of in-scope invoices joined to payments-to-date and customers → in-memory bucketing per invoice → group by customer → summary roll-up → serialize. Export endpoints re-use the same builder then format to CSV / HTML-to-PDF. Bulk send re-uses the per-customer projection to build each statement PDF and dispatches via `sendEmail`.

## Tech Stack
- **API:** `apps/zync-api` (Hono on Cloudflare Workers). New route module `apps/zync-api/src/routes/reports/ar-aging.ts`.
- **DB access:** `@zync/db` (Drizzle). New query helpers + bucket function in `packages/db/src/reports/ar-aging.ts`. Uses `tenantQuery` for tenant scoping, never raw Drizzle from routes (lint: `no-raw-drizzle-from-routes`).
- **Types:** `@zync/types` — shared `ArAgingReport`, `ArAgingBucket`, request/response DTOs.
- **Validation:** Zod schemas in the route (lint: `require-zod-validation-in-routes`).
- **Auth:** `authMiddleware`, `requirePermission('invoices:read')` from `@zync/auth`; bulk send additionally requires admin role.
- **Tier gating:** export endpoints gated to Business+ via `requireTier`/`meetsMinimumTier` (`@zync/auth`).
- **Email:** `sendEmail` (`@zync/notifications`) using the tenant email adapter for statement dispatch.
- **PDF:** HTML-to-PDF Worker pattern (same approach spec 183 / `invoice-pdf-customization` use; RTL/Hebrew; `window.print()` fallback). Reports-analytics explicitly rejects Cloudflare Browser Rendering — do not use it.
- **CSV:** server-side string generation in the Worker (no native binary).
- **UI:** `apps/zync-app` (Vite + React). New route `/reports/ar-aging`, page + components using `@zync/ui` (`DataTable`, `Dialog`, `Button`, `StatCard`, `Badge`, `Checkbox`, `EmptyState`, `Skeleton`). TanStack Query hook.
- **Bindings:** Hyperdrive (Postgres), the email queue/adapter binding already provisioned by `system-communications-notifications`.

## Wave Plan
| Sub-wave | Tasks | Files touched | Parallelizable? |
|----------|-------|---------------|-----------------|
| 11.a | 1 (types), 2 (db bucket+query) | `packages/types`, `packages/db` | 1 and 2 sequential (2 imports 1) |
| 11.b | 3 (report API), 4 (CSV/PDF export API) | `apps/zync-api/src/routes/reports` | 3 then 4 (4 reuses 3's builder) |
| 11.c | 5 (statement renderer+emailer), 6 (bulk-send API) | `apps/zync-api/src/lib`, `apps/zync-api/src/routes/reports` | 5 then 6 |
| 11.d | 7 (UI page + table), 8 (drill-down + reminder/statement actions), 9 (send-statements dialog) | `apps/zync-app/src/routes/reports` | 7 then 8, 9 (8 & 9 parallel after 7) |
| 11.e | 10 (i18n/RTL + a11y wiring) | `apps/zync-app`, locale files | after 7–9 |

## Tasks

### Task 1: Shared AR-aging types
**Blocks:** 2, 3, 4, 5, 6, 7, 8, 9  ·  **Blocked by:** —
**Files:**
- Create: `packages/types/src/reports/ar-aging.ts`
- Modify: `packages/types/src/index.ts` (re-export)
**Steps:**
- [ ] Define the bucket key union and per-invoice/per-customer/summary DTOs exactly matching the spec API response.
- [ ] Define the request DTOs for the report query and the bulk-statement body.
- [ ] Re-export all from `@zync/types` index.
**Schema / Interfaces:**
```ts
export type ArAgingBucketKey = 'current' | 'd1_30' | 'd31_60' | 'd61_90' | 'd90plus';

export interface ArAgingInvoice {
  id: string;
  number: string | null;          // invoices.invoice_number ?? invoices.proforma_number
  description: string | null;     // first invoice_lines.description or invoices.notes summary
  due_date: string;               // effective due date (ISO date), null due_date resolved via fallback
  age_days: number;               // as_of - effective due_date, in days (negative => Current)
  total: number;                  // invoices.total
  balance_due: number;            // total - SUM(payments.amount WHERE paid_at <= as_of)
  status: string;                 // invoices.status (one of included statuses)
  bucket: ArAgingBucketKey;
}

export interface ArAgingCustomerRow {
  customer_id: string;
  customer_name: string;          // customers.name
  contact_email: string | null;   // customers.email
  current: number;
  d1_30: number;
  d31_60: number;
  d61_90: number;
  d90plus: number;
  total: number;                  // sum of the five buckets (outstanding, not face value)
  invoices: ArAgingInvoice[];
}

export interface ArAgingSummary {
  current: number;
  d1_30: number;
  d31_60: number;
  d61_90: number;
  d90plus: number;
  total: number;
}

export interface ArAgingReport {
  as_of: string;                  // ISO date
  currency: string;               // report currency (single-currency view)
  summary: ArAgingSummary;
  customers: ArAgingCustomerRow[];
}

export interface ArAgingQuery {
  as_of?: string;                 // ISO date, default = today (tenant timezone)
  currency?: string;              // default = tenants.default_currency
}

export interface ArAgingStatementsRequest {
  customer_ids: string[];
  subject?: string;
  message?: string;
  as_of: string;                  // ISO date
}

export interface ArAgingStatementsResponse {
  sent: number;
}
```
**Acceptance:**
- [ ] `@zync/types` exports `ArAgingReport`, `ArAgingCustomerRow`, `ArAgingInvoice`, `ArAgingSummary`, `ArAgingBucketKey`, `ArAgingQuery`, `ArAgingStatementsRequest`, `ArAgingStatementsResponse`.
- [ ] `pnpm --filter @zync/types build` typechecks.

### Task 2: AR-aging bucket function + report query (`@zync/db`)
**Blocks:** 3, 4, 5, 6  ·  **Blocked by:** 1
**Files:**
- Create: `packages/db/src/reports/ar-aging.ts`
- Modify: `packages/db/src/index.ts` (re-export `assignAgingBucket`, `buildArAgingReport`)
**Steps:**
- [ ] Implement `assignAgingBucket(effectiveDueDate, asOf)` — the **canonical** bucket-assignment used by this report and reused by spec 183 / spec 20. Pure function, no DB.
- [ ] Implement `effectiveDueDate(invoice, tenantDefaultPaymentTermsDays)`: returns `invoice.due_date` when present, else `invoice.sent_at::date + default_payment_terms_days`.
- [ ] Implement `buildArAgingReport(db, tenantId, { asOf, currency })`: fetch in-scope invoices for the tenant filtered by included statuses and currency, LEFT JOIN a per-invoice payments-to-`asOf` subquery, compute `balance_due`, drop rows whose `balance_due <= 0`, bucket each, group by customer, roll up the summary. Scope strictly via `tenantQuery` (no raw Drizzle leaking tenant boundary).
- [ ] Resolve invoice display `number` as `COALESCE(invoice_number, proforma_number)` and `description` as the first invoice line description (subquery) falling back to `notes`.
- [ ] Order customers by `total` descending; within a customer order invoices by `age_days` descending.
**Schema / Interfaces:**
```ts
// Included statuses (verbatim from spec): SENT, APPROVED, TAX_ISSUED, PARTIALLY_PAID.
// Excluded: DRAFT, PAID, VOID, REJECTED, WRITTEN_OFF, BAD_DEBT.
export const AR_AGING_INCLUDED_STATUSES = ['SENT', 'APPROVED', 'TAX_ISSUED', 'PARTIALLY_PAID'] as const;

export function assignAgingBucket(effectiveDue: Date, asOf: Date): ArAgingBucketKey;
// Bucket rules (ageDays = floor((asOf - effectiveDue) / 1 day)):
//   effectiveDue >= asOf            -> 'current'   (not yet due)
//   1  <= ageDays <= 30             -> 'd1_30'
//   31 <= ageDays <= 60             -> 'd31_60'
//   61 <= ageDays <= 90             -> 'd61_90'
//   ageDays > 90                    -> 'd90plus'

export function effectiveDueDate(
  inv: { due_date: Date | null; sent_at: Date | null },
  defaultPaymentTermsDays: number,
): Date;

export async function buildArAgingReport(
  db: Db,
  tenantId: string,
  opts: { asOf: Date; currency: string },
): Promise<ArAgingReport>;
```
```sql
-- Core selection (parameters :tenantId, :asOf, :currency). balance_due is HISTORICAL:
-- the payment sum is bounded by paid_at <= :asOf so past snapshots stay accurate.
SELECT
  i.id,
  i.customer_id,
  c.name                                AS customer_name,
  c.email                               AS contact_email,
  COALESCE(i.invoice_number, i.proforma_number) AS number,
  i.status,
  i.total,
  i.due_date,
  i.sent_at,
  (i.total - COALESCE((
     SELECT SUM(p.amount)
     FROM invoice_payments p
     WHERE p.invoice_id = i.id
       AND p.paid_at <= :asOf
  ), 0))                                AS balance_due
FROM invoices i
JOIN customers c ON c.id = i.customer_id AND c.tenant_id = i.tenant_id
WHERE i.tenant_id = :tenantId
  AND i.currency = :currency
  AND i.status IN ('SENT', 'APPROVED', 'TAX_ISSUED', 'PARTIALLY_PAID')
HAVING (i.total - COALESCE((
     SELECT SUM(p.amount) FROM invoice_payments p
     WHERE p.invoice_id = i.id AND p.paid_at <= :asOf), 0)) > 0;
-- Note: HAVING shown for intent; in practice wrap as a subquery/CTE and filter balance_due > 0
-- in the outer query since balance_due is a computed expression, not a grouped aggregate.
```
**Acceptance:**
- [ ] `assignAgingBucket` returns `current` when the effective due date is on/after `as_of`, and the correct day-range bucket otherwise (unit-verifiable at boundaries 0/1/30/31/60/61/90/91).
- [ ] An invoice with a null `due_date` is bucketed using `sent_at + default_payment_terms_days`.
- [ ] A `PARTIALLY_PAID` invoice with a payment dated AFTER `as_of` still shows the full pre-payment balance for that `as_of`.
- [ ] Fully-paid-as-of invoices (`balance_due <= 0`) are excluded from the result.
- [ ] Customer roll-ups equal the sum of their invoice `balance_due` per bucket; summary equals the sum across customers.

### Task 3: Report API route `GET /api/reports/ar-aging`
**Blocks:** 4, 7  ·  **Blocked by:** 2
**Files:**
- Create: `apps/zync-api/src/routes/reports/ar-aging.ts`
- Modify: `apps/zync-api/src/routes/reports/index.ts` (mount), `apps/zync-api/src/app.ts` (ensure reports router mounted)
**Steps:**
- [ ] Add `authMiddleware` + `requirePermission('invoices:read')` to the route.
- [ ] Zod-validate query: `as_of` optional ISO date (default = today in tenant timezone via `tenants.default_timezone`), `currency` optional (default = `tenants.default_currency`).
- [ ] Call `buildArAgingReport(db, tenantId, { asOf, currency })` and return the `ArAgingReport` JSON.
- [ ] Use `tenantQuery` scoping; do not call raw Drizzle from the route.
**Schema / Interfaces:**
```
GET /api/reports/ar-aging?as_of=<ISO date>&currency=<code>
  Auth: authMiddleware + requirePermission('invoices:read')
  200 -> ArAgingReport
  400 -> invalid as_of / currency
```
```ts
const arAgingQuerySchema = z.object({
  as_of: z.string().date().optional(),
  currency: z.string().length(3).optional(),
});
```
**Acceptance:**
- [ ] `GET /api/reports/ar-aging` with no params returns the report `as_of` today in the tenant's default currency.
- [ ] `GET /api/reports/ar-aging?as_of=2026-01-01` returns a historical snapshot whose balances reflect only payments dated on/before 2026-01-01.
- [ ] Request without `invoices:read` is rejected 403.
- [ ] Response shape matches `ArAgingReport` exactly.

### Task 4: Export endpoints (CSV + PDF, Business+ gated)
**Blocks:** 7  ·  **Blocked by:** 3
**Files:**
- Create: `apps/zync-api/src/routes/reports/ar-aging-export.ts`
- Create: `apps/zync-api/src/lib/ar-aging-csv.ts`
- Create: `apps/zync-api/src/lib/ar-aging-pdf.ts`
- Modify: `apps/zync-api/src/routes/reports/index.ts` (mount)
**Steps:**
- [ ] Add `GET /api/reports/ar-aging/export.csv` and `GET /api/reports/ar-aging/export.pdf`, same query params as Task 3.
- [ ] Gate both behind `requirePermission('invoices:read')` AND Business+ tier (`requireTier`/`meetsMinimumTier`) — spec header: "All tiers (Business+ for export)".
- [ ] CSV: reuse `buildArAgingReport`, emit one row per customer with the five bucket columns + total, plus a summary header row; server-side string build, UTF-8 BOM for Excel/Hebrew correctness, `Content-Type: text/csv`.
- [ ] PDF: render the report HTML (RTL/Hebrew aware, summary table + per-customer rows) and convert via the HTML-to-PDF Worker pattern (reuse `invoice-pdf-customization` rendering config); `window.print()` fallback path documented. `Content-Type: application/pdf`. Do NOT use Cloudflare Browser Rendering.
**Schema / Interfaces:**
```
GET /api/reports/ar-aging/export.csv?as_of=&currency=  -> text/csv  (Business+)
GET /api/reports/ar-aging/export.pdf?as_of=&currency=  -> application/pdf (Business+)
  Both: authMiddleware + requirePermission('invoices:read') + Business+ tier
```
**Acceptance:**
- [ ] CSV download contains a summary line and one line per customer with Current / 1–30 / 31–60 / 61–90 / 90+ / Total columns matching the JSON report.
- [ ] PDF download renders with RTL/Hebrew when the tenant locale is `he`.
- [ ] A Free/Starter (below Business) tenant receives 403 on both export endpoints; the JSON report (Task 3) still works for them.

### Task 5: Self-contained customer statement renderer + emailer
**Blocks:** 6  ·  **Blocked by:** 2
**Files:**
- Create: `apps/zync-api/src/lib/customer-statement-render.ts`
- Create: `apps/zync-api/src/lib/customer-statement-send.ts`
**Steps:**
- [ ] Implement `renderCustomerStatementPdf(db, tenantId, customerId, asOf)`: builds the customer's outstanding-invoice projection (reuse the per-customer slice of `buildArAgingReport`) and renders a statement PDF via the HTML-to-PDF Worker pattern (RTL/Hebrew). Each line shows: invoice number, issue/due date, amount, balance due, days overdue.
- [ ] Implement `sendCustomerStatement(db, env, tenantId, { customerId, asOf, subject, message })`: renders the PDF, resolves the customer contact email (`customers.email`), and dispatches via `sendEmail` (tenant email adapter) with the PDF attachment.
- [ ] Keep the function contract aligned to spec 183's `POST /api/customers/:id/statement/send` body (`subject`, `message`, `as_of`) so spec 183 can later supersede this implementation without breaking the bulk caller.
- [ ] Skip-and-report customers with no contact email rather than failing the whole batch.
**Schema / Interfaces:**
```ts
export async function renderCustomerStatementPdf(
  db: Db, tenantId: string, customerId: string, asOf: Date,
): Promise<{ pdf: ArrayBuffer; outstanding: number; contactEmail: string | null }>;

export async function sendCustomerStatement(
  db: Db, env: Env, tenantId: string,
  args: { customerId: string; asOf: Date; subject?: string; message?: string },
): Promise<{ sent: boolean; reason?: 'no_email' }>;
```
**Acceptance:**
- [ ] `renderCustomerStatementPdf` produces a PDF listing exactly the customer's outstanding invoices as of `as_of` with days-overdue per line.
- [ ] `sendCustomerStatement` emails the PDF via `sendEmail` and returns `{ sent: false, reason: 'no_email' }` (not a throw) when the customer has no contact email.
- [ ] Subject/message templates interpolate `{business_name}` and `{date}` per the spec dialog defaults.

### Task 6: Bulk statement send API `POST /api/reports/ar-aging/statements`
**Blocks:** 9  ·  **Blocked by:** 5
**Files:**
- Create: `apps/zync-api/src/routes/reports/ar-aging-statements.ts`
- Modify: `apps/zync-api/src/routes/reports/index.ts` (mount)
**Steps:**
- [ ] Add the route with `authMiddleware` + `requirePermission('invoices:read')` + admin role check (spec: "Requires: invoices:read, admin").
- [ ] Zod-validate the body as `ArAgingStatementsRequest` (`customer_ids` non-empty array, `as_of` required ISO date, optional `subject`/`message`).
- [ ] Iterate `customer_ids`, call `sendCustomerStatement` for each (tenant-scoped), count successes, return `{ sent }`.
- [ ] Verify every `customer_id` belongs to the tenant before sending (defense-in-depth tenant scoping).
**Schema / Interfaces:**
```
POST /api/reports/ar-aging/statements
  Auth: authMiddleware + requirePermission('invoices:read') + admin role
  body: ArAgingStatementsRequest { customer_ids: string[]; subject?; message?; as_of }
  200 -> ArAgingStatementsResponse { sent: number }
  400 -> empty customer_ids / invalid as_of
  403 -> non-admin
```
```ts
const arAgingStatementsSchema = z.object({
  customer_ids: z.array(z.string().uuid()).min(1),
  subject: z.string().max(200).optional(),
  message: z.string().max(2000).optional(),
  as_of: z.string().date(),
});
```
**Acceptance:**
- [ ] Posting 3 customer ids sends 3 statements and returns `{ sent: 3 }` (minus any skipped for missing email).
- [ ] A non-admin caller with `invoices:read` is rejected 403.
- [ ] A `customer_id` not in the tenant is ignored/rejected, never emailed.

### Task 7: AR-aging report page + summary/customer table
**Blocks:** 8, 9, 10  ·  **Blocked by:** 3, 4
**Files:**
- Create: `apps/zync-app/src/routes/reports/ar-aging/page.tsx`
- Create: `apps/zync-app/src/routes/reports/ar-aging/use-ar-aging.ts`
- Create: `apps/zync-app/src/routes/reports/ar-aging/AgingSummaryCards.tsx`
- Create: `apps/zync-app/src/routes/reports/ar-aging/AgingTable.tsx`
- Modify: reports nav hub registration (the `/reports` hub route table) to add the AR-aging tile/link
**Steps:**
- [ ] Add the `/reports/ar-aging` route reachable from the reports navigation hub.
- [ ] `use-ar-aging.ts`: TanStack Query hook calling `GET /api/reports/ar-aging` with `as_of` + `currency` params; expose loading/error/data.
- [ ] Header: title "Accounts Receivable Aging", an `as_of` date picker (default today), and `Export PDF` / `Export CSV` buttons that hit the export endpoints (disabled + upsell tooltip when tenant tier < Business).
- [ ] `AgingSummaryCards`: five `StatCard`s (Current, 1–30 d, 31–60 d, 61–90 d, 90+ d) each showing amount and percent of total, plus a "Total outstanding" line.
- [ ] `AgingTable`: `DataTable` with columns Customer, Current, 1–30d, 31–60d, 61–90d, 90+d, Total; empty buckets render an em-dash; rows with a non-zero 90+ bucket show a "⚠ 90+ days overdue" `Badge`.
- [ ] Loading state via `Skeleton`; empty state via `EmptyState` when no outstanding invoices.
- [ ] Footer actions: `Send statements` (opens Task 9 dialog) and `Export all` (CSV).
**Acceptance:**
- [ ] `/reports/ar-aging` renders summary cards whose percentages sum to ~100% and a per-customer table matching the API response.
- [ ] Changing the `as_of` date refetches and updates buckets.
- [ ] Export buttons are disabled with an upsell affordance for sub-Business tenants.
- [ ] Empty data shows the empty state, not a broken table.

### Task 8: Customer-row drill-down + per-invoice actions
**Blocks:** 10  ·  **Blocked by:** 7
**Files:**
- Create: `apps/zync-app/src/routes/reports/ar-aging/CustomerInvoiceDrilldown.tsx`
- Modify: `apps/zync-app/src/routes/reports/ar-aging/AgingTable.tsx`
**Steps:**
- [ ] Add an expandable `[▾ N invoices]` control per customer row that reveals invoice-level detail from the already-fetched `invoices` array (no extra request).
- [ ] Each drill-down line shows: number, description, due date, amount/balance, and the bucket label (e.g. "Current", "2 days").
- [ ] `[View invoice]` opens `/invoices/:id` in a new tab.
- [ ] `[Send reminder]` triggers the payment-reminder send for that invoice (`POST /api/invoices/:id/reminders/send`, spec 58) and toasts the result.
- [ ] `[Statement]` per customer row links to `/customers/:id/statement` (spec 183) — link only; do not redefine the single-customer statement here.
**Acceptance:**
- [ ] Expanding a customer reveals its invoices with correct per-invoice age labels.
- [ ] `View invoice` opens the invoice detail in a new tab.
- [ ] `Send reminder` dispatches and shows a success/error toast.
- [ ] The customer `Statement` link navigates to `/customers/:id/statement`.

### Task 9: Send-statements multi-select dialog
**Blocks:** 10  ·  **Blocked by:** 7
**Files:**
- Create: `apps/zync-app/src/routes/reports/ar-aging/SendStatementsDialog.tsx`
**Steps:**
- [ ] `Dialog` listing each customer with a `Checkbox`, name, contact email, outstanding total, and a "⚠ 90+d" marker when applicable.
- [ ] Pre-select customers with outstanding balance in any > 30-day bucket (1–30 excluded per "outstanding > 30 days" default), per the spec's pre-selection rule.
- [ ] Editable Subject (default `Account statement — {business_name}`) and Message (default per spec dialog) fields.
- [ ] Submit posts `POST /api/reports/ar-aging/statements` with `{ customer_ids, subject, message, as_of }`; button label reflects count ("Send to N customers"); toast the `{ sent }` result and close on success.
- [ ] Disable submit when zero customers selected.
**Acceptance:**
- [ ] Opening the dialog pre-checks customers with balances older than 30 days and leaves current-only customers unchecked.
- [ ] Submitting calls the bulk endpoint and reports the number sent.
- [ ] The send button reflects the selected count and is disabled at zero selection.

### Task 10: i18n / RTL / accessibility wiring
**Blocks:** —  ·  **Blocked by:** 7, 8, 9
**Files:**
- Modify: locale message catalogs (`he`, `en`) for the AR-aging screen
- Modify: `apps/zync-app/src/routes/reports/ar-aging/*` components
**Steps:**
- [ ] Add Hebrew + English strings for all labels (bucket headers, summary, dialog, actions, empty state).
- [ ] Ensure the report layout flips correctly under RTL (`useDirection`); currency formatting uses the tenant locale.
- [ ] Tables use proper semantics: `DataTable` column headers associated with cells; the drill-down expander is a real `button` with `aria-expanded` and `aria-controls`; the "⚠ 90+ days overdue" marker has an accessible label (not icon-only).
- [ ] Dialog is focus-trapped, labelled (`aria-labelledby`), and Escape-dismissable; respects `prefers-reduced-motion` for any expand/collapse transition.
- [ ] Export-disabled upsell affordances are announced (not conveyed by color alone).
**Acceptance:**
- [ ] Switching tenant locale to Hebrew renders the report RTL with translated labels and ₪ formatting.
- [ ] Drill-down expander and send-statements dialog pass keyboard-only navigation and expose correct ARIA state.
- [ ] No accessibility violations from icon-only status markers (each has a text/`aria-label` equivalent).
