# Invoicing census — zync AR core + multideal IL provider dispatch

**Date:** 2026-06-17  
**Purpose:** load-bearing seam + data-shape evidence for `@platform-modules/invoicing` boundary spec.  
**Decision (locked):** zync provider-agnostic AR core → module; typed `InvoiceIssuer` jurisdiction-adapter seam; v0.0.1 ships IL + US adapters (US net-new).

---

## PART A — zync AR sub-ledger / invoicing core

### LOCATION + SHA

| Grain | Path | Pin |
|---|---|---|
| **path-scoped** | `/home/user/Projects/zync.is` — `packages/db/src/schema/invoices.ts`, `packages/db/src/queries/invoices.ts`, `packages/db/src/queries/{recurring-invoices,ar-aging,dunning,invoice-reminders,invoice-payments,invoice-automation,invoice-adapters}.ts`, `apps/zync-api/src/routes/invoices/`, `apps/zync-api/src/queues/invoice-generate.ts`, `apps/zync-api/src/routes/cron/recurring-invoice-generator.ts`, `apps/zync-api/src/integrations/invoice-adapters/`, `apps/zync-api/src/lib/invoice-snapshot.ts` | `5f86caa` |
| **repo HEAD** (coarse) | `/home/user/Projects/zync.is` | `3d113e1` |

### File inventory

**Schema (Drizzle/pg):** `packages/db/src/schema/invoices.ts` (invoices · invoice_lines · invoice_sequences), `invoice-payments.ts`, `recurring-invoices.ts`, `dunning.ts`, `invoice-adapters.ts` (integration_sync_logs only — credentials live in communications.adapter_credentials).

**Query seam (public AR API — routes import `@zync/db/queries`, never raw tables):** `packages/db/src/queries/invoices.ts`, `invoice-payments.ts`, `invoice-sequences.ts`, `recurring-invoices.ts`, `ar-aging.ts`, `dunning.ts`, `invoice-reminders.ts`, `invoice-automation.ts`, `invoice-adapters.ts`, `bad-debt.ts`, `settings-invoices.ts`, `time-invoice.ts`.

**HTTP / jobs:** `apps/zync-api/src/routes/invoices/` (index · payments · recurring · activity · receipt-issue · payment-link · reminders · dunning), `routes/reports/ar-aging-statements.ts`, `routes/invoice-adapters/router.ts` + `cron.ts`, `routes/cron/recurring-invoice-generator.ts`, `routes/cron/invoice-reminders.ts`, `queues/invoice-generate.ts`.

**PDF / snapshot:** `packages/db/src/queries/invoices.ts` (`renderInvoiceHtml`), `apps/zync-api/src/lib/invoice-snapshot.ts` (R2 put + `setInvoiceHtmlSnapshotUrl`).

**IL provider push (optional sync — NOT issuance):** `apps/zync-api/src/integrations/invoice-adapters/index.ts`.

**Ledger (derived, not stored):** `apps/zync-api/src/reports/movements.ts` — derives double-entry movements from invoices/receipts/expenses at export time.

**Boundary specs:** `docs/specs/2026-05-30-invoices-core.md`, `docs/specs/2026-06-01-invoice-receipt-document.md`.

### SEAM — public signatures by capability

Money in core AR is **NUMERIC(12,2) decimal strings** (not minor-unit integers). Idempotency via `invoices.dedup_key` partial unique `(tenant_id, dedup_key)`.

#### (a) Invoice issuance / generation

