# `@platform-modules/invoicing` — extraction design

**Date:** 2026-06-17
**Status:** designed (BUILD-approved at ×1 forward-need); supersedes the WATCH disposition in `2026-06-13-…-module-boundaries.md` §3.
**Audience:** AI coding agents first — optimize for activation, not prose.
**Census (the seam evidence, do not re-inline):** `docs/catalog/2026-06-17-invoicing-census.md` (zync AR core `@5f86caa` + multideal IL issuer `@09e4e9e0b`).
**Layer:** L3 (composite / bolt-on).

---

## 0. BLUF — the decision

Build **zync's provider-agnostic AR sub-ledger** as the `@platform-modules/invoicing` core (the invoice-document lifecycle), plus a typed **`InvoiceIssuer`** adapter seam for outbound per-jurisdiction tax-document creation (multideal's `VendorInvoiceProvider` is the reference). v0.0.1 ships the **IL** issuer adapter (×1 real donor: Morning/iCount); the **US** adapter is a flagged open item (§9, E). The AR core ships **explicitly unfrozen** (zync-shaped; freeze on the first non-donor consumer). Core money = **`bigint` minor-units + ISO-4217 currency**, gated by **swap-survival parity tests** against zync's `NUMERIC` output.

---

## 1. Disposition — WATCH → BUILD (supersedes boundary-spec §3)

§3 set **WATCH** on *multideal-only* evidence (one standalone async-issuance module). The 2026-06-17 zync census surfaced a **second, structurally richer donor**: a full provider-agnostic AR sub-ledger (issuance lifecycle · recurring · AR-aging · credit-notes · dunning · HTML-snapshot, 22 exports / 8 tables, S9-money-audited). The two donors are **complementary, not competing**:

| Donor | Contributes | Role |
|---|---|---|
| **zync** (`@5f86caa`) | the AR document lifecycle + collections | **the core** |
| **multideal** (`@09e4e9e0b`) | post-payment outbound tax-doc issuance per provider | **the `InvoiceIssuer` adapter reference** |

So it is **×2 complementary donors + real cross-class forward-need** (AR is broadly demanded across SaaS / commerce / marketplace classes). Per the build-at-×1 forward-need rule (Gate-1 holds: deleting the module relocates a full AR lifecycle into N apps; same seam across consumers; YAGNI on surface), this **promotes to BUILD**. Spec-first amendment to §3 required (§10).

---

## 2. Layer + dependency contract (corrects the registry)

**Registry line 366 declares `deps: [db, ledger]` — both wrong as hard deps. Correct:**

| Dep | Classification | Why |
|---|---|---|
| `@platform-modules/db` | **required** (`dependencies`, `workspace:^`) | `Database` / `Transaction<S>` handle; the schema is exported as `pgTable`s with no migration (Axis B). |
| `@platform-modules/tax` | **optional** (host composition) | The host resolves its jurisdiction's **legal date** → `resolveVatRate` → passes the rate/amount **in**. The core never calls tax internally (it cannot know the legal timezone — tax's own date contract). Mirrors the ledger lens: VAT is *supplied*, not *embedded*. |
| `@platform-modules/ledger` | **optional** (host composition) | The AR core is **ledger-agnostic**: balance is denormalized on the invoice (`total − amount_paid`); double-entry is **derived at export** (zync `movements.ts`), never posted in-module. |
| `@platform-modules/jobs` | **optional** | recurring-invoice + dunning crons (importable subpaths only). |
| `@platform-modules/mail` | **optional** | reminder / AR-statement sends (host-wired channel). |
| `@platform-modules/uploads` | **optional** | HTML-snapshot blob store (host R2/object-store). |

**Net: core dep = `[db]` only.** Everything else is an opt-in host composition behind a typed seam — the thin-core / R5-importable-growth shape. This is the load-bearing layering decision: it keeps invoicing installable into an app that has neither tax nor ledger.

---

## 3. Architecture

Two cohesive halves behind one package, each independently testable:

```
@platform-modules/invoicing
├─ (core barrel)         AR document lifecycle — jurisdiction-neutral, dep = [db]
│   ├─ issuance          createInvoice · sendInvoice · approveInvoice · issueTaxInvoice · autoIssueInvoice
│   ├─ payment           recordInvoicePayment · getInvoiceBalance (denorm amount_paid, overpayment)
│   ├─ credit-notes      createCreditNoteDraft · issueCreditNote · listCreditNotesForParent
│   ├─ ar-aging          assignAgingBucket · buildArAgingReport · buildArAgingCustomerStatement
│   ├─ sequences         nextInvoiceNumber (gap-free, per (tenant,type))
│   └─ document          renderInvoiceHtml + snapshot-key seam (no bundled PDF lib)
├─ ./recurring           recurring-template scheduling (importable subpath; optional jobs)
├─ ./dunning             payment-retry dunning + per-invoice reminders (importable subpath; optional jobs+mail)
└─ ./issuer              InvoiceIssuer adapter seam + IL (Morning/iCount) reference adapter
```

