From c1896e70fccb88aac17e3c38c80b32c38f417ce5 Mon Sep 17 00:00:00 2001 From: nexxo Date: Wed, 29 Jul 2026 01:41:46 +0200 Subject: [PATCH] Render an invoice, with the arithmetic held to integer cents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/Services/Billing/InvoiceDocument.php | 95 +++ app/Services/Billing/InvoiceMath.php | 109 +++ app/Services/Billing/InvoiceRenderer.php | 84 ++ app/Services/Billing/SampleInvoice.php | 99 +++ composer.json | 2 +- composer.lock | 964 +--------------------- lang/de/invoice.php | 25 + lang/en/invoice.php | 25 + resources/views/pdf/invoice.blade.php | 144 ++++ tests/Feature/Billing/InvoiceMathTest.php | 92 +++ 10 files changed, 697 insertions(+), 942 deletions(-) create mode 100644 app/Services/Billing/InvoiceDocument.php create mode 100644 app/Services/Billing/InvoiceMath.php create mode 100644 app/Services/Billing/InvoiceRenderer.php create mode 100644 app/Services/Billing/SampleInvoice.php create mode 100644 lang/de/invoice.php create mode 100644 lang/en/invoice.php create mode 100644 resources/views/pdf/invoice.blade.php create mode 100644 tests/Feature/Billing/InvoiceMathTest.php diff --git a/app/Services/Billing/InvoiceDocument.php b/app/Services/Billing/InvoiceDocument.php new file mode 100644 index 0000000..fd2f22b --- /dev/null +++ b/app/Services/Billing/InvoiceDocument.php @@ -0,0 +1,95 @@ + */ + private array $issuer = []; + + private string $logoFile = ''; + + private string $pageLabel = 'Seite'; + + /** @param array $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'); + } +} diff --git a/app/Services/Billing/InvoiceMath.php b/app/Services/Billing/InvoiceMath.php new file mode 100644 index 0000000..fa42d5d --- /dev/null +++ b/app/Services/Billing/InvoiceMath.php @@ -0,0 +1,109 @@ + $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'), ','); + } +} diff --git a/app/Services/Billing/InvoiceRenderer.php b/app/Services/Billing/InvoiceRenderer.php new file mode 100644 index 0000000..97f1a9d --- /dev/null +++ b/app/Services/Billing/InvoiceRenderer.php @@ -0,0 +1,84 @@ +render((array) $invoice->snapshot); + } + + /** @param array $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 $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 : ''; + } +} diff --git a/app/Services/Billing/SampleInvoice.php b/app/Services/Billing/SampleInvoice.php new file mode 100644 index 0000000..a73913b --- /dev/null +++ b/app/Services/Billing/SampleInvoice.php @@ -0,0 +1,99 @@ + */ + 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, + ]; + } +} diff --git a/composer.json b/composer.json index 2d8fee4..0bd2400 100644 --- a/composer.json +++ b/composer.json @@ -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": { diff --git a/composer.lock b/composer.lock index 65753b3..1cd5328 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "4dd7de1f16d082e59e6993debfbac9b1", + "content-hash": "08df94f724a2740a2df4b29fcd1eea9a", "packages": [ { "name": "bacon/bacon-qr-code", @@ -8352,953 +8352,23 @@ ], "time": "2026-06-08T20:24:16+00:00" }, - { - "name": "tecnickcom/tc-lib-barcode", - "version": "2.12.0", - "source": { - "type": "git", - "url": "https://github.com/tecnickcom/tc-lib-barcode.git", - "reference": "0614e905c903600c2491d3a90174a9a87a2fab9b" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/tecnickcom/tc-lib-barcode/zipball/0614e905c903600c2491d3a90174a9a87a2fab9b", - "reference": "0614e905c903600c2491d3a90174a9a87a2fab9b", - "shasum": "" - }, - "require": { - "ext-bcmath": "*", - "ext-gd": "*", - "php": ">=8.2", - "tecnickcom/tc-lib-color": "^2.13" - }, - "require-dev": { - "pdepend/pdepend": "^2.16", - "phpunit/phpunit": "^11.5 || ^12.5 || ^13.2" - }, - "type": "library", - "autoload": { - "psr-4": { - "Com\\Tecnick\\Barcode\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "LGPL-3.0-or-later" - ], - "authors": [ - { - "name": "Nicola Asuni", - "email": "info@tecnick.com", - "role": "lead" - } - ], - "description": "PHP library to generate linear and bidimensional barcodes", - "homepage": "https://tcpdf.org", - "keywords": [ - "3 of 9", - "ANSI MH10.8M-1983", - "CBC", - "CODABAR", - "CODE 11", - "CODE 128 A B C", - "CODE 39", - "CODE 93", - "EAN 13", - "EAN 8", - "ECC200", - "ISO IEC 15438 2006", - "ISO IEC 16022", - "ISO IEC 24778 2008", - "Intelligent Mail Barcode", - "Interleaved 2 of 5", - "KIX", - "Klant", - "MSI", - "Onecode", - "PHARMACODE", - "PHARMACODE TWO-TRACKS", - "POSTNET", - "RMS4CC", - "Standard 2 of 5", - "UPC-A", - "UPC-E", - "USD-3", - "USPS-B-3200", - "USS-93", - "aztec", - "barcode", - "datamatrix", - "pdf417", - "planet", - "qr-code", - "royal mail", - "tc-lib-barcode", - "upc" - ], - "support": { - "issues": "https://github.com/tecnickcom/tc-lib-barcode/issues", - "source": "https://github.com/tecnickcom/tc-lib-barcode" - }, - "funding": [ - { - "url": "https://github.com/sponsors/tecnickcom", - "type": "github" - } - ], - "time": "2026-07-17T19:05:47+00:00" - }, - { - "name": "tecnickcom/tc-lib-color", - "version": "2.13.1", - "source": { - "type": "git", - "url": "https://github.com/tecnickcom/tc-lib-color.git", - "reference": "38303066464928bbcfe8e54d006c828b02e4c62f" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/tecnickcom/tc-lib-color/zipball/38303066464928bbcfe8e54d006c828b02e4c62f", - "reference": "38303066464928bbcfe8e54d006c828b02e4c62f", - "shasum": "" - }, - "require": { - "php": ">=8.2" - }, - "require-dev": { - "pdepend/pdepend": "^2.16", - "phpunit/phpunit": "^11.5 || ^12.5 || ^13.2" - }, - "type": "library", - "autoload": { - "psr-4": { - "Com\\Tecnick\\Color\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "LGPL-3.0-or-later" - ], - "authors": [ - { - "name": "Nicola Asuni", - "email": "info@tecnick.com", - "role": "lead" - } - ], - "description": "PHP library to manipulate various color representations", - "homepage": "https://tcpdf.org", - "keywords": [ - "cmyk", - "color", - "colors", - "colour", - "colours", - "hsl", - "hsla", - "javascript", - "rgb", - "rgba", - "tc-lib-color", - "web" - ], - "support": { - "issues": "https://github.com/tecnickcom/tc-lib-color/issues", - "source": "https://github.com/tecnickcom/tc-lib-color" - }, - "funding": [ - { - "url": "https://github.com/sponsors/tecnickcom", - "type": "github" - } - ], - "time": "2026-07-17T19:02:53+00:00" - }, - { - "name": "tecnickcom/tc-lib-file", - "version": "3.7.1", - "source": { - "type": "git", - "url": "https://github.com/tecnickcom/tc-lib-file.git", - "reference": "45a9b04e530b80b0212af6de1c6c111c37f8eb35" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/tecnickcom/tc-lib-file/zipball/45a9b04e530b80b0212af6de1c6c111c37f8eb35", - "reference": "45a9b04e530b80b0212af6de1c6c111c37f8eb35", - "shasum": "" - }, - "require": { - "ext-curl": "*", - "php": ">=8.2" - }, - "require-dev": { - "pdepend/pdepend": "^2.16", - "phpunit/phpunit": "^13.2 || ^12.5 || ^11.5" - }, - "type": "library", - "autoload": { - "psr-4": { - "Com\\Tecnick\\File\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "LGPL-3.0-or-later" - ], - "authors": [ - { - "name": "Nicola Asuni", - "email": "info@tecnick.com", - "role": "lead" - } - ], - "description": "PHP library to read byte-level data from files", - "homepage": "https://tcpdf.org", - "keywords": [ - "Double", - "bit", - "byte", - "file", - "long", - "read", - "short", - "tc-lib-file" - ], - "support": { - "issues": "https://github.com/tecnickcom/tc-lib-file/issues", - "source": "https://github.com/tecnickcom/tc-lib-file" - }, - "funding": [ - { - "url": "https://github.com/sponsors/tecnickcom", - "type": "github" - } - ], - "time": "2026-07-17T19:03:05+00:00" - }, - { - "name": "tecnickcom/tc-lib-pdf", - "version": "8.67.2", - "source": { - "type": "git", - "url": "https://github.com/tecnickcom/tc-lib-pdf.git", - "reference": "f001da787064da712104f88250db6a6af66929f4" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/tecnickcom/tc-lib-pdf/zipball/f001da787064da712104f88250db6a6af66929f4", - "reference": "f001da787064da712104f88250db6a6af66929f4", - "shasum": "" - }, - "require": { - "php": ">=8.2", - "tecnickcom/tc-lib-barcode": "^2.12", - "tecnickcom/tc-lib-color": "^2.13", - "tecnickcom/tc-lib-file": "^3.7", - "tecnickcom/tc-lib-pdf-encrypt": "^2.9", - "tecnickcom/tc-lib-pdf-font": "^3.12", - "tecnickcom/tc-lib-pdf-graph": "^2.15", - "tecnickcom/tc-lib-pdf-image": "^3.12", - "tecnickcom/tc-lib-pdf-page": "^4.14", - "tecnickcom/tc-lib-pdf-parser": "^3.14", - "tecnickcom/tc-lib-pdf-sign": "^1.1", - "tecnickcom/tc-lib-unicode": "^2.11", - "tecnickcom/tc-lib-unicode-data": "^2.7" - }, - "require-dev": { - "pdepend/pdepend": "^2.16", - "phpunit/phpunit": "^11.5 || ^12.5 || ^13.2" - }, - "type": "library", - "autoload": { - "psr-4": { - "Com\\Tecnick\\Pdf\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "LGPL-3.0-or-later" - ], - "authors": [ - { - "name": "Nicola Asuni", - "email": "info@tecnick.com", - "role": "lead" - } - ], - "description": "PHP PDF Library", - "homepage": "https://tcpdf.org", - "keywords": [ - "PDFD32000-2008", - "TCPDF", - "document", - "pdf", - "tc-lib-pdf" - ], - "support": { - "issues": "https://github.com/tecnickcom/tc-lib-pdf/issues", - "source": "https://github.com/tecnickcom/tc-lib-pdf" - }, - "funding": [ - { - "url": "https://github.com/sponsors/tecnickcom", - "type": "github" - } - ], - "time": "2026-07-22T12:58:58+00:00" - }, - { - "name": "tecnickcom/tc-lib-pdf-encrypt", - "version": "2.9.1", - "source": { - "type": "git", - "url": "https://github.com/tecnickcom/tc-lib-pdf-encrypt.git", - "reference": "c13ae45c9ac5be8c1974c43727879411f0c557e4" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/tecnickcom/tc-lib-pdf-encrypt/zipball/c13ae45c9ac5be8c1974c43727879411f0c557e4", - "reference": "c13ae45c9ac5be8c1974c43727879411f0c557e4", - "shasum": "" - }, - "require": { - "ext-hash": "*", - "ext-openssl": "*", - "php": ">=8.2" - }, - "require-dev": { - "pdepend/pdepend": "^2.16", - "phpunit/phpunit": "^11.5 || ^12.5 || ^13.2" - }, - "suggest": { - "ext-posix": "*" - }, - "type": "library", - "autoload": { - "psr-4": { - "Com\\Tecnick\\Pdf\\Encrypt\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "LGPL-3.0-or-later" - ], - "authors": [ - { - "name": "Nicola Asuni", - "email": "info@tecnick.com", - "role": "lead" - } - ], - "description": "PHP library to encrypt data for PDF", - "homepage": "https://tcpdf.org", - "keywords": [ - "aes", - "encrypt", - "encryption", - "pdf", - "rc4", - "tc-lib-pdf-encrypt" - ], - "support": { - "issues": "https://github.com/tecnickcom/tc-lib-pdf-encrypt/issues", - "source": "https://github.com/tecnickcom/tc-lib-pdf-encrypt" - }, - "funding": [ - { - "url": "https://github.com/sponsors/tecnickcom", - "type": "github" - } - ], - "time": "2026-07-17T19:03:18+00:00" - }, - { - "name": "tecnickcom/tc-lib-pdf-filter", - "version": "2.10.1", - "source": { - "type": "git", - "url": "https://github.com/tecnickcom/tc-lib-pdf-filter.git", - "reference": "72d7606dbc56f475870222557488edec3d707e8c" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/tecnickcom/tc-lib-pdf-filter/zipball/72d7606dbc56f475870222557488edec3d707e8c", - "reference": "72d7606dbc56f475870222557488edec3d707e8c", - "shasum": "" - }, - "require": { - "ext-zlib": "*", - "php": ">=8.2" - }, - "require-dev": { - "pdepend/pdepend": "^2.16", - "phpunit/phpunit": "^11.5 || ^12.5 || ^13.2" - }, - "suggest": { - "ext-imagick": "Required for JPXDecode (JPEG 2000) filter support" - }, - "type": "library", - "autoload": { - "psr-4": { - "Com\\Tecnick\\Pdf\\Filter\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "LGPL-3.0-or-later" - ], - "authors": [ - { - "name": "Nicola Asuni", - "email": "info@tecnick.com", - "role": "lead" - } - ], - "description": "PHP library to decode PDF compression and encryption filters", - "homepage": "https://tcpdf.org", - "keywords": [ - "compression", - "encryption", - "filter", - "pdf", - "tc-lib-pdf-filter" - ], - "support": { - "issues": "https://github.com/tecnickcom/tc-lib-pdf-filter/issues", - "source": "https://github.com/tecnickcom/tc-lib-pdf-filter" - }, - "funding": [ - { - "url": "https://github.com/sponsors/tecnickcom", - "type": "github" - } - ], - "time": "2026-07-17T19:03:30+00:00" - }, - { - "name": "tecnickcom/tc-lib-pdf-font", - "version": "3.12.0", - "source": { - "type": "git", - "url": "https://github.com/tecnickcom/tc-lib-pdf-font.git", - "reference": "e84e765474d15f2d7f19f5ff8f42604c69a3a41a" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/tecnickcom/tc-lib-pdf-font/zipball/e84e765474d15f2d7f19f5ff8f42604c69a3a41a", - "reference": "e84e765474d15f2d7f19f5ff8f42604c69a3a41a", - "shasum": "" - }, - "require": { - "ext-json": "*", - "ext-zlib": "*", - "php": ">=8.2", - "tecnickcom/tc-lib-file": "^3.7", - "tecnickcom/tc-lib-pdf-encrypt": "^2.9", - "tecnickcom/tc-lib-unicode-data": "^2.7" - }, - "require-dev": { - "pdepend/pdepend": "^2.16", - "phpunit/phpunit": "^11.5 || ^12.5 || ^13.2" - }, - "type": "library", - "autoload": { - "psr-4": { - "Com\\Tecnick\\Pdf\\Font\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "LGPL-3.0-or-later" - ], - "authors": [ - { - "name": "Nicola Asuni", - "email": "info@tecnick.com", - "role": "lead" - } - ], - "description": "PHP library containing PDF page formats and definitions", - "homepage": "https://tcpdf.org", - "keywords": [ - "PFB", - "afm", - "font", - "import", - "pdf", - "tc-lib-pdf-font", - "ttf" - ], - "support": { - "issues": "https://github.com/tecnickcom/tc-lib-pdf-font/issues", - "source": "https://github.com/tecnickcom/tc-lib-pdf-font" - }, - "funding": [ - { - "url": "https://github.com/sponsors/tecnickcom", - "type": "github" - } - ], - "time": "2026-07-17T19:22:14+00:00" - }, - { - "name": "tecnickcom/tc-lib-pdf-graph", - "version": "2.15.0", - "source": { - "type": "git", - "url": "https://github.com/tecnickcom/tc-lib-pdf-graph.git", - "reference": "cdd4ee1ceafc0f2cfa525ef8f395c80e7c024495" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/tecnickcom/tc-lib-pdf-graph/zipball/cdd4ee1ceafc0f2cfa525ef8f395c80e7c024495", - "reference": "cdd4ee1ceafc0f2cfa525ef8f395c80e7c024495", - "shasum": "" - }, - "require": { - "ext-zlib": "*", - "php": ">=8.2", - "tecnickcom/tc-lib-color": "^2.13", - "tecnickcom/tc-lib-pdf-encrypt": "^2.9" - }, - "require-dev": { - "pdepend/pdepend": "^2.16", - "phpunit/phpunit": "^11.5 || ^12.5 || ^13.2" - }, - "type": "library", - "autoload": { - "psr-4": { - "Com\\Tecnick\\Pdf\\Graph\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "LGPL-3.0-or-later" - ], - "authors": [ - { - "name": "Nicola Asuni", - "email": "info@tecnick.com", - "role": "lead" - } - ], - "description": "PHP library containing PDF graphic and geometric methods", - "homepage": "https://tcpdf.org", - "keywords": [ - "geometry", - "graphic", - "pdf", - "tc-lib-pdf-graph", - "transformation" - ], - "support": { - "issues": "https://github.com/tecnickcom/tc-lib-pdf-graph/issues", - "source": "https://github.com/tecnickcom/tc-lib-pdf-graph" - }, - "funding": [ - { - "url": "https://github.com/sponsors/tecnickcom", - "type": "github" - } - ], - "time": "2026-07-17T19:18:50+00:00" - }, - { - "name": "tecnickcom/tc-lib-pdf-image", - "version": "3.12.0", - "source": { - "type": "git", - "url": "https://github.com/tecnickcom/tc-lib-pdf-image.git", - "reference": "d20db0e8e371ba1a44c2dc8358320599360be7f1" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/tecnickcom/tc-lib-pdf-image/zipball/d20db0e8e371ba1a44c2dc8358320599360be7f1", - "reference": "d20db0e8e371ba1a44c2dc8358320599360be7f1", - "shasum": "" - }, - "require": { - "ext-gd": "*", - "ext-zlib": "*", - "php": ">=8.2", - "tecnickcom/tc-lib-color": "^2.13", - "tecnickcom/tc-lib-file": "^3.7", - "tecnickcom/tc-lib-pdf-encrypt": "^2.9" - }, - "require-dev": { - "pdepend/pdepend": "^2.16", - "phpunit/phpunit": "^11.5 || ^12.5 || ^13.2" - }, - "type": "library", - "autoload": { - "psr-4": { - "Com\\Tecnick\\Pdf\\Image\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "LGPL-3.0-or-later" - ], - "authors": [ - { - "name": "Nicola Asuni", - "email": "info@tecnick.com", - "role": "lead" - } - ], - "description": "PHP library containing PDF Image methods", - "homepage": "https://tcpdf.org", - "keywords": [ - "image", - "jpeg", - "jpg", - "pdf", - "png", - "tc-lib-pdf-image" - ], - "support": { - "issues": "https://github.com/tecnickcom/tc-lib-pdf-image/issues", - "source": "https://github.com/tecnickcom/tc-lib-pdf-image" - }, - "funding": [ - { - "url": "https://github.com/sponsors/tecnickcom", - "type": "github" - } - ], - "time": "2026-07-17T19:20:33+00:00" - }, - { - "name": "tecnickcom/tc-lib-pdf-page", - "version": "4.14.0", - "source": { - "type": "git", - "url": "https://github.com/tecnickcom/tc-lib-pdf-page.git", - "reference": "a068a4b2da222bd9909bb1e9edeec901a69a6484" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/tecnickcom/tc-lib-pdf-page/zipball/a068a4b2da222bd9909bb1e9edeec901a69a6484", - "reference": "a068a4b2da222bd9909bb1e9edeec901a69a6484", - "shasum": "" - }, - "require": { - "ext-zlib": "*", - "php": ">=8.2", - "tecnickcom/tc-lib-color": "^2.13", - "tecnickcom/tc-lib-pdf-encrypt": "^2.9" - }, - "require-dev": { - "pdepend/pdepend": "^2.16", - "phpunit/phpunit": "^11.5 || ^12.5 || ^13.2" - }, - "type": "library", - "autoload": { - "psr-4": { - "Com\\Tecnick\\Pdf\\Page\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "LGPL-3.0-or-later" - ], - "authors": [ - { - "name": "Nicola Asuni", - "email": "info@tecnick.com", - "role": "lead" - } - ], - "description": "PHP library containing PDF page formats and definitions", - "homepage": "https://tcpdf.org", - "keywords": [ - "format", - "page", - "pdf", - "tc-lib-pdf-page" - ], - "support": { - "issues": "https://github.com/tecnickcom/tc-lib-pdf-page/issues", - "source": "https://github.com/tecnickcom/tc-lib-pdf-page" - }, - "funding": [ - { - "url": "https://github.com/sponsors/tecnickcom", - "type": "github" - } - ], - "time": "2026-07-17T19:17:39+00:00" - }, - { - "name": "tecnickcom/tc-lib-pdf-parser", - "version": "3.14.0", - "source": { - "type": "git", - "url": "https://github.com/tecnickcom/tc-lib-pdf-parser.git", - "reference": "8a1b64ba7a8f8c7d862765ad2fcd1841982ab5bb" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/tecnickcom/tc-lib-pdf-parser/zipball/8a1b64ba7a8f8c7d862765ad2fcd1841982ab5bb", - "reference": "8a1b64ba7a8f8c7d862765ad2fcd1841982ab5bb", - "shasum": "" - }, - "require": { - "php": ">=8.2", - "tecnickcom/tc-lib-pdf-filter": "^2.10" - }, - "require-dev": { - "pdepend/pdepend": "^2.16", - "phpunit/phpunit": "^11.5 || ^12.5 || ^13.2" - }, - "type": "library", - "autoload": { - "psr-4": { - "Com\\Tecnick\\Pdf\\Parser\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "LGPL-3.0-or-later" - ], - "authors": [ - { - "name": "Nicola Asuni", - "email": "info@tecnick.com", - "role": "lead" - } - ], - "description": "PHP library to parse PDF documents", - "homepage": "https://tcpdf.org", - "keywords": [ - "document", - "parser", - "pdf", - "tc-lib-pdf-parser" - ], - "support": { - "issues": "https://github.com/tecnickcom/tc-lib-pdf-parser/issues", - "source": "https://github.com/tecnickcom/tc-lib-pdf-parser" - }, - "funding": [ - { - "url": "https://github.com/sponsors/tecnickcom", - "type": "github" - } - ], - "time": "2026-07-17T19:15:54+00:00" - }, - { - "name": "tecnickcom/tc-lib-pdf-sign", - "version": "1.1.1", - "source": { - "type": "git", - "url": "https://github.com/tecnickcom/tc-lib-pdf-sign.git", - "reference": "c9d5125cdcfe6225951ce2bc165f46b4d0f79bc5" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/tecnickcom/tc-lib-pdf-sign/zipball/c9d5125cdcfe6225951ce2bc165f46b4d0f79bc5", - "reference": "c9d5125cdcfe6225951ce2bc165f46b4d0f79bc5", - "shasum": "" - }, - "require": { - "ext-hash": "*", - "ext-openssl": "*", - "php": ">=8.2" - }, - "require-dev": { - "pdepend/pdepend": "^2.16", - "phpunit/phpunit": "^11.5 || ^12.5 || ^13.2" - }, - "suggest": { - "ext-posix": "*" - }, - "type": "library", - "autoload": { - "psr-4": { - "Com\\Tecnick\\Pdf\\Sign\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "LGPL-3.0-or-later" - ], - "authors": [ - { - "name": "Nicola Asuni", - "email": "info@tecnick.com", - "role": "lead" - } - ], - "description": "PHP library to create and embed digital signatures (PKCS#7 / CAdES / PAdES) in PDF documents", - "homepage": "https://tcpdf.org", - "keywords": [ - "cades", - "digital-signature", - "pades", - "pdf", - "pkcs7", - "sign", - "signature", - "tc-lib-pdf-sign", - "timestamp" - ], - "support": { - "issues": "https://github.com/tecnickcom/tc-lib-pdf-sign/issues", - "source": "https://github.com/tecnickcom/tc-lib-pdf-sign" - }, - "funding": [ - { - "url": "https://github.com/sponsors/tecnickcom", - "type": "github" - } - ], - "time": "2026-07-17T19:03:43+00:00" - }, - { - "name": "tecnickcom/tc-lib-unicode", - "version": "2.11.0", - "source": { - "type": "git", - "url": "https://github.com/tecnickcom/tc-lib-unicode.git", - "reference": "ffc77054b8ea2b7de8cd3fd1c02ce8fd10bb17b5" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/tecnickcom/tc-lib-unicode/zipball/ffc77054b8ea2b7de8cd3fd1c02ce8fd10bb17b5", - "reference": "ffc77054b8ea2b7de8cd3fd1c02ce8fd10bb17b5", - "shasum": "" - }, - "require": { - "ext-mbstring": "*", - "php": ">=8.2", - "tecnickcom/tc-lib-unicode-data": "^2.7" - }, - "require-dev": { - "pdepend/pdepend": "^2.16", - "phpunit/phpunit": "^11.5 || ^12.5 || ^13.2" - }, - "type": "library", - "autoload": { - "psr-4": { - "Com\\Tecnick\\Unicode\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "LGPL-3.0-or-later" - ], - "authors": [ - { - "name": "Nicola Asuni", - "email": "info@tecnick.com", - "role": "lead" - } - ], - "description": "PHP library containing Unicode methods", - "homepage": "https://tcpdf.org", - "keywords": [ - "font", - "pdf", - "tc-lib-unicode", - "unicode", - "utf-8" - ], - "support": { - "issues": "https://github.com/tecnickcom/tc-lib-unicode/issues", - "source": "https://github.com/tecnickcom/tc-lib-unicode" - }, - "funding": [ - { - "url": "https://github.com/sponsors/tecnickcom", - "type": "github" - } - ], - "time": "2026-07-17T19:09:36+00:00" - }, - { - "name": "tecnickcom/tc-lib-unicode-data", - "version": "2.7.1", - "source": { - "type": "git", - "url": "https://github.com/tecnickcom/tc-lib-unicode-data.git", - "reference": "9354f1661e7bafcf2ee68e408ed07a3fcf264b98" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/tecnickcom/tc-lib-unicode-data/zipball/9354f1661e7bafcf2ee68e408ed07a3fcf264b98", - "reference": "9354f1661e7bafcf2ee68e408ed07a3fcf264b98", - "shasum": "" - }, - "require": { - "php": ">=8.2" - }, - "require-dev": { - "pdepend/pdepend": "^2.16", - "phpunit/phpunit": "^11.5 || ^12.5 || ^13.2" - }, - "type": "library", - "autoload": { - "psr-4": { - "Com\\Tecnick\\Unicode\\Data\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "LGPL-3.0-or-later" - ], - "authors": [ - { - "name": "Nicola Asuni", - "email": "info@tecnick.com", - "role": "lead" - } - ], - "description": "PHP library containing Unicode definitions", - "homepage": "https://tcpdf.org", - "keywords": [ - "font", - "pdf", - "tc-lib-unicode-data", - "unicode", - "utf-8" - ], - "support": { - "issues": "https://github.com/tecnickcom/tc-lib-unicode-data/issues", - "source": "https://github.com/tecnickcom/tc-lib-unicode-data" - }, - "funding": [ - { - "url": "https://github.com/sponsors/tecnickcom", - "type": "github" - } - ], - "time": "2026-07-17T19:03:56+00:00" - }, { "name": "tecnickcom/tcpdf", - "version": "7.0.4", + "version": "6.11.3", "source": { "type": "git", "url": "https://github.com/tecnickcom/TCPDF.git", - "reference": "7b8de35a6224b7791a91fc09427a18a0a8aecd83" + "reference": "b18f6119161019916c5bb07cb8da5205ae5c1b63" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/tecnickcom/TCPDF/zipball/7b8de35a6224b7791a91fc09427a18a0a8aecd83", - "reference": "7b8de35a6224b7791a91fc09427a18a0a8aecd83", + "url": "https://api.github.com/repos/tecnickcom/TCPDF/zipball/b18f6119161019916c5bb07cb8da5205ae5c1b63", + "reference": "b18f6119161019916c5bb07cb8da5205ae5c1b63", "shasum": "" }, "require": { "ext-curl": "*", - "php": ">=8.2", - "tecnickcom/tc-lib-pdf": "^8" - }, - "require-dev": { - "pdepend/pdepend": "^2.16", - "phpunit/phpunit": "^11.5 || ^12.5 || ^13.2" + "php": ">=7.1.0" }, "suggest": { "ext-gd": "Enables additional image handling in some workflows.", @@ -9310,7 +8380,19 @@ "autoload": { "classmap": [ "config", - "tcpdf.php" + "include", + "tcpdf.php", + "tcpdf_barcodes_1d.php", + "tcpdf_barcodes_2d.php", + "include/tcpdf_colors.php", + "include/tcpdf_filters.php", + "include/tcpdf_font_data.php", + "include/tcpdf_fonts.php", + "include/tcpdf_images.php", + "include/tcpdf_static.php", + "include/barcodes/datamatrix.php", + "include/barcodes/pdf417.php", + "include/barcodes/qrcode.php" ] }, "notification-url": "https://packagist.org/downloads/", @@ -9324,7 +8406,7 @@ "role": "lead" } ], - "description": "Deprecated legacy PDF engine for PHP. Use instead tecnickcom/tc-lib-pdf.", + "description": "Deprecated legacy PDF engine for PHP. For new projects use tecnickcom/tc-lib-pdf.", "homepage": "https://tcpdf.org", "keywords": [ "PDFD32000-2008", @@ -9341,11 +8423,11 @@ }, "funding": [ { - "url": "https://github.com/sponsors/tecnickcom", - "type": "github" + "url": "https://www.paypal.com/donate/?hosted_button_id=NZUEC5XS8MFBJ", + "type": "paypal" } ], - "time": "2026-07-13T10:06:03+00:00" + "time": "2026-04-21T17:00:18+00:00" }, { "name": "tijsverkoyen/css-to-inline-styles", diff --git a/lang/de/invoice.php b/lang/de/invoice.php new file mode 100644 index 0000000..f01194b --- /dev/null +++ b/lang/de/invoice.php @@ -0,0 +1,25 @@ + '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', +]; diff --git a/lang/en/invoice.php b/lang/en/invoice.php new file mode 100644 index 0000000..cc814ba --- /dev/null +++ b/lang/en/invoice.php @@ -0,0 +1,25 @@ + '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', +]; diff --git a/resources/views/pdf/invoice.blade.php b/resources/views/pdf/invoice.blade.php new file mode 100644 index 0000000..2cc599e --- /dev/null +++ b/resources/views/pdf/invoice.blade.php @@ -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. --}} + + + + + +
+ {{ collect([$issuer['name'] ?? null, $issuer['address'] ?? null, trim(($issuer['postcode'] ?? '').' '.($issuer['city'] ?? '')), $issuer['country'] ?? null])->filter()->join(' | ') }} +
+ + + + + + +
+ {{ $customer['name'] ?? '' }}
+ {{ $customer['address'] ?? '' }}
+ {{ trim(($customer['postcode'] ?? '').' '.($customer['city'] ?? '')) }}
+ {{ $customer['country'] ?? '' }} + @if (! empty($customer['vat_id'])) +
{{ __('invoice.customer_vat') }}: {{ $customer['vat_id'] }} + @endif +
+ +{{-- The data block, right, level with the address. Its own table because + TCPDF has no way to float one beside the other. --}} + + + + + @if (! empty($meta['customer_number'])) + + + + @endif + + + + @if (! empty($meta['due_on'])) + + + + @endif +
{{ __('invoice.date') }}{{ $meta['issued_on'] ?? '' }}
{{ __('invoice.customer_number') }}{{ $meta['customer_number'] }}
{{ __('invoice.number') }}{{ $meta['number'] ?? '' }}
{{ __('invoice.due') }}{{ $meta['due_on'] }}
+ +

