tax-ledger
OSS tax line-item splitter with provable invariants.
tax-ledger
refund splits per jurisdiction · invariant proven visually
- item 1 · $40.00
- item 2 · $30.00
- item 3 · $20.00
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
| Repo | github.com/mateokadiu/tax-ledger |
| License | MIT |
| Status | v1.0.0 shipped |
| Tests | property-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
- Cents-precision integer math with
Big.jsonly at jurisdiction boundaries. All internal storage and arithmetic is in cents. FX conversion and rate multiplication usesBig.jsto avoid float drift, then back to integer cents at the edge. - 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.
- 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.
- 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.jsinstances on the hot path insidesplit() - 3 built-in adapters (Stripe, Shopify, CSV)