| Export | Signature | Location |
|---|---|---|
| `createInvoice` | `(db, tenantId, actorId, input: createInvoiceSchema, countryCode, actorCtx?) → Promise<CreateInvoiceResult>` — `CreateInvoiceResult = InvoiceWithLines & { skipped: boolean }`; dedup via `dedupKey` ON CONFLICT | `packages/db/src/queries/invoices.ts:471` |
| `sendInvoice` | `(db, tenantId, id, actorId, countryCode, issueDate, actorCtx?) → Promise<Invoice>` — DRAFT/REJECTED→SENT; assigns `proformaNumber`; recomputes VAT from `getVatRateForDate` | `:806` |
| `approveInvoice` | `(db, tenantId, id, actorId, actorCtx?) → Promise<Invoice>` — SENT→APPROVED | `:897` |
| `issueTaxInvoice` | `(db, tenantId, id, actorId, taxIssueDate, countryCode, htmlSnapshotUrl?, actorCtx?) → Promise<Invoice>` — APPROVED→TAX_ISSUED; assigns `invoiceNumber` atomically | `:1125` |
| `issueTaxInvoiceTx` | same inside `DbTx`; `opts?: { allowedStatuses?, htmlSnapshotUrl?, actorCtx? }` | `:1030` |
| `autoIssueInvoice` | `(db, tenantId, actorId, input: autoIssueSchema, countryCode) → Promise<{ invoiceId, invoiceNumber }>` — skips proforma; creates TAX_ISSUED in one tx | `:1680` |
| `processRetainerInvoiceJob` / `processInvoiceGenerateJob` | queue consumers; call `createInvoice` + optional `sendInvoice`; dedup keys via `autoGenDedupKey` | `packages/db/src/queries/invoice-automation.ts` |

**IL two-stage model (provider-agnostic, jurisdiction-shaped):** proforma at SENT (`proformaNumber`), tax invoice at TAX_ISSUED (`invoiceNumber`). Status machine: `DRAFT | SENT | APPROVED | REJECTED | TAX_ISSUED | PAID | PARTIALLY_PAID | VOID | WRITTEN_OFF | BAD_DEBT`.

#### (b) Recurring / subscription invoice scheduling

| Export | Signature | Location |
|---|---|---|
| `createRecurringInvoiceTemplate` | `(db, tenantId, actorId, input) → Promise<RecurringInvoiceTemplate>` | `recurring-invoices.ts:216` |
| `listDueRecurringTemplates` | `(db, asOfDate: string) → Promise<RecurringInvoiceTemplate[]>` | `:353` |
| `hasInvoiceForPeriod` | `(db, templateId, periodDate) → Promise<boolean>` — idempotency guard | `:380` |
| `advanceRecurringTemplate` | `(db, template) → Promise<void>` — bumps `next_generation_date` | `:399` |
| Cron orchestration | POST `/api/cron/recurring-invoice-generator` → `createInvoice` + optional `sendInvoice` + link `recurring_template_id` | `apps/zync-api/src/routes/cron/recurring-invoice-generator.ts:42` |

Template table: `recurring_invoice_templates` (frequency weekly/monthly/quarterly/yearly, `line_items` JSONB, `auto_send`, `next_generation_date`). Queue path also exists for retainer/task triggers (`queues/invoice-generate.ts`).

#### (c) AR-aging projection

| Export | Signature | Location |
|---|---|---|
| `assignAgingBucket` | `(dueDateStr, asOfStr) → 'current' \| 'd1_30' \| 'd31_60' \| 'd61_90' \| 'd90plus'` | `ar-aging.ts:40` |
| `buildArAgingReport` | `(db, tenantId, { asOf?, currency? }) → Promise<ArAgingReport>` — outstanding statuses; balance = `total - amount_paid` | `:78` |
| `buildArAgingCustomerStatement` | `(db, tenantId, customerId, { asOf, currency }) → Promise<ArAgingCustomerStatementData>` | `:220` (approx) |
| Statement send (route) | POST `/api/reports/ar-aging/statements` `{ customer_ids, as_of, subject?, message? }` → `{ sent }` | `apps/zync-api/src/routes/reports/ar-aging-statements.ts:26` |

Outstanding filter: `status IN ('SENT','APPROVED','TAX_ISSUED','PARTIALLY_PAID')`.

#### (d) Credit notes

| Export | Signature | Location |
|---|---|---|
| `createCreditNoteDraft` | `(db, tenantId, parentId, actorId, input: { mode, reason, lines? }) → Promise<InvoiceWithLines>` — locks parent; enforces remaining creditable; `source='credit_note'` | `invoices.ts:1346` |
| `issueCreditNote` | `(db, tenantId, creditNoteId, actorId) → Promise<Invoice>` — DRAFT→TAX_ISSUED; `nextInvoiceNumber(tx, tenantId, 'credit_note')` | `:1463` |
| `listCreditNotesForParent` | `(db, tenantId, parentId) → Promise<{ items: Invoice[]; netInvoiced: number }>` | `:1533` |

