Put a discount on the line it belongs to, so the columns add up
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
parent
90131f088a
commit
d21e4f0eb2
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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 %',
|
||||
|
|
|
|||
|
|
@ -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 %',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue