# Financial Statements (P&L + Cash Flow) — Implementation Plan

**Spec:** docs/specs/2026-06-01-financial-statements.md  ·  **Slug:** financial-statements  ·  **Wave:** 13
**Depends on:** bad-debt-writeoff, expenses-module, foundation-auth-rbac, invoices-core, multi-currency, partial-payment-recording

## Goal
Deliver management-level (not double-entry) Profit & Loss and Cash Flow statements for Business+ tenants, aggregated read-only from existing invoices, expenses, payment, and contractor-payout records. P&L is accrual-basis (recognized when invoiced); Cash Flow is cash-basis (actual money movement). The feature adds no new domain tables — it adds reporting query services, four covering indexes, four API endpoints (two JSON + two XLSX), a shared formula-safe RTL-correct Excel/CSV export writer reused by tax-report specs (171, 175), and the two report pages.

## Architecture
Pure read/aggregation layer over upstream tables:
- `invoices` — columns `tenant_id`, `total`, `total_ils`, `status`, `source`, `tax_issue_date`, `amount_paid`, `vat_amount`, `bad_debt_at` (revenue, credit-note deductions, bad-debt deductions, receivables).
- `expenses` — columns `tenant_id`, `amount` (ILS-normalized gross), `status`, `expense_date`, `expense_category` (expense lines by category; cash expenses paid).
- `invoice_payments` (owned by partial-payment-recording) — columns `tenant_id`, `invoice_id`, `amount`, `paid_at` (cash received).
- `payout_bills` (owned by contractor-payouts) — columns `tenant_id`, `net_amount`, `status`, `paid_at` (contractor payout expense, post-withholding net).

Revenue sums use `invoices.total_ils` when present, else `invoices.total` (both ILS for ILS invoices) per multi-currency spec. Credit notes are invoices with `source = 'credit_note'` and negative totals. Bad-debt deductions key on `invoices.bad_debt_at` (set by bad-debt-writeoff).

Data flow: Hono API route → Zod-validated query (`from`, `to`, optional `compare`) → report service in `@zync/db` (tenant-scoped via `tenantQuery`) running parameterized aggregate SQL → JSON response, or → shared export writer → XLSX/CSV stream. RBAC via `authMiddleware` + `requirePermission('reports:read' | 'reports:export')` and `requireTier('business')`. Pages live in the app (`apps/zync-app`), fetching via React Query hooks.

Upstream exports consumed: `authMiddleware`, `requirePermission`, `requireTier`, `tenantQuery`, `createDb`/`DB`, `buildPaginated` (not needed here), UI primitives (`Card`, `StatCard`, `DataTable`, `Select`, `Button`, `Tabs`, `Skeleton`, `EmptyState`, `ErrorState`), `useDirection`, `SUPPORTED_LOCALES`, `Locale`.

## Tech Stack
- **API:** `apps/zync-api` (Hono on Cloudflare Workers), Drizzle ORM against Neon Postgres via Hyperdrive. Zod for query validation.
- **Export:** XLSX via `exceljs`; native CSV builder for CSV; new shared package `@zync/exporters`. **Workers caveat:** verify `exceljs` bundles and runs under the Workers runtime (`nodejs_compat`) — it relies on Node streams + zip and is edge-heavy. If it fails to bundle/run, swap to a Workers-friendly writer (e.g. `write-excel-file`) keeping the same `@zync/exporters` interface. Permissions `reports:read` / `reports:export` are already seeded upstream by foundation-auth-rbac (no new permission rows needed). (formula-injection neutralization + RTL/Hebrew-font worksheet setup). CP1255 encoding hook reserved for spec 171 (PCN874) — exposed as an optional encoder, not used by this spec's outputs which are UTF-8 XLSX.
- **App:** `apps/zync-app` (Vite + React), TanStack Query, design-system primitives from `@zync/ui`.
- **DB migrations:** Drizzle migration files in `packages/db`.
- **Bindings:** `DB` (Hyperdrive Postgres). No new bindings.

