# Uniform Format Export (מבנה אחיד / BKMVDATA) — Implementation Plan

**Spec:** docs/specs/2026-06-01-uniform-format-export.md  ·  **Slug:** uniform-format-export  ·  **Wave:** 13
**Depends on:** expenses-module, foundation-auth-rbac, invoice-credit-notes, invoice-receipt-document, invoices-core, system-i18n, vendors-suppliers

## Goal
Deliver the Israeli Tax Authority "uniform structure" audit export (מבנה אחיד) — a legally mandated bulk audit file that any IL computerized bookkeeping system must produce on demand over a date range. The feature generates two fixed-format, CP1255-encoded files (`INI.txt` index + `BKMVDATA.txt` data), bundles them into a ZIP stored in R2, and serves them via a 24-hour signed URL. It is distinct from live per-invoice ITA registration (`ita-einvoice`, spec 165): this is a bulk, offline, documents-only audit export.

## Architecture
The export reads document data already produced by upstream modules and re-emits it as fixed-field records keyed by a 4-char record-type code:
- **A000 / A100** — business header + accounting opening. Business primary ID (ע.מ/ח.פ) is `tenants.vat_number` (settings-isolated copy `tenant_settings.ita_vat_number`); the Zync software registration number is a deployment-global constant from env binding `ZYNC_ITA_SOFTWARE_ID` (UI shows it as "auto / Zync ITA registration").
- **C100 / D110** — invoice and credit-note headers + lines, from `invoices` + `invoice_lines`. Credit notes are `invoices` rows WHERE `source = 'credit_note'` (with `parent_invoice_id`); they are a filter on the same tables, not a new table.
- **C100 / D120** — receipt headers + payment detail, from `receipts` + `receipt_payment_lines` (invoice-receipt-document). `invoice_payments` (defined in partial-payment-recording, not a dependency here) is referenced by name only.
- **B100 / B110 / M100** — journal movements, chart-of-accounts entries, inventory items. **Zero-count in v1.** Zync is a documents-and-cash system, not a double-entry GL; ITA permits a documents-only (`ערכים בלבד`) export. Journal/account records are produced only when `accountant-export` (spec 181) is enabled, which is NOT a dependency here, so this plan emits count=0 for them.

**v1 scope note (unused dependencies):** `expenses-module` and `vendors-suppliers` are present as build-order metadata only. The spec body (record-type table and `generateUniformExport` steps 1–6) maps NO purchase/expense/vendor record. Purchase documents are NOT emitted as C100 in v1; they are deferred to the `accountant-export` ledger. This plan follows the spec body verbatim and does not invent an expense→C100 mapping.

Generation streams records to avoid Worker memory limits, accumulating per-record-code counts that feed `INI.txt`. Output is persisted to `uniform_export_jobs`. Large ranges run through the existing `export.generate` queue and store the ZIP in R2 (`STORAGE` binding). Access requires `reports:export` permission AND OWNER/ADMIN role; every generation is audited.

## Tech Stack
- **App:** `apps/zync-api` (Hono, Cloudflare Workers) — routes, generation, queue handler, CP1255 lib.
- **App:** `apps/zync-app` (Vite + React) — `/reports/uniform-format` page.
- **Packages:** `@zync/db` (Drizzle schema + migration), `@zync/types` (job/record types), `@zync/ui` (Button, Card, Select, Badge), `@zync/auth` (`requirePermission`, `requireTier`, `authMiddleware`).
- **Bindings:** `STORAGE` (R2), `QUEUE` (queue `export.generate`), Hyperdrive→Neon Postgres `DB`. New env: `ZYNC_ITA_SOFTWARE_ID` (string constant).
- **Libraries:** `zod` (route validation), `fflate` or Workers-native ZIP assembly for the archive.

