Skip to content
A Bekkour
Writing

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.

Al Mokhtar Bekkour

Senior Rails & Go engineer in Quebec. I build multi-tenant SaaS and financial integrations, and ship my own products — Constat, Railyard, and Recall. Open to work.

← All writing