{{ $meta['title'] ?? __('invoice.title') }}

+

{{ $meta['number'] ?? '' }}

+ +@if (! empty($meta['salutation'])) +

{{ $meta['salutation'] }}

+@endif +@if (! empty($meta['intro'])) +

{{ $meta['intro'] }}

+@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. --}} + + + + + + + + + + @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 + + + + + + + + + @endforeach +
{{ __('invoice.col_pos') }}{{ __('invoice.col_description') }}{{ __('invoice.col_quantity') }}{{ __('invoice.col_unit_net') }}{{ __('invoice.col_total_net') }}{{ __('invoice.col_total_gross') }}
{{ $index + 1 }} + {{ $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($net) }}{{ InvoiceMath::money($gross) }}
+ + + @if (! empty($totals['adjustment'])) + + + + + + @endif + + + + + + + + + + + + + + + +
{{ $meta['adjustment_label'] ?? __('invoice.adjustment') }}{{ InvoiceMath::money((int) $totals['adjustment']) }}
{{ __('invoice.net') }}{{ InvoiceMath::money((int) ($totals['net'] ?? 0)) }}
{{ __('invoice.tax', ['rate' => rtrim(rtrim(number_format(($totals['rate_basis_points'] ?? 2000) / 100, 2, ',', '.'), '0'), ',')]) }}{{ InvoiceMath::money((int) ($totals['tax'] ?? 0)) }}
{{ __('invoice.gross') }}{{ InvoiceMath::money((int) ($totals['gross'] ?? 0)) }} {{ $meta['currency'] ?? 'EUR' }}
+ +@if (! empty($meta['payment_terms'])) +

{{ __('invoice.payment_title') }}

+

{{ $meta['payment_terms'] }}

+@endif + +@if (! empty($meta['closing'])) +

{{ $meta['closing'] }}

+@endif diff --git a/tests/Feature/Billing/InvoiceMathTest.php b/tests/Feature/Billing/InvoiceMathTest.php new file mode 100644 index 0000000..de156c2 --- /dev/null +++ b/tests/Feature/Billing/InvoiceMathTest.php @@ -0,0 +1,92 @@ +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'); +});