Credit notes are **first-class invoice rows** (`parent_invoice_id`, negative line qty, negative totals). `CreditNoteError` codes: `PARENT_NOT_CREDITABLE`, `CREDIT_EXCEEDS_PARENT`, `REASON_REQUIRED`, `NOT_DRAFT`, `DUPLICATE_DRAFT`.

#### (e) Dunning

Two subsystems:

**Payment-retry dunning** (`dunning.ts`): schedule steps `{ offsetDays, action: 'email_reminder' \| 'suspend_access' \| 'flag_for_review' }`; log in `dunning_log`.

| Export | Signature | Location |
|---|---|---|
| `listDunningSchedules` | `(db, tenantId) → Promise<DunningScheduleObject[]>` | `dunning.ts:77` |
| `upsertDunningSchedule` | `(db, tenantId, input) → Promise<DunningScheduleObject>` | `:89` |
| `appendDunningLog` | `(db, tenantId, invoiceId, offsetDays, action, result, errorMsg?) → Promise<void>` | `:128` |

**Invoice payment reminders** (`invoice-reminders.ts`): per-invoice columns on `invoices` (`reminder_last_sent_at`, `next_reminder_at`, `next_reminder_offset`, `reminders_disabled`); tenant schedule JSONB.

| Export | Signature | Location |
|---|---|---|
| `computeNextReminder` | `(dueDate, schedule: ReminderStage[], lastSentOffset?) → NextReminder \| null` | `invoice-reminders.ts:62` |
| `selectReminderDueInvoices` | `(db) → Promise<ReminderDueInvoice[]>` | `:145` |
| `advanceInvoiceReminder` | `(db, invoiceId, tenantId, offset) → Promise<void>` | `:180` |

Cron: `routes/cron/invoice-reminders.ts`.

#### (f) PDF / document generation

| Export | Signature | Location |
|---|---|---|
| `renderInvoiceHtml` | `(invoice: InvoiceWithLines, opts: { tenantName, tenantTaxId?, customerName, locale?, r2PublicUrl?, ... }) → string` | `invoices.ts:1815` |
| `setInvoiceHtmlSnapshotUrl` | `(db, tenantId, id, htmlSnapshotUrl) → Promise<Invoice>` | `:1764` |
| `captureInvoiceHtmlSnapshot` (host) | loads tenant/customer, calls `renderInvoiceHtml`, puts HTML to R2, persists key on invoice | `apps/zync-api/src/lib/invoice-snapshot.ts` |

No in-module PDF library — **HTML snapshot stored in R2**; served via authenticated GET. Browser print-to-PDF / host conversion is implied.

#### IL invoice adapter seam (sync only — separate from AR issuance)

```ts
// apps/zync-api/src/integrations/invoice-adapters/index.ts:24
interface InvoiceAdapter {
  testConnection(): Promise<void>
  pushInvoice(invoiceId: string, tenantId: string, db: Db): Promise<string> // externalId
}
```

Push requires invoice `status IN ('TAX_ISSUED','PAID')`. Providers: `morning | icount | rivhit | invoice4u | easycount`. Config/credentials via `invoice-adapters` queries + encrypted `adapter_credentials`.

**Primary seam function count (capabilities a–f):** **22** exported operations across `invoices.ts`, `recurring-invoices.ts`, `ar-aging.ts`, `invoice-reminders.ts`, `dunning.ts` (6 issuance + 4 recurring + 3 aging + 3 credit-note + 6 dunning/reminders + 2 PDF).

### TABLES

