skip to content
← back to all projects
shipped github ↗

tax-ledger

OSS tax line-item splitter with provable invariants.

TypeScript Big.js Property-based testing

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

A small library for splitting a sales-tax-inclusive order into per-jurisdiction line items and proving the sum invariant holds — across refunds, partial captures, FX conversion, and rounding. Property-tested. No fluff. The function you wish your e-commerce backend already had.

What it is

A TypeScript package shipping two primitives plus adapters:

  • split(order, jurisdictions) — Takes a list of line items with cents-precision amounts and a jurisdiction stack (state, county, city), returns a flat array of { itemId, jurisdiction, taxCents } rows. Sum of rows always equals sum of input tax (proven via property test with 10k generated orders).
  • reconcile(originalSplits, refunds) — Given the original split + a list of refunds (full or partial), returns the new totals + the deltas per jurisdiction. The deltas always sum to the negative of refunded tax (also proven).

Adapters for Stripe, Shopify, and a generic CSV importer. Multi-currency via stored FX rate snapshots.

Status

Repogithub.com/mateokadiu/tax-ledger
LicenseMIT
Statusv1.0.0 shipped
Testsproperty-based, 10k cases per invariant

The problem I was solving

Tax math goes wrong in three places — rounding, refund splits, and FX conversion. Most accounting systems hide the math behind an opaque service 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 all-cents arithmetic so the sum still balances. Get one decimal wrong and reconciliation fails.

I wanted a library where the math is exposed, the invariants are proven, and the bug surface is small.

Key decisions

  1. Cents-precision integer math with Big.js only at jurisdiction boundaries. All internal storage and arithmetic is in cents. FX conversion and rate multiplication uses Big.js to avoid float drift, then back to integer cents at the edge.
  2. Largest-remainder method for rounding splits. The classic apportionment problem — give each jurisdiction its floor share, then distribute the remaining cents to jurisdictions with the largest fractional remainder. Provably sums to the input.
  3. Property-based tests for invariants. “For any 1≤items≤10, any 1≤jurisdictions≤4, any non-negative rates: sum of split rows == input tax.” 10k cases per release.
  4. Adapters as separate entry points. tax-ledger/stripe, tax-ledger/shopify. Core has zero external deps.

Numbers

  • 10,000 property-test cases per invariant per release
  • 0 allocations of Big.js instances on the hot path inside split()
  • 3 built-in adapters (Stripe, Shopify, CSV)