159 lines
5.7 KiB
PHP
159 lines
5.7 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 line's net after its own discount or surcharge.
|
|
*
|
|
* Per line rather than per invoice, so the columns add up. With one
|
|
* discount applied under the table, the gross column summed to more than
|
|
* the total — the reader who checks a column found a document that does not
|
|
* agree with itself, and had no way to know which figure was the real one.
|
|
*
|
|
* @param array{type:string, value:int}|null $adjustment
|
|
*/
|
|
public static function lineTotal(int $unitNetCents, int $quantityMilli, ?array $adjustment = null): int
|
|
{
|
|
$net = self::lineNet($unitNetCents, $quantityMilli);
|
|
|
|
if ($adjustment === null) {
|
|
return $net;
|
|
}
|
|
|
|
return $net + self::adjustment($net, (string) $adjustment['type'], (int) $adjustment['value']);
|
|
}
|
|
|
|
/**
|
|
* "Rabatt 10 %" or "Aufschlag 15,00" — the label printed under a line.
|
|
*
|
|
* Built here rather than in the template so the wording and the sign can
|
|
* never disagree with the arithmetic they describe.
|
|
*
|
|
* @param array{type:string, value:int}|null $adjustment
|
|
*/
|
|
public static function adjustmentLabel(?array $adjustment): ?string
|
|
{
|
|
if ($adjustment === null || (int) $adjustment['value'] === 0) {
|
|
return null;
|
|
}
|
|
|
|
$value = (int) $adjustment['value'];
|
|
$word = $value < 0 ? __('invoice.discount') : __('invoice.surcharge');
|
|
|
|
return $adjustment['type'] === self::PERCENT
|
|
? $word.' '.rtrim(rtrim(number_format(abs($value) / 100, 2, ',', '.'), '0'), ',').' %'
|
|
: $word.' '.self::money(abs($value));
|
|
}
|
|
|
|
/**
|
|
* 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);
|
|
}
|
|
|
|
/**
|
|
* Every figure the document shows, from the lines and one adjustment.
|
|
*
|
|
* @param array<int, array{unit_net_cents:int, quantity_milli:int}> $lines
|
|
* @param array{type:string, value:int}|null $adjustment
|
|
* @return array{lines_net:int, adjustment:int, net:int, tax:int, gross:int}
|
|
*/
|
|
public static function totals(array $lines, ?array $adjustment, int $rateBasisPoints): array
|
|
{
|
|
$linesNet = 0;
|
|
|
|
foreach ($lines as $line) {
|
|
// Each line's own discount is inside its total, so the column the
|
|
// reader adds up is the column the sum is taken from.
|
|
$linesNet += self::lineTotal(
|
|
(int) $line['unit_net_cents'],
|
|
(int) $line['quantity_milli'],
|
|
$line['adjustment'] ?? null,
|
|
);
|
|
}
|
|
|
|
$adjusted = $adjustment === null
|
|
? 0
|
|
: self::adjustment($linesNet, (string) $adjustment['type'], (int) $adjustment['value']);
|
|
|
|
$net = $linesNet + $adjusted;
|
|
$tax = self::tax($net, $rateBasisPoints);
|
|
|
|
return [
|
|
'lines_net' => $linesNet,
|
|
'adjustment' => $adjusted,
|
|
'net' => $net,
|
|
'tax' => $tax,
|
|
'gross' => $net + $tax,
|
|
];
|
|
}
|
|
|
|
/** "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'), ',');
|
|
}
|
|
}
|