From 735daf46a47bd599bd821af6fa7248c8a05644a4 Mon Sep 17 00:00:00 2001 From: nexxo Date: Wed, 29 Jul 2026 00:57:06 +0200 Subject: [PATCH] Lay the foundation for self-issued invoices: series, numbers, frozen documents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An invoice is a tax document, so two things drive the shape of this. It is frozen at issue. Everything the finished document says — issuer address, VAT number, bank details, customer address, every line, the rate, the totals — is copied into the invoice row when the number is assigned. The PDF is rendered from that and never stored, which is what was asked for; rendering it from today's settings instead would have been simpler and wrong, because an address changed in 2028 would rewrite an invoice from 2026. The width of this table IS the feature. Numbers are gapless and ascending, which is a legal requirement rather than a preference, and that is why none of it is derived from the invoices table. "Highest number plus one" hands the same number to two sales that land together and hands a used number back out after a row is removed — a number already printed on a document somebody holds. The counter is a column, read and advanced under a row lock inside the caller's transaction, and taking one outside a transaction throws rather than quietly working. Four Rechnungskreise seeded — Rechnung, Gutschrift, Storno, Anzahlung — each with its own prefix and its own counter, because a credit note is not an invoice with a minus sign in front of it. Seeded rather than left to the operator: an installation with no series cannot issue anything, and discovering that at the first sale is the worst possible moment. TCPDF added, for the renderer that comes next. There is deliberately no test for the outside-a-transaction guard, and a note where it would have been: RefreshDatabase opens a transaction around every test, so the guard cannot fire and a green test for it would be green for a reason that has nothing to do with the guard. Co-Authored-By: Claude Opus 5 --- app/Models/Invoice.php | 61 ++ app/Models/InvoiceSeries.php | 41 + app/Services/Billing/InvoiceNumbers.php | 77 ++ composer.json | 1 + composer.lock | 997 +++++++++++++++++- ...026_07_29_130000_create_invoice_tables.php | 135 +++ tests/Feature/Billing/InvoiceNumberTest.php | 98 ++ 7 files changed, 1409 insertions(+), 1 deletion(-) create mode 100644 app/Models/Invoice.php create mode 100644 app/Models/InvoiceSeries.php create mode 100644 app/Services/Billing/InvoiceNumbers.php create mode 100644 database/migrations/2026_07_29_130000_create_invoice_tables.php create mode 100644 tests/Feature/Billing/InvoiceNumberTest.php diff --git a/app/Models/Invoice.php b/app/Models/Invoice.php new file mode 100644 index 0000000..98a6e27 --- /dev/null +++ b/app/Models/Invoice.php @@ -0,0 +1,61 @@ + 'array', + 'issued_on' => 'date', + 'due_on' => 'date', + 'sent_at' => 'datetime', + 'net_cents' => 'integer', + 'tax_cents' => 'integer', + 'gross_cents' => 'integer', + ]; + } + + public function series(): BelongsTo + { + return $this->belongsTo(InvoiceSeries::class, 'invoice_series_id'); + } + + public function customer(): BelongsTo + { + return $this->belongsTo(Customer::class); + } + + public function order(): BelongsTo + { + return $this->belongsTo(Order::class); + } + + /** What this document cancels, if it is a cancellation. */ + public function cancels(): BelongsTo + { + return $this->belongsTo(Invoice::class, 'cancels_invoice_id'); + } +} diff --git a/app/Models/InvoiceSeries.php b/app/Models/InvoiceSeries.php new file mode 100644 index 0000000..526efa7 --- /dev/null +++ b/app/Models/InvoiceSeries.php @@ -0,0 +1,41 @@ + 'boolean', + 'active' => 'boolean', + 'digits' => 'integer', + 'next_number' => 'integer', + 'counter_year' => 'integer', + ]; + } + + public function invoices(): HasMany + { + return $this->hasMany(Invoice::class); + } +} diff --git a/app/Services/Billing/InvoiceNumbers.php b/app/Services/Billing/InvoiceNumbers.php new file mode 100644 index 0000000..148fd4e --- /dev/null +++ b/app/Services/Billing/InvoiceNumbers.php @@ -0,0 +1,77 @@ +format('Y'); + + // lockForUpdate, not an atomic increment: the year check below has to + // see a value nobody else can change between the read and the write. + $locked = InvoiceSeries::query() + ->whereKey($series->getKey()) + ->lockForUpdate() + ->firstOrFail(); + + $sequence = (int) $locked->next_number; + $counterYear = $locked->counter_year; + + // A yearly series starts at 1 in a new year. Checked against the stored + // year rather than against "is this January": an installation that + // issued nothing for fourteen months must still restart, and one that + // issues on New Year's Eve at 23:59 must not restart for the invoice + // that follows a minute later under the old year. + if ($locked->yearly_reset && $counterYear !== null && (int) $counterYear !== $year) { + $sequence = 1; + } + + $locked->forceFill([ + 'next_number' => $sequence + 1, + 'counter_year' => $locked->yearly_reset ? $year : null, + ])->save(); + + return [$this->format($locked, $sequence, $year), $sequence, $locked->yearly_reset ? $year : null]; + } + + /** "RE-2026-0001", or "RE-0001" for a series that does not reset. */ + private function format(InvoiceSeries $series, int $sequence, int $year): string + { + $padded = str_pad((string) $sequence, (int) $series->digits, '0', STR_PAD_LEFT); + + return $series->yearly_reset + ? sprintf('%s-%d-%s', $series->prefix, $year, $padded) + : sprintf('%s-%s', $series->prefix, $padded); + } +} diff --git a/composer.json b/composer.json index c78ddb6..2d8fee4 100644 --- a/composer.json +++ b/composer.json @@ -15,6 +15,7 @@ "livewire/livewire": "^3.0", "phpseclib/phpseclib": "^3.0", "spatie/laravel-permission": "^8.3", + "tecnickcom/tcpdf": "^7.0", "wire-elements/modal": "^3.0" }, "require-dev": { diff --git a/composer.lock b/composer.lock index c080988..65753b3 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": "4afacce87fbd845b941534e375dc082b", + "content-hash": "4dd7de1f16d082e59e6993debfbac9b1", "packages": [ { "name": "bacon/bacon-qr-code", @@ -8352,6 +8352,1001 @@ ], "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", + "source": { + "type": "git", + "url": "https://github.com/tecnickcom/TCPDF.git", + "reference": "7b8de35a6224b7791a91fc09427a18a0a8aecd83" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/tecnickcom/TCPDF/zipball/7b8de35a6224b7791a91fc09427a18a0a8aecd83", + "reference": "7b8de35a6224b7791a91fc09427a18a0a8aecd83", + "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" + }, + "suggest": { + "ext-gd": "Enables additional image handling in some workflows.", + "ext-imagick": "Enables additional image format support when available.", + "ext-zlib": "Recommended for compressed streams and related features.", + "tecnickcom/tc-lib-pdf": "Modern replacement for TCPDF for new projects." + }, + "type": "library", + "autoload": { + "classmap": [ + "config", + "tcpdf.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-3.0-or-later" + ], + "authors": [ + { + "name": "Nicola Asuni", + "email": "info@tecnick.com", + "role": "lead" + } + ], + "description": "Deprecated legacy PDF engine for PHP. Use instead tecnickcom/tc-lib-pdf.", + "homepage": "https://tcpdf.org", + "keywords": [ + "PDFD32000-2008", + "TCPDF", + "barcodes", + "datamatrix", + "pdf", + "pdf417", + "qrcode" + ], + "support": { + "issues": "https://github.com/tecnickcom/TCPDF/issues", + "source": "https://github.com/tecnickcom/TCPDF" + }, + "funding": [ + { + "url": "https://github.com/sponsors/tecnickcom", + "type": "github" + } + ], + "time": "2026-07-13T10:06:03+00:00" + }, { "name": "tijsverkoyen/css-to-inline-styles", "version": "v2.4.0", diff --git a/database/migrations/2026_07_29_130000_create_invoice_tables.php b/database/migrations/2026_07_29_130000_create_invoice_tables.php new file mode 100644 index 0000000..1f415da --- /dev/null +++ b/database/migrations/2026_07_29_130000_create_invoice_tables.php @@ -0,0 +1,135 @@ +id(); + $table->uuid()->unique(); + + // 'invoice' | 'credit_note' | 'cancellation' | 'deposit'. A string + // rather than an enum column: adding a kind must not be a migration + // on a table that holds legal records. + $table->string('kind', 32); + $table->string('name'); + + // "RE", "GS", "ST" — what the number starts with. + $table->string('prefix', 12); + + // The year is part of the number and resets the counter, or it is + // not and does not. Both are lawful; mixing them is not, so it is + // recorded per series rather than assumed. + $table->boolean('yearly_reset')->default(true); + $table->unsignedSmallInteger('digits')->default(4); + + // The counter itself. Never derived from the invoices table: a + // deleted or failed row would silently reuse a number that has + // already been on a document somebody has. + $table->unsignedInteger('next_number')->default(1); + $table->unsignedSmallInteger('counter_year')->nullable(); + + $table->boolean('active')->default(true); + $table->timestamps(); + + $table->unique(['kind', 'prefix']); + }); + + Schema::create('invoices', function (Blueprint $table) { + $table->id(); + $table->uuid()->unique(); + + $table->foreignId('invoice_series_id')->constrained()->restrictOnDelete(); + $table->foreignId('customer_id')->nullable()->constrained()->nullOnDelete(); + $table->foreignId('order_id')->nullable()->constrained()->nullOnDelete(); + + // The number as it appears on the document, and its parts, so a + // list can be sorted and filtered without parsing the string back. + $table->string('number')->unique(); + $table->unsignedSmallInteger('number_year')->nullable(); + $table->unsignedInteger('number_sequence'); + + $table->date('issued_on'); + $table->date('due_on')->nullable(); + + // Everything the document says. See the class comment: this is what + // makes the PDF reproducible without storing the PDF. + $table->json('snapshot'); + + // Denormalised out of the snapshot, because a list of invoices has + // to be sortable by money without opening every JSON column. + $table->integer('net_cents'); + $table->integer('tax_cents'); + $table->integer('gross_cents'); + $table->string('currency', 3); + + // A cancellation points at what it cancels. Nothing is ever + // deleted or edited — a wrong invoice is corrected by issuing + // another document, which is the only lawful way to correct one. + $table->foreignId('cancels_invoice_id')->nullable()->constrained('invoices')->nullOnDelete(); + + $table->timestamp('sent_at')->nullable(); + $table->timestamps(); + + $table->index(['customer_id', 'issued_on']); + }); + + // The four an Austrian business needs on day one. Seeded rather than + // left to the operator: an installation with no series cannot issue an + // invoice at all, and discovering that at the moment of the first sale + // is the worst possible time. + $now = now(); + + foreach ([ + ['invoice', 'Rechnung', 'RE'], + ['credit_note', 'Gutschrift', 'GS'], + ['cancellation', 'Storno', 'ST'], + ['deposit', 'Anzahlung', 'AZ'], + ] as [$kind, $name, $prefix]) { + DB::table('invoice_series')->insert([ + 'uuid' => (string) Str::uuid(), + 'kind' => $kind, + 'name' => $name, + 'prefix' => $prefix, + 'yearly_reset' => true, + 'digits' => 4, + 'next_number' => 1, + 'counter_year' => null, + 'active' => true, + 'created_at' => $now, + 'updated_at' => $now, + ]); + } + } + + public function down(): void + { + Schema::dropIfExists('invoices'); + Schema::dropIfExists('invoice_series'); + } +}; diff --git a/tests/Feature/Billing/InvoiceNumberTest.php b/tests/Feature/Billing/InvoiceNumberTest.php new file mode 100644 index 0000000..7392337 --- /dev/null +++ b/tests/Feature/Billing/InvoiceNumberTest.php @@ -0,0 +1,98 @@ +where('kind', 'invoice')->firstOrFail(); + + $numbers = []; + + foreach (range(1, 3) as $ignored) { + DB::transaction(function () use ($s, &$numbers) { + [$number] = app(InvoiceNumbers::class)->next($s, 2026); + $numbers[] = $number; + }); + } + + expect($numbers)->toBe(['RE-2026-0001', 'RE-2026-0002', 'RE-2026-0003']); +}); + +it('restarts a yearly series in a new year, and only then', function () { + // Checked against the stored year, not against "is it January": an + // installation that issued nothing for fourteen months must still restart, + // and one issuing at 23:59 on New Year's Eve must not restart the invoice + // that follows a minute later under the old year. + $s = InvoiceSeries::query()->where('kind', 'invoice')->firstOrFail(); + + DB::transaction(fn () => app(InvoiceNumbers::class)->next($s, 2026)); + DB::transaction(fn () => app(InvoiceNumbers::class)->next($s, 2026)); + + [$next] = DB::transaction(fn () => app(InvoiceNumbers::class)->next($s->refresh(), 2027)); + + expect($next)->toBe('RE-2027-0001'); + + [$again] = DB::transaction(fn () => app(InvoiceNumbers::class)->next($s->refresh(), 2027)); + + expect($again)->toBe('RE-2027-0002'); +}); + +it('never restarts a series that does not reset yearly', function () { + $s = InvoiceSeries::query()->where('kind', 'invoice')->firstOrFail(); + $s->update(['yearly_reset' => false, 'next_number' => 41]); + + [$number] = DB::transaction(fn () => app(InvoiceNumbers::class)->next($s->refresh(), 2026)); + expect($number)->toBe('RE-0041'); + + [$across] = DB::transaction(fn () => app(InvoiceNumbers::class)->next($s->refresh(), 2027)); + expect($across)->toBe('RE-0042'); +}); + +it('keeps every Rechnungskreis on its own counter', function () { + // A credit note is not an invoice with a minus sign in front of it, and + // sharing a counter would put gaps in both. + $invoice = InvoiceSeries::query()->where('kind', 'invoice')->firstOrFail(); + $credit = InvoiceSeries::query()->where('kind', 'credit_note')->firstOrFail(); + + [$a] = DB::transaction(fn () => app(InvoiceNumbers::class)->next($invoice, 2026)); + [$b] = DB::transaction(fn () => app(InvoiceNumbers::class)->next($credit, 2026)); + [$c] = DB::transaction(fn () => app(InvoiceNumbers::class)->next($invoice->refresh(), 2026)); + + expect($a)->toBe('RE-2026-0001') + ->and($b)->toBe('GS-2026-0001') + ->and($c)->toBe('RE-2026-0002'); +}); + +/* + * There is deliberately no test for the "must be inside a transaction" guard. + * + * RefreshDatabase wraps every test in one, so DB::transactionLevel() is already + * above zero before the first line of any test here runs — the guard cannot + * fire, and a test written to prove it does would be green for a reason that + * has nothing to do with the guard. A test that passes for the wrong reason is + * worse than no test, so this note stands in its place. + */ + +it('starts every seeded Rechnungskreis at one', function () { + // An installation with no series cannot issue an invoice at all, and + // finding that out at the moment of the first sale is the worst possible + // time. + // + // toEqual, not toBe: pluck returns them in whatever order the database + // hands them back, and the order of four seeded rows is not the assertion. + expect(InvoiceSeries::query()->pluck('prefix', 'kind')->all())->toEqual([ + 'invoice' => 'RE', + 'credit_note' => 'GS', + 'cancellation' => 'ST', + 'deposit' => 'AZ', + ]); +});