| Table | Key columns | Money | Idempotency / unique | FK refs |
|---|---|---|---|---|
| `invoices` | status, invoice_number, proforma_number, currency, vat_rate, subtotal, vat_amount, total, amount_paid, overpayment_amount, dedup_key, source, parent_invoice_id, external_id, external_provider, html_snapshot_url, reminder_*, bad_debt_* | NUMERIC(12,2) | `(tenant_id, invoice_number)` uniq; `(tenant_id, proforma_number)` uniq; partial uniq `(tenant_id, dedup_key) WHERE dedup_key IS NOT NULL` | tenants, customers, projects, users, recurring_invoice_templates, self (parent) |
| `invoice_lines` | description, quantity NUMERIC(10,3), unit_price, discount_pct, line_total, taxable, position | NUMERIC | — | invoices, tenants, expenses (optional) |
| `invoice_sequences` | `(tenant_id, type)` PK; type ∈ invoice/proforma/credit_note; last_number int; prefix | — | PK | tenants |
| `invoice_payments` | amount, currency, paid_at, source, reference, receipt_id | NUMERIC(12,2) | — | tenants, invoices, users |
| `recurring_invoice_templates` | line_items JSONB, frequency, next_generation_date, auto_send, status | vat_rate NUMERIC(5,4) | — | tenants, customers, users |
| `dunning_schedules` | offset_days, action, email_template_id | — | `(tenant_id, offset_days)` uniq | tenants |
| `dunning_log` | invoice_id, offset_days, action, result | — | — | tenants, invoices |
| `integration_sync_logs` | provider, entity_type, entity_id, external_id, direction, status | — | — | tenants, users |

**Table count (invoicing AR domain):** **8**.

### DEPS + COUPLING

| Concern | How zync couples | Provider-agnostic? |
|---|---|---|
| **Ledger / double-entry** | No ledger module. `movements.ts` **derives** journal lines from invoices/receipts/expenses using `coa_mappings` at export time. AR balance = invoice denorm (`total - amount_paid`). | Core AR is agnostic; ledger is export adapter |
| **Billing / payments** | `recordInvoicePayment` / gateway webhooks update `invoice_payments` + denorm; `autoIssueInvoice` for auto_charge; payment gateways (Stripe/PayPlus/Cardcom) separate | Agnostic |
| **Tax / VAT** | `vat_rates` table + `getVatRateForDate(countryCode, date)`; rate frozen on invoice at send/tax-issue | Rate lookup is jurisdiction input; IL two-stage numbers baked into status model |
| **PDF** | In-module HTML render; R2 blob via host (`invoice-snapshot.ts`) | Render agnostic; storage host-delegated |
| **Jobs / queue** | CF Queue `invoice-generate` (retainer/task/fixed-deposit); cron for recurring + reminders | Agnostic message shapes |
| **IL external invoicing** | Optional **push** adapters after TAX_ISSUED; `external_id` / `external_provider` on invoice | **Coupled** — adapter package, not core issuance |

### MATURITY / hardening

| Area | Evidence |
|---|---|
| **Idempotent issuance** | `dedup_key` + `ON CONFLICT DO NOTHING` → `{ skipped: true }`; recurring `hasInvoiceForPeriod`; automation `autoGenDedupKey` |
| **Atomic credit-note application** | Parent row `FOR UPDATE`; `lockParentAndGetRemainingCreditable`; credit issued in same tx as sequence assignment |
| **AR balance invariants** | `recordInvoicePayment` transactional insert + denorm update; `OverpaymentError`; `getInvoiceBalance` reconciles payments |
| **Gap-free numbering** | `nextInvoiceNumber` — `INSERT … ON CONFLICT DO UPDATE RETURNING` per `(tenant_id, type)` |
| **Tests** | `packages/db/test/bulk-operations/bulk-invoice-status.test.ts`; `packages/db/test/security/s9-money-validation.test.ts` (invoice money paths); `packages/db/test/reports/profit-loss.test.ts`, `cash-flow.test.ts`. **No dedicated credit-note or ar-aging unit tests found.** |
| **Audit** | Security marathon S9 money validation; entity-history capture on status transitions |

---

## PART B — multideal IL provider dispatch (`InvoiceIssuer` reference)

### LOCATION + SHA

| Grain | Path | Pin |
|---|---|---|
| **path-scoped** | `/home/user/Projects/multideal` — `apps/web/src/server/invoicing/`, `apps/web/src/server/workflows/invoice-job.ts`, `apps/web/src/server/db/schema.ts` (vendor_invoice_settings · invoices) | `09e4e9e0b` |
| **repo HEAD** (coarse) | `/home/user/Projects/multideal` | `61f7d9ffe` |

### File inventory

