tax-ledger
Append-only tax ledger downstream of Avalara / TaxJar / Stripe Tax / Fonoa.
tax-ledger
refund splits per jurisdiction · invariant proven visually
- item 1 · $40.00
- item 2 · $30.00
- item 3 · $20.00
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-ledgeris 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 byamountCentsor byquantity. 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 Taxcreate_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 helpers — toComponentTotals() 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
| Repo | github.com/mateokadiu/tax-ledger |
| License | MIT — public from day one |
| Latest | v1.1.0 (2026-07-15) |
| Tests | 147 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
- 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. Decimal.jsfor 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 emittedamountCentsis an integer.- Largest-remainder (Hamilton’s method) allocator. Sum-preserving by construction. Deterministic tiebreak by row position (not row id) — replay-stable across runs.
- Zod at the input boundary. Engine responses are untyped JSON. Validate once, trust downstream.
- One ISO-4217 currency per ledger. Mixed-currency reconcile is refused with
CurrencyMismatchError. FX belongs upstream — convert before revising into a different currency. - Pure core, opt-in adapters. Core has zero external deps beyond
decimal.js,zod, anduuid. Avalara / TaxJar / Stripe-tax / Fonoa adapters are separate npm packages; install only what you use. - 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.amountCentsto the cent- No fractional cents. Every emitted
amountCentsis an integer - Allocator total preservation.
sum(allocate(totalCents, weights)) === totalCentsfor 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
computeRemainingQuantityto1) reconcile()— fold an engine-quoted reversal back into the ledger with a sum assertion'vat'tax type plus optionaltaxCode,taxBehavior(inclusive/exclusive), andengineTaxType(the engine’s native label) flowing through every verb and the Drizzle adapter- Stripe reversal bridge —
toStripeReversal()buildscreate_reversalparams (full / flat / per-line) andreversalTotals()reads the reversed tax back off the response - Richer
Jurisdiction— optionalcountry/region/namecarried from the engine, persisted as nullable Drizzle columns, so the ledger reconciles 1-1 instead of collapsing totype + 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
Ledger—toComponentTotals()androllupBy([...])
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.jsintermediate, integer cents I/O) - MIT, public from day one