DEV Community

Cover image for Round late — a two-cent bug in a price per square metre

Round late — a two-cent bug in a price per square metre

A customer orders 12.75 m² of flooring. The cart line says €318.75. The order summary says €318.69.

Six cents. Nobody notices for months, and then someone does, and now you are explaining arithmetic to a merchant who reasonably wants to know which of your two numbers is the real one.

The bug

The module sells by dimension: the shopper enters a width and a height, the module works out an area and multiplies by a unit price. The tax-excluded unit price was being rounded to two decimals before the multiplication:

// wrong
$unit  = round($product->getPriceWithoutTax(), 2);   // 25.0000 -> 25.00, fine
$total = $unit * $area;                              // but not always fine
Enter fullscreen mode Exit fullscreen mode

With a clean unit price nothing happens. With a price that is not exactly representable in two decimals — which is normal the moment tax-inclusive pricing, a currency conversion or a specific-price rule is involved — the rounding error is multiplied by the quantity:

true unit price   24.995000
rounded to 2dp    24.99          (-0.005)
× 12.75 m²        318.6225  vs  318.68625   -> 6.4 cents lost
Enter fullscreen mode Exit fullscreen mode

The larger the order, the larger the gap. On a 200 m² order it is a euro.

The rule is the boring one everybody knows and half of us implement backwards: carry full precision through the arithmetic and round once, at the end, for display.

// right
$total = round($product->getPriceWithoutTax() * $area, 2);
Enter fullscreen mode Exit fullscreen mode

Why the cart hid it

This is the part that made it survive so long. The cart line and the order summary did not compute the number the same way. One path used the rounded unit price, the other used a total the module had already transmitted. They agreed for every price that survives two-decimal rounding, which is most of them, and diverged only for the rest.

So the bug was:

  • invisible in testing, because test products have prices like 10.00;
  • invisible in most orders, for the same reason;
  • visible to exactly the merchants whose catalogue has awkward prices, who are also the merchants least able to explain it to their accountant.

A discrepancy that only appears with unusual data is worse than one that appears always. An always-bug gets found on day one. This kind waits.

Where to look for it in your own code

Search for a rounded value being assigned to a variable and then used in arithmetic. The name is the tell — $unitPrice, $rate, $perItem, $hourlyRate — anything singular that will later be multiplied.

Some concrete places this hides:

  • Rounding for display and reusing the display value. Formatting a price for the page and then feeding that same variable into a total.
  • Storing a rounded intermediate. A column with DECIMAL(10,2) for something that is not a final amount. The database rounds for you, silently, and now the precision is gone for good.
  • Rounding inside a loop. Rounding each line and summing, versus summing and rounding. Both are defensible for invoices — many jurisdictions require per-line rounding — but you must pick one deliberately and apply it in both the cart and the order, or you get exactly our six cents.

That last one is the nuance that keeps this from being a slogan. "Round late" is right for a single computation. For a document with many lines, what matters is that every place that computes the document rounds the same way. Our bug was not that we rounded early in one place; it was that we rounded early in one place and late in another.

The test worth writing

Fixing it is one line. Keeping it fixed is the interesting part, because the next person to touch this code will reasonably think a rounded unit price is a sensible thing to have.

Our regression test asserts both halves:

// The fixed path: full precision, round once.
ok(abs(priceFor(10.00, 0.20, 1.0, true)  - 12.00) < 0.0001, 'whole quantity');
ok(abs(priceFor(10.00, 0.20, 2.5, true)  - 30.00) < 0.0001, 'fractional quantity');

// And the un-flagged legacy path STILL reproduces the old bug on purpose,
// so a future caller cannot silently drop the fix by omitting the flag.
ok(abs(priceFor(10.00, 0.20, 2.5, false) - 30.00) > 0.0001, 'legacy path unchanged');
Enter fullscreen mode Exit fullscreen mode

The second assertion is the one people leave out. It documents that the old behaviour is still reachable and deliberately so, which means the day someone deletes the flag, a test fails and explains why instead of a merchant emailing you about six cents.

The whole thing runs as plain PHP with no framework bootstrapped — the pricing function takes numbers and returns a number, which is the other half of making this testable at all. If your price calculation can only be exercised by building a cart, you will not write this test, and that is a design problem rather than a discipline problem.

The short version

  • Round once, at the end, for display.
  • If a document has lines, decide once how it rounds and make every code path that renders it agree.
  • Prices like 10.00 in your fixtures will not catch this. Use 24.995.
  • Assert the bug is still reproducible on the path you deliberately left alone.

From a per-square-metre pricing module at MEG Venture. It took three releases to get right, mostly because each fix was correct and incomplete.

Top comments (0)