Skip to content

Breakdowns

phil/tax

A sale may be taxed by several taxes at once, each on the same base, each rounded on its own line and itemised on the invoice. A single-rate model returns one rate per sale and cannot express this.

TypeRole
TaxLineOne line: a rate, its base, its amount
TaxBreakdownSeveral lines on the same base; totals and reversal
MultiRateTaxCalculatorApplies every rate to the base and returns the breakdown, or takes the taxes out of a price that includes them

Example

use Phil\Money\Currency;
use Phil\Money\Money;
use Phil\Tax\MultiRateTaxCalculator;
use Phil\Tax\TaxRate;

$base = Money::of(1_999, Currency::of('CAD')); // 19.99

$breakdown = new MultiRateTaxCalculator()->calculate($base, [
    TaxRate::of('GST', 50_000), // 5 %
    TaxRate::of('QST', 99_750), // 9.975 %
]);

$breakdown->lines[0]->amount->minorUnits;    // 100  (99.95 rounded)
$breakdown->line('QST')?->amount->minorUnits; // 199  (199.40 rounded), on 19.99, not on 19.99 + GST
$breakdown->totalTax()->minorUnits;          // 299
$breakdown->totalWithTax()->minorUnits;      // 2298

$credit = $breakdown->reversed();            // base -1999, lines -100 and -199

TaxLine

MemberBehaviour
static computed(TaxRate $rate, Money $base): selfAmount computed by $rate->taxOn($base)
static reported(TaxRate $rate, Money $base, Money $amount): selfAmount as an external tax engine reported it, kept as given; CurrencyMismatch when amount and base differ in currency
public TaxRate $rate, public Money $base, public Money $amountThe value
reversed(): selfSame rate, base and amount negated

reported() exists for applications that delegate taxation to an external engine: the library then only itemises what the engine computed, and does not recompute it.

TaxBreakdown

MemberBehaviour
static of(Money $base, list<TaxLine> $lines): selfChecks that every line is on exactly this base and that a rate code appears at most once
public Money $base, public list<TaxLine> $linesThe value, lines in the given order
line(string $code): ?TaxLineThe line of a rate code, or null
totalTax(): MoneySum of the line amounts; zero in the base's currency when there is no line
totalWithTax(): MoneyBase plus total tax
reversed(): selfThe breakdown of a credit note: base and every line negated

Refusals of of():

CaseException
A line's base is in another currencyPhil\Money\Exception\CurrencyMismatch
A line's base is another amountInvalidBreakdown: every rate of a breakdown applies to the same base
A rate code appears twiceInvalidBreakdown
  • Every rate applies to the same base, never to the base plus another tax. Compounding is a different rule and would need a different type.
  • Each line is rounded on its own, as the receipt shows it, never the sum of the rates once: two rates of 5 % on 0.10 give 1 cent each, 2 cents in all, where rounding the sum once would give 1.
  • A credit note reverses the itemised taxes with reversed() rather than editing the invoice.

MultiRateTaxCalculator

final readonly class MultiRateTaxCalculator
{
    /** @param list<TaxRate> $rates */
    public function calculate(Money $base, array $rates): TaxBreakdown;

    /** @param list<TaxRate> $rates */
    public function extract(Money $price, array $rates): TaxBreakdown;
}

calculate() gives one computed line per rate, in the order given; no rate gives an empty breakdown. extract() takes the taxes out of a price that includes them, as the next section shows. Stateless, no constructor argument.

Prices that include their taxes

Where the price on the tag is the price paid, extract() takes the taxes out of it instead of adding them:

$breakdown = new MultiRateTaxCalculator()->extract(Money::of(1_000, Currency::of('CAD')), [
    TaxRate::of('GST', 50_000),
    TaxRate::of('QST', 99_750),
]);

$breakdown->lines[0]->amount->minorUnits;  // 44   (5 % of 8.70)
$breakdown->lines[1]->amount->minorUnits;  // 87   (9.975 % of 8.70)
$breakdown->base->minorUnits;              // 869  (10.00 less the taxes)
$breakdown->totalWithTax()->minorUnits;    // 1000, the price, always
  • The base before taxes is the price divided by one plus every rate, rounded half away from zero: 8.70 here.
  • Each tax is computed on it and rounded on its own line, as calculate() does.
  • The breakdown's base is the price less those taxes, so a rounding cent stays in the base and the price paid never changes: 8.69 here. Its lines are reported() on that base.
  • A negative price gives exactly the negated taxes, for a refund.
  • A price too large for the computation throws UntaxableBase: its magnitude may not exceed intdiv(PHP_INT_MAX - parts, 1000000) minor units, where parts is one million plus the parts per million of every rate.