## Wave Plan
| Sub-wave | Tasks | Files touched | Parallelizable? |
|----------|-------|---------------|-----------------|
| 13.a | 1 (CP1255 lib), 2 (DB table + migration), 3 (types) | `lib/cp1255.ts`, `@zync/db` schema, `@zync/types` | Yes — independent |
| 13.b | 4 (record builders), 5 (INI builder) | `reports/uniform-format/records.ts`, `ini.ts` | Builders parallel after 3; INI after 4 |
| 13.c | 6 (generateUniformExport stream), 7 (queue handler + R2/ZIP) | `reports/uniform-format.ts`, `queues/uniform-export.ts` | 6 then 7 |
| 13.d | 8 (API routes) | `routes/reports-uniform-format.ts` | After 2,6,7 |
| 13.e | 9 (i18n strings), 10 (UI page) | `@zync/i18n`, `pages/reports/UniformFormatExport.tsx` | 9 parallel; 10 after 8,9 |

## Tasks

### Task 1: CP1255 (Windows-Hebrew) encoding module
**Blocks:** 4, 6  ·  **Blocked by:** —
**Files:**
- Create: `apps/zync-api/src/lib/cp1255.ts`
**Steps:**
- [ ] Build a bundled CP1255 mapping table (Unicode code point → CP1255 byte) covering ASCII 0x00–0x7F (identity) plus the Hebrew block and CP1255-specific punctuation (0x80–0xFF range per the Windows-1255 codepage).
- [ ] Export `encodeCP1255(line: string): Uint8Array` that maps each character to its CP1255 byte.
- [ ] For characters outside CP1255 (rare; e.g. emoji in a free-text field), apply ITA fallback: transliterate where a sensible ASCII fold exists, otherwise strip the character; collect a list of stripped/transliterated chars.
- [ ] Export `encodeCP1255WithReport(line: string): { bytes: Uint8Array; substitutions: string[] }` so the generator can log fallbacks per line.
- [ ] Do NOT use the runtime `TextEncoder` for output bytes — it emits UTF-8 and ITA tooling rejects it. `TextEncoder` may only be used for intermediate string handling, never for the final file bytes.
**Schema / Interfaces:**
```ts
export function encodeCP1255(line: string): Uint8Array
export function encodeCP1255WithReport(line: string): { bytes: Uint8Array; substitutions: string[] }
```
**Acceptance:**
- [ ] `encodeCP1255('שלום')` yields the canonical CP1255 byte sequence for the Hebrew letters (not UTF-8 multi-byte).
- [ ] A string containing an emoji returns bytes with the emoji stripped and a non-empty `substitutions` array via `encodeCP1255WithReport`.

### Task 2: `uniform_export_jobs` table + migration
**Blocks:** 6, 7, 8  ·  **Blocked by:** —
**Files:**
- Modify: `packages/db/src/schema/reports.ts` (or create if absent)
- Create: `packages/db/migrations/<timestamp>_uniform_export_jobs.sql`
- Modify: `packages/db/src/schema/index.ts` (export the table)
**Steps:**
- [ ] Add the Drizzle table definition mirroring the canonical DDL below.
- [ ] Write the SQL migration with the exact DDL; add an index on `(tenant_id, created_at)` for the list endpoint.
- [ ] Export the table and its inferred row type from `@zync/db`.
**Schema / Interfaces:**
```sql
CREATE TABLE uniform_export_jobs (
  id                  UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id           UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
  period_from         DATE NOT NULL,
  period_to           DATE NOT NULL,
  mode                TEXT NOT NULL CHECK (mode IN ('documents','documents_journal')),
  status              TEXT NOT NULL DEFAULT 'pending'
                        CHECK (status IN ('pending','running','done','error')),
  record_counts       JSONB,                                  -- {"C100": 412, "D110": 1033, ...}
  r2_key              TEXT,
  download_expires_at TIMESTAMPTZ,
  generated_by        UUID REFERENCES users(id) ON DELETE SET NULL,
  error_message       TEXT,
  created_at          TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX idx_uniform_export_jobs_tenant ON uniform_export_jobs(tenant_id, created_at DESC);
```
**Acceptance:**
- [ ] Migration applies cleanly against Neon Postgres; `mode` and `status` reject out-of-enum values.
- [ ] `created_at` is `NOT NULL DEFAULT now()`; FKs are UUID→UUID.