## Wave Plan
| Sub-wave | Tasks | Files touched | Parallelizable? |
|----------|-------|---------------|-----------------|
| 13.a | 1 (indexes migration) | `packages/db/migrations`, `packages/db/src/schema` | Independent — start first |
| 13.b | 2 (shared export writer) | `packages/exporters/**` | Parallel with 13.a |
| 13.c | 3 (P&L service), 4 (Cash Flow service) | `packages/db/src/reports/**` | Parallel with each other; need 13.a |
| 13.d | 5 (P&L routes), 6 (Cash Flow routes) | `apps/zync-api/src/routes/reports/**` | Parallel; need 13.b, 13.c |
| 13.e | 7 (P&L page), 8 (Cash Flow page) | `apps/zync-app/src/features/reports/**` | Parallel; need 13.d |
| 13.f | 9 (tier/permission gating + nav), 10 (tests) | route guards, nav, test files | Sequential after 13.e |

## Tasks

### Task 1: Reporting covering indexes (migration)
**Blocks:** 3, 4  ·  **Blocked by:** —
**Files:**
- Create: `packages/db/migrations/0NNN_financial_statements_report_indexes.sql`
- Modify: `packages/db/src/schema/invoices.ts`, `packages/db/src/schema/expenses.ts`, `packages/db/src/schema/invoice-payments.ts`, `packages/db/src/schema/payout-bills.ts` (add `index()` declarations so Drizzle introspection matches)
**Steps:**
- [ ] Add the four covering indexes exactly as specified. `invoice_payments.tenant_id` already exists (owned by partial-payment-recording), so index on `(tenant_id, paid_at)` directly — no join fallback needed.
- [ ] Use `CREATE INDEX IF NOT EXISTS` to be idempotent against any pre-existing partial-payment index `idx_invoice_payments_tenant`.
- [ ] Mirror each index in the corresponding Drizzle schema file's table definition so `drizzle-kit` does not attempt to drop them.
**Schema / Interfaces:**
```sql
CREATE INDEX IF NOT EXISTS idx_expenses_report
  ON expenses (tenant_id, status, expense_date);
CREATE INDEX IF NOT EXISTS idx_invoice_payments_rpt
  ON invoice_payments (tenant_id, paid_at);
CREATE INDEX IF NOT EXISTS idx_payout_bills_rpt
  ON payout_bills (tenant_id, status, paid_at);
CREATE INDEX IF NOT EXISTS idx_invoices_report
  ON invoices (tenant_id, status, tax_issue_date);
```
**Acceptance:**
- [ ] Migration applies cleanly on a branch DB and re-applies idempotently.
- [ ] `EXPLAIN` on each report query (Task 3/4) shows an Index/Bitmap scan, not Seq Scan, over a per-tenant date range.

### Task 2: Shared financial export writer (`@zync/exporters`)
**Blocks:** 5, 6  ·  **Blocked by:** —
**Files:**
- Create: `packages/exporters/package.json`
- Create: `packages/exporters/src/index.ts`
- Create: `packages/exporters/src/xlsx.ts`
- Create: `packages/exporters/src/csv.ts`
- Create: `packages/exporters/src/sanitize.ts`
- Modify: `pnpm-workspace.yaml` (already globs `packages/*`; confirm), root `tsconfig` references if used
**Steps:**
- [ ] Implement `neutralizeFormula(value: string): string`: if the string's first character is one of `=`, `+`, `-`, `@`, `\t` (tab), or `\r` (carriage return), prefix a single quote `'`. Apply to every string cell value (headers and data) before writing. This blocks spreadsheet formula-injection from attacker-influenced free text (bank-transaction descriptions via expenses, customer/vendor names).
- [ ] Implement `writeXlsx(opts: XlsxExportOptions): Promise<Uint8Array>` using `exceljs`: create workbook; for each sheet set `worksheet.views = [{ rightToLeft: true }]`; set an explicit Hebrew-capable font (`{ name: 'Arial' }`, fallback `David`) on header row and all data cells; run every string value through `neutralizeFormula`; format numeric columns with thousands separators / 2-dp where flagged as currency.
- [ ] Implement `writeCsv(opts: CsvExportOptions, encoding?: 'utf-8' | 'cp1255'): Uint8Array`: comma-separated, RFC-4180 quoting, every cell value run through `neutralizeFormula`. `cp1255` encoding path is provided for spec 171 (PCN874) reuse; default `utf-8` with BOM so Excel opens Hebrew correctly.
- [ ] Export all public functions and the option types from `src/index.ts`.
**Schema / Interfaces:**
```typescript
export interface ExportColumn {
  key: string;
  header: string;          // Hebrew or English label
  type?: 'text' | 'number' | 'currency' | 'date';
}
export interface ExportSheet {
  name: string;
  columns: ExportColumn[];
  rows: Record<string, string | number | null>[];
  rightToLeft?: boolean;   // default true
}
export interface XlsxExportOptions {
  sheets: ExportSheet[];
  fontName?: string;       // default 'Arial'
}
export interface CsvExportOptions {
  sheet: ExportSheet;
}
export function neutralizeFormula(value: string): string;
export function writeXlsx(opts: XlsxExportOptions): Promise<Uint8Array>;
export function writeCsv(opts: CsvExportOptions, encoding?: 'utf-8' | 'cp1255'): Uint8Array;
```
**Acceptance:**
- [ ] A cell value `=SUM(A1:A9)` is written as `'=SUM(A1:A9)` (leading apostrophe) in both XLSX and CSV.
- [ ] Generated XLSX worksheet has `views[0].rightToLeft === true` and Hebrew header font set.
- [ ] CSV `cp1255` path encodes Hebrew bytes in code page 1255; `utf-8` path emits a UTF-8 BOM.
- [ ] Package builds with no `@zync/db` or app dependency (pure utility).

