skip to content
← back to all projects
shipped github ↗

tax-ledger

Append-only tax ledger downstream of Avalara / TaxJar / Stripe Tax / Fonoa.

TypeScript Decimal.js Zod fast-check Drizzle

tax-ledger

refund splits per jurisdiction · invariant proven visually

shipped
order$90.00
  • item 1 · $40.00
  • item 2 · $30.00
  • item 3 · $20.00
CA state (6%)$5.40
SF county (1.5%)$1.35
SF city (0.5%)$0.45
tax total · $7.20no refunds yet

Elevator pitch

Every team that ships sales tax in production grows the same 1,000-line Math.floor(ratio * tax) blob across their refund flow. After three partial refunds the books drift a few cents. After a thousand orders, a few dollars. Accountants get involved. tax-ledger is that 1,000 lines factored into a ~600-line pure function with property-based tests proving the books always reconcile.

What it is

A TypeScript monorepo (@tax-ledger/core + adapters) that sits downstream of a real tax engine — Avalara, TaxJar, Stripe Tax, Fonoa — and turns the engine’s response plus your line items into an append-only ledger: per-line, per-jurisdiction, per-tax-type rows that sum back to the engine’s total to the cent and stay reconciled across the order lifecycle.

Five verbs on the core:

  • split(input, opts?) — Engine response in, ledger out. One row per (line, jurisdiction, taxType). Validated by Zod at the boundary.
  • refund(ledger, spec, opts?) — Refund by amountCents or by quantity. Emits delta rows that sum exactly to the negative of the refunded tax.
  • partialCapture(ledger, spec, opts?) — Capture less than was authorized; ledger entries adjust proportionally.
  • revise(ledger, newOrder, opts?) — Engine re-quote (address change, line edit). Emits the diff against the prior ledger.
  • reconcile(ledger, delta, { expectTotalCents?, toleranceCents? }) — Fold an engine-provided refund/reversal (Stripe Tax create_reversal, Avalara refund/adjust) into the ledger and assert it sums to what the engine reported, within tolerance. Catches drift on the boundary.

Plus reporting helperstoComponentTotals() returns { salesTax, shippingTax, bottleDeposits, vat, additional, total }, and rollupBy([...]) groups by any dimension.

Why it exists

Tax math goes wrong in three places: rounding, refund splits, and revisions. Most platforms hide it behind an opaque engine call. The moment a customer refunds one item out of three, the tax delta has to be apportioned back to each jurisdiction in proportion to its original contribution, with cents-precision arithmetic so the sum still balances. Get one decimal wrong and reconciliation fails.

tax-ledger exposes the math, proves the invariants, and keeps the bug surface small.

Status

Repogithub.com/mateokadiu/tax-ledger
LicenseMIT — public from day one
Latestv1.1.0 (2026-07-15)
Tests147 across 6 packages, including 21 property-based suites at 500–1000 fast-check runs each
Packages@tax-ledger/{core, avalara, taxjar, stripe-tax, fonoa, drizzle}

Key decisions

  1. Append-only ledger, deltas as new rows. Refunds and revisions never mutate prior rows — they emit new rows with signed amountCents. Replay is a fold. An auditable ledger that never mutates is a precondition for shipping to accounting.
  2. Decimal.js for intermediate math, integer cents for I/O. Arbitrary precision avoids both FP drift and BigInt footguns on rates with more than 2 decimal places. Every emitted amountCents is an integer.
  3. Largest-remainder (Hamilton’s method) allocator. Sum-preserving by construction. Deterministic tiebreak by row position (not row id) — replay-stable across runs.
  4. Zod at the input boundary. Engine responses are untyped JSON. Validate once, trust downstream.
  5. One ISO-4217 currency per ledger. Mixed-currency reconcile is refused with CurrencyMismatchError. FX belongs upstream — convert before revising into a different currency.
  6. Pure core, opt-in adapters. Core has zero external deps beyond decimal.js, zod, and uuid. Avalara / TaxJar / Stripe-tax / Fonoa adapters are separate npm packages; install only what you use.
  7. Determinism is opt-in. Every mutating verb accepts { now?, generateId? } for snapshot tests and event-sourced replays. Omit for the default real clock + UUIDv7.

Invariants the property tests prove

Every property holds over 500–1000 fast-check-generated orders:

  • sum(split(input).taxes) === input.totalTaxCents (with a 1-cent rounding-residual row if the engine itself drifted)
  • sum(refund(ledger, spec)) === -spec.amountCents to the cent
  • No fractional cents. Every emitted amountCents is an integer
  • Allocator total preservation. sum(allocate(totalCents, weights)) === totalCents for any positive or negative total
  • Idempotent reconcile. revise(ledger, sameOrder) is always a no-op; replay of the same event produces the same delta
  • No-collapse jurisdiction. Every (jurisdiction, taxType) on the source line is preserved as its own row, refunded proportionally

What 1.1 added on top of 1.0

  • Quantity-based refunds that actually work (1.0 stubbed computeRemainingQuantity to 1)
  • reconcile() — fold an engine-quoted reversal back into the ledger with a sum assertion
  • 'vat' tax type plus optional taxCode, taxBehavior (inclusive / exclusive), and engineTaxType (the engine’s native label) flowing through every verb and the Drizzle adapter
  • Stripe reversal bridgetoStripeReversal() builds create_reversal params (full / flat / per-line) and reversalTotals() reads the reversed tax back off the response
  • Richer Jurisdiction — optional country / region / name carried from the engine, persisted as nullable Drizzle columns, so the ledger reconciles 1-1 instead of collapsing to type + code
  • Currency minor-unit helpers in core (minorUnitExponent, toMinorUnits, fromMinorUnits, formatMinorUnits) — JPY=0, KWD=3, …; multi-currency isn’t implicitly 2-decimal anymore
  • @tax-ledger/fonoa — new adapter for Fonoa’s Tax calculation response (EU VAT / GST), honoring tax-inclusive pricing and treating reverse-charge / exempt lines as zero-tax
  • Reporting verbs on LedgertoComponentTotals() and rollupBy([...])

Numbers

  • 147 tests across the workspace (up from 110 in 1.0)
  • 6 packages (core + 4 engine adapters + Drizzle persistence)
  • 21 property-based suites, 500–1000 runs each
  • 0 float operations on amounts (Decimal.js intermediate, integer cents I/O)
  • MIT, public from day one