### Task 3: Shared types for jobs and records
**Blocks:** 4, 6, 8, 10  ·  **Blocked by:** —
**Files:**
- Create: `packages/types/src/uniform-export.ts`
- Modify: `packages/types/src/index.ts` (re-export)
**Steps:**
- [ ] Define the job DTO, the record-type code union, the export mode union, and the API request/response shapes.
**Schema / Interfaces:**
```ts
export type UniformExportMode = 'documents' | 'documents_journal'
export type UniformExportStatus = 'pending' | 'running' | 'done' | 'error'
export type UniformRecordCode =
  | 'A000' | 'A100' | 'C100' | 'D110' | 'D120'
  | 'B100' | 'B110' | 'M100' | 'Z900'

export interface UniformExportJob {
  id: string
  tenantId: string
  periodFrom: string          // YYYY-MM-DD
  periodTo: string            // YYYY-MM-DD
  mode: UniformExportMode
  status: UniformExportStatus
  recordCounts: Partial<Record<UniformRecordCode, number>> | null
  downloadUrl?: string        // signed, present only when status==='done' and not expired
  downloadExpiresAt: string | null
  generatedBy: string | null
  createdAt: string
}

export interface StartUniformExportInput {
  from: string                // YYYY-MM-DD
  to: string                  // YYYY-MM-DD
  mode: UniformExportMode
}
```
**Acceptance:**
- [ ] Types compile and are importable from `@zync/types`.

### Task 4: BKMVDATA record builders
**Blocks:** 5, 6  ·  **Blocked by:** 1, 3
**Files:**
- Create: `apps/zync-api/src/reports/uniform-format/records.ts`
**Steps:**
- [ ] Implement fixed-width field formatters: `numField(value, width)` zero-padded right-aligned; `agorot(amountIls)` = `Math.round(amount * 100)` as integer string (no decimal separator); `dateField(d)` → `YYYYMMDD`; `textField(value, width)` left-aligned, space-padded, CP1255-safe.
- [ ] Each builder returns a finished line string beginning with its 4-char record code (chars 1–4 of every line are the code).
- [ ] `buildA000(header)` — software registration no. (`ZYNC_ITA_SOFTWARE_ID`), business primary ID (`tenants.vat_number`), period from/to.
- [ ] `buildA100()` — accounting opening record (generated, static fields per ITA layout).
- [ ] `buildC100(doc)` — document header for an invoice / credit note / receipt; `doc_type` distinguishes them.
- [ ] `buildD110(line)` — document line item from `invoice_lines` (and credit-note lines, same table filtered by `source='credit_note'`).
- [ ] `buildD120(payment)` — receipt/payment detail from `receipt_payment_lines` (method, cheque fields, card last-four/brand, bank reference).
- [ ] `buildZ900(counts, controlSum)` — closing record with per-record totals and a control sum.
- [ ] Each builder runs its output through `encodeCP1255WithReport` at emit time (in Task 6) — builders themselves return strings; encoding happens in the stream.
**Schema / Interfaces:**
```ts
export interface A000Header {
  softwareRegNo: string          // ZYNC_ITA_SOFTWARE_ID
  businessPrimaryId: string      // tenants.vat_number
  periodFrom: string             // YYYYMMDD
  periodTo: string               // YYYYMMDD
}
export function buildA000(h: A000Header): string
export function buildA100(): string
export function buildC100(doc: {
  docType: 'invoice' | 'credit_note' | 'receipt' | 'invoice_receipt'
  number: string | null
  issueDate: string              // YYYYMMDD
  customerId: string
  totalIls: number               // emitted in agorot
  vatIls: number
  currency: string
}): string
export function buildD110(line: {
  parentDocNumber: string | null
  position: number
  description: string
  quantity: number
  unitPriceIls: number
  lineTotalIls: number
}): string
export function buildD120(p: {
  parentDocNumber: string | null
  method: 'cash' | 'bank_transfer' | 'cheque' | 'credit_card' | 'other'
  amountIls: number
  chequeNumber?: string | null
  chequeBank?: string | null
  chequeBranch?: string | null
  chequeAccount?: string | null
  chequeDueDate?: string | null
  cardLastFour?: string | null
  cardBrand?: string | null
  reference?: string | null
}): string
export function buildZ900(counts: Record<string, number>, controlSumIls: number): string
```
**Acceptance:**
- [ ] Every returned line's first 4 chars equal the record code.
- [ ] Monetary fields contain no decimal separator (agorot integers); dates are `YYYYMMDD`; numeric fields are zero-padded right-aligned.