`apps/web/src/server/invoicing/types.ts`, `provider.ts` (legacy), `registry.ts`, `get-provider.ts`, `morning.ts`, `icount.ts`, `noop.ts`, `not-implemented.ts`, `mock-provider.ts`  
`apps/web/src/server/workflows/invoice-job.ts`  
`apps/web/src/server/db/schema.ts` — `vendor_invoice_settings`, `invoices` (job store)  
`apps/web/src/pages/api/vendor/invoicing/{settings,providers,test-connection}.ts`  
`apps/web/src/server/crypto/invoice-credentials.ts`  
`apps/web/src/pages/api/payments/stripe/webhook.ts` — calls `enqueueInvoiceJobsOnPaid`  
`apps/web/src/server/payments/finalize.ts` — **legacy** platform `InvoiceProvider.issueTaxDoc` (COUPON/GROUP; mock only)  
`apps/web/src/lib/enums/invoice-provider.ts`  
`apps/web/tests/unit/invoicing/mock-provider.test.ts`

### SEAM — provider adapter interface

**Primary (ITEM purchases — target `InvoiceIssuer` reference):**

```ts
// apps/web/src/server/invoicing/types.ts:4-31
interface InvoiceCreateArgs {
  vendor: { id: string; name: string; vatNumber?: string }
  buyer: { id: string; name: string; email: string }
  purchase: { id: string; createdAt: Date }
  lineItems: Array<{ description: string; qty: number; unitAgorot: number }>
  grossAgorot: number
  vatAgorot: number
}

interface InvoiceCreateResult {
  documentId: string
  documentNumber: string
  documentUrl: string
}

interface VendorInvoiceProvider {
  kind: string
  credentialsSchema: z.ZodTypeAny
  validateCredentials(creds: unknown): Promise<{ ok: true } | { ok: false; reason: string }>
  createInvoice(args: InvoiceCreateArgs): Promise<InvoiceCreateResult>
  voidInvoice?(documentId: string): Promise<void>
}
```

**Dispatch:**

```ts
// registry.ts:33
getVendorInvoiceProvider(kind: string): VendorInvoiceProvider
// morning | icount | rivhit | invoice4u | easycount | meshulam | self_handled
```

**IL implementations (Morning + iCount):** both POST `https://api.icount.co.il/api/v3.php` with `action: 'create_doc'`, `doc_type: 400` (חשבונית מס קבלה), credentials `{ apiUser, apiPass, companyId }`, VAT + line items as **plain-decimal agorot strings** via `formatAgorotPlain`. Return provider `doc_id`, `doc_number`, `url`.

**Legacy platform path (NOT the ITEM flow — do not port as v0.0.1 IL adapter):**

```ts
// provider.ts:27-30
interface InvoiceProvider {
  issueTaxDoc(input: IssueTaxDocInput): Promise<IssueTaxDocOutcome>
  voidTaxDoc(input: { taxDocId; reason }): Promise<{ ok: boolean }>
  getTaxDocPdfUrl(taxDocId: string): Promise<string | null>
}
```

`getInvoiceProvider(env)` returns `MockInvoiceProvider` only (`get-provider.ts:5-7`).

### VAT handling

- ITEM flow: `getVatSchedule` + `findRateInSchedule(vatSchedule, purchaseDateIl)` — **inclusive gross → split** `vatAgorot = round(gross * rate / (100+rate))`, `netAgorot = gross - vat` (`invoice-job.ts:102-108`).
- Provider payload sends `vat: formatAgorotPlain(args.vatAgorot)` and per-line agorot prices.
- Legacy `issueTaxDoc` uses `getCurrentVatRate(db)` + `vatPct` on input (`finalize.ts:50`).

### Document numbering / compliance

- **No host-side gap-free sequence** — provider returns `documentNumber` / `doc_number`.
- `doc_type: 400` hard-coded (tax invoice-receipt).
- Encrypted vendor credentials (AES-GCM: `credentialsCiphertext`, `iv`, `tag`); `INVOICE_KEK` env.
- `vendor_invoice_settings.lastTestAt/Ok/Error` for connection test audit.

### TABLES

