Building an invoice tax engine in Rails that accountants trust
6 min read
Invoicing looks like arithmetic until you actually ship it. Then a customer emails to say the total is off by a cent, and you discover that “add 18% tax” and “back 18% tax out of a tax-inclusive price” round to different numbers — and that your accountant, your spreadsheet, and your Ruby all disagree about which line the rounding happens on.
This is how I built the tax engine for a bookkeeping product used by freelancers who send real invoices to real clients. The goal was narrow and unglamorous: every total the app shows must match what an accountant gets by hand. No floats, no drift, no “close enough.”
Two ways to tax, and they don’t round the same
Prices come in two flavours. Tax-exclusive: the price is before tax, and you add tax on top (common in North America). Tax-inclusive: the price already contains the tax, and you have to back it out to show the tax line (common in Australia, the EU). These are not the same calculation run backwards — they round at different points, so they produce different totals.
I modelled each as a strategy object with the same interface, so the rest of the engine never branches on tax mode. Exclusive adds on top:
module InvoicingCalculations
module Strategy
class ExclusiveStrategy
def initialize(rounding_strategy:)
@rounding_strategy = rounding_strategy
end
def item_tax(item_tax_calculation)
result = item_tax_calculation.subtotal * item_tax_calculation.tax_rate_multiplier
@rounding_strategy.apply(result)
end
end
end
end
Inclusive backs the tax out of a price that already contains it — price / (1 + rate) is the
pre-tax amount, and the difference is the tax:
module InvoicingCalculations
module Strategy
class InclusiveStrategy
One = BigDecimal('1')
private_constant :One
def item_tax(item_tax_calculation)
price_without_tax = item_tax_calculation.subtotal / (item_tax_calculation.tax_rate_multiplier + One)
tax = item_tax_calculation.subtotal - price_without_tax
@rounding_strategy.apply(tax)
end
end
end
end
Two objects, one item_tax method each. Adding a third mode later (or a country with quirky
rules) is a new class, not another if threaded through the whole calculator.
Rounding is a decision, so it gets its own object
The single most common way to get money wrong in Ruby is to compute with Float. 0.1 + 0.2
is not 0.3, and once that error is in a subtotal it compounds across every line. Everything in
this engine is BigDecimal, and rounding happens in exactly one place — a tiny object whose
only job is to be the rounding policy:
module InvoicingCalculations
class RoundingStrategy
def initialize(decimal_places:)
@decimal_places = decimal_places
end
def apply(value)
value.round(@decimal_places)
end
end
end
That looks trivial, and that’s the point. Rounding is a business decision (how many places,
which direction on a tie), not something you sprinkle at call sites. When it lives in one
object, there is exactly one line to change and one thing to test. BigDecimal#round is
half-up by default, which is what invoices expect.
The rule that actually matters: round per line, then sum
Here’s the part that trips people up. Do you round the tax on each line and then add the lines, or add the untaxed lines and round the tax once at the end? Those give different totals, and accountants have a firm opinion: round each line item’s tax, then sum the rounded amounts — do not round again.
The calculator does exactly that. Each line computes and rounds its own tax; the document tax is a plain sum with no further rounding:
tax = items_calculations.map(&:tax_including_discount).reduce(Zero, :+)
Discounts have a wrinkle I got wrong the first time I read this code. Internally the calculator derives a discount from the rounded subtotals to assemble the total — but the discount it displays is computed a different way: sum each line’s raw, unrounded discount, then round that sum once.
preview_discount = rounding_strategy.apply(
items_calculations.map(&:subtotal_discount_unrounded).reduce(Zero, :+)
)
# ...later: Totals.new(..., discount: preview_discount, ...)
So the shown discount is “sum then round” while the tax is “round then sum” — different on purpose, because that’s what the accountant’s sheet does for each. On most invoices the two discount computations land on the same cent, which is exactly why the test in the next section pins the displayed figure: it’s the tripwire for the day they diverge.
Proving it: golden-master tests against a real spreadsheet
I didn’t want to assert my own understanding of the math — my understanding was the thing under test. So the tests are golden masters: an accountant built the reference invoices in a spreadsheet, exported them to CSV, and the suite replays each one and asserts every line item and every total against the spreadsheet’s numbers.
The whole test is a loop over the fixtures:
describe InvoicingCalculations::InvoicingCalculations do
InvoicingCalculationsCsvExample.list.each do |strategy, example_data|
it "calculates totals for #{strategy} - #{example_data.name} correctly" do
calculations = InvoicingCalculations::InvoicingCalculations.calculate(
document_data: # ...built from the CSV row...
)
expect(calculations).to have_attributes(
subtotal: BigDecimal(example_data.subtotal_amount),
discount: BigDecimal(example_data.discount_amount),
tax: BigDecimal(example_data.tax_amount),
total: BigDecimal(example_data.total_amount)
)
end
end
end
The fixtures are the interesting artifact. Each CSV carries the accountant’s own notes in a column — the literal method, tie-breaking included:
Website designs, 3,143.99, 1.00, TRUE, 398.06, ...
"First calculate the line item total inc. discount: (rate x quantity) -
(rate x quantity x discount%), then round result to two decimal places,
0.5 goes up."
One warning about trusting those note cells: some are stale templates. One still reads “divide by 11” — the formula for 10% tax — sitting beside a line whose numbers were plainly worked out at 18%. The arithmetic in the fixture is right; the comment next to it lied. Test the numbers, not the prose.
There are fixtures for exclusive and inclusive, for 0%/10%/17% discounts, edge cases, and — my favourite — a surcharge case whose notes contain a correction: the first documented method for apportioning tax into a card-payment surcharge was wrong, someone caught it, and the fixture now records the right way (tax is added on top of discounts, and a slice of the surcharge is itself taxable). That correction lives in the test, so the code can never quietly regress to the old behaviour.
That surcharge rule is the one genuinely gnarly bit of the engine — you take the tax’s share of the pre-surcharge total and apply it to the fee, guarding the division:
payment_surcharge_tax = if document_data.payment_surcharge_paid.zero?
BigDecimal('0')
else
raise 'Division by zero...' if total.zero?
tax_ratio_in_total = tax / (total - document_data.payment_surcharge_paid)
rounding_strategy.apply(document_data.payment_surcharge_paid * tax_ratio_in_total)
end
The strategy pattern is what gets written up, but it isn’t what made this reliable. What made it
reliable was refusing to compute money in Float, giving rounding exactly one home so there’s a
single line to change and a single test to break, and committing to the accountant’s order of
operations — round each line’s tax, then sum — rather than whatever ActiveRecord makes convenient.
And then not trusting my own version of the math: the reference numbers came from someone whose whole
job is getting invoices right, and the tests answer to them, not to me.
I’ve built billing and invoicing in Rails where “off by a cent” is a genuine bug and the person on the other end is a freelancer who needs the number to match their books. If that’s what you’re building, get in touch — or read on about charging a card once.