### Task 5: INI.txt builder
**Blocks:** 6  ·  **Blocked by:** 4
**Files:**
- Create: `apps/zync-api/src/reports/uniform-format/ini.ts`
**Steps:**
- [ ] Build `INI.txt`: echo the same A000 header fields, then one summary line per record-type code with its count (including the zero counts for B100/B110/M100).
- [ ] When B100/B110 counts are 0, declare the export as documents-only (`ערכים בלבד`) mode in the header per ITA rules.
- [ ] Run all lines through CP1255 encoding.
**Schema / Interfaces:**
```ts
export function buildINI(header: A000Header, counts: Record<UniformRecordCode, number>): string[]
```
**Acceptance:**
- [ ] `INI.txt` lists every record code with a count; codes with zero records appear with count 0.
- [ ] When journal counts are 0, the header flags documents-only (`ערכים בלבד`).

### Task 6: `generateUniformExport` streaming generator
**Blocks:** 7  ·  **Blocked by:** 1, 4, 5
**Files:**
- Create: `apps/zync-api/src/reports/uniform-format.ts`
**Steps:**
- [ ] Implement `generateUniformExport(env, tenantId, from, to, mode)` producing `{ bkmvdata: Uint8Array, ini: Uint8Array, counts }`.
- [ ] Resolve A000 header: `businessPrimaryId` from `tenants.vat_number` (fall back to `tenant_settings.ita_vat_number`); `softwareRegNo` from `env.ZYNC_ITA_SOFTWARE_ID`.
- [ ] Emit `A000` then `A100`.
- [ ] Stream invoices in `[from,to]` where `source IN ('invoice','proforma')` and a tax/issue date in range; per invoice emit `C100` then `D110` for each `invoice_lines` row (ordered by `position`). Use `tenantQuery` (never raw Drizzle from routes); paginate/cursor to bound memory.
- [ ] Stream receipts in range (`status='ISSUED'`, `issued_at` in range): emit `C100` (doc_type receipt/invoice_receipt) then `D120` for each `receipt_payment_lines` row.
- [ ] Stream credit notes: `invoices` where `source='credit_note'` in range; emit `C100` + `D110` for their lines (negative totals carried through).
- [ ] If `mode='documents_journal'` AND the `accountant-export` ledger is enabled, emit `B110` (accounts) and `B100` (movements) from the derived ledger; otherwise leave their counts at 0. v1 default path is documents-only.
- [ ] `M100` (inventory) count is always 0 in v1 (no inventory module).
- [ ] Accumulate `counts[code]++` on every emitted record; track a running control sum (total ILS in agorot) for `Z900`.
- [ ] Emit `Z900` last with counts + control sum.
- [ ] Encode each line via `encodeCP1255WithReport`; concatenate bytes with `\n` (0x0A) line terminators; log any non-empty `substitutions` with the line context.
- [ ] Build `INI.txt` via `buildINI` from the same header + final counts.
- [ ] Amounts: monetary fields use ILS values converted to agorot integers (`receipts.amount_ils` when present for foreign-currency receipts; `invoices.total`/`subtotal`/`vat_amount` for ILS docs).
**Schema / Interfaces:**
```ts
export async function generateUniformExport(
  env: Env,
  tenantId: string,
  from: string,                 // YYYY-MM-DD
  to: string,                   // YYYY-MM-DD
  mode: UniformExportMode,
): Promise<{ bkmvdata: Uint8Array; ini: Uint8Array; counts: Record<UniformRecordCode, number> }>
```
**Acceptance:**
- [ ] For a tenant with N issued invoices and M receipts in range, `counts.C100` = invoices + credit notes + receipts, `counts.D110` = total invoice/credit-note lines, `counts.D120` = total receipt payment lines.
- [ ] `counts.B100`, `counts.B110`, `counts.M100` are 0 in documents-only mode.
- [ ] Output bytes are CP1255 (Hebrew letters single-byte), newline-terminated.

