Render an invoice, with the arithmetic held to integer cents
TCPDF 6, deliberately, after installing 7 and finding out what it is. Version 7 is not a newer TCPDF; it is a compatibility shim over tc-lib-pdf whose own mapping table lists 115 of 293 methods as stubs, and whose font package ships no font files at all — the first render died on a missing helvetica.json. The 6.x line carries a hundred and sixty-five fonts, a working writeHTML and real header and footer hooks, and is what everybody means by TCPDF. The body is a Blade template handed to writeHTML rather than a hundred Cell() calls with millimetre coordinates, because this table has to be adjusted the first time somebody sees it on paper, and nobody adjusts the second kind. InvoiceMath is integer cents end to end, and rounds once. A percentage applied line by line and rounded each time drifts from the same percentage applied to the sum — three lines at 3,33 € less 10 % is 99 cents that way and 100 cents the honest way, and the customer's calculator is what finds it. Gross is always derived from net and never the reverse: taking VAT back out of a gross figure returns a different number often enough to matter. A discount and a surcharge are one operation with opposite signs, so nothing has to remember which of two flags means which. Quantities are thousandths, because hours, gigabytes and part-months are all real, and are printed with the trailing zeroes stripped — "12,000 Std." reads as a defect. Every line shows quantity, unit price net, total net AND total gross: this goes to businesses and to consumers, and each reads a different column first. Verifying it took two goes and both failures were mine. `strings` over a PDF set in a Unicode font finds nothing, because the text is stored as subset glyph ids — the check has to render in a core font to be a check at all. And two of the strings I then declared missing were simply expectations I had got wrong. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>feat/granted-plans
parent
77bd30ca56
commit
c1896e70fc
|
|
@ -0,0 +1,95 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Billing;
|
||||
|
||||
use TCPDF;
|
||||
|
||||
/**
|
||||
* The page furniture: the logo block at the top and the footer at the bottom.
|
||||
*
|
||||
* A subclass because TCPDF draws headers and footers through overridden
|
||||
* methods, and the footer has to repeat on every page — an invoice that runs to
|
||||
* two pages must carry the company's register and VAT numbers on both, or the
|
||||
* second page is not part of a valid invoice.
|
||||
*
|
||||
* The body is not built here. It is a Blade template rendered to HTML and
|
||||
* handed to writeHTML(), because a table of line items expressed as a hundred
|
||||
* Cell() calls with millimetre coordinates is a table nobody will ever adjust.
|
||||
*/
|
||||
final class InvoiceDocument extends TCPDF
|
||||
{
|
||||
/** @var array<string, mixed> */
|
||||
private array $issuer = [];
|
||||
|
||||
private string $logoFile = '';
|
||||
|
||||
private string $pageLabel = 'Seite';
|
||||
|
||||
/** @param array<string, mixed> $issuer */
|
||||
public function setIssuer(array $issuer, string $logoFile = '', string $pageLabel = 'Seite'): void
|
||||
{
|
||||
$this->issuer = $issuer;
|
||||
$this->logoFile = $logoFile;
|
||||
$this->pageLabel = $pageLabel;
|
||||
}
|
||||
|
||||
public function Header(): void // phpcs:ignore
|
||||
{
|
||||
if ($this->logoFile === '' || ! is_file($this->logoFile)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Top right, height-constrained. Width 0 lets TCPDF keep the aspect
|
||||
// ratio — a logo squeezed to a fixed box is worse than none.
|
||||
$this->Image($this->logoFile, 140, 12, 0, 18, '', '', 'T', false, 300, 'R');
|
||||
}
|
||||
|
||||
public function Footer(): void // phpcs:ignore
|
||||
{
|
||||
$this->SetY(-26);
|
||||
$this->SetFont('dejavusans', '', 6.5);
|
||||
$this->SetTextColor(110, 110, 122);
|
||||
|
||||
$columns = [
|
||||
[
|
||||
$this->issuer['name'] ?? '',
|
||||
$this->issuer['address'] ?? '',
|
||||
trim(($this->issuer['postcode'] ?? '').' '.($this->issuer['city'] ?? '')),
|
||||
],
|
||||
[
|
||||
$this->issuer['phone'] ?? '',
|
||||
$this->issuer['email'] ?? '',
|
||||
$this->issuer['website'] ?? '',
|
||||
],
|
||||
[
|
||||
$this->issuer['register_number'] ?? '',
|
||||
$this->issuer['vat_id'] ?? '',
|
||||
$this->issuer['register_court'] ?? '',
|
||||
],
|
||||
[
|
||||
$this->issuer['bank_name'] ?? '',
|
||||
$this->issuer['iban'] ?? '',
|
||||
$this->issuer['bic'] ?? '',
|
||||
],
|
||||
];
|
||||
|
||||
// A hairline above it, the same one the totals block uses, so the page
|
||||
// reads as one document rather than two halves.
|
||||
$this->SetDrawColor(233, 233, 238);
|
||||
$this->Line(20, $this->GetY() - 3, 190, $this->GetY() - 3);
|
||||
|
||||
$width = 42.5;
|
||||
|
||||
foreach ($columns as $index => $lines) {
|
||||
$this->SetXY(20 + ($index * $width), -24);
|
||||
// Empty entries are dropped rather than printed as blank lines: a
|
||||
// company with no register number should not have a gap where one
|
||||
// would be.
|
||||
$this->MultiCell($width - 2, 3, implode("\n", array_filter($lines, fn ($l) => trim((string) $l) !== '')), 0, 'L', false, 0, '', '', true, 0, false, true, 0, 'T');
|
||||
}
|
||||
|
||||
$this->SetXY(150, -30);
|
||||
$this->SetFont('dejavusans', '', 6.5);
|
||||
$this->Cell(40, 3, $this->pageLabel.' '.$this->getAliasNumPage().'/'.$this->getAliasNbPages(), 0, 0, 'R');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,109 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Billing;
|
||||
|
||||
/**
|
||||
* The arithmetic on an invoice, in cents, rounded once.
|
||||
*
|
||||
* Everything is integer cents from end to end. Money in floats accumulates a
|
||||
* cent here and there and then the sum printed under a column does not match
|
||||
* the column — which on an invoice is not a rounding artefact, it is a document
|
||||
* that does not add up.
|
||||
*
|
||||
* Rounding happens ONCE, at the point a figure is shown or stored, never per
|
||||
* line and then again on the total. A percentage discount applied line by line
|
||||
* and rounded each time drifts from the same discount applied to the sum, and
|
||||
* the customer's calculator is the one that will find it.
|
||||
*
|
||||
* Net is the basis for everything. Gross is derived and never the other way
|
||||
* round: taking VAT out of a gross figure and putting it back gives a different
|
||||
* number often enough to matter.
|
||||
*/
|
||||
final class InvoiceMath
|
||||
{
|
||||
/** A discount or surcharge as a percentage of something. */
|
||||
public const PERCENT = 'percent';
|
||||
|
||||
/** A discount or surcharge as a fixed amount in cents. */
|
||||
public const AMOUNT = 'amount';
|
||||
|
||||
/**
|
||||
* One line's net total before any invoice-level adjustment.
|
||||
*
|
||||
* Quantity carries three decimals — hours, gigabytes and part-months are
|
||||
* all real — so it is held as thousandths and divided out at the end,
|
||||
* rather than multiplying a float by a price.
|
||||
*/
|
||||
public static function lineNet(int $unitNetCents, int $quantityMilli): int
|
||||
{
|
||||
return (int) round($unitNetCents * $quantityMilli / 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* A discount or surcharge, as a signed amount in cents.
|
||||
*
|
||||
* Positive raises the total, negative lowers it — so a discount is passed
|
||||
* as a negative value and a surcharge as a positive one, and nothing has to
|
||||
* remember which of two flags means which.
|
||||
*
|
||||
* @param string $type PERCENT (value in hundredths of a percent) or
|
||||
* AMOUNT (value in cents)
|
||||
*/
|
||||
public static function adjustment(int $baseCents, string $type, int $value): int
|
||||
{
|
||||
return $type === self::PERCENT
|
||||
? (int) round($baseCents * $value / 10000)
|
||||
: $value;
|
||||
}
|
||||
|
||||
/** VAT on a net figure. `$rateBasisPoints` is 2000 for 20 %. */
|
||||
public static function tax(int $netCents, int $rateBasisPoints): int
|
||||
{
|
||||
return (int) round($netCents * $rateBasisPoints / 10000);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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{type:string, value:int}|null $adjustment
|
||||
* @return array{lines_net:int, adjustment:int, net:int, tax:int, gross:int}
|
||||
*/
|
||||
public static function totals(array $lines, ?array $adjustment, int $rateBasisPoints): array
|
||||
{
|
||||
$linesNet = 0;
|
||||
|
||||
foreach ($lines as $line) {
|
||||
$linesNet += self::lineNet((int) $line['unit_net_cents'], (int) $line['quantity_milli']);
|
||||
}
|
||||
|
||||
$adjusted = $adjustment === null
|
||||
? 0
|
||||
: self::adjustment($linesNet, (string) $adjustment['type'], (int) $adjustment['value']);
|
||||
|
||||
$net = $linesNet + $adjusted;
|
||||
$tax = self::tax($net, $rateBasisPoints);
|
||||
|
||||
return [
|
||||
'lines_net' => $linesNet,
|
||||
'adjustment' => $adjusted,
|
||||
'net' => $net,
|
||||
'tax' => $tax,
|
||||
'gross' => $net + $tax,
|
||||
];
|
||||
}
|
||||
|
||||
/** "1.234,56" — Austrian formatting, done here so no view invents its own. */
|
||||
public static function money(int $cents): string
|
||||
{
|
||||
return number_format($cents / 100, 2, ',', '.');
|
||||
}
|
||||
|
||||
/** "12", "1,5", "0,25" — trailing zeroes dropped, because "12,000 Std." reads as a defect. */
|
||||
public static function quantity(int $quantityMilli): string
|
||||
{
|
||||
$formatted = number_format($quantityMilli / 1000, 3, ',', '.');
|
||||
|
||||
return rtrim(rtrim($formatted, '0'), ',');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Billing;
|
||||
|
||||
use App\Models\Invoice;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Facades\View;
|
||||
|
||||
/**
|
||||
* An invoice as a PDF, built only from what was frozen into the document.
|
||||
*
|
||||
* Nothing here reads today's settings. Every value — the issuer's address, its
|
||||
* VAT number, the rate, the customer's address, the lines — comes from the
|
||||
* snapshot taken when the number was assigned. That is what makes it safe to
|
||||
* store no PDF at all: rendering the same invoice in 2033 produces the same
|
||||
* page it produced on the day it was issued, whatever has changed in between.
|
||||
*
|
||||
* The one exception is the logo file, which is referenced by path rather than
|
||||
* copied. A replaced logo therefore appears on old invoices too — recorded here
|
||||
* as a known limitation rather than hidden: keeping every superseded logo
|
||||
* forever is a real cost, and a changed mark on a reprint misleads nobody about
|
||||
* what was charged.
|
||||
*/
|
||||
final class InvoiceRenderer
|
||||
{
|
||||
public function forInvoice(Invoice $invoice): string
|
||||
{
|
||||
return $this->render((array) $invoice->snapshot);
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $snapshot */
|
||||
public function render(array $snapshot): string
|
||||
{
|
||||
$issuer = (array) ($snapshot['issuer'] ?? []);
|
||||
|
||||
$pdf = new InvoiceDocument('P', 'mm', 'A4', true, 'UTF-8');
|
||||
$pdf->setIssuer($issuer, $this->logoFile($issuer), __('invoice.page'));
|
||||
|
||||
$pdf->SetCreator('CluPilot');
|
||||
$pdf->SetAuthor((string) ($issuer['name'] ?? ''));
|
||||
$pdf->SetTitle((string) ($snapshot['meta']['number'] ?? ''));
|
||||
|
||||
// Room at the bottom for the four-column footer, which is fixed height
|
||||
// and would otherwise be written over the last line of the table.
|
||||
$pdf->SetMargins(20, 34, 20);
|
||||
$pdf->SetAutoPageBreak(true, 34);
|
||||
$pdf->SetFont('dejavusans', '', 9);
|
||||
|
||||
$pdf->AddPage();
|
||||
|
||||
// Rendered from a Blade template: a table of line items expressed as
|
||||
// Cell() calls with millimetre coordinates is a table nobody will ever
|
||||
// adjust, and this one has to be adjusted the first time somebody sees
|
||||
// it on paper.
|
||||
$pdf->writeHTML(
|
||||
View::make('pdf.invoice', ['s' => $snapshot])->render(),
|
||||
true, false, true, false, ''
|
||||
);
|
||||
|
||||
return $pdf->Output('', 'S');
|
||||
}
|
||||
|
||||
/**
|
||||
* The logo on disk, or nothing.
|
||||
*
|
||||
* Nothing is a perfectly good answer: an invoice without a logo is still a
|
||||
* valid invoice, and refusing to render one because an image is missing
|
||||
* would block the only document that actually has to exist.
|
||||
*
|
||||
* @param array<string, mixed> $issuer
|
||||
*/
|
||||
private function logoFile(array $issuer): string
|
||||
{
|
||||
$path = trim((string) ($issuer['logo_path'] ?? ''));
|
||||
|
||||
if ($path === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
$full = Storage::disk('public')->path($path);
|
||||
|
||||
return is_file($full) ? $full : '';
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Billing;
|
||||
|
||||
use App\Support\CompanyProfile;
|
||||
|
||||
/**
|
||||
* A specimen invoice, for looking at the layout before anything is issued.
|
||||
*
|
||||
* It uses the real company details where they have been entered and obvious
|
||||
* placeholders where they have not, so the preview shows what is still missing
|
||||
* rather than a page that looks finished and is not.
|
||||
*
|
||||
* The customer and the lines are invented on purpose and say so. A specimen
|
||||
* that looks like a real document is a specimen somebody eventually files.
|
||||
*/
|
||||
final class SampleInvoice
|
||||
{
|
||||
/** @return array<string, mixed> */
|
||||
public static function snapshot(): array
|
||||
{
|
||||
$issuer = CompanyProfile::all();
|
||||
|
||||
foreach (['name' => 'Ihr Firmenwortlaut e.U.', 'address' => 'Musterstraße 1', 'postcode' => '1010', 'city' => 'Wien', 'vat_id' => 'ATU00000000'] as $field => $placeholder) {
|
||||
if (trim((string) ($issuer[$field] ?? '')) === '') {
|
||||
$issuer[$field] = $placeholder;
|
||||
}
|
||||
}
|
||||
|
||||
$rate = (int) round(CompanyProfile::taxRate() * 100);
|
||||
|
||||
$lines = [
|
||||
[
|
||||
'description' => 'CluPilot Cloud — Paket Start',
|
||||
'details' => ['Abrechnungszeitraum 01.08.2026 – 31.08.2026', '500 GB Speicher, 4 Kerne, 8 GB RAM'],
|
||||
'quantity_milli' => 1000,
|
||||
'unit' => '',
|
||||
'unit_net_cents' => 1900,
|
||||
],
|
||||
[
|
||||
'description' => 'Zusatzspeicher 250 GB',
|
||||
'details' => ['Add-on, monatlich'],
|
||||
'quantity_milli' => 2000,
|
||||
'unit' => '',
|
||||
'unit_net_cents' => 500,
|
||||
],
|
||||
[
|
||||
'description' => 'Zusätzliches Datenvolumen',
|
||||
'details' => ['Add-on, monatlich'],
|
||||
'quantity_milli' => 1000,
|
||||
'unit' => '',
|
||||
'unit_net_cents' => 900,
|
||||
],
|
||||
[
|
||||
'description' => 'Einrichtung und Datenübernahme',
|
||||
'details' => ['einmalig, 2,5 Stunden'],
|
||||
'quantity_milli' => 2500,
|
||||
'unit' => 'Std.',
|
||||
'unit_net_cents' => 9000,
|
||||
],
|
||||
];
|
||||
|
||||
// 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];
|
||||
|
||||
return [
|
||||
'issuer' => $issuer,
|
||||
'customer' => [
|
||||
'name' => 'Muster GmbH (Beispiel)',
|
||||
'address' => 'Beispielgasse 12/3',
|
||||
'postcode' => '1030',
|
||||
'city' => 'Wien',
|
||||
'country' => 'Österreich',
|
||||
'vat_id' => 'ATU11111111',
|
||||
],
|
||||
'meta' => [
|
||||
'title' => __('invoice.title'),
|
||||
'number' => 'RE-'.now()->format('Y').'-0001',
|
||||
'customer_number' => 'KN-0001',
|
||||
'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',
|
||||
'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.',
|
||||
'closing' => 'Dies ist ein Muster zur Ansicht des Layouts. Es ist keine Rechnung und keine Zahlungsaufforderung.',
|
||||
],
|
||||
'lines' => $lines,
|
||||
'totals' => $totals,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -15,7 +15,7 @@
|
|||
"livewire/livewire": "^3.0",
|
||||
"phpseclib/phpseclib": "^3.0",
|
||||
"spatie/laravel-permission": "^8.3",
|
||||
"tecnickcom/tcpdf": "^7.0",
|
||||
"tecnickcom/tcpdf": "^6.10",
|
||||
"wire-elements/modal": "^3.0"
|
||||
},
|
||||
"require-dev": {
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,25 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
'title' => 'Rechnung',
|
||||
'number' => 'Rechnungs-Nr.',
|
||||
'date' => 'Datum',
|
||||
'due' => 'Fällig am',
|
||||
'customer_number' => 'Kunde',
|
||||
'customer_vat' => 'UID',
|
||||
'page' => 'Seite',
|
||||
|
||||
'col_pos' => 'Pos',
|
||||
'col_description' => 'Beschreibung',
|
||||
'col_quantity' => 'Menge',
|
||||
'col_unit_net' => 'Einzelpreis netto',
|
||||
'col_total_net' => 'Gesamt netto',
|
||||
'col_total_gross' => 'Gesamt brutto',
|
||||
|
||||
'adjustment' => 'Rabatt',
|
||||
'net' => 'Nettobetrag',
|
||||
'tax' => 'Umsatzsteuer :rate %',
|
||||
'gross' => 'Gesamtsumme',
|
||||
|
||||
'payment_title' => 'Zahlungsbedingungen',
|
||||
];
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
'title' => 'Invoice',
|
||||
'number' => 'Invoice no.',
|
||||
'date' => 'Date',
|
||||
'due' => 'Due',
|
||||
'customer_number' => 'Customer',
|
||||
'customer_vat' => 'VAT ID',
|
||||
'page' => 'Page',
|
||||
|
||||
'col_pos' => 'Pos',
|
||||
'col_description' => 'Description',
|
||||
'col_quantity' => 'Qty',
|
||||
'col_unit_net' => 'Unit price net',
|
||||
'col_total_net' => 'Total net',
|
||||
'col_total_gross' => 'Total gross',
|
||||
|
||||
'adjustment' => 'Discount',
|
||||
'net' => 'Net amount',
|
||||
'tax' => 'VAT :rate %',
|
||||
'gross' => 'Total',
|
||||
|
||||
'payment_title' => 'Payment terms',
|
||||
];
|
||||
|
|
@ -0,0 +1,144 @@
|
|||
{{--
|
||||
The body of an invoice, for TCPDF's writeHTML().
|
||||
|
||||
Tables and inline styles, and a narrower dialect than even an email: TCPDF
|
||||
understands a small subset of HTML and almost no CSS. No flexbox, no grid,
|
||||
no shorthand borders on table cells, no percentage padding. What works is a
|
||||
table with widths, and text-align.
|
||||
|
||||
Every value comes from the frozen snapshot ($s). Nothing here reads a
|
||||
setting or a model — that is the whole reason no PDF has to be stored.
|
||||
--}}
|
||||
@php
|
||||
use App\Services\Billing\InvoiceMath;
|
||||
|
||||
$issuer = $s['issuer'] ?? [];
|
||||
$customer = $s['customer'] ?? [];
|
||||
$meta = $s['meta'] ?? [];
|
||||
$lines = $s['lines'] ?? [];
|
||||
$totals = $s['totals'] ?? [];
|
||||
@endphp
|
||||
|
||||
{{-- Sender line above the address, small — the line that shows through a
|
||||
window envelope and tells the postman where to return it. --}}
|
||||
<table cellpadding="0" cellspacing="0" style="width:100%;">
|
||||
<tr>
|
||||
<td style="width:58%; font-size:6.5pt; color:#6e6e7a; border-bottom:0.2mm solid #b4b4be;">
|
||||
{{ collect([$issuer['name'] ?? null, $issuer['address'] ?? null, trim(($issuer['postcode'] ?? '').' '.($issuer['city'] ?? '')), $issuer['country'] ?? null])->filter()->join(' | ') }}
|
||||
</td>
|
||||
<td style="width:42%;"></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<table cellpadding="0" cellspacing="0" style="width:100%; margin-top:2mm;">
|
||||
<tr>
|
||||
<td style="width:58%; font-size:10pt; line-height:145%;">
|
||||
<b>{{ $customer['name'] ?? '' }}</b><br>
|
||||
{{ $customer['address'] ?? '' }}<br>
|
||||
{{ trim(($customer['postcode'] ?? '').' '.($customer['city'] ?? '')) }}<br>
|
||||
{{ $customer['country'] ?? '' }}
|
||||
@if (! empty($customer['vat_id']))
|
||||
<br>{{ __('invoice.customer_vat') }}: {{ $customer['vat_id'] }}
|
||||
@endif
|
||||
</td>
|
||||
<td style="width:42%;"></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
{{-- The data block, right, level with the address. Its own table because
|
||||
TCPDF has no way to float one beside the other. --}}
|
||||
<table cellpadding="1" cellspacing="0" style="width:100%; margin-top:14mm; font-size:9pt;">
|
||||
<tr><td style="width:55%;"></td>
|
||||
<td style="width:22%; color:#43434e;">{{ __('invoice.date') }}</td>
|
||||
<td style="width:23%; text-align:right;">{{ $meta['issued_on'] ?? '' }}</td></tr>
|
||||
@if (! empty($meta['customer_number']))
|
||||
<tr><td></td>
|
||||
<td style="color:#43434e;">{{ __('invoice.customer_number') }}</td>
|
||||
<td style="text-align:right;">{{ $meta['customer_number'] }}</td></tr>
|
||||
@endif
|
||||
<tr><td></td>
|
||||
<td style="color:#43434e;">{{ __('invoice.number') }}</td>
|
||||
<td style="text-align:right;"><b>{{ $meta['number'] ?? '' }}</b></td></tr>
|
||||
@if (! empty($meta['due_on']))
|
||||
<tr><td></td>
|
||||
<td style="color:#43434e;">{{ __('invoice.due') }}</td>
|
||||
<td style="text-align:right;">{{ $meta['due_on'] }}</td></tr>
|
||||
@endif
|
||||
</table>
|
||||
|
||||
<h1 style="font-size:17pt; margin-top:10mm; margin-bottom:0;">{{ $meta['title'] ?? __('invoice.title') }}</h1>
|
||||
<p style="font-size:17pt; margin-top:0;">{{ $meta['number'] ?? '' }}</p>
|
||||
|
||||
@if (! empty($meta['salutation']))
|
||||
<p style="font-size:9.5pt; margin-top:6mm;">{{ $meta['salutation'] }}</p>
|
||||
@endif
|
||||
@if (! empty($meta['intro']))
|
||||
<p style="font-size:9.5pt; line-height:150%;">{{ $meta['intro'] }}</p>
|
||||
@endif
|
||||
|
||||
{{-- Quantity, net unit price, net total AND gross total per line: the invoice
|
||||
goes to businesses and to consumers, and each reads a different column
|
||||
first. --}}
|
||||
<table cellpadding="3" cellspacing="0" style="width:100%; margin-top:6mm; font-size:8.5pt;">
|
||||
<tr style="font-weight:bold; background-color:#fafafb;">
|
||||
<td style="width:7%; border-bottom:0.2mm solid #b4b4be;">{{ __('invoice.col_pos') }}</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:14%; text-align:right; border-bottom:0.2mm solid #b4b4be;">{{ __('invoice.col_unit_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>
|
||||
@foreach ($lines as $index => $line)
|
||||
@php
|
||||
$net = InvoiceMath::lineNet((int) $line['unit_net_cents'], (int) $line['quantity_milli']);
|
||||
$gross = $net + InvoiceMath::tax($net, (int) ($totals['rate_basis_points'] ?? 2000));
|
||||
@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="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>
|
||||
<td style="text-align:right; border-bottom:0.1mm solid #e9e9ee;">{{ InvoiceMath::money($gross) }}</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</table>
|
||||
|
||||
<table cellpadding="3" cellspacing="0" style="width:100%; margin-top:4mm; font-size:9pt;">
|
||||
@if (! empty($totals['adjustment']))
|
||||
<tr>
|
||||
<td style="width:60%;"></td>
|
||||
<td style="width:25%;">{{ $meta['adjustment_label'] ?? __('invoice.adjustment') }}</td>
|
||||
<td style="width:15%; text-align:right;">{{ InvoiceMath::money((int) $totals['adjustment']) }}</td>
|
||||
</tr>
|
||||
@endif
|
||||
<tr>
|
||||
<td></td>
|
||||
<td>{{ __('invoice.net') }}</td>
|
||||
<td style="text-align:right;">{{ InvoiceMath::money((int) ($totals['net'] ?? 0)) }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td></td>
|
||||
<td>{{ __('invoice.tax', ['rate' => rtrim(rtrim(number_format(($totals['rate_basis_points'] ?? 2000) / 100, 2, ',', '.'), '0'), ',')]) }}</td>
|
||||
<td style="text-align:right;">{{ InvoiceMath::money((int) ($totals['tax'] ?? 0)) }}</td>
|
||||
</tr>
|
||||
<tr style="font-weight:bold;">
|
||||
<td></td>
|
||||
<td style="border-top:0.2mm solid #b4b4be;">{{ __('invoice.gross') }}</td>
|
||||
<td style="text-align:right; border-top:0.2mm solid #b4b4be;">{{ InvoiceMath::money((int) ($totals['gross'] ?? 0)) }} {{ $meta['currency'] ?? 'EUR' }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
@if (! empty($meta['payment_terms']))
|
||||
<p style="font-size:9pt; margin-top:10mm;"><b>{{ __('invoice.payment_title') }}</b></p>
|
||||
<p style="font-size:9pt; line-height:150%; margin-top:0;">{{ $meta['payment_terms'] }}</p>
|
||||
@endif
|
||||
|
||||
@if (! empty($meta['closing']))
|
||||
<p style="font-size:9pt; line-height:150%; margin-top:6mm;">{{ $meta['closing'] }}</p>
|
||||
@endif
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
<?php
|
||||
|
||||
use App\Services\Billing\InvoiceMath;
|
||||
|
||||
/**
|
||||
* The arithmetic, which on an invoice is the whole product.
|
||||
*
|
||||
* A document that does not add up is not a rounding artefact, it is a document
|
||||
* that does not add up — and the customer's calculator is what finds it.
|
||||
*/
|
||||
it('multiplies a fractional quantity without losing a cent', function () {
|
||||
// 12,5 hours at 100,00 €
|
||||
expect(InvoiceMath::lineNet(10000, 12500))->toBe(125000);
|
||||
|
||||
// 0,25 months at 19,00 € — 4,75, not 4,74 and not 4,76
|
||||
expect(InvoiceMath::lineNet(1900, 250))->toBe(475);
|
||||
});
|
||||
|
||||
it('applies a percentage to the sum, not line by line', function () {
|
||||
// Three lines at 3,33 € with 10 % off. Rounded per line, each discount is
|
||||
// 33 cents and the total is 99 — but 10 % of 9,99 € is 100 cents. One cent,
|
||||
// on the line the customer checks first.
|
||||
$lines = [
|
||||
['unit_net_cents' => 333, 'quantity_milli' => 1000],
|
||||
['unit_net_cents' => 333, 'quantity_milli' => 1000],
|
||||
['unit_net_cents' => 333, 'quantity_milli' => 1000],
|
||||
];
|
||||
|
||||
$totals = InvoiceMath::totals($lines, ['type' => InvoiceMath::PERCENT, 'value' => -1000], 2000);
|
||||
|
||||
expect($totals['lines_net'])->toBe(999)
|
||||
->and($totals['adjustment'])->toBe(-100)
|
||||
->and($totals['net'])->toBe(899);
|
||||
});
|
||||
|
||||
it('treats a surcharge as the same operation with the other sign', function () {
|
||||
// One rule, not two flags somebody has to remember the meaning of.
|
||||
$lines = [['unit_net_cents' => 10000, 'quantity_milli' => 1000]];
|
||||
|
||||
$discounted = InvoiceMath::totals($lines, ['type' => InvoiceMath::PERCENT, 'value' => -2000], 2000);
|
||||
$surcharged = InvoiceMath::totals($lines, ['type' => InvoiceMath::PERCENT, 'value' => 2000], 2000);
|
||||
|
||||
expect($discounted['net'])->toBe(8000)
|
||||
->and($surcharged['net'])->toBe(12000);
|
||||
});
|
||||
|
||||
it('takes a fixed amount off as readily as a percentage', function () {
|
||||
$lines = [['unit_net_cents' => 10000, 'quantity_milli' => 1000]];
|
||||
|
||||
$totals = InvoiceMath::totals($lines, ['type' => InvoiceMath::AMOUNT, 'value' => -1500], 2000);
|
||||
|
||||
expect($totals['net'])->toBe(8500)
|
||||
->and($totals['tax'])->toBe(1700)
|
||||
->and($totals['gross'])->toBe(10200);
|
||||
});
|
||||
|
||||
it('adds up: net plus tax is gross, on the figures actually printed', function () {
|
||||
// The assertion that matters, because it is the one a reader performs.
|
||||
$lines = [
|
||||
['unit_net_cents' => 10000, 'quantity_milli' => 12000],
|
||||
['unit_net_cents' => 31200, 'quantity_milli' => 20000],
|
||||
];
|
||||
|
||||
$totals = InvoiceMath::totals($lines, ['type' => InvoiceMath::PERCENT, 'value' => -2000], 2000);
|
||||
|
||||
expect($totals['lines_net'])->toBe(744000)
|
||||
->and($totals['adjustment'])->toBe(-148800)
|
||||
->and($totals['net'])->toBe(595200)
|
||||
->and($totals['tax'])->toBe(119040)
|
||||
->and($totals['gross'])->toBe(714240)
|
||||
->and($totals['net'] + $totals['tax'])->toBe($totals['gross']);
|
||||
});
|
||||
|
||||
it('derives gross from net and never the other way round', function () {
|
||||
// 19,99 € net at 20 % is 23,99 €. Taking VAT back out of 23,99 gives 19,99
|
||||
// here and 19,992 elsewhere — which is why only one direction is offered.
|
||||
$totals = InvoiceMath::totals([['unit_net_cents' => 1999, 'quantity_milli' => 1000]], null, 2000);
|
||||
|
||||
expect($totals['tax'])->toBe(400)
|
||||
->and($totals['gross'])->toBe(2399);
|
||||
});
|
||||
|
||||
it('formats money and quantities the way the document reads them', function () {
|
||||
expect(InvoiceMath::money(714240))->toBe('7.142,40')
|
||||
->and(InvoiceMath::money(-14880))->toBe('-148,80')
|
||||
->and(InvoiceMath::money(0))->toBe('0,00');
|
||||
|
||||
// "12,000 Std." reads as a defect.
|
||||
expect(InvoiceMath::quantity(12000))->toBe('12')
|
||||
->and(InvoiceMath::quantity(1500))->toBe('1,5')
|
||||
->and(InvoiceMath::quantity(250))->toBe('0,25');
|
||||
});
|
||||
Loading…
Reference in New Issue