diff --git a/app/Services/Billing/InvoiceMath.php b/app/Services/Billing/InvoiceMath.php index fa42d5d..55f682b 100644 --- a/app/Services/Billing/InvoiceMath.php +++ b/app/Services/Billing/InvoiceMath.php @@ -39,6 +39,49 @@ final class InvoiceMath 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. * @@ -74,7 +117,13 @@ final class InvoiceMath $linesNet = 0; foreach ($lines as $line) { - $linesNet += self::lineNet((int) $line['unit_net_cents'], (int) $line['quantity_milli']); + // 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 diff --git a/app/Services/Billing/SampleInvoice.php b/app/Services/Billing/SampleInvoice.php index 0f3b57b..28bb78d 100644 --- a/app/Services/Billing/SampleInvoice.php +++ b/app/Services/Billing/SampleInvoice.php @@ -78,15 +78,15 @@ final class SampleInvoice 'quantity_milli' => 2500, 'unit' => 'Std.', 'unit_net_cents' => 9000, + // On the line rather than under the table, so the gross column + // adds up to the total printed beneath it. + 'adjustment' => ['type' => InvoiceMath::PERCENT, 'value' => -1000], ], ]; - // A discount on the specimen deliberately: it is the part of the layout - // most likely to be wrong, and the only way to find that out is to see - // it printed. - $adjustment = ['type' => InvoiceMath::PERCENT, 'value' => -1000]; - - $totals = InvoiceMath::totals($lines, $adjustment, $rate) + ['rate_basis_points' => $rate]; + // No invoice-wide adjustment on the specimen: the discount sits on the + // line it belongs to, which is what keeps the columns adding up. + $totals = InvoiceMath::totals($lines, null, $rate) + ['rate_basis_points' => $rate]; return [ 'issuer' => $issuer, @@ -107,7 +107,6 @@ final class SampleInvoice 'currency' => 'EUR', 'salutation' => 'Sehr geehrte Damen und Herren,', 'intro' => 'wir erlauben uns, für die im Folgenden aufgeführten Leistungen in Rechnung zu stellen.', - 'adjustment_label' => 'Rabatt 10 %', 'payment_terms' => trim((string) CompanyProfile::get('payment_terms')) !== '' ? (string) CompanyProfile::get('payment_terms') : 'Zahlbar ohne Abzug innerhalb von '.CompanyProfile::get('payment_days', 14).' Tagen auf das unten angeführte Konto.', diff --git a/lang/de/invoice.php b/lang/de/invoice.php index f01194b..a48ac50 100644 --- a/lang/de/invoice.php +++ b/lang/de/invoice.php @@ -16,6 +16,8 @@ return [ 'col_total_net' => 'Gesamt netto', 'col_total_gross' => 'Gesamt brutto', + 'discount' => 'Rabatt', + 'surcharge' => 'Aufschlag', 'adjustment' => 'Rabatt', 'net' => 'Nettobetrag', 'tax' => 'Umsatzsteuer :rate %', diff --git a/lang/en/invoice.php b/lang/en/invoice.php index cc814ba..2f358f0 100644 --- a/lang/en/invoice.php +++ b/lang/en/invoice.php @@ -16,6 +16,8 @@ return [ 'col_total_net' => 'Total net', 'col_total_gross' => 'Total gross', + 'discount' => 'Discount', + 'surcharge' => 'Surcharge', 'adjustment' => 'Discount', 'net' => 'Net amount', 'tax' => 'VAT :rate %', diff --git a/resources/views/pdf/invoice.blade.php b/resources/views/pdf/invoice.blade.php index b6bca60..3ccd2aa 100644 --- a/resources/views/pdf/invoice.blade.php +++ b/resources/views/pdf/invoice.blade.php @@ -88,12 +88,16 @@ @foreach ($lines as $index => $line) @php - $net = InvoiceMath::lineNet((int) $line['unit_net_cents'], (int) $line['quantity_milli']); + // The line's own discount is inside its total, so this column adds + // up to the sum printed beneath it. With one discount applied under + // the table, it did not. + $net = InvoiceMath::lineTotal((int) $line['unit_net_cents'], (int) $line['quantity_milli'], $line['adjustment'] ?? null); $gross = $net + InvoiceMath::tax($net, (int) ($totals['rate_basis_points'] ?? 2000)); + $adjustmentLabel = InvoiceMath::adjustmentLabel($line['adjustment'] ?? null); @endphp {{ $index + 1 }} - {{ $line['description'] ?? '' }}@foreach (($line['details'] ?? []) as $detail)
{{ $detail }}@endforeach + {{ $line['description'] ?? '' }}@foreach (($line['details'] ?? []) as $detail)
{{ $detail }}@endforeach@if ($adjustmentLabel)
{{ $adjustmentLabel }}@endif {{ InvoiceMath::quantity((int) $line['quantity_milli']) }}@if (! empty($line['unit'])) {{ $line['unit'] }}@endif {{ InvoiceMath::money((int) $line['unit_net_cents']) }} {{ InvoiceMath::money($net) }} diff --git a/tests/Feature/Billing/InvoiceMathTest.php b/tests/Feature/Billing/InvoiceMathTest.php index de156c2..b18d4c6 100644 --- a/tests/Feature/Billing/InvoiceMathTest.php +++ b/tests/Feature/Billing/InvoiceMathTest.php @@ -90,3 +90,42 @@ it('formats money and quantities the way the document reads them', function () { ->and(InvoiceMath::quantity(1500))->toBe('1,5') ->and(InvoiceMath::quantity(250))->toBe('0,25'); }); + +it('puts a line’s discount inside that line, so the columns add up', function () { + // The reason it moved off the summary block. With one discount under the + // table, the gross column summed to more than the total — a document that + // does not agree with itself, and no way for the reader to know which + // figure is the real one. + $lines = [ + ['unit_net_cents' => 1900, 'quantity_milli' => 1000], + ['unit_net_cents' => 9000, 'quantity_milli' => 2500, 'adjustment' => ['type' => InvoiceMath::PERCENT, 'value' => -1000]], + ]; + + $columnNet = 0; + $columnGross = 0; + + foreach ($lines as $line) { + $net = InvoiceMath::lineTotal($line['unit_net_cents'], $line['quantity_milli'], $line['adjustment'] ?? null); + $columnNet += $net; + $columnGross += $net + InvoiceMath::tax($net, 2000); + } + + $totals = InvoiceMath::totals($lines, null, 2000); + + expect($columnNet)->toBe($totals['net']) + ->and($columnGross)->toBe($totals['gross']) + // 225,00 less 10 % is 202,50; plus 19,00 is 221,50. + ->and($totals['net'])->toBe(22150); +}); + +it('labels an adjustment so the words cannot disagree with the arithmetic', function () { + expect(InvoiceMath::adjustmentLabel(['type' => InvoiceMath::PERCENT, 'value' => -1000])) + ->toBe(__('invoice.discount').' 10 %') + ->and(InvoiceMath::adjustmentLabel(['type' => InvoiceMath::PERCENT, 'value' => 1550])) + ->toBe(__('invoice.surcharge').' 15,5 %') + ->and(InvoiceMath::adjustmentLabel(['type' => InvoiceMath::AMOUNT, 'value' => -2500])) + ->toBe(__('invoice.discount').' 25,00') + // Nothing at all rather than "Rabatt 0,00", which reads as a mistake. + ->and(InvoiceMath::adjustmentLabel(['type' => InvoiceMath::AMOUNT, 'value' => 0]))->toBeNull() + ->and(InvoiceMath::adjustmentLabel(null))->toBeNull(); +});