### Task 7: Queue handler — ZIP assembly + R2 upload + signed URL
**Blocks:** 8  ·  **Blocked by:** 2, 6
**Files:**
- Create: `apps/zync-api/src/queues/uniform-export.ts`
- Modify: `apps/zync-api/src/index.ts` (register `export.generate` queue consumer branch for `type: 'uniform-format'`)
**Steps:**
- [ ] On message `{ type: 'uniform-format', jobId, tenantId, from, to, mode, userId }`: set job `status='running'`.
- [ ] Call `generateUniformExport`; assemble a ZIP containing `INI.txt` + `BKMVDATA.txt` (raw CP1255 bytes — do not re-encode inside the ZIP).
- [ ] Upload ZIP to R2 (`STORAGE`) at key `exports/uniform-format/{tenantId}/{jobId}.zip`.
- [ ] Generate a pre-signed R2 URL with **24h TTL**; set `r2_key`, `record_counts`, `status='done'`, `download_expires_at = now() + interval '24 hours'`.
- [ ] On any failure: set `status='error'`, store `error_message`; do not throw past the consumer (ack to avoid poison-loop unless retryable).
- [ ] Reuse the existing `export.generate` queue infra (spec 28) rather than a new queue.
**Acceptance:**
- [ ] A completed job has `status='done'`, a populated `r2_key`, `record_counts`, and `download_expires_at` ~24h out.
- [ ] The R2 object is a valid ZIP whose two entries are byte-identical to the generator output.

### Task 8: API routes
**Blocks:** 10  ·  **Blocked by:** 2, 3, 6, 7
**Files:**
- Create: `apps/zync-api/src/routes/reports-uniform-format.ts`
- Modify: `apps/zync-api/src/index.ts` (mount router)
**Steps:**
- [ ] `POST /api/reports/uniform-format` — zod-validate `{ from, to, mode }`; insert a `uniform_export_jobs` row (`status='pending'`, `generated_by`); enqueue `export.generate` message `{ type:'uniform-format', jobId, tenantId, from, to, mode, userId }`; return `{ id }`.
- [ ] `GET /api/reports/uniform-format` — list this tenant's jobs (newest first) with status + counts.
- [ ] `GET /api/reports/uniform-format/:id` — return job status; when `status='done'` and not past `download_expires_at`, attach a fresh signed ZIP URL (regenerate from `r2_key`); if expired return the job without a URL (and a flag).
- [ ] Guard ALL three routes with `authMiddleware`, then `requirePermission('reports:export')` AND OWNER/ADMIN role enforcement (both required — full financial history).
- [ ] Emit an audit log entry on `POST` (generation start) inside the same transaction as the job insert (`require-audit-in-transaction`).
- [ ] Use `tenantQuery`/`systemQuery` helpers; no raw Drizzle from route handlers (`no-raw-drizzle-from-routes`).
**Schema / Interfaces:**
```ts
// POST /api/reports/uniform-format
// body: StartUniformExportInput → 202 { id: string }
// GET  /api/reports/uniform-format         → UniformExportJob[]
// GET  /api/reports/uniform-format/:id      → UniformExportJob (downloadUrl present when done & fresh)
const startSchema = z.object({
  from: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
  to:   z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
  mode: z.enum(['documents', 'documents_journal']),
})
```
**Acceptance:**
- [ ] A non-OWNER/ADMIN user with `reports:export` is rejected (role check), and an OWNER without `reports:export` is rejected (permission check).
- [ ] `POST` writes both the job row and an audit entry atomically; `GET /:id` returns a working signed URL only while unexpired.

