Put a discount on the line it belongs to, so the columns add up
tests / pest (push) Failing after 8m5s Details
tests / assets (push) Successful in 21s Details
tests / release (push) Has been skipped Details

With one discount applied under the table, the gross column summed to 315,60
against a total of 284,04. Nothing was miscalculated — the discount simply came
after — but a reader who adds up a column found a document that does not agree
with itself, and no way to tell which figure was the real one. On an invoice
that is not a presentation detail.

A discount or surcharge now sits on its line, percentage or fixed amount, and
is inside that line's total. The label under the line is built from the same
value that did the arithmetic, so the words cannot end up describing a
different number than the one printed beside them.

The invoice-wide adjustment stays in the arithmetic for anything that genuinely
applies to the whole document, but nothing uses it by default.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feat/granted-plans
nexxo 2026-07-29 01:52:16 +02:00
parent 90131f088a
commit d21e4f0eb2
6 changed files with 105 additions and 10 deletions

View File

@ -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

View File

@ -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.',

View File

@ -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 %',

View File

@ -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 %',

View File

@ -88,12 +88,16 @@
</tr>
@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
<tr>
<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</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="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($net) }}</td>

View File

@ -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 lines 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();
});