### 3.1 Money model — `bigint` minor-units + ISO-4217 currency (the crux)

**Decision: core money = `{ minor: bigint; currency: CurrencyCode }`, NOT zync's `NUMERIC(12,2)` decimal strings.** Rationale, strongest first:

1. **Seam-interop (the lead reason).** `@platform-modules/tax` and `@platform-modules/ledger` already speak **`bigint` minor-units**. A `NUMERIC`-string core would **convert at every `applyVat` / `extractVat` / ledger-export boundary** — and a string↔bigint money conversion is *itself a money hazard* (parse precision, scale assumptions). One representation across the money modules removes a whole class of boundary bugs.
2. **`NUMERIC` is exact decimal — its only defect is *scale*, not the type.** `NUMERIC(12,2)` is not float-corrupt; it is correct decimal. But the hard-coded **2-decimal scale** is an IL/2-decimal assumption that **breaks 0-decimal (JPY) and 3-decimal (BHD/KWD) currencies**. The seam must be shaped for the unknown adopter (north-star), not for the donor's home currency.
3. **Multi-currency is then a data concern, not a contract break.** Carry `currency: ISO-4217` on every money value + on the invoice row. **Multi-*exponent* rounding (per-currency minor-unit scale) is deferred** (freeze-rule — no non-IL donor); v0.0.1 exercises ILS (exponent 2) and delegates rounding to `@platform-modules/tax`.

**The cost — named, not skated past (R4 / §5.1 tension).** Invoice line math is materially harder than tax's single `amount × rate`: `line_total = qty(NUMERIC 10,3, *fractional*) × unit_price × (1 − discount_pct)`, summed per line, *then* VAT on the subtotal. Porting that to `bigint` is a **net-new re-derivation of zync's S9-money-audited rounding — NOT a byte-faithful port.** That is exactly the kind of audit-hardened money code §5.1/R4 says to preserve, so the divergence must be *earned by proof*:

> **HARD GATE — swap-survival parity tests.** The bigint port ships **iff** a parity suite asserts **bigint output ≡ zync's `NUMERIC` output** on real fixtures: fractional-qty lines, multi-line discounts, credit-note negatives, partial payments, exact-half VAT boundaries. (This is the embed skill's swap-survival assertion turned on our own port.) **No parity-test commitment → keep `NUMERIC`.** The parity oracle is generated from zync's actual query outputs, pinned in the census `@5f86caa`.

**Line-math representation (bigint).** `qty` → integer **milli-units** (`qty × 1000`); `unit_price` → `minor` (bigint); `discount_pct` → basis points. `line_total_minor = roundHalfUp( qtyMilli × unitPriceMinor × (10_000 − discountBp), 1000 × 10_000 )` using tax's robust `floor((2a + b) / (2b))` form (exact for any denominator). `subtotal = Σ line_total`. VAT via `tax.applyVat(subtotal, rate)` (exclusive) **or** `tax.extractVat(gross, rate)` (inclusive) — host picks by pricing model (Pattern C). **No float anywhere on the money path.**

### 3.2 Document model + lifecycle (jurisdiction-neutral)

Single invoice-document table; **IL two-stage is a jurisdiction profile on one state machine, not a separate module.** Status:
`DRAFT | SENT | APPROVED | REJECTED | TAX_ISSUED | PAID | PARTIALLY_PAID | VOID | WRITTEN_OFF | BAD_DEBT`.
IL profile: `proforma_number` assigned at `SENT`, gap-free `invoice_number` at `TAX_ISSUED`. A jurisdiction with single-stage issuance simply transitions `DRAFT → TAX_ISSUED` (assigns `invoice_number`, skips proforma) via `autoIssueInvoice`.

**Tables** (Axis B — exported `pgTable`s, no migration; host owns DDL): `invoices`, `invoice_lines`, `invoice_sequences`, `invoice_payments`, and (subpath) `recurring_invoice_templates`, `dunning_schedules`, `dunning_log`. Shapes + key columns: census §TABLES (do not re-inline). Money columns become `bigint` + a `currency` column per the §3.1 decision.

### 3.3 Hardening invariants (port byte-faithful where representation allows)