### Task 9: i18n strings (Hebrew + English)
**Blocks:** 10  ·  **Blocked by:** —
**Files:**
- Modify: `packages/i18n/src/locales/he/reports.json`
- Modify: `packages/i18n/src/locales/en/reports.json`
**Steps:**
- [ ] Add keys for page title (מבנה אחיד / Uniform Format Export), description, period (tax year / custom from–to), mode labels (documents only `ערכים בלבד` / documents + journal), primary ID label (ע.מ/ח.פ), software reg. no. label, Generate button, recent-exports list, status badges, and the expired-download message.
- [ ] Provide complete Hebrew and English translations for every key (no English fallback left in `he`).
**Acceptance:**
- [ ] Every UI string in Task 10 resolves via i18n in both `he` and `en`; no hardcoded user-facing text.

### Task 10: `/reports/uniform-format` UI page
**Blocks:** —  ·  **Blocked by:** 8, 9
**Files:**
- Create: `apps/zync-app/src/pages/reports/UniformFormatExport.tsx`
- Modify: `apps/zync-app/src/router.tsx` (route `/reports/uniform-format`, guarded by `reports:export` + OWNER/ADMIN)
- Modify: reports navigation hub + `/settings/data` link entries (add link to this page)
**Steps:**
- [ ] Build the form: Period selector (radio: Tax year `[year ▾]` | Custom `[from]–[to]`); Mode selector (radio: Documents only [default] | Documents + journal); read-only Primary ID (from business profile `tenants.vat_number`); read-only Software reg. no. (Zync constant); `[Generate export]` button.
- [ ] Wire React Query: `useMutation` → `POST`; `useQuery` list → `GET`; poll `GET /:id` while status is pending/running.
- [ ] Render "Recent exports" list with period label, generated date, status `Badge`, and a `[⬇ ZIP]` download button that opens the signed URL (disabled/relabeled when expired).
- [ ] Use only `@zync/ui` primitives (Button, Card, Select, Badge, Form) and design tokens — no hardcoded colors/spacing/radius; no raw HTML in pages.
- [ ] Honor RTL: page is fully RTL in Hebrew (logical properties via the design system); Hebrew labels render right-to-left.
- [ ] Respect `prefers-reduced-motion` for any generation spinner/progress affordance.
**Acceptance:**
- [ ] Submitting the form starts a job and the new job appears in "Recent exports" with a live-updating status.
- [ ] In Hebrew locale the page renders RTL with translated labels; the download button yields a valid ZIP for a completed job.
- [ ] Page passes the lint rules: `no-hardcoded-colors`, `no-hardcoded-spacing`, `no-raw-html-in-pages`.

## Cross-cutting compliance summary
- **Security:** `reports:export` AND OWNER/ADMIN on every route; generation audited in-transaction; signed URL TTL 24h (not 48h); R2 key namespaced per tenant.
- **Encoding (legal):** CP1255 is mandatory and non-substitutable — bundled mapping table, never `TextEncoder` for output bytes; out-of-codepage chars transliterated/stripped and logged.
- **i18n/RTL:** all strings via `@zync/i18n` (he + en); page RTL-correct in Hebrew.
- **Performance:** streaming generation + `export.generate` queue for large/multi-year ranges; cursor-bounded reads.
- **A11y:** reduced-motion honored; semantic form controls via `@zync/ui`.