| Table | Columns (money) | Idempotency | Role |
|---|---|---|---|
| `vendor_invoice_settings` | — | PK `vendor_id` | Per-vendor `provider` enum, encrypted creds, `settings_json` |
| `invoices` | `gross_agorot`, `vat_agorot`, `net_agorot` **INTEGER agorot** | `UNIQUE (purchase_id, provider)` | Async job store: `status` pending→running→succeeded\|failed; provider document fields on success |

Job statuses: `pending | running | succeeded | failed`. Retry: `retry_count` max 5, exponential backoff from `updated_at`.

### Charge / order mapping

| Trigger | Flow |
|---|---|
| Stripe `payment_intent.succeeded` webhook | `enqueueInvoiceJobsOnPaid(env, { paymentIntentId })` — loads ITEM purchases, reads `vendor_invoice_settings.provider`, inserts `invoices` row `status='pending'` (`webhook.ts:331`, `invoice-job.ts:62`) |
| Job execution | `runInvoiceJob(env, jobId)` — atomic claim `pending→running`, decrypt creds, `getVendorInvoiceProvider`, `createInvoice`, persist `provider_document_*` |

**Pattern:** **standalone async job** triggered by payment success — **not** Pattern-A inline billing side-effect. `runInvoiceJob` is exported but **no in-repo caller found** (enqueue only in webhook; worker/cron wiring external to searched paths).

**Scope:** ITEM vendor purchases only (`dealType === 'ITEM'`). COUPON/GROUP uses legacy `issueInvoices` in `finalize.ts` with mock `InvoiceProvider`.

### DEPS + COUPLING

- **No AR sub-ledger** — single-purpose outbound tax-doc jobs.
- **No credit notes / dunning / aging.**
- **Money:** agorot integers (contrasts with zync NUMERIC decimal).
- **PDF:** provider-hosted `documentUrl` (not generated in-app).

### MATURITY

- Idempotent enqueue via `(purchase_id, provider)` unique.
- Atomic job claim; credential validation before provider call.
- Tests: `mock-provider.test.ts` (legacy `InvoiceProvider` only); **no morning/icount integration tests.**

---

## TOP-5 SPEC-SHAPING OBSERVATIONS

1. **Core AR contract:** Port zync's tenant-scoped invoice document model — 10-state lifecycle, line items, gap-free sequences (`proforma` / `invoice` / `credit_note`), NUMERIC decimal money, denormalized `amount_paid`, `dedup_key` idempotency, credit notes as linked negative invoices — as the provider-agnostic `@platform-modules/invoicing` core; IL two-stage SENT→TAX_ISSUED is a jurisdiction profile on the same state machine, not a separate module.

2. **Ledger boundary:** Zync does not own a ledger primitive — AR balance lives on `invoices`; double-entry is **derived at export** from invoices/receipts/expenses (`movements.ts`). The invoicing module should expose AR totals + payment application; **compose with `@platform-modules/ledger` (if any) at the host**, not embed journal posting.

3. **InvoiceIssuer adapter:** Multideal's `VendorInvoiceProvider.createInvoice(InvoiceCreateArgs)→InvoiceCreateResult` is the outbound issuance seam (agorot, VAT split, encrypted creds, provider doc_type). Zync's `InvoiceAdapter.pushInvoice` is the **inverse** (sync already-issued AR to IL SaaS). v0.0.1 `InvoiceIssuer` should unify **outbound tax-document creation** per jurisdiction: IL needs schedule-driven VAT split + provider credentials + doc_type enum (400 today); US (net-new) needs different doc types and likely no iCount/Morning — no push-after-issue requirement unless adopter opts in.

4. **PDF location:** Zync = in-module `renderInvoiceHtml` + host blob store (R2 key on invoice). Multideal = provider-returned URL. Module should ship HTML render + snapshot key seam; PDF is either host print-to-PDF or adapter `documentUrl` — not bundled pdfkit in core.

5. **Credit-notes / dunning convergence:** Fully in zync core (credit notes = `source='credit_note'` invoices with parent lock; dunning = schedule+log + per-invoice reminder cron). Multideal has neither. Spec should treat credit-note + dunning + AR-aging as **core AR capabilities**; multideal contributes only the **post-payment IL issuance adapter**, not collections semantics.
