258 lines
10 KiB
PHP
258 lines
10 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Billing;
|
|
|
|
/**
|
|
* The arithmetic on an invoice, in cents, rounded once.
|
|
*
|
|
* Everything is integer cents from end to end. Money in floats accumulates a
|
|
* cent here and there and then the sum printed under a column does not match
|
|
* the column — which on an invoice is not a rounding artefact, it is a document
|
|
* that does not add up.
|
|
*
|
|
* Rounding happens ONCE, at the point a figure is shown or stored, never per
|
|
* line and then again on the total. A percentage discount applied line by line
|
|
* and rounded each time drifts from the same discount applied to the sum, and
|
|
* the customer's calculator is the one that will find it.
|
|
*
|
|
* Net is the basis for everything. Gross is derived and never the other way
|
|
* round: taking VAT out of a gross figure and putting it back gives a different
|
|
* number often enough to matter.
|
|
*/
|
|
final class InvoiceMath
|
|
{
|
|
/** A discount or surcharge as a percentage of something. */
|
|
public const PERCENT = 'percent';
|
|
|
|
/** A discount or surcharge as a fixed amount in cents. */
|
|
public const AMOUNT = 'amount';
|
|
|
|
/**
|
|
* One line's net total before any invoice-level adjustment.
|
|
*
|
|
* Quantity carries three decimals — hours, gigabytes and part-months are
|
|
* all real — so it is held as thousandths and divided out at the end,
|
|
* rather than multiplying a float by a price.
|
|
*/
|
|
public static function lineNet(int $unitNetCents, int $quantityMilli): int
|
|
{
|
|
return (int) round($unitNetCents * $quantityMilli / 1000);
|
|
}
|
|
|
|
/**
|
|
* A discount or surcharge, as a signed amount in cents.
|
|
*
|
|
* Positive raises the total, negative lowers it — so a discount is passed
|
|
* as a negative value and a surcharge as a positive one, and nothing has to
|
|
* remember which of two flags means which.
|
|
*
|
|
* @param string $type PERCENT (value in hundredths of a percent) or
|
|
* AMOUNT (value in cents)
|
|
*/
|
|
public static function adjustment(int $baseCents, string $type, int $value): int
|
|
{
|
|
return $type === self::PERCENT
|
|
? (int) round($baseCents * $value / 10000)
|
|
: $value;
|
|
}
|
|
|
|
/** VAT on a net figure. `$rateBasisPoints` is 2000 for 20 %. */
|
|
public static function tax(int $netCents, int $rateBasisPoints): int
|
|
{
|
|
return (int) round($netCents * $rateBasisPoints / 10000);
|
|
}
|
|
|
|
/**
|
|
* The VAT already contained in a figure that includes it.
|
|
*
|
|
* The exact inverse of tax() for every figure tax() can produce, and — this
|
|
* is the part that matters — it works for the figures it CANNOT produce as
|
|
* well. At 20 % one integer in six is not the gross of any whole-cent net
|
|
* (there is no net whose gross is 3 cents), and Stripe hands us exactly such
|
|
* figures whenever it prorates a term by days. Extracting the tax from the
|
|
* gross and taking the rest as net therefore always adds back up to the
|
|
* amount that was charged, which putting a rate on top of a rounded net
|
|
* would not.
|
|
*/
|
|
public static function taxInGross(int $grossCents, int $rateBasisPoints): int
|
|
{
|
|
return (int) round($grossCents * $rateBasisPoints / (10000 + $rateBasisPoints));
|
|
}
|
|
|
|
/**
|
|
* Every figure the document shows, from the lines and one adjustment.
|
|
*
|
|
* @param array<int, array{unit_net_cents:int, quantity_milli:int, adjustment?:array{type:string, value:int}}> $lines
|
|
* @param array{type:string, value:int}|null $adjustment
|
|
* @return array{subtotal:int, lines_net:int, adjustment:int, net:int, tax:int, gross:int}
|
|
*/
|
|
public static function totals(array $lines, ?array $adjustment, int $rateBasisPoints): array
|
|
{
|
|
// The subtotal is the sum of the FULL line prices — the column the
|
|
// reader can add up on the page. Discounts are collected separately and
|
|
// shown as one figure beneath it.
|
|
//
|
|
// Printing each line already reduced was the previous attempt, and it
|
|
// was worse: a line reading 202,50 next to "Rabatt 10 %" leaves the
|
|
// reader unable to tell whether the discount is in that number or still
|
|
// to come. Full price, then one subtraction, is a sum anybody can
|
|
// follow.
|
|
$subtotal = 0;
|
|
$lineDiscounts = 0;
|
|
|
|
foreach ($lines as $line) {
|
|
$full = self::lineNet((int) $line['unit_net_cents'], (int) $line['quantity_milli']);
|
|
$subtotal += $full;
|
|
|
|
if (! empty($line['adjustment'])) {
|
|
$lineDiscounts += self::adjustment(
|
|
$full,
|
|
(string) $line['adjustment']['type'],
|
|
(int) $line['adjustment']['value'],
|
|
);
|
|
}
|
|
}
|
|
|
|
// An adjustment on the whole document, on top of any on the lines.
|
|
$wide = $adjustment === null
|
|
? 0
|
|
: self::adjustment($subtotal, (string) $adjustment['type'], (int) $adjustment['value']);
|
|
|
|
$adjusted = $lineDiscounts + $wide;
|
|
$net = $subtotal + $adjusted;
|
|
$tax = self::tax($net, $rateBasisPoints);
|
|
|
|
return [
|
|
'subtotal' => $subtotal,
|
|
'lines_net' => $subtotal, // kept: older snapshots read this name
|
|
'adjustment' => $adjusted,
|
|
'net' => $net,
|
|
'tax' => $tax,
|
|
'gross' => $net + $tax,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* A document whose lines state what was CHARGED, turned into the net lines
|
|
* it prints and the totals it adds up to.
|
|
*
|
|
* Everything this platform sells is priced gross: the figure on the price
|
|
* sheet is the figure on the Stripe Price and the figure taken from the
|
|
* card, and the document's job is to say how that one amount divides into
|
|
* net and VAT — not to rebuild it from a net figure and a rate. Rebuilding
|
|
* it is what produced an invoice for 214,80 € against a charge of 179,00 €,
|
|
* and it cannot be made to land on the cent for a prorated amount.
|
|
*
|
|
* So the split happens ONCE, on the total, exactly as totals() rounds once:
|
|
* the VAT is extracted from the sum of the lines and the rest is the net. The
|
|
* per-line net figures are then shares of that net, with the odd cent given
|
|
* to the largest line, so the column on the page still adds up to the
|
|
* subtotal underneath it.
|
|
*
|
|
* A reverse-charge customer comes through here at a rate of zero, and the
|
|
* whole charged amount becomes net — which is what their document has to
|
|
* say, since they paid the same total as everybody else.
|
|
*
|
|
* @param array<int, array<string, mixed>> $lines `unit_net_cents` holding the amount charged
|
|
* @return array{lines: array<int, array<string, mixed>>, totals: array<string, int>}
|
|
*/
|
|
public static function fromCharged(array $lines, int $rateBasisPoints): array
|
|
{
|
|
$lines = array_values($lines);
|
|
|
|
$charged = array_map(
|
|
fn (array $line) => self::lineNet((int) $line['unit_net_cents'], (int) $line['quantity_milli']),
|
|
$lines,
|
|
);
|
|
|
|
$chargedTotal = array_sum($charged);
|
|
$tax = self::taxInGross($chargedTotal, $rateBasisPoints);
|
|
$net = $chargedTotal - $tax;
|
|
|
|
$shares = self::shareOut($charged, $chargedTotal, $net);
|
|
|
|
foreach ($lines as $index => $line) {
|
|
$quantity = (int) $line['quantity_milli'];
|
|
$units = intdiv($quantity, 1000);
|
|
|
|
// The quantity survives only where the net share still divides by it
|
|
// exactly. Where it does not, the line collapses to one of itself at
|
|
// the whole amount — a unit price the reader could multiply into a
|
|
// different answer than the line beside it is worse than no unit
|
|
// price at all, and it is the same rule StripeInvoiceLines applies
|
|
// to Stripe's own amounts.
|
|
$divides = $units >= 1 && $units * 1000 === $quantity && $shares[$index] % $units === 0;
|
|
|
|
$lines[$index]['quantity_milli'] = $divides ? $quantity : 1000;
|
|
$lines[$index]['unit_net_cents'] = $divides ? intdiv($shares[$index], $units) : $shares[$index];
|
|
}
|
|
|
|
return [
|
|
'lines' => $lines,
|
|
'totals' => [
|
|
'subtotal' => $net,
|
|
'lines_net' => $net,
|
|
// No discount is expressible on a charged document: the amount
|
|
// was taken as it stands, and a discount here would describe a
|
|
// reduction that never reached the customer's card.
|
|
'adjustment' => 0,
|
|
'net' => $net,
|
|
'tax' => $tax,
|
|
'gross' => $chargedTotal,
|
|
],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* `$total` divided between lines in proportion to `$parts`, to the cent.
|
|
*
|
|
* The odd cent goes to the biggest line, because it is the one where a cent
|
|
* is least visible and because it has to go somewhere: shares that are each
|
|
* rounded on their own do not add up to the sum they are shares of.
|
|
*
|
|
* @param array<int, int> $parts
|
|
* @return array<int, int>
|
|
*/
|
|
private static function shareOut(array $parts, int $partsTotal, int $total): array
|
|
{
|
|
if ($parts === []) {
|
|
return [];
|
|
}
|
|
|
|
if ($partsTotal === 0) {
|
|
// Nothing to divide in proportion to. Everything on the first line
|
|
// rather than a division by zero; a zero-total document has one line
|
|
// in practice anyway.
|
|
return array_map(fn (int $index) => $index === 0 ? $total : 0, array_keys($parts));
|
|
}
|
|
|
|
$shares = array_map(fn (int $part) => (int) round($part * $total / $partsTotal), $parts);
|
|
|
|
$largest = 0;
|
|
|
|
foreach ($parts as $index => $part) {
|
|
if (abs($part) > abs($parts[$largest])) {
|
|
$largest = $index;
|
|
}
|
|
}
|
|
|
|
$shares[$largest] += $total - array_sum($shares);
|
|
|
|
return $shares;
|
|
}
|
|
|
|
/** "1.234,56" — Austrian formatting, done here so no view invents its own. */
|
|
public static function money(int $cents): string
|
|
{
|
|
return number_format($cents / 100, 2, ',', '.');
|
|
}
|
|
|
|
/** "12", "1,5", "0,25" — trailing zeroes dropped, because "12,000 Std." reads as a defect. */
|
|
public static function quantity(int $quantityMilli): string
|
|
{
|
|
$formatted = number_format($quantityMilli / 1000, 3, ',', '.');
|
|
|
|
return rtrim(rtrim($formatted, '0'), ',');
|
|
}
|
|
}
|