Print every line at full price and take the discount off once, in euros
tests / pest (push) Failing after 7m40s Details
tests / assets (push) Successful in 22s Details
tests / release (push) Has been skipped Details

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 <noreply@anthropic.com>
feat/granted-plans
nexxo 2026-07-29 01:59:56 +02:00
parent d21e4f0eb2
commit 4646880b90
6 changed files with 91 additions and 97 deletions

View File

@ -39,49 +39,6 @@ final class InvoiceMath
return (int) round($unitNetCents * $quantityMilli / 1000); 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. * 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. * 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<int, array{unit_net_cents:int, quantity_milli:int, adjustment?:array{type:string, value:int}}> $lines
* @param array{type:string, value:int}|null $adjustment * @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 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) { foreach ($lines as $line) {
// Each line's own discount is inside its total, so the column the $full = self::lineNet((int) $line['unit_net_cents'], (int) $line['quantity_milli']);
// reader adds up is the column the sum is taken from. $subtotal += $full;
$linesNet += self::lineTotal(
(int) $line['unit_net_cents'], if (! empty($line['adjustment'])) {
(int) $line['quantity_milli'], $lineDiscounts += self::adjustment(
$line['adjustment'] ?? null, $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 ? 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); $tax = self::tax($net, $rateBasisPoints);
return [ return [
'lines_net' => $linesNet, 'subtotal' => $subtotal,
'lines_net' => $subtotal, // kept: older snapshots read this name
'adjustment' => $adjusted, 'adjustment' => $adjusted,
'net' => $net, 'net' => $net,
'tax' => $tax, 'tax' => $tax,

View File

@ -84,8 +84,8 @@ final class SampleInvoice
], ],
]; ];
// No invoice-wide adjustment on the specimen: the discount sits on the // The discount is entered on the line it belongs to and shown once, in
// line it belongs to, which is what keeps the columns adding up. // euros, beneath the subtotal — the lines themselves stay at full price.
$totals = InvoiceMath::totals($lines, null, $rate) + ['rate_basis_points' => $rate]; $totals = InvoiceMath::totals($lines, null, $rate) + ['rate_basis_points' => $rate];
return [ return [
@ -105,6 +105,7 @@ final class SampleInvoice
'issued_on' => now()->local()->format('d.m.Y'), 'issued_on' => now()->local()->format('d.m.Y'),
'due_on' => now()->local()->addDays((int) CompanyProfile::get('payment_days', 14))->format('d.m.Y'), 'due_on' => now()->local()->addDays((int) CompanyProfile::get('payment_days', 14))->format('d.m.Y'),
'currency' => 'EUR', 'currency' => 'EUR',
'adjustment_label' => 'Rabatt',
'salutation' => 'Sehr geehrte Damen und Herren,', 'salutation' => 'Sehr geehrte Damen und Herren,',
'intro' => 'wir erlauben uns, für die im Folgenden aufgeführten Leistungen in Rechnung zu stellen.', 'intro' => 'wir erlauben uns, für die im Folgenden aufgeführten Leistungen in Rechnung zu stellen.',
'payment_terms' => trim((string) CompanyProfile::get('payment_terms')) !== '' 'payment_terms' => trim((string) CompanyProfile::get('payment_terms')) !== ''

View File

@ -18,6 +18,8 @@ return [
'discount' => 'Rabatt', 'discount' => 'Rabatt',
'surcharge' => 'Aufschlag', 'surcharge' => 'Aufschlag',
'col_unit_gross' => 'Einzelpreis brutto',
'subtotal' => 'Zwischensumme',
'adjustment' => 'Rabatt', 'adjustment' => 'Rabatt',
'net' => 'Nettobetrag', 'net' => 'Nettobetrag',
'tax' => 'Umsatzsteuer :rate %', 'tax' => 'Umsatzsteuer :rate %',

View File

@ -18,6 +18,8 @@ return [
'discount' => 'Discount', 'discount' => 'Discount',
'surcharge' => 'Surcharge', 'surcharge' => 'Surcharge',
'col_unit_gross' => 'Unit price gross',
'subtotal' => 'Subtotal',
'adjustment' => 'Discount', 'adjustment' => 'Discount',
'net' => 'Net amount', 'net' => 'Net amount',
'tax' => 'VAT :rate %', 'tax' => 'VAT :rate %',

View File

@ -83,41 +83,52 @@
<td style="width:41%; border-bottom:0.2mm solid #b4b4be;">{{ __('invoice.col_description') }}</td> <td style="width:41%; border-bottom:0.2mm solid #b4b4be;">{{ __('invoice.col_description') }}</td>
<td style="width:12%; text-align:right; border-bottom:0.2mm solid #b4b4be;">{{ __('invoice.col_quantity') }}</td> <td style="width:12%; text-align:right; border-bottom:0.2mm solid #b4b4be;">{{ __('invoice.col_quantity') }}</td>
<td style="width:14%; text-align:right; border-bottom:0.2mm solid #b4b4be;">{{ __('invoice.col_unit_net') }}</td> <td style="width:14%; text-align:right; border-bottom:0.2mm solid #b4b4be;">{{ __('invoice.col_unit_net') }}</td>
{{-- 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. --}}
<td style="width:13%; text-align:right; border-bottom:0.2mm solid #b4b4be;">{{ __('invoice.col_unit_gross') }}</td>
<td style="width:13%; text-align:right; border-bottom:0.2mm solid #b4b4be;">{{ __('invoice.col_total_net') }}</td> <td style="width:13%; text-align:right; border-bottom:0.2mm solid #b4b4be;">{{ __('invoice.col_total_net') }}</td>
<td style="width:13%; text-align:right; border-bottom:0.2mm solid #b4b4be;">{{ __('invoice.col_total_gross') }}</td>
</tr> </tr>
@foreach ($lines as $index => $line) @foreach ($lines as $index => $line)
@php @php
// The line's own discount is inside its total, so this column adds // Full price throughout. The discount is collected and shown once,
// up to the sum printed beneath it. With one discount applied under // in euros, beneath the subtotal — a line printed already-reduced
// the table, it did not. // leaves the reader guessing whether it is reduced.
$net = InvoiceMath::lineTotal((int) $line['unit_net_cents'], (int) $line['quantity_milli'], $line['adjustment'] ?? null); $rate = (int) ($totals['rate_basis_points'] ?? 2000);
$gross = $net + InvoiceMath::tax($net, (int) ($totals['rate_basis_points'] ?? 2000)); $net = InvoiceMath::lineNet((int) $line['unit_net_cents'], (int) $line['quantity_milli']);
$adjustmentLabel = InvoiceMath::adjustmentLabel($line['adjustment'] ?? null); $unitGross = (int) $line['unit_net_cents'] + InvoiceMath::tax((int) $line['unit_net_cents'], $rate);
@endphp @endphp
<tr> <tr>
<td style="border-bottom:0.1mm solid #e9e9ee;">{{ $index + 1 }}</td> <td style="border-bottom:0.1mm solid #e9e9ee;">{{ $index + 1 }}</td>
<td style="border-bottom:0.1mm solid #e9e9ee;">{{ $line['description'] ?? '' }}@foreach (($line['details'] ?? []) as $detail)<br><span style="color:#6e6e7a;">{{ $detail }}</span>@endforeach@if ($adjustmentLabel)<br><span style="color:#b8500a;">{{ $adjustmentLabel }}</span>@endif</td> <td style="border-bottom:0.1mm solid #e9e9ee;">{{ $line['description'] ?? '' }}@foreach (($line['details'] ?? []) as $detail)<br><span style="color:#6e6e7a;">{{ $detail }}</span>@endforeach</td>
<td style="text-align:right; border-bottom:0.1mm solid #e9e9ee;">{{ InvoiceMath::quantity((int) $line['quantity_milli']) }}@if (! empty($line['unit'])) {{ $line['unit'] }}@endif</td> <td style="text-align:right; border-bottom:0.1mm solid #e9e9ee;">{{ InvoiceMath::quantity((int) $line['quantity_milli']) }}@if (! empty($line['unit'])) {{ $line['unit'] }}@endif</td>
<td style="text-align:right; border-bottom:0.1mm solid #e9e9ee;">{{ InvoiceMath::money((int) $line['unit_net_cents']) }}</td> <td style="text-align:right; border-bottom:0.1mm solid #e9e9ee;">{{ InvoiceMath::money((int) $line['unit_net_cents']) }}</td>
<td style="text-align:right; border-bottom:0.1mm solid #e9e9ee;">{{ InvoiceMath::money($unitGross) }}</td>
<td style="text-align:right; border-bottom:0.1mm solid #e9e9ee;">{{ InvoiceMath::money($net) }}</td> <td style="text-align:right; border-bottom:0.1mm solid #e9e9ee;">{{ InvoiceMath::money($net) }}</td>
<td style="text-align:right; border-bottom:0.1mm solid #e9e9ee;">{{ InvoiceMath::money($gross) }}</td>
</tr> </tr>
@endforeach @endforeach
</table> </table>
<table cellpadding="3" cellspacing="0" style="width:100%; margin-top:4mm; font-size:9pt;"> <table cellpadding="3" cellspacing="0" style="width:100%; margin-top:4mm; font-size:9pt;">
{{-- 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'])) @if (! empty($totals['adjustment']))
<tr> <tr>
<td style="width:60%;"></td> <td style="width:60%;"></td>
<td style="width:25%;">{{ $meta['adjustment_label'] ?? __('invoice.adjustment') }}</td> <td style="width:25%;">{{ __('invoice.subtotal') }}</td>
<td style="width:15%; text-align:right;">{{ InvoiceMath::money((int) $totals['adjustment']) }}</td> <td style="width:15%; text-align:right;">{{ InvoiceMath::money((int) ($totals['subtotal'] ?? 0)) }}</td>
</tr>
<tr>
<td></td>
<td>{{ $meta['adjustment_label'] ?? __('invoice.adjustment') }}</td>
<td style="text-align:right;">{{ InvoiceMath::money((int) $totals['adjustment']) }}</td>
</tr> </tr>
@endif @endif
<tr> <tr>
<td></td> <td style="width:60%;"></td>
<td>{{ __('invoice.net') }}</td> <td style="width:25%;">{{ __('invoice.net') }}</td>
<td style="text-align:right;">{{ InvoiceMath::money((int) ($totals['net'] ?? 0)) }}</td> <td style="width:15%; text-align:right;">{{ InvoiceMath::money((int) ($totals['net'] ?? 0)) }}</td>
</tr> </tr>
<tr> <tr>
<td></td> <td></td>

View File

@ -91,41 +91,46 @@ it('formats money and quantities the way the document reads them', function () {
->and(InvoiceMath::quantity(250))->toBe('0,25'); ->and(InvoiceMath::quantity(250))->toBe('0,25');
}); });
it('puts a lines discount inside that line, so the columns add up', function () { it('shows lines at full price and takes the discount off once, in euros', function () {
// The reason it moved off the summary block. With one discount under the // Two attempts got here. Applying one discount under the table left the
// table, the gross column summed to more than the total — a document that // gross column summing to more than the total; printing each line already
// does not agree with itself, and no way for the reader to know which // reduced left a line reading 202,50 beside "Rabatt 10 %" with no way to
// figure is the real one. // tell whether the discount was in that number or still to come. Full
// price, a subtotal, one subtraction — a sum anybody can follow.
$lines = [ $lines = [
['unit_net_cents' => 1900, 'quantity_milli' => 1000], ['unit_net_cents' => 1900, 'quantity_milli' => 1000],
['unit_net_cents' => 9000, 'quantity_milli' => 2500, 'adjustment' => ['type' => InvoiceMath::PERCENT, 'value' => -1000]], ['unit_net_cents' => 9000, 'quantity_milli' => 2500, 'adjustment' => ['type' => InvoiceMath::PERCENT, 'value' => -1000]],
]; ];
$columnNet = 0; $column = 0;
$columnGross = 0;
foreach ($lines as $line) { foreach ($lines as $line) {
$net = InvoiceMath::lineTotal($line['unit_net_cents'], $line['quantity_milli'], $line['adjustment'] ?? null); $column += InvoiceMath::lineNet($line['unit_net_cents'], $line['quantity_milli']);
$columnNet += $net;
$columnGross += $net + InvoiceMath::tax($net, 2000);
} }
$totals = InvoiceMath::totals($lines, null, 2000); $totals = InvoiceMath::totals($lines, null, 2000);
expect($columnNet)->toBe($totals['net']) // The column the reader can add up on the page IS the subtotal.
->and($columnGross)->toBe($totals['gross']) expect($column)->toBe($totals['subtotal'])
// 225,00 less 10 % is 202,50; plus 19,00 is 221,50. ->and($totals['subtotal'])->toBe(24400)
->and($totals['net'])->toBe(22150); ->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 () { it('adds up discounts from several lines into one figure', function () {
expect(InvoiceMath::adjustmentLabel(['type' => InvoiceMath::PERCENT, 'value' => -1000])) // Each line can carry its own, and the document shows their sum. Printing
->toBe(__('invoice.discount').' 10 %') // three separate discount rows under the table is a summary block nobody
->and(InvoiceMath::adjustmentLabel(['type' => InvoiceMath::PERCENT, 'value' => 1550])) // reads.
->toBe(__('invoice.surcharge').' 15,5 %') $lines = [
->and(InvoiceMath::adjustmentLabel(['type' => InvoiceMath::AMOUNT, 'value' => -2500])) ['unit_net_cents' => 10000, 'quantity_milli' => 1000, 'adjustment' => ['type' => InvoiceMath::PERCENT, 'value' => -1000]],
->toBe(__('invoice.discount').' 25,00') ['unit_net_cents' => 5000, 'quantity_milli' => 1000, 'adjustment' => ['type' => InvoiceMath::AMOUNT, 'value' => -500]],
// Nothing at all rather than "Rabatt 0,00", which reads as a mistake. ['unit_net_cents' => 2000, 'quantity_milli' => 1000],
->and(InvoiceMath::adjustmentLabel(['type' => InvoiceMath::AMOUNT, 'value' => 0]))->toBeNull() ];
->and(InvoiceMath::adjustmentLabel(null))->toBeNull();
$totals = InvoiceMath::totals($lines, null, 2000);
expect($totals['subtotal'])->toBe(17000)
->and($totals['adjustment'])->toBe(-1500)
->and($totals['net'])->toBe(15500);
}); });