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