From 4646880b9034d24f811a8bdff4c605a37e055a29 Mon Sep 17 00:00:00 2001 From: nexxo Date: Wed, 29 Jul 2026 01:59:56 +0200 Subject: [PATCH] Print every line at full price and take the discount off once, in euros MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second correction to the same block, and this one came from the right place: a line reading 202,50 beside "Rabatt 10 %" does not say whether the discount is in that number or still to come. It was unreadable, and unreadable on the one figure a customer checks. Lines are at full price now. Discounts are still entered per line — that is where they belong and where somebody decides them — but they are summed and shown once, in euros, under a subtotal that the "Gesamt netto" column adds up to. Subtotal, one subtraction, VAT, total: a sum anybody can follow without being told which figures are already adjusted. The gross line-total column is gone and a gross UNIT price takes its place. Full-price gross totals would sum to 315,60 against a total of 288,60 — the same contradiction in a different column. Nobody adds up unit prices, and a consumer reading this wants to know what one of the things costs. lineTotal() and adjustmentLabel() went with it. Nothing calls them any more, and a helper kept for a layout that no longer exists is the next person's wrong turn. Co-Authored-By: Claude Opus 5 --- app/Services/Billing/InvoiceMath.php | 87 ++++++++--------------- app/Services/Billing/SampleInvoice.php | 5 +- lang/de/invoice.php | 2 + lang/en/invoice.php | 2 + resources/views/pdf/invoice.blade.php | 39 ++++++---- tests/Feature/Billing/InvoiceMathTest.php | 53 +++++++------- 6 files changed, 91 insertions(+), 97 deletions(-) diff --git a/app/Services/Billing/InvoiceMath.php b/app/Services/Billing/InvoiceMath.php index 55f682b..e2d279e 100644 --- a/app/Services/Billing/InvoiceMath.php +++ b/app/Services/Billing/InvoiceMath.php @@ -39,49 +39,6 @@ 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. * @@ -108,33 +65,49 @@ final class InvoiceMath /** * Every figure the document shows, from the lines and one adjustment. * - * @param array $lines + * @param array $lines * @param array{type:string, value:int}|null $adjustment - * @return array{lines_net:int, adjustment:int, net:int, tax:int, gross:int} + * @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 { - $linesNet = 0; + // 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) { - // 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, - ); + $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'], + ); + } } - $adjusted = $adjustment === null + // An adjustment on the whole document, on top of any on the lines. + $wide = $adjustment === null ? 0 - : self::adjustment($linesNet, (string) $adjustment['type'], (int) $adjustment['value']); + : self::adjustment($subtotal, (string) $adjustment['type'], (int) $adjustment['value']); - $net = $linesNet + $adjusted; + $adjusted = $lineDiscounts + $wide; + $net = $subtotal + $adjusted; $tax = self::tax($net, $rateBasisPoints); return [ - 'lines_net' => $linesNet, + 'subtotal' => $subtotal, + 'lines_net' => $subtotal, // kept: older snapshots read this name 'adjustment' => $adjusted, 'net' => $net, 'tax' => $tax, diff --git a/app/Services/Billing/SampleInvoice.php b/app/Services/Billing/SampleInvoice.php index 28bb78d..536e014 100644 --- a/app/Services/Billing/SampleInvoice.php +++ b/app/Services/Billing/SampleInvoice.php @@ -84,8 +84,8 @@ final class SampleInvoice ], ]; - // No invoice-wide adjustment on the specimen: the discount sits on the - // line it belongs to, which is what keeps the columns adding up. + // The discount is entered on the line it belongs to and shown once, in + // euros, beneath the subtotal — the lines themselves stay at full price. $totals = InvoiceMath::totals($lines, null, $rate) + ['rate_basis_points' => $rate]; return [ @@ -105,6 +105,7 @@ final class SampleInvoice 'issued_on' => now()->local()->format('d.m.Y'), 'due_on' => now()->local()->addDays((int) CompanyProfile::get('payment_days', 14))->format('d.m.Y'), 'currency' => 'EUR', + 'adjustment_label' => 'Rabatt', 'salutation' => 'Sehr geehrte Damen und Herren,', 'intro' => 'wir erlauben uns, für die im Folgenden aufgeführten Leistungen in Rechnung zu stellen.', 'payment_terms' => trim((string) CompanyProfile::get('payment_terms')) !== '' diff --git a/lang/de/invoice.php b/lang/de/invoice.php index a48ac50..315afba 100644 --- a/lang/de/invoice.php +++ b/lang/de/invoice.php @@ -18,6 +18,8 @@ return [ 'discount' => 'Rabatt', 'surcharge' => 'Aufschlag', + 'col_unit_gross' => 'Einzelpreis brutto', + 'subtotal' => 'Zwischensumme', 'adjustment' => 'Rabatt', 'net' => 'Nettobetrag', 'tax' => 'Umsatzsteuer :rate %', diff --git a/lang/en/invoice.php b/lang/en/invoice.php index 2f358f0..0b0bfb5 100644 --- a/lang/en/invoice.php +++ b/lang/en/invoice.php @@ -18,6 +18,8 @@ return [ 'discount' => 'Discount', 'surcharge' => 'Surcharge', + 'col_unit_gross' => 'Unit price gross', + 'subtotal' => 'Subtotal', '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 3ccd2aa..5f7f3c4 100644 --- a/resources/views/pdf/invoice.blade.php +++ b/resources/views/pdf/invoice.blade.php @@ -83,41 +83,52 @@ {{ __('invoice.col_description') }} {{ __('invoice.col_quantity') }} {{ __('invoice.col_unit_net') }} + {{-- A unit price, not a line total. Nobody adds up unit prices, so this + column cannot contradict the sum — which a full-price gross TOTAL + column would, by 315,60 against 288,60. --}} + {{ __('invoice.col_unit_gross') }} {{ __('invoice.col_total_net') }} - {{ __('invoice.col_total_gross') }} @foreach ($lines as $index => $line) @php - // 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); + // Full price throughout. The discount is collected and shown once, + // in euros, beneath the subtotal — a line printed already-reduced + // leaves the reader guessing whether it is reduced. + $rate = (int) ($totals['rate_basis_points'] ?? 2000); + $net = InvoiceMath::lineNet((int) $line['unit_net_cents'], (int) $line['quantity_milli']); + $unitGross = (int) $line['unit_net_cents'] + InvoiceMath::tax((int) $line['unit_net_cents'], $rate); @endphp {{ $index + 1 }} - {{ $line['description'] ?? '' }}@foreach (($line['details'] ?? []) as $detail)
{{ $detail }}@endforeach@if ($adjustmentLabel)
{{ $adjustmentLabel }}@endif + {{ $line['description'] ?? '' }}@foreach (($line['details'] ?? []) as $detail)
{{ $detail }}@endforeach {{ InvoiceMath::quantity((int) $line['quantity_milli']) }}@if (! empty($line['unit'])) {{ $line['unit'] }}@endif {{ InvoiceMath::money((int) $line['unit_net_cents']) }} + {{ InvoiceMath::money($unitGross) }} {{ InvoiceMath::money($net) }} - {{ InvoiceMath::money($gross) }} @endforeach + {{-- The subtotal is what the "Gesamt netto" column adds up to. Without it + the discount below has nothing to be a discount OF, which is exactly + what made the previous version unreadable. --}} @if (! empty($totals['adjustment'])) - - + + + + + + + @endif - - - + + + diff --git a/tests/Feature/Billing/InvoiceMathTest.php b/tests/Feature/Billing/InvoiceMathTest.php index b18d4c6..41d4ee7 100644 --- a/tests/Feature/Billing/InvoiceMathTest.php +++ b/tests/Feature/Billing/InvoiceMathTest.php @@ -91,41 +91,46 @@ it('formats money and quantities the way the document reads them', function () { ->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. +it('shows lines at full price and takes the discount off once, in euros', function () { + // Two attempts got here. Applying one discount under the table left the + // gross column summing to more than the total; printing each line already + // reduced left a line reading 202,50 beside "Rabatt 10 %" with no way to + // tell whether the discount was in that number or still to come. Full + // price, a subtotal, one subtraction — a sum anybody can follow. $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; + $column = 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); + $column += InvoiceMath::lineNet($line['unit_net_cents'], $line['quantity_milli']); } $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); + // The column the reader can add up on the page IS the subtotal. + expect($column)->toBe($totals['subtotal']) + ->and($totals['subtotal'])->toBe(24400) + ->and($totals['adjustment'])->toBe(-2250) + ->and($totals['net'])->toBe(22150) + ->and($totals['net'] + $totals['tax'])->toBe($totals['gross']); }); -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(); +it('adds up discounts from several lines into one figure', function () { + // Each line can carry its own, and the document shows their sum. Printing + // three separate discount rows under the table is a summary block nobody + // reads. + $lines = [ + ['unit_net_cents' => 10000, 'quantity_milli' => 1000, 'adjustment' => ['type' => InvoiceMath::PERCENT, 'value' => -1000]], + ['unit_net_cents' => 5000, 'quantity_milli' => 1000, 'adjustment' => ['type' => InvoiceMath::AMOUNT, 'value' => -500]], + ['unit_net_cents' => 2000, 'quantity_milli' => 1000], + ]; + + $totals = InvoiceMath::totals($lines, null, 2000); + + expect($totals['subtotal'])->toBe(17000) + ->and($totals['adjustment'])->toBe(-1500) + ->and($totals['net'])->toBe(15500); });
{{ $meta['adjustment_label'] ?? __('invoice.adjustment') }}{{ InvoiceMath::money((int) $totals['adjustment']) }}{{ __('invoice.subtotal') }}{{ InvoiceMath::money((int) ($totals['subtotal'] ?? 0)) }}
{{ $meta['adjustment_label'] ?? __('invoice.adjustment') }}{{ InvoiceMath::money((int) $totals['adjustment']) }}
{{ __('invoice.net') }}{{ InvoiceMath::money((int) ($totals['net'] ?? 0)) }}{{ __('invoice.net') }}{{ InvoiceMath::money((int) ($totals['net'] ?? 0)) }}