- **Idempotent issuance:** `dedup_key` partial-unique `(tenant_id, dedup_key) WHERE dedup_key IS NOT NULL`; `ON CONFLICT DO NOTHING` → `{ skipped: true }`. Recurring guard `hasInvoiceForPeriod`.
- **Gap-free numbering:** `nextInvoiceNumber` = `INSERT … ON CONFLICT DO UPDATE … RETURNING` per `(tenant_id, type)`; type ∈ `invoice | proforma | credit_note`. Number assigned **in the same tx** as the issuing transition.
- **Atomic credit-note application:** parent row `FOR UPDATE`; `lockParentAndGetRemainingCreditable`; `CREDIT_EXCEEDS_PARENT` enforced before issuing; credit note is a **first-class invoice row** (`parent_invoice_id`, negative qty/totals, `source='credit_note'`).
- **AR balance invariant:** `recordInvoicePayment` = transactional insert + denorm `amount_paid` update; `OverpaymentError` on over-application; `getInvoiceBalance` reconciles payments against `total`.

### 3.4 `InvoiceIssuer` adapter seam (outbound tax-doc creation)

The adapter is **tax-agnostic** — VAT is computed by the caller and passed in (multideal proof: `vatAgorot` is a field on `InvoiceCreateArgs`). Seam (generalized from multideal `VendorInvoiceProvider`, minor-units-normalized):

```ts
interface InvoiceIssuer {
  readonly kind: string                                   // 'morning' | 'icount' | …
  readonly credentialsSchema: StandardSchemaV1            // adopter-supplied validator (no bundled zod)
  validateCredentials(creds: unknown): Promise<{ ok: true } | { ok: false; reason: string }>
  createInvoice(args: InvoiceCreateArgs): Promise<InvoiceCreateResult>   // → { documentId, documentNumber, documentUrl }
  voidInvoice?(documentId: string): Promise<void>
}
// InvoiceCreateArgs carries Money (minor+currency), line items, vendor/buyer, and the
// host-resolved vat amount — NOT a tax dependency.
```

