$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> $lines `unit_net_cents` holding the amount charged * @return array{lines: array>, totals: array} */ 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 $parts * @return array */ 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'), ','); } }