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'); +});