levelmax World 5 Explicit Design
  1. 1
  2. 2
  3. 3
  4. 4
  5. 5
  6. B
L
Level 5.2 ~15 min Target: PHPStan max

Money

A £10 discount, spread across three £5 lines so each line's VAT is right, comes to £3.33, £3.33 and £3.33. That's £9.99. The order says £10 off, the lines say £9.99, and once a month someone in finance spends an afternoon finding the penny.

Money is the value object everyone needs and most codebases get wrong in the same three ways.

1. Floats can't hold pennies

A float is binary. It can hold ½ and ¼ exactly, but not ⅒, so 0.1 + 0.2 is 0.30000000000000004 and 19.99 * 100 is 1998.9999999999998. Rounding at the end hides that most of the time, and "most of the time" is the problem.

Store an integer count of the smallest unit: pence, cents. 1349 is exactly £13.49, adding two of them is exact, and PHPStan can tell an int from a float.

2. An amount without a currency is a guess

1349 is £13.49 or €13.49, and adding one to the other gives a number that's neither. Keep the currency in the same object, and refuse to combine different ones:

public function plus(Money $other): self
{
    if ($other->currency !== $this->currency) {
        throw new InvalidArgumentException("Can't add {$other->currency->value} to {$this->currency->value}");
    }

    return new self($this->pence + $other->pence, $this->currency);
}

Currency is a closed set, so it's an enum (1.6), and !== compares cases safely.

3. Don't divide money. Allocate it

Dividing 1000p by three has no exact answer, so the question is where the spare penny goes. Allocation answers it: work out each share rounded down, then hand the pennies left over to the first shares, one each. The shares always add up to the original amount.

1000p in the ratio 1 : 1 : 1   →  333 + 333 + 333 = 999, 1p left  →  334, 333, 333
5p    in the ratio 70 : 30     →  3 + 1 = 4, 1p left              →  4, 1

Ratios cover splitting a bill evenly (1, 1, 1) and proportionally: spreading a discount by each line's price is allocate(...$linePrices).

round(10 / 3, 2) × 3£9.99
allocate(1, 1, 1)£3.34 + £3.33 + £3.33 = £10.00
The penny was in the float all along.

Power-up

Before
<?php

final readonly class Money
{
    public function __construct(public float $amount) {} // pounds, and pounds of what?

    /** @return list<Money> */
    public function split(int $parts): array
    {
        $share = round($this->amount / $parts, 2);

        return array_fill(0, $parts, new self($share)); // 10.00 / 3 → 3.33 × 3 = 9.99
    }
}
After
<?php

final readonly class Money
{
    public function __construct(public int $pence, public Currency $currency) {}

    /** @return list<Money> shares that always add up to this amount */
    public function allocate(int ...$ratios): array
    {
        $total = array_sum($ratios);
        $roundedDown = array_map(fn (int $ratio): int => intdiv($this->pence * $ratio, $total), $ratios);
        $left = $this->pence - array_sum($roundedDown);

        $shares = [];
        foreach ($roundedDown as $i => $pence) {
            $shares[] = new self($i < $left ? $pence + 1 : $pence, $this->currency); // 334, 333, 333
        }

        return $shares;
    }
}

Quick check

1. Why store £13.49 as the integer 1349 rather than the float 13.49?
2. What should allocate(1, 1, 1) return for 100p?
3. (int) (19.99 * 100) is…

CHALLENGE

A £10 discount spread across three £5 lines comes to £9.99, because Money holds a float of pounds and split() rounds each share. It will also add euros to pounds without complaint.

Rewrite Money around int $pence and its Currency. plus() refuses a different currency with an InvalidArgumentException. allocate(int ...$ratios) replaces split() and returns shares that always add up to the whole, and refuses ratios that are negative or add up to nothing. Add equals(), and keep format() working without a float.

REFERENCE
intdiv(7, 2);        // 3, an int, rounded towards zero
7 % 2;               // 1
sprintf('%02d', 5);  // '05'
Running on PHP 8.3…

Run the tests, run PHPStan, or submit for both. Ctrl/⌘ + Enter submits from the editor.