- **IL reference adapter (v0.0.1):** Morning/iCount — POST `api.icount.co.il/api/v3.php`, `doc_type 400` (חשבונית מס קבלה), minor-units → `formatAgorotPlain` decimal strings, returns provider `doc_id/doc_number/url`. **Credentials = host-supplied encrypted store** (AES-GCM, `INVOICE_KEK`-class env), never in-module, never committed.
- **Provider doc-numbering:** the IL provider returns the legal document number — the host-side gap-free `invoice_sequences` is for jurisdictions where *the platform* numbers (zync), not where the *provider* does (multideal). Both modes supported; the issuer result records `external_id` / `external_provider` on the invoice row.
- **Inverse `pushInvoice` (zync's sync-already-issued-AR-to-SaaS) is a DEFERRED optional capability**, not the v0.0.1 issuer seam — different direction, no convergence yet.

### 3.5 Document / PDF

`renderInvoiceHtml(invoice, opts) → string` ships in-module; **no bundled PDF library.** The HTML snapshot is stored by the host (object-store key persisted on the invoice via `setInvoiceHtmlSnapshotUrl`); PDF is host print-to-PDF **or** an issuer's provider-hosted `documentUrl`. Render agnostic, storage host-delegated.

---

## 4. v0.0.1 capability cut (R5 harvest vs YAGNI surface)

All six zync groups are mature → **harvest, don't discard** (R5). The cut is about *where each sits*, not whether it ships:

| Capability | v0.0.1 placement | Reason |
|---|---|---|
| issuance lifecycle · payment application · credit-notes · AR-aging · sequences · HTML-snapshot | **core barrel** | the irreducible AR document model |
| recurring-invoice scheduling | **`./recurring` subpath** (optional `jobs`) | needs a scheduler; importable, not core |
| dunning + per-invoice reminders | **`./dunning` subpath** (optional `jobs`+`mail`) | collections policy; importable, not core |
| `InvoiceIssuer` seam + IL adapter | **`./issuer` subpath** | provider SDK behind an adapter (peer dep) |

**Freeze status: the AR core ships EXPLICITLY UNFROZEN (zync-shaped).** The likely first embed (multideal) exercises only the `InvoiceIssuer` seam — *no non-donor app consumes the AR core in v0.0.1*. Per the freeze-rule, the AR-core contract is labeled `zync-shaped, unfrozen — freeze on first non-donor consumer`. The `InvoiceIssuer` seam is **IL-concrete** (one real donor); its envelope is not frozen against US until §9-E resolves.

---

## 5. Error handling

Typed errors only; **no bare throw at the seam, no cross-package `instanceof`** (structural brand-guards — two deduped copies break identity). Error families: `CreditNoteError` (`PARENT_NOT_CREDITABLE | CREDIT_EXCEEDS_PARENT | REASON_REQUIRED | NOT_DRAFT | DUPLICATE_DRAFT`), `OverpaymentError`, `SequenceError`, `IssuerError` (`INVALID_CREDENTIALS | PROVIDER_REJECTED | DOC_TYPE_UNSUPPORTED`). Each ships an `isXError(e): e is XError` structural guard. Trust-boundary inputs (`createInvoiceSchema`, money, dates) validated at the public boundary; malformed → throw (hard floor).

---

## 6. Testing (one behavioral test per export + the load-bearing gates)

1. **Money parity (HARD GATE, §3.1):** bigint output ≡ zync `NUMERIC` on fractional-qty / multi-line-discount / credit-note / partial-payment / exact-half-VAT fixtures.
2. **Idempotency:** duplicate `dedup_key` → `{ skipped: true }`, single row.
3. **Gap-free numbering:** concurrent `nextInvoiceNumber` → contiguous, no gaps, no dupes (per-tenant).
4. **Credit-note atomicity:** `CREDIT_EXCEEDS_PARENT` blocks over-credit; parent lock serializes concurrent drafts.
5. **AR-aging buckets:** `assignAgingBucket` boundary days (0 / 30 / 31 / 60 / 61 / 90 / 90+).
6. **Issuer adapter:** mock issuer round-trip + IL adapter contract test (credential validate, payload shape, result mapping) — provider HTTP mocked.
7. **Lifecycle transitions:** every illegal transition rejected; legal transition assigns the right number in-tx.

Harness vehicle: headless module → root `tools/` smoke + esbuild bundle-check (per the harness-by-surface rule); PGLite for the DB-backed behavioral tests.

---

## 7. Data flow (issuance, the spine)

```
host resolves legal date (YYYY-MM-DD) ─▶ tax.resolveVatRate(schedule, date) ─▶ VatRate (bp)
   │
createInvoice(db, tenant, actor, input, currency)        # DRAFT, dedup_key idempotent
   │  line math (bigint) ─▶ subtotal ─▶ tax.applyVat/extractVat(rate) ─▶ vat, total
sendInvoice            # DRAFT→SENT, proforma_number (IL profile)
approveInvoice         # SENT→APPROVED
issueTaxInvoice        # APPROVED→TAX_ISSUED, gap-free invoice_number in-tx
   │  (optional) issuer.createInvoice(args) ─▶ external_id/provider/url   # outbound provider doc
recordInvoicePayment   # → PARTIALLY_PAID / PAID, amount_paid denorm
   │  (optional, export-time) ledger.deriveMovements(invoice) at host    # never in-module
```

---

## 8. Architecture Decisions (collapses + rejected candidates)

- **REJECTED — separate `@platform-modules/invoice-issuer` package.** The issuer seam is consumed only by invoicing; deletion test → complexity relocates into invoicing anyway. Kept as a `./issuer` subpath. (Deep seam: provider SDK fully hidden behind the interface.)
- **REJECTED — embed ledger / tax in the core.** Both fail the embedded-vs-derived test (ledger derived at export; VAT supplied by host). Demoted to optional compositions → core dep `[db]` only.
- **REJECTED — port zync's `NUMERIC` representation.** Loses seam-interop with tax/ledger; defers, not solves, the multi-currency scale problem. Replaced by bigint+currency under the parity gate (§3.1).
- **COLLAPSED — recurring + dunning into subpaths**, not the core barrel (importable growth, optional jobs/mail).
- **KEPT — IL two-stage as a jurisdiction profile** on one state machine (not a separate module): the deletion test scatters the proforma/tax-invoice columns back into the one document table regardless.

---

## 9. Open items (resolve before the plan skill)

- **E — US issuer adapter (user tie-break, does NOT block the spec body).** User locked "IL + US". Counter-evidence gathered this design: **0 US donors**; US sales tax is a *different model* (provider/Stripe-Tax strategy, **not built** — tax boundary-spec §5); that §5 explicitly warns *"do NOT freeze the adapter envelope from IL + guess."* The `InvoiceIssuer` **seam is identical** either way — E only decides whether a US *implementation* ships in v0.0.1 or is seam-ready-deferred. **Discriminating question for the user:** real US consumer/need now, or aspirational? Aspirational → seam-ready, defer impl (freeze-rule). Real → build it. **Default = honor the lock** if unanswered.
- **Multi-exponent rounding** (per-currency minor-unit scale) — deferred to first non-2-decimal adopter.

## 10. Reconciliation items (spec-first amendments owed)

1. `2026-06-13-…-boundaries.md` §3: **WATCH → BUILD** (zync second donor) — amend with a dated note.
2. `docs/registry.json` line ~366: invoicing `deps: [db, ledger]` → **`deps: [db]`, optional `[tax, ledger, jobs, mail, uploads]`**; `status: WATCH → designed`.
3. Tax registry line 203 already lists `consumed_by: [billing, invoicing]` — keep (tax is an *optional* invoicing composition, still a real consumer).
