Money Bugs: Currency, Rounding, Discounts and VAT
0.1 + 0.2 is not 0.3, KWD has three decimals, round() picks a rule for you, and a naive discount split loses a piastre. Integer minor units, a bounded Money value object, line-level VAT rounding, and largest-remainder splits - every code block executed on PHP 8.4, with a 19-test suite that runs the blocks extracted from this article.
المحتويات
Most production money bugs trace back to one assumption: money is just a number. It isn't. This article collects four recurring money bugs with the fix for each. Every code block below was executed on PHP 8.4 exactly as printed - the test suite extracts the blocks from this markdown and runs them, so what you read is what ran.
Bug 1: floats are not money#
var_dump(0.1 + 0.2 === 0.3); // bool(false)
$cart = 0.0;
foreach (array_fill(0, 10, 0.1) as $price) { $cart += $price; }
var_dump($cart); // float(0.9999999999999999)
var_dump($cart === 1.0); // bool(false)Those are the real outputs. IEEE 754 doubles can't represent 0.1 exactly, and the error compounds in loops. The moment you compare a float sum to an expected total - a payment callback, an invoice reconciliation - you ship a bug that only appears for some customers.
The fix: store and calculate in integer minor units. Piastres, cents, fils - the smallest unit of the currency. 1999 piastres, never 19.99 pounds.
Bug 2: not all currencies have two decimals#
EGP, USD, EUR, SAR have two decimal places. KWD and BHD have three. JPY has zero. A hardcoded round($amount, 2) silently corrupts a Kuwaiti dinar amount (12.345 KWD becomes 12.35) and invents yen fractions that don't exist.
The fix: a Money value object that carries the currency and knows its exponent. This one is deliberately a bounded, nonnegative tutorial API - it rejects negatives, excess precision, unknown currencies, and out-of-range amounts. It validates the normalized digit string before any integer cast, so an arbitrarily long input is rejected instead of overflowing or saturating, and it assumes 64-bit PHP for its PHP_INT_MAX arithmetic bounds:
final class Money
{
private const EXPONENTS = [
'EGP' => 2, 'USD' => 2, 'EUR' => 2, 'SAR' => 2,
'KWD' => 3, 'BHD' => 3, 'JPY' => 0,
];
// amounts stay far below PHP_INT_MAX even after tax/mul arithmetic
private const MAX_MINOR = 1_000_000_000_000;
private function __construct(
public readonly int $minor, // amount in smallest units, nonnegative
public readonly string $currency // ISO 4217
) {}
public static function of(string $amount, string $currency): self
{
if (!isset(self::EXPONENTS[$currency])) {
throw new InvalidArgumentException("unsupported currency $currency");
}
$amount = trim($amount);
if (!preg_match('/^\d+(\.\d+)?$/', $amount)) {
throw new InvalidArgumentException("not a nonnegative decimal string: $amount");
}
[$i, $f] = array_pad(explode('.', $amount, 2), 2, '');
$exp = self::EXPONENTS[$currency];
if (strlen($f) > $exp) {
throw new InvalidArgumentException("too many decimal places for $currency");
}
// compare the normalized minor-unit digit string BEFORE any (int) cast,
// so arbitrarily long inputs are rejected instead of overflowing/saturating
$digits = ltrim(ltrim($i, '0') . str_pad($f, $exp, '0'), '0');
$max = (string) self::MAX_MINOR;
if (strlen($digits) > strlen($max) || (strlen($digits) === strlen($max) && $digits > $max)) {
throw new InvalidArgumentException('amount out of supported range');
}
return new self($digits === '' ? 0 : (int)$digits, $currency);
}
public function add(self $o): self
{
if ($o->currency !== $this->currency) {
throw new LogicException('currency mismatch - convert first');
}
if ($this->minor > self::MAX_MINOR - $o->minor) {
throw new LogicException('amount out of supported range');
}
return new self($this->minor + $o->minor, $this->currency);
}
public function mul(int $qty): self
{
if ($qty < 0) {
throw new InvalidArgumentException('quantity must be nonnegative');
}
if ($qty > 0 && $this->minor > intdiv(self::MAX_MINOR, $qty)) {
throw new LogicException('amount out of supported range');
}
return new self($this->minor * $qty, $this->currency);
}
public function taxBps(int $bps): self
{
// $bps in basis points: 1400 = 14.00%. Half-up, per line, integer only.
if ($bps < 0 || $bps > 100_000) {
throw new InvalidArgumentException('basis points out of range');
}
if ($this->minor > intdiv(PHP_INT_MAX - 5000, max(1, $bps))) {
throw new LogicException('amount out of supported range');
}
$tax = intdiv($this->minor * $bps + 5000, 10000);
if ($tax > self::MAX_MINOR) {
throw new LogicException('amount out of supported range');
}
return new self($tax, $this->currency);
}
public function format(): string
{
$exp = self::EXPONENTS[$this->currency];
$i = intdiv($this->minor, 10 ** $exp);
$f = $exp ? '.' . str_pad((string)($this->minor % 10 ** $exp), $exp, '0') : '';
return $i . $f . ' ' . $this->currency;
}
}Real output: 12.345 KWD stays 12.345 KWD, 1200 JPY stays 1200 JPY, and adding EGP to USD throws instead of quietly summing piastres and cents. In a Laravel app, cast money columns with this object (a custom Eloquent cast or a package like moneyphp/money) and keep the DB column as a big integer.
Bug 3: rounding is a business decision, not a default#
PHP's round() defaults to half-up (PHP_ROUND_HALF_UP) - it picks a rule for you, and gateways, tax authorities and invoice standards don't all expect the same one. On the boundary cases, executed with the integer implementations below (input in minor units x1000, so 2500 = 2.5 piastres):
| Rule | 2.5 -> | 3.5 -> |
|---|---|---|
| half-up | 3 | 4 |
| half-even (banker's) | 2 | 4 |
| down (truncate) | 2 | 3 |
| up (ceiling) | 3 | 4 |
function roundHalfUp(int $m3): int
{
if ($m3 < 0 || $m3 > PHP_INT_MAX - 1000) {
throw new InvalidArgumentException('milliunits out of supported range');
}
return intdiv($m3 + 500, 1000);
}
function roundHalfEven(int $m3): int
{
if ($m3 < 0) {
throw new InvalidArgumentException('milliunits must be nonnegative');
}
$q = intdiv($m3, 1000);
$r = $m3 % 1000;
if ($r > 500 || ($r === 500 && $q % 2 === 1)) { $q++; }
return $q;
}
function roundDown(int $m3): int
{
if ($m3 < 0) {
throw new InvalidArgumentException('milliunits must be nonnegative');
}
return intdiv($m3, 1000);
}
function roundUp(int $m3): int
{
if ($m3 < 0 || $m3 > PHP_INT_MAX - 1000) {
throw new InvalidArgumentException('milliunits out of supported range');
}
return intdiv($m3 + 999, 1000);
}Tax calculation is where this bites. Whether your invoices must round tax per line or on the invoice total depends on your jurisdiction's invoicing rules - find out which your filings expect, then implement exactly that and test the boundary cases. Per-line rounding half-up (the common expectation) is what taxBps() above does, in integer arithmetic.
What the standard rates actually are (primary sources, read 9 Nov 2026)#
| Country | Standard VAT rate | Primary source (exact URLs in References) |
|---|---|---|
| Egypt | 14% (since fiscal year 2017/2018) | Egyptian Tax Authority's official English translation of VAT Law 67/2016, Article 3 |
| Saudi Arabia | 15% (5% from 1 Jan 2018, raised to 15% on 1 Jul 2020 by Royal Order A/638) | ZATCA's official VAT page |
| UAE | 5% | Federal Tax Authority FAQ |
These are standard rates - the headline number, not the whole story. Each system also has zero-rated categories (charged 0%, input VAT still recoverable), exempt categories (no VAT charged, input VAT not recoverable), special rates or treatments for specific supplies, and registration thresholds that decide whether you charge anything at all. Which treatment applies depends on what you sell and who you are: verify your specific supply against the authority's current guidance or a tax advisor before hardcoding anything. Rates and rules change; this article is engineering, not tax advice.
Bug 4: the discount that doesn't add up#
Split a 10.00 EGP discount across three 33.33 EGP lines. The naive way rounds each share and quietly changes the total:
// naive: round(1000 * 3333 / 9999) = 333 per line -> 999 total. One piastre vanished.The fix is the largest remainder method, in integer arithmetic only (no floats, despite the temptation to compute proportions as doubles): floor every share with intdiv, keep the integer remainders, then hand the leftover minor units to the lines with the biggest remainders - ties broken deterministically by line order:
function splitDiscount(array $lineMinors, int $discountMinor): array
{
if ($discountMinor < 0) {
throw new InvalidArgumentException('discount must be nonnegative');
}
if (!array_is_list($lineMinors)) {
throw new InvalidArgumentException('line amounts must be a list (tie order is insertion order)');
}
$total = 0;
foreach ($lineMinors as $m) {
if (!is_int($m) || $m <= 0 || $m > 1_000_000_000_000) {
throw new InvalidArgumentException('line amounts must be positive integers in range');
}
if ($m > PHP_INT_MAX - $total) {
throw new LogicException('subtotal out of supported range');
}
$total += $m;
}
if ($total === 0) {
throw new InvalidArgumentException('line subtotal must be positive');
}
if ($discountMinor > $total) {
throw new InvalidArgumentException('discount cannot exceed subtotal');
}
$biggest = max($lineMinors);
if ($discountMinor > 0 && $discountMinor > intdiv(PHP_INT_MAX, $biggest)) {
throw new LogicException('amounts out of supported range');
}
$shares = []; $remainders = []; $assigned = 0;
foreach ($lineMinors as $i => $m) {
$shares[$i] = intdiv($discountMinor * $m, $total); // floor, integer only
$remainders[$i] = ($discountMinor * $m) % $total; // integer remainder
$assigned += $shares[$i];
}
$left = $discountMinor - $assigned; // always < count(lines)
// deterministic: biggest remainder first, ties broken by line order
$order = array_keys($remainders);
usort($order, fn($a, $b) => $remainders[$b] <=> $remainders[$a] ?: $a <=> $b);
foreach ($order as $i) {
if ($left <= 0) break;
$shares[$i]++; $left--;
}
return $shares;
}Executed output: 3.34 / 3.33 / 3.33 = 10.00 EGP exactly, while the naive version gives 3.33 x 3 = 9.99. The test suite pins the invariants: shares always sum to the exact discount, no share drifts more than one minor unit from its exact proportion, equal remainders resolve deterministically by line (insertion) order, and an empty or zero subtotal, negative or over-subtotal discounts, non-integer or out-of-range lines, and non-list arrays all throw. A zero discount is legal and returns all-zero shares.
Common mistakes#
| Mistake | What it costs |
|---|---|
float for money, "we'll round at the end" |
Rounding can't fix drift that already changed a comparison or a sum. |
number_format($x, 2) for every currency |
Corrupts KWD/BHD (3 decimals) and JPY (0 decimals). |
| Silently truncating excess precision | "19.999" becoming "19.99" hides upstream bugs; reject it loudly. |
| Float proportions for splitting money | Reintroduces the exact drift integer minor units exist to avoid. |
| Rounding tax per invoice when the jurisdiction expects per line (or vice versa) | Totals disagree with filings by piastres; reconciliation fails. |
| Hardcoding "the VAT rate" without a read date and source | Rates change; cite the authority's page and the date you read it. |
The checklist#
- Integer minor units everywhere; floats never touch money.
- Money carries its currency; arithmetic across currencies throws.
- Respect the exponent - 2 isn't universal (KWD 3, JPY 0). Reject excess precision.
- Round with a named rule in integer arithmetic; test the boundary cases (x.5).
- Split discounts with largest remainder, integer-only, deterministic ties; assert the sum invariant.
- Get tax rates from the authority's own pages on the day you ship; zero-rated and exempt are separate cases from the standard rate.
Key takeaways#
Floats drift, currencies differ, rounding is a choice, and naive splits lose piastres. A small bounded Money object plus integer-only arithmetic reduces the risk of all four bug classes - within its explicit scope: nonnegative amounts, no FX conversion, no refunds. And a test suite that runs the exact code you publish keeps the article honest.
References#
- Egyptian Tax Authority - VAT Law 67/2016 (official English translation, Article 3: standard rate 14% from FY 2017/2018): https://www.eta.gov.eg/sites/default/files/2024-02/Law-english-no.67-2016.pdf.pdf
- ZATCA (Saudi Arabia) - What is VAT? (15% from 1 July 2020): https://zatca.gov.sa/ar/RulesRegulations/VAT/Pages/About-Vat.aspx
- UAE Federal Tax Authority - FAQ, standard VAT rate 5%: https://tax.gov.ae/en/faq.aspx?keyword=What+is+the+standard+rate+of+VAT+in+the+UAE%3F
- PHP manual - round(): https://www.php.net/manual/en/function.round.php
Need a payments or invoicing system that survives these bugs? See /en/services.