### Task 3: P&L aggregation service
**Blocks:** 5  ·  **Blocked by:** 1
**Files:**
- Create: `packages/db/src/reports/profit-loss.ts`
- Modify: `packages/db/src/reports/index.ts` (export barrel)
**Steps:**
- [ ] Implement `getProfitLoss(db, tenantId, { from, to })` returning the `ProfitLossReport` shape. Use parameterized SQL; all amounts in ILS.
- [ ] Gross revenue: `SUM(COALESCE(total_ils, total))` over `invoices` WHERE `tenant_id = $1` AND `status IN ('TAX_ISSUED','PARTIALLY_PAID','PAID')` AND `source <> 'credit_note'` AND `tax_issue_date BETWEEN $2 AND $3`. (Rationale for the `source <> 'credit_note'` predicate — an intentional refinement of the spec's literal SQL: credit notes are negative-total invoices and are subtracted explicitly on the next line, so excluding them from gross prevents double-counting their negative value.)
- [ ] Credit-note deductions: `SUM(ABS(COALESCE(total_ils, total)))` WHERE `source = 'credit_note'` AND `tax_issue_date` in period.
- [ ] Bad-debt write-offs: `SUM(COALESCE(total_ils, total))` WHERE `bad_debt_at IS NOT NULL` AND `bad_debt_at::date BETWEEN from AND to`. (Covers both `BAD_DEBT` and `WRITTEN_OFF` since both set `bad_debt_at`.)
- [ ] **Known caveat (documented decision, faithful to spec):** a written-off invoice's status becomes `BAD_DEBT`/`WRITTEN_OFF`, so it is already excluded from the gross-revenue `status IN (...)` filter, yet it is also subtracted here via `bad_debt_at`. For an invoice issued and written off within the same period this double-removes it (net revenue understated by that invoice). This matches the spec's stated data sources verbatim; if the business wants single-removal, gross would need to also include `BAD_DEBT`/`WRITTEN_OFF` status. Leave as-spec'd; flag for product review.
- [ ] Net revenue = gross − credit_notes − bad_debts.
- [ ] Expenses by category: `SELECT expense_category, SUM(amount) FROM expenses WHERE tenant_id = $1 AND status = 'COMPLETED' AND expense_date BETWEEN $2 AND $3 GROUP BY expense_category`. Map to `Record<string, number>`; null category → `'Other'`. `total_expenses` = sum of group values.
- [ ] Contractor payouts: `SUM(net_amount) FROM payout_bills WHERE tenant_id = $1 AND status = 'PAID' AND paid_at::date BETWEEN $2 AND $3`. Use `net_amount` (post-withholding) per spec architecture decision.
- [ ] Gross profit = net_revenue − total_expenses. Net profit = gross_profit − contractor_payouts. Gross margin pct = `net_revenue = 0 ? 0 : round(gross_profit / net_revenue * 100, 1)`.
- [ ] Comparison: when `compare` is true, compute the immediately-preceding period of equal duration (`prevTo = from - 1 day`, `prevFrom = prevTo - (to - from)`) and return it under `comparison`, plus per-line `% change` left to the UI.
- [ ] Coalesce all SUMs with `COALESCE(..., 0)` so empty periods return zeros, not null.
**Schema / Interfaces:**
```typescript
export interface ProfitLossReport {
  period: { from: string; to: string }; // YYYY-MM-DD
  revenue: { gross: number; credit_notes: number; bad_debts: number; net: number };
  expenses: { total: number; by_category: Record<string, number> };
  contractor_payouts: number;
  gross_profit: number;
  net_profit: number;
  gross_margin_pct: number;
  comparison?: Omit<ProfitLossReport, 'comparison'>;
}
export function getProfitLoss(
  db: DB,
  tenantId: string,
  args: { from: string; to: string; compare?: boolean },
): Promise<ProfitLossReport>;
```
**Acceptance:**
- [ ] All aggregates use ILS values (`total_ils` preferred, `total` fallback) and are tenant-scoped.
- [ ] Bad-debt and credit-note lines are excluded from gross revenue but subtracted to reach net revenue (no double counting).
- [ ] `compare: true` returns a `comparison` block for the prior equal-length window; `gross_margin_pct` is 0 when net revenue is 0.

### Task 4: Cash Flow aggregation service
**Blocks:** 6  ·  **Blocked by:** 1
**Files:**
- Create: `packages/db/src/reports/cash-flow.ts`
- Modify: `packages/db/src/reports/index.ts` (export barrel)
**Steps:**
- [ ] Implement `getCashFlow(db, tenantId, { from, to })` returning `CashFlowReport`. All ILS, parameterized, tenant-scoped.
- [ ] Payments received: `SUM(amount) FROM invoice_payments WHERE tenant_id = $1 AND paid_at::date BETWEEN $2 AND $3`.
- [ ] Expenses paid: `SUM(amount) FROM expenses WHERE tenant_id = $1 AND status = 'COMPLETED' AND expense_date BETWEEN $2 AND $3`.
- [ ] Contractor payouts (cash): `SUM(net_amount) FROM payout_bills WHERE tenant_id = $1 AND status = 'PAID' AND paid_at::date BETWEEN $2 AND $3`.
- [ ] Net operating = received − expenses_paid − contractor_payouts.
- [ ] Receivables are point-in-time (not period-bounded): `tax_issued` = `SUM(COALESCE(total_ils, total)) FROM invoices WHERE tenant_id = $1 AND status = 'TAX_ISSUED'`; `partially_paid_balance` = `SUM(COALESCE(total_ils, total) - COALESCE(amount_paid, 0)) FROM invoices WHERE tenant_id = $1 AND status = 'PARTIALLY_PAID'`.
- [ ] `COALESCE(..., 0)` on all SUMs.
**Schema / Interfaces:**
```typescript
export interface CashFlowReport {
  period: { from: string; to: string };
  received_from_customers: number;
  expenses_paid: number;
  contractor_payouts: number;
  net_operating: number;
  receivables: { tax_issued: number; partially_paid_balance: number };
}
export function getCashFlow(
  db: DB,
  tenantId: string,
  args: { from: string; to: string },
): Promise<CashFlowReport>;
```
**Acceptance:**
- [ ] Cash lines use actual movement (`invoice_payments.paid_at`, `payout_bills.paid_at`, `expenses.expense_date`).
- [ ] Receivables ignore the period filter (point-in-time snapshot of currently outstanding invoices).
- [ ] Empty tenant returns all zeros.

### Task 5: P&L API routes (JSON + XLSX)
**Blocks:** 7, 9  ·  **Blocked by:** 2, 3
**Files:**
- Create: `apps/zync-api/src/routes/reports/pl.ts`
- Modify: `apps/zync-api/src/routes/reports/index.ts` (mount), `apps/zync-api/src/app.ts` (register reports router if not already)
**Steps:**
- [ ] Define Zod schema `plQuerySchema`: `{ from: z.string().date(), to: z.string().date(), compare: z.coerce.boolean().optional() }`. Reject `to < from` with 400.
- [ ] `GET /api/reports/pl`: chain `authMiddleware`, `requireTier('business')`, `requirePermission('reports:read')`, validate query, call `getProfitLoss`, return JSON.
- [ ] `GET /api/reports/pl/xlsx`: same guards but `requirePermission('reports:export')`; build an `ExportSheet` (Revenue lines, Expenses-by-category rows, profit summary rows) and stream via `writeXlsx`. Set headers `Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet` and `Content-Disposition: attachment; filename="profit-loss-<from>_<to>.xlsx"`.
- [ ] Use the request locale to pick Hebrew vs English column headers; pass `rightToLeft: true` for Hebrew.
- [ ] Preserve CSP / security middleware already applied app-wide; do not relax it for downloads.
**Schema / Interfaces:**
```
GET /api/reports/pl
    query: { from: YYYY-MM-DD, to: YYYY-MM-DD, compare?: boolean }
    → ProfitLossReport (Task 3)
    guards: authMiddleware, requireTier('business'), requirePermission('reports:read')

GET /api/reports/pl/xlsx
    query: { from, to, compare? }
    → 200 application/vnd.openxmlformats-officedocument.spreadsheetml.sheet (attachment)
    guards: authMiddleware, requireTier('business'), requirePermission('reports:export')
```
**Acceptance:**
- [ ] Unauthenticated → 401; Freelancer tier → 403; missing `reports:read` → 403.
- [ ] Invalid/missing dates or `to < from` → 400 with a Zod error body.
- [ ] XLSX download requires `reports:export`; file opens RTL in Excel with Hebrew headers intact and no formula executes from any cell.

### Task 6: Cash Flow API routes (JSON + XLSX)
**Blocks:** 8, 9  ·  **Blocked by:** 2, 4
**Files:**
- Create: `apps/zync-api/src/routes/reports/cashflow.ts`
- Modify: `apps/zync-api/src/routes/reports/index.ts` (mount)
**Steps:**
- [ ] Zod `cashflowQuerySchema`: `{ from: z.string().date(), to: z.string().date() }`; reject `to < from`.
- [ ] `GET /api/reports/cashflow`: `authMiddleware` + `requireTier('business')` + `requirePermission('reports:read')`, validate, call `getCashFlow`, return JSON.
- [ ] `GET /api/reports/cashflow/xlsx`: same guards with `requirePermission('reports:export')`; build `ExportSheet` (Cash-from-operations lines, Receivables lines) and stream via `writeXlsx` with the same headers/filename convention (`cash-flow-<from>_<to>.xlsx`).
**Schema / Interfaces:**
```
GET /api/reports/cashflow
    query: { from: YYYY-MM-DD, to: YYYY-MM-DD }
    → CashFlowReport (Task 4)
    guards: authMiddleware, requireTier('business'), requirePermission('reports:read')

GET /api/reports/cashflow/xlsx
    query: { from, to }
    → 200 xlsx (attachment)
    guards: authMiddleware, requireTier('business'), requirePermission('reports:export')
```
**Acceptance:**
- [ ] Same guard/validation behavior as Task 5.
- [ ] XLSX is formula-safe and RTL-correct.

### Task 7: P&L report page (`/reports/pnl`)
**Blocks:** 9  ·  **Blocked by:** 5
**Files:**
- Create: `apps/zync-app/src/features/reports/ProfitLossPage.tsx`
- Create: `apps/zync-app/src/features/reports/useProfitLoss.ts` (React Query hook)
- Modify: `apps/zync-app/src/router.tsx` (route registration)
**Steps:**
- [ ] `useProfitLoss({ from, to, compare })` hook → `GET /api/reports/pl`; typed against `ProfitLossReport`.
- [ ] Render with design-system `Card`/`StatCard`: Revenue block (gross, less credit notes, less bad-debt, net), Expenses-by-category block, Gross profit + gross margin %, Contractor payouts, Net profit (before tax).
- [ ] Period selector: Monthly / Quarterly / Annual / Custom range (compute `from`/`to` client-side). Comparison toggle renders current vs previous side-by-side with % change.
- [ ] Year/period `Select` and `[Export PDF]` / `[Excel]` buttons; Excel button hits `/api/reports/pl/xlsx`. (PDF: render the page via existing print/PDF path; if none exists, the Excel export is the required deliverable and PDF reuses browser print.)
- [ ] Show the informational footnote verbatim: "Excludes personal income tax and national insurance. For tax preparation, consult your accountant." as an `aria-live="polite"`-free static `role="note"` region.
- [ ] States: `Skeleton` while loading, `ErrorState` on failure, `EmptyState` when all zeros. Respect `useDirection()` for RTL; numbers via `Intl.NumberFormat(locale, { style:'currency', currency:'ILS' })`. Honor `prefers-reduced-motion` for any transitions.
**Acceptance:**
- [ ] Page renders all P&L lines from the API; comparison mode shows two columns + % change.
- [ ] RTL layout correct in Hebrew; currency formatted as ₪ with thousands separators.
- [ ] Excel button downloads the XLSX; footnote always visible.

### Task 8: Cash Flow report page (`/reports/cashflow`)
**Blocks:** 9  ·  **Blocked by:** 6
**Files:**
- Create: `apps/zync-app/src/features/reports/CashFlowPage.tsx`
- Create: `apps/zync-app/src/features/reports/useCashFlow.ts`
- Modify: `apps/zync-app/src/router.tsx`
**Steps:**
- [ ] `useCashFlow({ from, to })` hook → `GET /api/reports/cashflow`.
- [ ] Render Cash-from-operations block (payments received +, expenses paid −, contractor payouts −, net operating) and Outstanding-receivables block (TAX_ISSUED, PARTIALLY_PAID balance, total).
- [ ] Period selector (single month default per spec); `[Export]` button → `/api/reports/cashflow/xlsx`.
- [ ] Footnote verbatim: "Cash flow based on actual payment records." plus a link/CTA "Comparison against bank statement: import statement →" routing to bank-statement-import when that module is enabled (guard with `useModuleEnabled`); hide the CTA otherwise.
- [ ] `Skeleton` / `ErrorState` / `EmptyState`; RTL via `useDirection()`; ₪ formatting; signed +/− coloring with non-color cue (leading sign + `aria-label`).
**Acceptance:**
- [ ] All cash-flow lines render from the API; receivables reflect current outstanding invoices.
- [ ] Inflows/outflows distinguished by sign and label, not color alone (a11y).
- [ ] Bank-statement CTA only shows when that module is enabled.

### Task 9: Tier/permission gating, navigation, and audit
**Blocks:** 10  ·  **Blocked by:** 7, 8
**Files:**
- Modify: `apps/zync-app/src/features/reports/*` (route guards), app navigation/sidebar config, `apps/zync-api/src/routes/reports/*` (audit logging on export)
**Steps:**
- [ ] Gate both pages behind `useTierGate('business')`; show the upgrade/upsell modal for Freelancer-tier users instead of the report.
- [ ] Add "Financial Statements" (P&L, Cash Flow) entries to the Reports navigation section, visible only when the user has `reports:read` and tenant tier ≥ Business.
- [ ] Emit a tenant-audit-log entry on each XLSX export (action `report.export`, target `pl`/`cashflow`, with period) so exports of financial data are traceable — wrap inside the request transaction per `require-audit-in-transaction`.
- [ ] Ensure routes use `tenantQuery`/`requirePermission`/`requireTier` (no raw Drizzle from routes; Zod validation present) to satisfy lint rules `no-raw-drizzle-from-routes`, `require-zod-validation-in-routes`.
**Acceptance:**
- [ ] Freelancer-tier user sees upgrade modal, not the report data.
- [ ] Nav links hidden without `reports:read`.
- [ ] Each export writes an audit entry within the same transaction.

### Task 10: Tests
**Blocks:** —  ·  **Blocked by:** 9
**Files:**
- Create: `packages/exporters/test/sanitize.test.ts`
- Create: `packages/db/test/reports/profit-loss.test.ts`
- Create: `packages/db/test/reports/cash-flow.test.ts`
- Create: `apps/zync-api/test/reports.test.ts`
**Steps:**
- [ ] `neutralizeFormula`: asserts each of `=`, `+`, `-`, `@`, `\t`, `\r` leading chars gets the apostrophe; benign strings untouched; verify in both XLSX cell values and CSV output.
- [ ] P&L service: seed invoices (TAX_ISSUED/PARTIALLY_PAID/PAID, a credit note, a bad-debt invoice), expenses across categories, paid payout_bills; assert gross/credit/bad-debt/net revenue, expenses-by-category, contractor payouts (net), gross/net profit, gross margin %, and the comparison window. Assert tenant isolation (other tenant's rows excluded).
- [ ] Cash Flow service: seed invoice_payments, COMPLETED expenses, PAID payout_bills, plus TAX_ISSUED and PARTIALLY_PAID invoices; assert received/expenses/payouts/net_operating and point-in-time receivables.
- [ ] API: assert 401 (no auth), 403 (Freelancer tier), 403 (missing permission), 400 (bad dates / `to<from`), 200 JSON shape, and XLSX `Content-Type`/`Content-Disposition` + RTL worksheet view.
**Acceptance:**
- [ ] All tests pass against a Neon branch DB.
- [ ] EXPLAIN check (from Task 1 acceptance) confirms index usage on the seeded dataset.
