├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── composer.json ├── config └── invoices.php ├── database ├── factories │ ├── InvoiceFactory.php │ └── InvoiceItemFactory.php └── migrations │ ├── add_denormalized_columns_to_invoices_table.php.stub │ ├── add_discounts_column_to_invoices_table.php.stub │ ├── add_serial_number_details_columns_to_invoices_table.php.stub │ ├── add_type_column_to_invoices_table.php.stub │ ├── create_invoice_items_table.php.stub │ ├── create_invoices_table.php.stub │ └── migrate_serial_number_details_columns_to_invoices_table.php.stub ├── pint.json ├── resources ├── lang │ ├── br │ │ └── invoice.php │ ├── de │ │ └── invoice.php │ ├── en │ │ └── invoice.php │ ├── fr │ │ └── invoice.php │ └── pt │ │ └── invoice.php └── views │ └── default │ ├── includes │ ├── address.blade.php │ └── party.blade.php │ ├── invoice.blade.php │ ├── layout.blade.php │ └── style.blade.php ├── src ├── Casts │ └── Discounts.php ├── Commands │ └── DenormalizeInvoicesCommand.php ├── Concerns │ └── FormatForPdf.php ├── Contracts │ └── GenerateSerialNumber.php ├── Enums │ ├── InvoiceState.php │ └── InvoiceType.php ├── InvoiceDiscount.php ├── InvoiceServiceProvider.php ├── Models │ ├── Invoice.php │ └── InvoiceItem.php ├── Pdf │ ├── PdfInvoice.php │ └── PdfInvoiceItem.php ├── SerialNumberGenerator.php └── Support │ ├── Address.php │ ├── Buyer.php │ ├── PaymentInstruction.php │ └── Seller.php └── workbench ├── resources ├── images │ ├── qrcode.png │ └── qrcode.svg └── views │ └── demo.blade.php └── routes └── web.php /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to `laravel-invoices` will be documented in this file. 4 | 5 | ## v3.0.2 - 2024-03-25 6 | 7 | **Full Changelog**: https://github.com/ElegantEngineeringTech/laravel-invoices/compare/v3.0.1...v3.0.2 8 | 9 | ## v3.0.1 - 2024-03-25 10 | 11 | ### What's Changed 12 | 13 | - Bump dependabot/fetch-metadata from 1.6.0 to 2.0.0 by @dependabot in https://github.com/ElegantEngineeringTech/laravel-invoices/pull/16 14 | 15 | **Full Changelog**: https://github.com/ElegantEngineeringTech/laravel-invoices/compare/v3.0.0...v3.0.1 16 | 17 | ## v3.0.0 - 2024-03-25 18 | 19 | ** Breaking change ** 20 | 21 | - `getPreviousInvoice` method replaces `getLatestSerialNumberCount`. 22 | 23 | ## v2.3.7 Laravel 11 - 2024-03-16 24 | 25 | **Full Changelog**: https://github.com/ElegantEngineeringTech/laravel-invoices/compare/v2.3.6...v2.3.7 26 | 27 | ## v2.3.6 - 2024-03-14 28 | 29 | ### What's Changed 30 | 31 | - Bump ramsey/composer-install from 2 to 3 by @dependabot in https://github.com/ElegantEngineeringTech/laravel-invoices/pull/15 32 | 33 | **Full Changelog**: https://github.com/ElegantEngineeringTech/laravel-invoices/compare/v2.3.5...v2.3.6 34 | 35 | ## v2.3.5 - 2024-02-05 36 | 37 | **Full Changelog**: https://github.com/ElegantEngineeringTech/laravel-invoices/compare/v2.3.4...v2.3.5 38 | 39 | ## v2.3.4 - 2024-02-05 40 | 41 | **Full Changelog**: https://github.com/ElegantEngineeringTech/laravel-invoices/compare/v2.3.3...v2.3.4 42 | 43 | ## v2.3.3 - 2024-02-05 44 | 45 | ### What's Changed 46 | 47 | - Bump aglipanci/laravel-pint-action from 2.3.0 to 2.3.1 by @dependabot in https://github.com/ElegantEngineeringTech/laravel-invoices/pull/13 48 | 49 | **Full Changelog**: https://github.com/ElegantEngineeringTech/laravel-invoices/compare/v2.3.2...v2.3.3 50 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) elegantly 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Everything You Need to Manage Invoices in Laravel 2 | 3 | [![Latest Version on Packagist](https://img.shields.io/packagist/v/elegantly/laravel-invoices.svg?style=flat-square)](https://packagist.org/packages/elegantly/laravel-invoices) 4 | [![GitHub Tests Action Status](https://img.shields.io/github/actions/workflow/status/ElegantEngineeringTech/laravel-invoices/run-tests.yml?branch=main&label=tests&style=flat-square)](https://github.com/ElegantEngineeringTech/laravel-invoices/actions?query=workflow%3Arun-tests+branch%3Amain) 5 | [![GitHub Code Style Action Status](https://img.shields.io/github/actions/workflow/status/ElegantEngineeringTech/laravel-invoices/fix-php-code-style-issues.yml?branch=main&label=code%20style&style=flat-square)](https://github.com/ElegantEngineeringTech/laravel-invoices/actions?query=workflow%3A"Fix+PHP+code+style+issues"+branch%3Amain) 6 | [![Total Downloads](https://img.shields.io/packagist/dt/elegantly/laravel-invoices.svg?style=flat-square)](https://packagist.org/packages/elegantly/laravel-invoices) 7 | 8 | This package provides a robust, easy-to-use system for managing invoices within a Laravel application, with options for database storage, serial numbering, and PDF generation. 9 | 10 | ![laravel-invoices](https://repository-images.githubusercontent.com/527661364/f98e92f9-62a6-48a1-a7b1-1a587b92a430) 11 | 12 | ## Interactive Demo 13 | 14 | Try out [the interactive demo](https://elegantly.dev/laravel-invoices) to explore package capabilities. 15 | 16 | ## Table of Contents 17 | 18 | - [Requirements](#requirements) 19 | - [Installation](#installation) 20 | - [The `PdfInvoice` Class](#the-pdfinvoice-class) 21 | - [Full Example](#full-example) 22 | - [Rendering the Invoice as a PDF](#rendering-the-invoice-as-a-pdf) 23 | - [Storing the PDF in a file](#storing-the-pdf-in-a-file) 24 | - [Downloading the Invoice as a PDF](#downloading-the-invoice-as-a-pdf) 25 | - [From a controller](#from-a-controller) 26 | - [From a Livewire component](#from-a-livewire-component) 27 | - [Rendering the Invoice as a view](#rendering-the-invoice-as-a-view) 28 | - [Rendering the Invoice within a View](#rendering-the-invoice-within-a-view) 29 | - [Adding Taxes](#adding-taxes) 30 | - [Tax by Percentage](#tax-by-percentage) 31 | - [Tax as a Fixed Amount](#tax-as-a-fixed-amount) 32 | - [Adding Discounts](#adding-discounts) 33 | - [Discount by Percentage](#discount-by-percentage) 34 | - [Discount as a Fixed Amount](#discount-as-a-fixed-amount) 35 | - [Adding Payment Instructions](#adding-payment-instructions) 36 | - [QR Code Generation](#qr-code-generation) 37 | - [Customization](#customization) 38 | - [Customizing Fonts](#customizing-fonts) 39 | - [Customizing the Invoice Template](#customizing-the-invoice-template) 40 | - [The `Invoice` Eloquent Model](#the-invoice-eloquent-model) 41 | - [Complete Example](#complete-example) 42 | - [Generating Unique Serial Numbers](#generating-unique-serial-numbers) 43 | - [Using Multiple Prefixes and Series for Serial Numbers](#using-multiple-prefixes-and-series-for-serial-numbers) 44 | - [Customizing the Serial Number Format](#customizing-the-serial-number-format) 45 | - [Converting an `Invoice` Model to a `PdfInvoice`](#converting-an-invoice-model-to-a-pdfinvoice) 46 | - [Display, Download, and Store Invoices](#display-download-and-store-invoices) 47 | - [Attaching Invoices to Mailables](#attaching-invoices-to-mailables) 48 | - [Attaching Invoices to Notifications](#attaching-invoices-to-notifications) 49 | - [Customizing PDF Output from the Model](#customizing-pdf-output-from-the-model) 50 | - [Using a Custom PdfInvoice Class](#using-a-custom-pdfinvoice-class) 51 | - [Casting `state` and `type` to Enums](#casting-state-and-type-to-enums) 52 | - [Using a Dynamic Logo](#using-a-dynamic-logo) 53 | - [Testing](#testing) 54 | - [Changelog](#changelog) 55 | - [Contributing](#contributing) 56 | - [Security Vulnerabilities](#security-vulnerabilities) 57 | - [Credits](#credits) 58 | - [License](#license) 59 | 60 | ## Requirements 61 | 62 | - PHP 8.1+ 63 | - Laravel 11.0+ 64 | - `dompdf/dompdf` for PDF rendering 65 | - `elegantly/laravel-money` for money computation which use `brick\money` under the hood 66 | 67 | ## Installation 68 | 69 | You can install the package via composer: 70 | 71 | ```bash 72 | composer require elegantly/laravel-invoices 73 | ``` 74 | 75 | If you intent to store your invoices using the Eloquent Model, you must publish and run the migrations with: 76 | 77 | ```bash 78 | php artisan vendor:publish --tag="invoices-migrations" 79 | php artisan migrate 80 | ``` 81 | 82 | You can publish the config file with: 83 | 84 | ```bash 85 | php artisan vendor:publish --tag="invoices-config" 86 | ``` 87 | 88 | This is the contents of the published config file: 89 | 90 | ```php 91 | use Elegantly\Invoices\Models\Invoice; 92 | use Elegantly\Invoices\InvoiceDiscount; 93 | use Elegantly\Invoices\Models\InvoiceItem; 94 | use Elegantly\Invoices\Enums\InvoiceType; 95 | 96 | return [ 97 | 98 | 'model_invoice' => Invoice::class, 99 | 'model_invoice_item' => InvoiceItem::class, 100 | 101 | 'discount_class' => InvoiceDiscount::class, 102 | 103 | 'cascade_invoice_delete_to_invoice_items' => true, 104 | 105 | 'serial_number' => [ 106 | /** 107 | * If true, will generate a serial number on creation 108 | * If false, you will have to set the serial_number yourself 109 | */ 110 | 'auto_generate' => true, 111 | 112 | /** 113 | * Define the serial number format used for each invoice type 114 | * 115 | * P: Prefix 116 | * S: Serie 117 | * M: Month 118 | * Y: Year 119 | * C: Count 120 | * Example: IN0012-220234 121 | * Repeat letter to set the length of each information 122 | * Examples of formats: 123 | * - PPYYCCCC : IN220123 (default) 124 | * - PPPYYCCCC : INV220123 125 | * - PPSSSS-YYCCCC : INV0001-220123 126 | * - SSSS-CCCC: 0001-0123 127 | * - YYCCCC: 220123 128 | */ 129 | 'format' => 'PPYYCCCC', 130 | 131 | /** 132 | * Define the default prefix used for each invoice type 133 | */ 134 | 'prefix' => [ 135 | InvoiceType::Invoice->value => 'IN', 136 | InvoiceType::Quote->value => 'QO', 137 | InvoiceType::Credit->value => 'CR', 138 | InvoiceType::Proforma->value => 'PF', 139 | ], 140 | 141 | ], 142 | 143 | 'date_format' => 'Y-m-d', 144 | 145 | 'default_seller' => [ 146 | 'name' => null, 147 | 'address' => [ 148 | 'street' => null, 149 | 'city' => null, 150 | 'postal_code' => null, 151 | 'state' => null, 152 | 'country' => null, 153 | ], 154 | 'email' => null, 155 | 'phone' => null, 156 | 'tax_number' => null, 157 | 'company_number' => null, 158 | ], 159 | 160 | /** 161 | * ISO 4217 currency code 162 | */ 163 | 'default_currency' => 'USD', 164 | 165 | 'pdf' => [ 166 | 167 | 'paper' => [ 168 | 'size' => 'a4', 169 | 'orientation' => 'portrait', 170 | ], 171 | 172 | /** 173 | * Default DOM PDF options 174 | * 175 | * @see Available options https://github.com/barryvdh/laravel-dompdf#configuration 176 | */ 177 | 'options' => [ 178 | 'isRemoteEnabled' => true, 179 | 'isPhpEnabled' => false, 180 | 'fontHeightRatio' => 1, 181 | /** 182 | * Supported values are: 'DejaVu Sans', 'Helvetica', 'Courier', 'Times', 'Symbol', 'ZapfDingbats' 183 | */ 184 | 'defaultFont' => 'Helvetica', 185 | 186 | 'fontDir' => storage_path('fonts'), // advised by dompdf (https://github.com/dompdf/dompdf/pull/782) 187 | 'fontCache' => storage_path('fonts'), 188 | 'tempDir' => sys_get_temp_dir(), 189 | 'chroot' => realpath(base_path()), 190 | ], 191 | 192 | /** 193 | * The logo displayed in the PDF 194 | */ 195 | 'logo' => null, 196 | 197 | /** 198 | * The template used to render the PDF 199 | */ 200 | 'template' => 'default.layout', 201 | 202 | 'template_data' => [ 203 | /** 204 | * The color displayed at the top of the PDF 205 | */ 206 | 'color' => '#050038', 207 | ], 208 | 209 | ], 210 | 211 | ]; 212 | ``` 213 | 214 | ## The `PdfInvoice` Class 215 | 216 | This package provides a powerful, standalone `PdfInvoice` class. Its main functionalities include the ability to: 217 | 218 | - Display your invoice as a PDF document. 219 | - Render your invoice within a Blade view. 220 | 221 | The `PdfInvoice` class is also integrated with the `Invoice` Eloquent Model, allowing you to easily convert an `Invoice` model instance into its PDF representation. 222 | 223 | You can even use this package exclusively for the `PdfInvoice` class if you don't require database storage for your invoices. 224 | 225 | ### Full Example 226 | 227 | ```php 228 | use \Elegantly\Invoices\Pdf\PdfInvoice; 229 | use \Elegantly\Invoices\Pdf\PdfInvoiceItem; 230 | use \Elegantly\Invoices\Support\Seller; 231 | use \Elegantly\Invoices\Support\Buyer; 232 | use \Elegantly\Invoices\Support\Address; 233 | use \Elegantly\Invoices\Support\PaymentInstruction; 234 | use \Elegantly\Invoices\InvoiceDiscount; 235 | use Brick\Money\Money; 236 | 237 | $pdfInvoice = new PdfInvoice( 238 | name: "Invoice", 239 | state: "paid", 240 | serial_number: "INV-241200001", 241 | seller: new Seller( 242 | company: 'elegantly', 243 | name: 'Quentin Gabriele', // (optional) 244 | address: new Address( 245 | street: "Place de l'Opéra", 246 | city: 'Paris', 247 | postal_code: '75009', 248 | country: 'France', 249 | ), 250 | email: 'john.doe@example.com', 251 | tax_number: 'FR123456789', 252 | fields: [ 253 | // Custom fields to display with the seller 254 | "foo" => "bar" 255 | ] 256 | ), 257 | buyer: new Buyer( 258 | company: "Doe Corporation" // (optional) 259 | name: 'John Doe', // (optional) 260 | address: new Address( 261 | street: '8405 Old James St.Rochester', 262 | city: 'New York', 263 | postal_code: '14609', 264 | state: 'NY', 265 | country: 'United States', 266 | ), 267 | shipping_address: new Address( // (optional) 268 | street: [ // multiple lines street 269 | '8405 Old James St.Rochester', 270 | 'Apartment 1', 271 | ], 272 | city: 'New York', 273 | postal_code: '14609', 274 | state: 'NY', 275 | country: 'United States', 276 | ), 277 | email: 'john.doe@example.com', 278 | fields: [ 279 | // Custom fields to display with the buyer 280 | "foo" => "bar" 281 | ] 282 | ), 283 | description: "An invoice description", 284 | created_at: now(), 285 | due_at: now(), 286 | paid_at: now(), 287 | tax_label: "VAT France (20%)", 288 | fields: [ // custom fields to display at the top 289 | 'Order' => "PO0234" 290 | ], 291 | items: [ 292 | new PdfInvoiceItem( 293 | label: "Laratranslate Unlimitted" , 294 | unit_price: Money::of(99.0, 'USD'), 295 | tax_percentage: 20.0, 296 | quantity: 1, 297 | description: "Elegant All-in-One Translations Manager for Laravel", 298 | ), 299 | ], 300 | discounts: [ 301 | new InvoiceDiscount( 302 | name: "Summer offer", 303 | code: "SUMMER", 304 | percent_off: 50, 305 | ) 306 | ], 307 | paymentInstructions: [ 308 | new PaymentInstruction( 309 | name: 'Bank Transfer', 310 | description: 'Make a direct bank transfer using the details below.', 311 | qrcode: 'data:image/png;base64,' . base64_encode( 312 | file_get_contents(__DIR__.'/../resources/images/qrcode.png') 313 | ), 314 | fields: [ 315 | 'Bank Name' => 'Acme Bank', 316 | 'Account Number' => '12345678', 317 | 'IBAN' => 'GB12ACME12345678123456', 318 | 'SWIFT/BIC' => 'ACMEGB2L', 319 | 'Reference' => 'INV-0032/001', 320 | 'Pay online', 321 | ], 322 | ), 323 | ], 324 | logo: public_path('/images/logo.png'), // local path or base64 string 325 | template: "default.layout", // use the default template or use your own 326 | templateData: [ // custom date to pass to the template 327 | 'color' => '#050038' 328 | ], 329 | ); 330 | ``` 331 | 332 | ### Rendering the Invoice as a PDF 333 | 334 | ```php 335 | namespace App\Http\Controllers; 336 | 337 | use Elegantly\Invoices\Pdf\PdfInvoice; 338 | 339 | class InvoiceController extends Controller 340 | { 341 | public function showAsPdf() 342 | { 343 | $pdfInvoice = new PdfInvoice( 344 | // ... 345 | ); 346 | 347 | return $pdfInvoice->stream(); 348 | } 349 | } 350 | ``` 351 | 352 | ### Storing the PDF in a file 353 | 354 | ```php 355 | namespace App\Http\Controllers; 356 | 357 | use Elegantly\Invoices\Pdf\PdfInvoice; 358 | use Illuminate\Support\Facades\Storage; 359 | 360 | class InvoiceController extends Controller 361 | { 362 | public function store() 363 | { 364 | $pdfInvoice = new PdfInvoice( 365 | // ... 366 | ); 367 | 368 | Storage::put( 369 | "path/to/{$pdfInvoice->getFilename()}", 370 | $pdfInvoice->getPdfOutput() 371 | ); 372 | 373 | // ... 374 | } 375 | } 376 | ``` 377 | 378 | ### Downloading the Invoice as a PDF 379 | 380 | #### From a controller 381 | 382 | To download the PDF, simply return the `download` method. 383 | 384 | ```php 385 | namespace App\Http\Controllers; 386 | 387 | use Elegantly\Invoices\Pdf\PdfInvoice; 388 | 389 | class InvoiceController extends Controller 390 | { 391 | public function download() 392 | { 393 | $pdfInvoice = new PdfInvoice( 394 | // ... 395 | ); 396 | 397 | return $pdfInvoice->download( 398 | /** 399 | * (optional) 400 | * The default filename is the serial_number 401 | */ 402 | filename: 'invoice.pdf' 403 | ); 404 | } 405 | } 406 | ``` 407 | 408 | #### From a Livewire component 409 | 410 | To download the PDF from a Livewire component, use the `streamDownload` method as shown below: 411 | 412 | ```php 413 | namespace App\Http\Controllers; 414 | 415 | use Elegantly\Invoices\Pdf\PdfInvoice; 416 | 417 | class Invoice extends Component 418 | { 419 | public function download() 420 | { 421 | $pdfInvoice = new PdfInvoice( 422 | // ... 423 | ); 424 | 425 | return response()->streamDownload(function () use ($pdfInvoice) { 426 | echo $pdf->getPdfOutput(); 427 | }, $pdf->getFilename()); // The default filename is the serial number 428 | } 429 | } 430 | ``` 431 | 432 | ### Rendering the Invoice as a view 433 | 434 | ```php 435 | namespace App\Http\Controllers; 436 | 437 | use Elegantly\Invoices\Pdf\PdfInvoice; 438 | 439 | class InvoiceController extends Controller 440 | { 441 | public function showAsView() 442 | { 443 | $pdfInvoice = new PdfInvoice( 444 | // ... 445 | ); 446 | 447 | return $pdfInvoice->view(); 448 | } 449 | } 450 | ``` 451 | 452 | ### Rendering the Invoice within a View 453 | 454 | You can embed the invoice within a larger Blade view to create interfaces like an "invoice builder," similar to the [interactive demo](https://elegantly.devlaravel-invoices). 455 | 456 | To do this, include the main invoice partial in your view as shown below: 457 | 458 | ```php 459 |
460 | @include('invoices::default.invoice', ['invoice' => $invoice]) 461 |
462 | ``` 463 | 464 | This approach allows for seamless integration of the invoice into a dynamic and customizable user interface. 465 | 466 | > [!NOTE] 467 | > The default template uses Tailwind CSS for styling. This ensures seamless integration with websites already using Tailwind. 468 | > If your project doesn't use Tailwind, the invoice styling may not appear as intended. 469 | 470 | ### Adding Taxes 471 | 472 | Taxes are applied to individual `PdfInvoiceItem` item. You can define them either as a percentage or a fixed amount. 473 | 474 | #### Tax by Percentage 475 | 476 | To add a tax as a percentage, set the `tax_percentage` property on the `PdfInvoiceItem`. This value should be a float between 0 and 100. 477 | 478 | ```php 479 | use \Elegantly\Invoices\Pdf\PdfInvoiceItem; 480 | 481 | new PdfInvoiceItem( 482 | label: "Laratranslate Unlimitted" , 483 | unit_price: Money::of(99.0, 'USD'), 484 | tax_percentage: 20.0, // a float between 0.0 and 100.0 485 | ), 486 | ``` 487 | 488 | #### Tax as a Fixed Amount 489 | 490 | To apply a tax as a specific monetary amount, set the `unit_tax` property on the `PdfInvoiceItem`. 491 | 492 | ```php 493 | use \Elegantly\Invoices\Pdf\PdfInvoiceItem; 494 | 495 | new PdfInvoiceItem( 496 | label: "Laratranslate Unlimitted" , 497 | unit_price: Money::of(99.0, 'USD'), 498 | unit_tax: Money::of(19.8, 'USD'), 499 | ), 500 | ``` 501 | 502 | ### Adding Discounts 503 | 504 | Discounts are represented by the `InvoiceDiscount` class and are applied to the entire `PdfInvoice`. They cannot be attached to individual `PdfInvoiceItem`s at this time. 505 | 506 | - You can add multiple discounts to a single invoice. 507 | - Discounts can be specified as a fixed amount (`amount_off`) or a percentage (`percent_off`). If both are provided for the same discount, the `amount_off` value takes precedence. 508 | 509 | #### Discount by Percentage 510 | 511 | To apply a discount as a percentage, set the `percent_off` property. 512 | 513 | ```php 514 | use \Elegantly\Invoices\Pdf\PdfInvoice; 515 | use \Elegantly\Invoices\InvoiceDiscount; 516 | use Brick\Money\Money; 517 | 518 | $pdfInvoice = new PdfInvoice( 519 | // ... 520 | discounts: [ 521 | new InvoiceDiscount( 522 | name: "Summer offer", 523 | code: "SUMMER", 524 | percent_off: 20.0, 525 | ) 526 | ], 527 | ); 528 | ``` 529 | 530 | #### Discount as a Fixed Amount 531 | 532 | To apply a discount as a fixed amount, set the `amount_off` property. 533 | 534 | ```php 535 | use \Elegantly\Invoices\Pdf\PdfInvoice; 536 | use \Elegantly\Invoices\InvoiceDiscount; 537 | use Brick\Money\Money; 538 | 539 | $pdfInvoice = new PdfInvoice( 540 | // ... 541 | discounts: [ 542 | new InvoiceDiscount( 543 | name: "Summer offer", 544 | code: "SUMMER", 545 | amount_off: Money::of(20.0, 'USD'), 546 | ) 547 | ], 548 | ); 549 | ``` 550 | 551 | ### Adding Payment Instructions 552 | 553 | You can include detailed payment instructions directly within the generated PDF invoice. This can be helpful for providing bank transfer details, QR codes for quick payments, and custom payment links. 554 | 555 | Here’s an example of how to add a payment instruction: 556 | 557 | ```php 558 | use \Elegantly\Invoices\Pdf\PdfInvoice; 559 | use \Elegantly\Invoices\Support\PaymentInstruction; 560 | 561 | $pdfInvoice = new PdfInvoice( 562 | // ... 563 | paymentInstructions: [ 564 | new PaymentInstruction( 565 | name: 'Bank Transfer', 566 | description: 'Make a direct bank transfer using the details below.', 567 | qrcode: 'data:image/png;base64,' . base64_encode( 568 | file_get_contents(__DIR__.'/../resources/images/qrcode.png') 569 | ), 570 | fields: [ 571 | 'Bank Name' => 'Acme Bank', 572 | 'Account Number' => '12345678', 573 | 'IBAN' => 'GB12ACME12345678123456', 574 | 'SWIFT/BIC' => 'ACMEGB2L', 575 | 'Reference' => 'INV-0032/001', 576 | 'Pay online', 577 | ], 578 | ), 579 | ] 580 | ); 581 | ``` 582 | 583 | > **Note:** You can include HTML tags (e.g., links) within the `fields` array for interactive content. 584 | 585 | #### QR Code Generation 586 | 587 | To dynamically generate QR codes, I recommend using the [`chillerlan/php-qrcode`](https://github.com/chillerlan/php-qrcode) package. It provides a simple and flexible API for generating QR codes in various formats. 588 | 589 | ### Customization 590 | 591 | #### Customizing Fonts 592 | 593 | See the [Dompdf font guide](https://github.com/dompdf/dompdf). 594 | 595 | #### Customizing the Invoice Template 596 | 597 | To customize the invoice template, first publish the package's views: 598 | 599 | ```bash 600 | php artisan vendor:publish --tag="invoices-views" 601 | ``` 602 | 603 | After publishing, you can modify the Blade files in `resources/views/vendor/invoices/` to suit your needs. 604 | 605 | > [!NOTE] 606 | > If you introduce new CSS classes in your custom template, ensure you define their styles in the style.blade.php file. 607 | 608 | Alternatively, to use a completely different custom template, you can specify its path in the configuration file: 609 | 610 | > [!WARNING] 611 | > Your custom template file must be in `resources/views/vendor/invoices` 612 | 613 | ```php 614 | return [ 615 | 616 | // ... 617 | 618 | 'pdf' => [ 619 | 620 | /** 621 | * The template used to render the PDF 622 | */ 623 | 'template' => 'my-custom.layout', 624 | 625 | 'template_data' => [ 626 | /** 627 | * The color displayed at the top of the PDF 628 | */ 629 | 'color' => '#050038', 630 | ], 631 | 632 | ], 633 | 634 | ]; 635 | ``` 636 | 637 | Ensure that your custom template follows the same structure and conventions as the default one to maintain compatibility with various use cases. 638 | 639 | ## The `Invoice` Eloquent Model 640 | 641 | The design of the `Invoice` Eloquent Model closely mirrors that of the `PdfInvoice` class. 642 | 643 | This model provides powerful features for: 644 | 645 | - Generating unique and complex serial numbers. 646 | - Attaching your invoice to any other Eloquent model. 647 | - Easily including your invoice as an attachment in emails. 648 | 649 | > [!NOTE] 650 | > Remember to publish and run the database migrations 651 | 652 | ### Complete Example 653 | 654 | The following example demonstrates how to create and store an invoice. 655 | 656 | For this illustration, let's assume the following application structure: 657 | 658 | - `Team` models have `User` models. 659 | - `Team` models can have multiple `Invoice` models. 660 | - `Invoice` models can be attached to `Offer` models. 661 | 662 | ```php 663 | use App\Models\Team; 664 | use App\Models\Order; 665 | 666 | use Brick\Money\Money; 667 | use Elegantly\Invoices\Models\Invoice; 668 | use Elegantly\Invoices\Enums\InvoiceState; 669 | use Elegantly\Invoices\Enums\InvoiceType; 670 | 671 | $customer = Team::find(1); 672 | $order = Order::find(2); 673 | 674 | $invoice = new Invoice( 675 | 'type'=> "invoice", 676 | 'state'=> "paid", 677 | 'seller_information'=> config('invoices.default_seller'), 678 | 'buyer_information'=>[ 679 | 'company'=> "Doe Corporation" // (optional) 680 | 'name'=> 'John Doe', // (optional) 681 | 'address'=> [ 682 | 'street'=> '8405 Old James St.Rochester', 683 | 'city'=> 'New York', 684 | 'postal_code'=> '14609', 685 | 'state'=> 'NY', 686 | 'country'=> 'United States', 687 | ], 688 | 'shipping_address'=> [ // (optional) 689 | 'street'=> [ // multiple lines street 690 | '8405 Old James St.Rochester', 691 | 'Apartment 1', 692 | ], 693 | 'city'=> 'New York', 694 | 'postal_code'=> '14609', 695 | 'state'=> 'NY', 696 | 'country'=> 'United States', 697 | ] 698 | 'email'=> 'john.doe@example.com', 699 | 'fields'=> [ 700 | // Custom fields to display with the buyer 701 | "foo" => "bar" 702 | ] 703 | ], 704 | 'description'=> "An invoice description", 705 | 'due_at'=> now(), 706 | 'paid_at'=> now(), 707 | 'tax_type'=> "eu_VAT_FR", 708 | 'tax_exempt'=> null, 709 | ); 710 | 711 | // Learn more about the serial number in the next section 712 | $invoice->configureSerialNumber( 713 | prefix: "ORD", 714 | serie: $customer->id, 715 | year: now()->format('Y'), 716 | month: now()->format('m') 717 | ) 718 | 719 | $invoice->buyer()->associate($customer); // optionnally associate the invoice to any model 720 | $invoice->invoiceable()->associate($order); // optionnally associate the invoice to any model 721 | 722 | $invoice->save(); 723 | 724 | $invoice->items()->saveMany([ 725 | new InvoiceItem([ 726 | 'label' => "Laratranslate Unlimitted", 727 | 'description' => "Elegant All-in-One Translations Manager for Laravel", 728 | 'unit_price' => Money::of(99.0, 'USD'), 729 | 'tax_percentage' => 20.0, 730 | 'quantity' => 1, 731 | ]), 732 | ]); 733 | ``` 734 | 735 | ### Generating Unique Serial Numbers 736 | 737 | This package provides a simple and reliable way to generate serial numbers automatically, such as "INV240001". 738 | 739 | You can configure the format of your serial numbers in the configuration file. The default format is `PPYYCCCC`, where each letter has a specific meaning (see the config file for details). 740 | 741 | When `invoices.serial_number.auto_generate` is set to `true`, a unique serial number is assigned to each new invoice automatically. 742 | 743 | Serial numbers are generated sequentially, with each new serial number based on the latest available one. To define what qualifies as the `previous` serial number, you can extend the `Elegantly\Invoices\Models\Invoice` class and override the `getPreviousInvoice` method. 744 | 745 | By default, the previous invoice is determined based on criteria such as prefix, series, year, and month for accurate, scoped numbering. 746 | 747 | ### Using Multiple Prefixes and Series for Serial Numbers 748 | 749 | In more complex applications, you may need to use different prefixes and/or series for your invoices. 750 | 751 | For instance, you might want to define a unique series for each user, creating serial numbers that look like: `INV0001-2400X`, where `0001` represents the user’s ID, `24` the year and `X` the index of the invoice. 752 | 753 | > [!NOTE] 754 | > When using IDs for series, it's recommended to plan for future growth to avoid overflow. 755 | > Even if you have a limited number of users now, ensure that the ID can accommodate the maximum number of digits allowed by the serial number format. 756 | 757 | When creating an invoice, you can dynamically specify the prefix and series with `configureSerialNumber` method: 758 | 759 | ```php 760 | use Elegantly\Invoices\Models\Invoice; 761 | $invoice = new Invoice(); 762 | 763 | $invoice->configureSerialNumber( 764 | prefix: "ORG", 765 | serie: $buyer_id, 766 | ); 767 | ``` 768 | 769 | ### Customizing the Serial Number Format 770 | 771 | In most cases, the format of your serial numbers should remain consistent, so it's recommended to set it in the configuration file. 772 | 773 | The format you choose will determine the types of information you need to provide to `configureSerialNumber`. 774 | 775 | Below is an example of the most complex serial number format you can create with this package: 776 | 777 | ```php 778 | 779 | $invoice = new Invoice(); 780 | 781 | $invoice->configureSerialNumber( 782 | format: "PP-SSSSSS-YYMMCCCC", 783 | prefix: "IN", 784 | serie: 100, 785 | year: now()->format('Y'), 786 | month: now()->format('m') 787 | ); 788 | 789 | $invoice->save(); 790 | 791 | $invoice->serial_number; // IN-000100-24010001 792 | ``` 793 | 794 | ### Converting an `Invoice` Model to a `PdfInvoice` 795 | 796 | You can obtained a `PdfInvoice` class from your `Invoice` model by calling the `toPdfInvoice` method: 797 | 798 | ```php 799 | $invoice = Invoice::first(); 800 | 801 | $pdfInvoice = $invoice->toPdfInvoice(); 802 | ``` 803 | 804 | ### Display, Download, and Store Invoices 805 | 806 | You can then stream the `PdfInvoice` instance directly or initiate a download: 807 | 808 | ```php 809 | namespace App\Http\Controllers; 810 | 811 | use App\Models\Invoice; 812 | use Illuminate\Http\Request; 813 | 814 | class InvoiceController extends Controller 815 | { 816 | public function show(Request $request, string $serial) 817 | { 818 | /** @var Invoice $invoice */ 819 | $invoice = Invoice::where('serial_number', $serial)->firstOrFail(); 820 | 821 | $this->authorize('view', $invoice); 822 | 823 | return $invoice->toPdfInvoice()->stream(); 824 | } 825 | 826 | public function download(Request $request, string $serial) 827 | { 828 | /** @var Invoice $invoice */ 829 | $invoice = Invoice::where('serial_number', $serial)->firstOrFail(); 830 | 831 | $this->authorize('view', $invoice); 832 | 833 | return $invoice->toPdfInvoice()->download(); 834 | } 835 | 836 | public function store(Request $request, string $serial) 837 | { 838 | /** @var Invoice $invoice */ 839 | $invoice = Invoice::where('serial_number', $serial)->firstOrFail(); 840 | 841 | Storage::put( 842 | "path/to/invoice.pdf", 843 | $invoice->toPdfInvoice()->getPdfOutput() 844 | ); 845 | 846 | // ... 847 | } 848 | } 849 | ``` 850 | 851 | ### Attaching Invoices to Mailables 852 | 853 | You can easily attach an invoice to your `Mailable` as follows: 854 | 855 | ```php 856 | namespace App\Mail; 857 | 858 | use App\Models\Invoice; 859 | use Illuminate\Bus\Queueable; 860 | use Illuminate\Mail\Mailable; 861 | use Illuminate\Queue\SerializesModels; 862 | 863 | class PaymentInvoice extends Mailable 864 | { 865 | use Queueable, SerializesModels; 866 | 867 | /** 868 | * Create a new message instance. 869 | */ 870 | public function __construct( 871 | protected Invoice $invoice, 872 | ) {} 873 | 874 | 875 | public function attachments(): array 876 | { 877 | return [ 878 | $this->invoice->toMailAttachment() 879 | ]; 880 | } 881 | } 882 | ``` 883 | 884 | ### Attaching Invoices to Notifications 885 | 886 | You can easily attach an invoice to your `Notification` as follows: 887 | 888 | ```php 889 | namespace App\Mail; 890 | 891 | use App\Models\Invoice; 892 | use Illuminate\Bus\Queueable; 893 | use Illuminate\Notifications\Notification; 894 | use Illuminate\Notifications\Messages\MailMessage; 895 | 896 | class PaymentInvoice extends Notification implements ShouldQueue 897 | { 898 | use Queueable; 899 | 900 | /** 901 | * Create a new message instance. 902 | */ 903 | public function __construct( 904 | protected Invoice $invoice, 905 | ) {} 906 | 907 | public function toMail($notifiable) 908 | { 909 | return (new MailMessage) 910 | ->attach($this->invoice->toMailAttachment()); 911 | } 912 | } 913 | ``` 914 | 915 | ### Customizing PDF Output from the Model 916 | 917 | To customize how your `Invoice` model is converted into a `PdfInvoice` object, follow these steps: 918 | 919 | 1. **Create a Custom Invoice Model**: 920 | 921 | Define your own `App\Models\Invoice` class and ensure it extends the base `Elegantly\Invoices\Models\Invoice`. 922 | 923 | ```php 924 | namespace App\Models; 925 | 926 | class Invoice extends \Elegantly\Invoices\Models\Invoice 927 | { 928 | // ... 929 | } 930 | ``` 931 | 932 | 2. **Override the `toPdfInvoice` Method**: 933 | 934 | In your custom `Invoice` model, override the `toPdfInvoice` method. This is where you'll implement your specific logic to construct and return the `PdfInvoice` object with your desired customizations. 935 | 936 | ```php 937 | namespace App\Models; 938 | 939 | use Elegantly\Invoices\Pdf\PdfInvoice; 940 | 941 | class Invoice extends \Elegantly\Invoices\Models\Invoice 942 | { 943 | function toPdfInvoice(): PdfInvoice 944 | { 945 | return new PdfInvoice( 946 | // ... your custom PdfInvoice properties and configuration 947 | ); 948 | } 949 | } 950 | ``` 951 | 952 | 3. **Update the Package Configuration**: 953 | 954 | First, if you haven't already, publish the package's configuration file: 955 | 956 | ```bash 957 | php artisan vendor:publish --tag="invoices-config" 958 | ``` 959 | 960 | Then, modify the `config/invoices.php` file to tell the package to use your custom model by updating the `model_invoice` key: 961 | 962 | ```php 963 | return [ 964 | // ... 965 | 966 | 'model_invoice' => \App\Models\Invoice::class, 967 | 968 | // ... 969 | ]; 970 | ``` 971 | 972 | #### Using a Custom PdfInvoice Class 973 | 974 | You can extend the default PdfInvoice class provided by the package to customize its behavior, such as changing the generated filename or adding additional logic. 975 | 976 | 1. Create Your Custom PdfInvoice Class 977 | 978 | ```php 979 | class PdfInvoice extends \Elegantly\Invoices\Pdf\PdfInvoice 980 | { 981 | 982 | public function __construct( 983 | // your custom constructor 984 | ){ 985 | // ... 986 | } 987 | 988 | public function getFilename(): string 989 | { 990 | return str($this->serial_number) 991 | ->replace(['/', '\\'], '_') 992 | ->append('.pdf') 993 | ->value(); 994 | } 995 | } 996 | ``` 997 | 998 | In this example, we're overriding the `getFilename` method. 999 | 1000 | 2. Return Your Custom `PdfInvoice` from the Invoice Model 1001 | 1002 | Update your `Invoice` model to return an instance of your custom `PdfInvoice` class. 1003 | 1004 | ```php 1005 | namespace App\Models; 1006 | 1007 | use App\ValueObjects\PdfInvoice; 1008 | 1009 | class Invoice extends \Elegantly\Invoices\Models\Invoice 1010 | { 1011 | function toPdfInvoice(): PdfInvoice 1012 | { 1013 | return new PdfInvoice( 1014 | // Pass any required data to your custom PdfInvoice constructor 1015 | ); 1016 | } 1017 | } 1018 | ``` 1019 | 1020 | By overriding the `toPdfInvoice` method, you can inject your custom logic while preserving compatibility with the rest of the package. 1021 | 1022 | ### Casting `state` and `type` to Enums 1023 | 1024 | By default, the `type` and `state` properties on the `Invoice` model are stored as strings. This approach offers flexibility, as it doesn't restrict you to predefined values and they are not automatically cast to Enum objects. 1025 | 1026 | However, you might prefer to cast these properties to Enum objects for better type safety and code clarity. You can use your own custom Enums or the ones provided by this package (e.g., `Elegantly\Invoices\Enums\InvoiceState`, `Elegantly\Invoices\Enums\InvoiceType`). 1027 | 1028 | To enable Enum casting for these properties, follow these steps: 1029 | 1030 | 1. **Create a Custom `Invoice` Model**: 1031 | 1032 | Define your own `App\Models\Invoice` class that extends `\Elegantly\Invoices\Models\Invoice`. 1033 | In this custom model, override the `casts()` method to specify the Enum classes for the `type` and `state` attributes. 1034 | 1035 | ```php 1036 | namespace App\Models; 1037 | 1038 | use Elegantly\Invoices\Enums\InvoiceState; 1039 | use Elegantly\Invoices\Enums\InvoiceType; 1040 | 1041 | class Invoice extends \Elegantly\Invoices\Models\Invoice 1042 | { 1043 | protected function casts(): array 1044 | { 1045 | return [ 1046 | ...parent::casts(), // Merge with parent casts for other potential attributes 1047 | 'type' => InvoiceType::class, 1048 | 'state' => InvoiceState::class, 1049 | ]; 1050 | } 1051 | } 1052 | ``` 1053 | 1054 | 2. **Publish Package Configuration**: 1055 | 1056 | If you haven't already, publish the package's configuration file: 1057 | 1058 | ```bash 1059 | php artisan vendor:publish --tag="invoices-config" 1060 | ``` 1061 | 1062 | 3. **Update Configuration to Use Your Custom Model**: 1063 | 1064 | Modify the `config/invoices.php` file and update the `model_invoice` key to point to your newly created custom `Invoice` model: 1065 | 1066 | ```php 1067 | return [ 1068 | // ... 1069 | 1070 | 'model_invoice' => \App\Models\Invoice::class, 1071 | 1072 | // ... 1073 | ]; 1074 | ``` 1075 | 1076 | ### Using a Dynamic Logo 1077 | 1078 | In scenarios where the invoice logo needs to be set dynamically (for instance, allowing users to upload their own company logo), you can achieve this by overriding the `getLogo` method in your `Invoice` model. 1079 | 1080 | Follow these steps: 1081 | 1082 | 1. **Create a Custom `Invoice` Model**: 1083 | 1084 | Define your own `App\Models\Invoice` that extends `\Elegantly\Invoices\Models\Invoice` class. 1085 | Inside this custom model, implement the `getLogo` method to return the path or data for your dynamic logo. 1086 | 1087 | > [!NOTE] 1088 | > The `getLogo` method must return either a base64-encoded data URL (e.g., `data:image/png;base64,...`) or a local filesystem path to the logo image. 1089 | 1090 | Here's an example of how you might implement this: 1091 | 1092 | ```php 1093 | namespace App\Models; 1094 | . 1095 | use Illuminate\Http\File; 1096 | 1097 | class Invoice extends \Elegantly\Invoices\Models\Invoice 1098 | { 1099 | public function getLogo(): ?string 1100 | { 1101 | $logoPath = public_path('logo.png'); // Replace with your dynamic logic 1102 | 1103 | if (!file_exists($logoPath)) { 1104 | return null; // Or a default logo 1105 | } 1106 | 1107 | $file = new File($logoPath); 1108 | $mime = $file->getMimeType(); 1109 | $logoData = "data:{$mime};base64," . base64_encode(file_get_contents($logoPath)); // Use file_get_contents for raw data 1110 | 1111 | return $logoData; 1112 | } 1113 | } 1114 | ``` 1115 | 1116 | 2. **Publish Package Configuration**: 1117 | 1118 | If you haven't done so already, publish the package's configuration file: 1119 | 1120 | ```bash 1121 | php artisan vendor:publish --tag="invoices-config" 1122 | ``` 1123 | 1124 | 3. **Update Configuration to Use Your Custom Model**: 1125 | 1126 | Modify the `config/invoices.php` file and update the `model_invoice` key to point to your custom `Invoice` model: 1127 | 1128 | ```php 1129 | return [ 1130 | // ... 1131 | 1132 | 'model_invoice' => \App\Models\Invoice::class, 1133 | 1134 | // ... 1135 | ]; 1136 | ``` 1137 | 1138 | ## Testing 1139 | 1140 | ```bash 1141 | composer test 1142 | ``` 1143 | 1144 | ## Changelog 1145 | 1146 | Please see [CHANGELOG](CHANGELOG.md) for more information on what has changed recently. 1147 | 1148 | ## Contributing 1149 | 1150 | Please see [CONTRIBUTING](CONTRIBUTING.md) for details. 1151 | 1152 | ## Security Vulnerabilities 1153 | 1154 | Please review [our security policy](../../security/policy) on how to report security vulnerabilities. 1155 | 1156 | ## Credits 1157 | 1158 | - [Quentin Gabriele](https://github.com/QuentinGab) 1159 | - [All Contributors](../../contributors) 1160 | 1161 | ## License 1162 | 1163 | The MIT License (MIT). Please see [License File](LICENSE.md) for more information. 1164 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "elegantly/laravel-invoices", 3 | "description": "Store invoices safely in your Laravel application", 4 | "keywords": [ 5 | "elegantly", 6 | "laravel", 7 | "invoices", 8 | "laravel-invoices" 9 | ], 10 | "homepage": "https://github.com/ElegantEngineeringTech/laravel-invoices", 11 | "license": "MIT", 12 | "authors": [ 13 | { 14 | "name": "Quentin Gabriele", 15 | "email": "quentin.gabriele@gmail.com", 16 | "role": "Developer" 17 | } 18 | ], 19 | "require": { 20 | "php": "^8.1", 21 | "dompdf/dompdf": "^3.1", 22 | "elegantly/laravel-money": "^2.1.0", 23 | "illuminate/contracts": "^11.0||^12.0", 24 | "spatie/laravel-package-tools": "^1.14" 25 | }, 26 | "require-dev": { 27 | "larastan/larastan": "^3.0", 28 | "laravel/pint": "^1.14", 29 | "nunomaduro/collision": "^8.1.1", 30 | "orchestra/testbench": "^9.0.0||^10.0.0", 31 | "pestphp/pest": "^3.0", 32 | "pestphp/pest-plugin-arch": "^3.0", 33 | "pestphp/pest-plugin-laravel": "^3.0", 34 | "phpstan/extension-installer": "^1.3||^2.0", 35 | "phpstan/phpstan-deprecation-rules": "^1.1||^2.0", 36 | "phpstan/phpstan-phpunit": "^1.3||^2.0" 37 | }, 38 | "autoload": { 39 | "psr-4": { 40 | "Elegantly\\Invoices\\": "src", 41 | "Elegantly\\Invoices\\Database\\Factories\\": "database/factories" 42 | } 43 | }, 44 | "autoload-dev": { 45 | "psr-4": { 46 | "Elegantly\\Invoices\\Tests\\": "tests", 47 | "Workbench\\App\\": "workbench/app/", 48 | "Workbench\\Database\\Factories\\": "workbench/database/factories/", 49 | "Workbench\\Database\\Seeders\\": "workbench/database/seeders/" 50 | } 51 | }, 52 | "scripts": { 53 | "post-autoload-dump": [ 54 | "@clear", 55 | "@prepare", 56 | "@composer run prepare" 57 | ], 58 | "clear": "@php vendor/bin/testbench package:purge-skeleton --ansi", 59 | "prepare": "@php vendor/bin/testbench package:discover --ansi", 60 | "build": "@php vendor/bin/testbench workbench:build --ansi", 61 | "start": [ 62 | "Composer\\Config::disableProcessTimeout", 63 | "@composer run build", 64 | "@php vendor/bin/testbench serve" 65 | ], 66 | "analyse": "vendor/bin/phpstan analyse --memory-limit 500M", 67 | "test": "vendor/bin/pest", 68 | "test-coverage": "vendor/bin/pest --coverage", 69 | "format": "vendor/bin/pint", 70 | "serve": [ 71 | "Composer\\Config::disableProcessTimeout", 72 | "@build", 73 | "@php vendor/bin/testbench serve --ansi" 74 | ], 75 | "lint": [ 76 | "@php vendor/bin/pint", 77 | "@php vendor/bin/phpstan analyse" 78 | ] 79 | }, 80 | "config": { 81 | "sort-packages": true, 82 | "allow-plugins": { 83 | "pestphp/pest-plugin": true, 84 | "phpstan/extension-installer": true 85 | } 86 | }, 87 | "extra": { 88 | "laravel": { 89 | "providers": [ 90 | "Elegantly\\Invoices\\InvoiceServiceProvider" 91 | ], 92 | "aliases": { 93 | "Invoice": "Elegantly\\Invoices\\Facades\\Invoice" 94 | } 95 | } 96 | }, 97 | "minimum-stability": "dev", 98 | "prefer-stable": true 99 | } 100 | -------------------------------------------------------------------------------- /config/invoices.php: -------------------------------------------------------------------------------- 1 | Invoice::class, 13 | 'model_invoice_item' => InvoiceItem::class, 14 | 15 | 'discount_class' => InvoiceDiscount::class, 16 | 17 | 'cascade_invoice_delete_to_invoice_items' => true, 18 | 19 | 'serial_number' => [ 20 | /** 21 | * If true, will generate a serial number on creation 22 | * If false, you will have to set the serial_number yourself 23 | */ 24 | 'auto_generate' => true, 25 | 26 | /** 27 | * Define the serial number format used for each invoice type 28 | * 29 | * P: Prefix 30 | * S: Serie 31 | * M: Month 32 | * Y: Year 33 | * C: Count 34 | * Example: IN0012-220234 35 | * Repeat letter to set the length of each information 36 | * Examples of formats: 37 | * - PPYYCCCC : IN220123 (default) 38 | * - PPPYYCCCC : INV220123 39 | * - PPSSSS-YYCCCC : INV0001-220123 40 | * - SSSS-CCCC: 0001-0123 41 | * - YYCCCC: 220123 42 | */ 43 | 'format' => 'PPYYCCCC', 44 | 45 | /** 46 | * Define the default prefix used for each invoice type 47 | */ 48 | 'prefix' => [ 49 | InvoiceType::Invoice->value => 'IN', 50 | InvoiceType::Quote->value => 'QO', 51 | InvoiceType::Credit->value => 'CR', 52 | InvoiceType::Proforma->value => 'PF', 53 | ], 54 | 55 | ], 56 | 57 | 'date_format' => 'Y-m-d', 58 | 59 | 'default_seller' => [ 60 | 'company' => null, 61 | 'name' => null, 62 | 'address' => [ 63 | 'street' => null, 64 | 'city' => null, 65 | 'postal_code' => null, 66 | 'state' => null, 67 | 'country' => null, 68 | ], 69 | 'email' => null, 70 | 'phone' => null, 71 | 'tax_number' => null, 72 | 'fields' => [ 73 | // 74 | ], 75 | ], 76 | 77 | /** 78 | * ISO 4217 currency code 79 | */ 80 | 'default_currency' => 'USD', 81 | 82 | 'pdf' => [ 83 | 84 | 'paper' => [ 85 | 'size' => 'a4', 86 | 'orientation' => 'portrait', 87 | ], 88 | 89 | /** 90 | * Default DOM PDF options 91 | * 92 | * @see Available options https://github.com/barryvdh/laravel-dompdf#configuration 93 | */ 94 | 'options' => [ 95 | 'isRemoteEnabled' => true, 96 | 'isPhpEnabled' => false, 97 | 'fontHeightRatio' => 1, 98 | /** 99 | * Supported values are: 'DejaVu Sans', 'Helvetica', 'Courier', 'Times', 'Symbol', 'ZapfDingbats' 100 | */ 101 | 'defaultFont' => 'Helvetica', 102 | 103 | 'fontDir' => storage_path('fonts'), // advised by dompdf (https://github.com/dompdf/dompdf/pull/782) 104 | 'fontCache' => storage_path('fonts'), 105 | 'tempDir' => sys_get_temp_dir(), 106 | 'chroot' => realpath(base_path()), 107 | ], 108 | 109 | /** 110 | * The logo displayed in the PDF 111 | */ 112 | 'logo' => null, 113 | 114 | /** 115 | * The template used to render the PDF 116 | */ 117 | 'template' => 'default.layout', 118 | 119 | 'template_data' => [ 120 | /** 121 | * The color displayed at the top of the PDF 122 | */ 123 | 'color' => '#050038', 124 | ], 125 | 126 | ], 127 | 128 | ]; 129 | -------------------------------------------------------------------------------- /database/factories/InvoiceFactory.php: -------------------------------------------------------------------------------- 1 | 18 | */ 19 | class InvoiceFactory extends Factory 20 | { 21 | protected $model = Invoice::class; 22 | 23 | public function definition() 24 | { 25 | $created_at = fake()->dateTime( 26 | max: Carbon::create(2024, 12, 31) 27 | ); 28 | 29 | return [ 30 | 'type' => InvoiceType::Invoice->value, 31 | 'state' => InvoiceState::Draft->value, 32 | 'state_set_at' => fake()->dateTimeBetween($created_at), 33 | 'updated_at' => fake()->dateTimeBetween($created_at), 34 | 'created_at' => $created_at, 35 | 'due_at' => fake()->dateTimeBetween($created_at, '+ 30 days'), 36 | 'description' => fake()->sentence(), 37 | // @phpstan-ignore-next-line 38 | 'seller_information' => Seller::fromArray(config('invoices.default_seller')), 39 | 'buyer_information' => new Buyer( 40 | name : fake()->company(), 41 | address : new Address( 42 | street: fake()->streetName(), 43 | city: fake()->city(), 44 | postal_code : fake()->postcode(), 45 | country: fake()->country(), 46 | ), 47 | email : fake()->email(), 48 | phone : fake()->phoneNumber(), 49 | tax_number : (string) fake()->numberBetween(12345678, 99999999), 50 | ), 51 | ]; 52 | } 53 | 54 | public function quote(): static 55 | { 56 | return $this->state([ 57 | 'type' => InvoiceType::Quote, 58 | ]); 59 | } 60 | 61 | public function proforma(): static 62 | { 63 | return $this->state([ 64 | 'type' => InvoiceType::Proforma, 65 | ]); 66 | } 67 | 68 | public function invoice(): static 69 | { 70 | return $this->state([ 71 | 'type' => InvoiceType::Invoice, 72 | ]); 73 | } 74 | 75 | public function credit(): static 76 | { 77 | return $this->state([ 78 | 'type' => InvoiceType::Credit, 79 | ]); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /database/factories/InvoiceItemFactory.php: -------------------------------------------------------------------------------- 1 | 13 | */ 14 | class InvoiceItemFactory extends Factory 15 | { 16 | protected $model = InvoiceItem::class; 17 | 18 | public function definition() 19 | { 20 | $currency = config()->string('invoices.default_currency'); 21 | 22 | $price = Money::ofMinor(fake()->numberBetween(1000, 100000), $currency); 23 | $unit_tax = Money::ofMinor(fake()->numberBetween(0, $price->getAmount()->toFloat()), $currency); 24 | 25 | $useTaxPercentage = fake()->boolean(); 26 | 27 | return [ 28 | 'label' => fake()->sentence(), 29 | 'description' => fake()->sentence(), 30 | 'unit_price' => $price->getMinorAmount()->toInt(), 31 | 'currency' => $price->getCurrency()->getCurrencyCode(), 32 | 'unit_tax' => ! $useTaxPercentage ? $unit_tax : null, 33 | 'tax_percentage' => $useTaxPercentage ? fake()->numberBetween(0, 100) : null, 34 | 'quantity' => fake()->numberBetween(1, 10), 35 | ]; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /database/migrations/add_denormalized_columns_to_invoices_table.php.stub: -------------------------------------------------------------------------------- 1 | bigInteger('subtotal_amount')->nullable(); 16 | $table->bigInteger('discount_amount')->nullable(); 17 | $table->bigInteger('tax_amount')->nullable(); 18 | $table->bigInteger('total_amount')->nullable(); 19 | $table->string('currency')->nullable(); 20 | }); 21 | } 22 | 23 | /** 24 | * Reverse the migrations. 25 | */ 26 | public function down(): void 27 | { 28 | Schema::table('invoices', function (Blueprint $table) { 29 | $table->dropColumn('subtotal_amount'); 30 | $table->dropColumn('discount_amount'); 31 | $table->dropColumn('tax_amount'); 32 | $table->dropColumn('total_amount'); 33 | $table->dropColumn('currency'); 34 | }); 35 | } 36 | }; 37 | -------------------------------------------------------------------------------- /database/migrations/add_discounts_column_to_invoices_table.php.stub: -------------------------------------------------------------------------------- 1 | json('discounts')->nullable(); 16 | }); 17 | } 18 | 19 | /** 20 | * Reverse the migrations. 21 | */ 22 | public function down(): void 23 | { 24 | Schema::table('invoices', function (Blueprint $table) { 25 | $table->dropColumn('discounts'); 26 | }); 27 | } 28 | }; 29 | -------------------------------------------------------------------------------- /database/migrations/add_serial_number_details_columns_to_invoices_table.php.stub: -------------------------------------------------------------------------------- 1 | string('serial_number_format'); 16 | $table->string('serial_number_prefix')->nullable(); 17 | $table->unsignedBigInteger('serial_number_serie')->nullable(); 18 | $table->unsignedSmallInteger('serial_number_year')->nullable(); 19 | $table->unsignedTinyInteger('serial_number_month')->nullable(); 20 | $table->unsignedBigInteger('serial_number_count'); 21 | }); 22 | 23 | } 24 | 25 | /** 26 | * Reverse the migrations. 27 | */ 28 | public function down(): void 29 | { 30 | Schema::table('invoices', function (Blueprint $table) { 31 | $table->dropColumn('serial_number_prefix'); 32 | $table->dropColumn('serial_number_serie'); 33 | $table->dropColumn('serial_number_year'); 34 | $table->dropColumn('serial_number_month'); 35 | $table->dropColumn('serial_number_count'); 36 | $table->dropColumn('serial_number_format'); 37 | }); 38 | } 39 | }; 40 | -------------------------------------------------------------------------------- /database/migrations/add_type_column_to_invoices_table.php.stub: -------------------------------------------------------------------------------- 1 | string('type')->default('invoice')->index(); 16 | $table->foreignId('parent_id')->nullable(); 17 | }); 18 | } 19 | 20 | /** 21 | * Reverse the migrations. 22 | */ 23 | public function down(): void 24 | { 25 | Schema::table('invoices', function (Blueprint $table) { 26 | $table->dropColumn('type'); 27 | $table->dropColumn('parent_id'); 28 | }); 29 | } 30 | }; 31 | -------------------------------------------------------------------------------- /database/migrations/create_invoice_items_table.php.stub: -------------------------------------------------------------------------------- 1 | id(); 13 | 14 | $table->integer('quantity')->default(1); 15 | $table->string('quantity_unit')->nullable(); // such as hours, days, number, ... 16 | 17 | /** 18 | * All amount are in the same currency 19 | **/ 20 | $table->bigInteger('unit_price')->nullable(); 21 | $table->string('currency')->nullable(); 22 | 23 | /** 24 | * Store taxes as an amount for each unit 25 | * Total taxes will be computed by multiplying with quantity 26 | * Ideal for complex situation where taxes are combined 27 | **/ 28 | $table->bigInteger('unit_tax')->nullable(); 29 | 30 | /** 31 | * Store taxes as a percentage of the amount 32 | * Ideal for most use common situation such as VAT in Europe 33 | * Will be overriden by unit_tax if unit_tax is defined 34 | **/ 35 | $table->decimal('tax_percentage', 5, 2)->nullable(); 36 | 37 | $table->bigInteger('unit_discount')->nullable(); 38 | $table->decimal('discount_percentage', 5, 2)->nullable(); 39 | 40 | $table->string('label')->nullable(); 41 | $table->text('description')->nullable(); 42 | 43 | $table->foreignId('invoice_id')->index(); 44 | 45 | $table->json('metadata')->nullable(); 46 | 47 | $table->timestamps(); 48 | }); 49 | } 50 | 51 | public function down() 52 | { 53 | Schema::dropIfExists('invoice_items'); 54 | } 55 | }; 56 | -------------------------------------------------------------------------------- /database/migrations/create_invoices_table.php.stub: -------------------------------------------------------------------------------- 1 | id(); 13 | $table->string('serial_number')->unique(); 14 | 15 | $table->text('description')->nullable(); 16 | 17 | /** 18 | * Store the type of tax collected and display the right label 19 | **/ 20 | $table->string('tax_type')->nullable(); 21 | $table->string('tax_exempt')->nullable(); // Tax exemption status like reverse charge 22 | 23 | /** 24 | * In most of the cases, you want to store the logo like it was when the invoice was created. 25 | * That's why we will store it in the database. 26 | **/ 27 | $table->binary('logo')->nullable(); 28 | 29 | $table->string('state')->index(); 30 | $table->dateTime('state_set_at')->nullable(); 31 | 32 | $table->dateTime('due_at')->nullable(); 33 | 34 | /** 35 | * As invoices are a capture of a transaction at a specific moment in time, 36 | * You should store all the information in the model itself and limit dependencies to relations 37 | **/ 38 | $table->json('buyer_information')->nullable(); 39 | $table->json('seller_information')->nullable(); 40 | 41 | /** 42 | * Attach the invoice to a transaction, a mission or any parent 43 | **/ 44 | $table->nullableMorphs('invoiceable'); 45 | 46 | /** 47 | * Typically this relationship will refer to your users, teams or companies 48 | **/ 49 | $table->nullableMorphs('buyer'); 50 | 51 | /** 52 | * If your application is a marketplace with both buyers and seller, you would certainly like to 53 | * attach the invoice to both of them 54 | **/ 55 | $table->nullableMorphs('seller'); 56 | 57 | $table->json('metadata')->nullable(); 58 | 59 | $table->timestamps(); 60 | }); 61 | } 62 | 63 | public function down() 64 | { 65 | Schema::dropIfExists('invoices'); 66 | } 67 | }; 68 | -------------------------------------------------------------------------------- /database/migrations/migrate_serial_number_details_columns_to_invoices_table.php.stub: -------------------------------------------------------------------------------- 1 | chunkById(1_000, function (Collection $invoices) { 19 | /** @var Collection $invoices */ 20 | 21 | foreach ($invoices as $invoice) { 22 | Model::withoutTimestamps( 23 | fn () => $invoice 24 | ->denormalizeSerialNumber() 25 | ->saveQuietly() 26 | ); 27 | } 28 | }); 29 | } 30 | 31 | /** 32 | * Reverse the migrations. 33 | */ 34 | public function down(): void 35 | { 36 | // 37 | } 38 | }; 39 | -------------------------------------------------------------------------------- /pint.json: -------------------------------------------------------------------------------- 1 | { 2 | "preset": "laravel", 3 | "rules": { 4 | "declare_strict_types": true, 5 | "explicit_string_variable": true 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /resources/lang/br/invoice.php: -------------------------------------------------------------------------------- 1 | 'Fatura', 12 | 'serial_number' => 'Número da fatura', 13 | 'due_at' => 'Vencimento em', 14 | 'created_at' => 'Criada em', 15 | 'paid_at' => 'Paga em', 16 | 'description' => 'Descrição', 17 | 'total_amount' => 'Total', 18 | 'tax' => 'Imposto', 19 | 'tax_label' => 'Imposto', 20 | 'subtotal_amount' => 'Subtotal', 21 | 'subtotal_discounted_amount' => 'Subtotal após o desconto', 22 | 'amount' => 'Valor', 23 | 'unit_price' => 'Preço unitário', 24 | 'quantity' => 'Qtd.', 25 | 'discount_name' => 'Desconto', 26 | 27 | 'from' => 'De', 28 | 'to' => 'Para', 29 | 'shipping_to' => 'Entregar em', 30 | 31 | 'states' => [ 32 | 'draft' => 'Rascunho', 33 | 'pending' => 'Pendente', 34 | 'paid' => 'Paga', 35 | 'refunded' => 'Reembolsada', 36 | ], 37 | 38 | 'types' => [ 39 | 'invoice' => 'Fatura', 40 | 'quote' => 'Orçamento', 41 | 'credit' => 'Nota de crédito', 42 | 'proforma' => 'Fatura Proforma', 43 | ], 44 | 45 | 'page' => 'Página', 46 | ]; 47 | -------------------------------------------------------------------------------- /resources/lang/de/invoice.php: -------------------------------------------------------------------------------- 1 | 'Rechnung', 12 | 'serial_number' => 'Rechnung Nr.', 13 | 'due_at' => 'Fällig am', 14 | 'created_at' => 'Rechnungsdatum', 15 | 'paid_at' => 'Bezahlt am', 16 | 'description' => 'Beschreibung', 17 | 'total_amount' => 'Gesamtbetrag', 18 | 'tax' => 'Steuer', 19 | 'tax_label' => 'Steuer', 20 | 'subtotal_amount' => 'Zwischensumme', 21 | 'subtotal_discounted_amount' => 'Zwischensumme nach Rabatt', 22 | 'amount' => 'Betrag', 23 | 'unit_price' => 'Einzelpreis', 24 | 'quantity' => 'Menge', 25 | 'discount_name' => 'Rabatt', 26 | 27 | 'from' => 'Rechnungsersteller', 28 | 'to' => 'Rechnungsempfänger', 29 | 'shipping_to' => 'Versandadresse', 30 | 31 | 'states' => [ 32 | 'draft' => 'Entwurf', 33 | 'pending' => 'Ausstehend', 34 | 'paid' => 'Bezahlt', 35 | 'refunded' => 'Erstattet', 36 | ], 37 | 38 | 'types' => [ 39 | 'invoice' => 'Rechnung', 40 | 'quote' => 'Angebot', 41 | 'credit' => 'Gutschrift', 42 | 'proforma' => 'Proforma-Rechnung', 43 | ], 44 | 45 | 'page' => 'Seite', 46 | ]; 47 | -------------------------------------------------------------------------------- /resources/lang/en/invoice.php: -------------------------------------------------------------------------------- 1 | 'Invoice', 12 | 'serial_number' => 'Invoice number', 13 | 'due_at' => 'Date due', 14 | 'created_at' => 'Date of issue', 15 | 'paid_at' => 'Date of payment', 16 | 'description' => 'Description', 17 | 'total_amount' => 'Total', 18 | 'tax' => 'Tax', 19 | 'tax_label' => 'Tax', 20 | 'subtotal_amount' => 'Subtotal', 21 | 'subtotal_discounted_amount' => 'Subtotal After Discount', 22 | 'amount' => 'Amount', 23 | 'unit_price' => 'Unit price', 24 | 'quantity' => 'Qty', 25 | 'discount_name' => 'Discount', 26 | 27 | 'from' => 'Bill From', 28 | 'to' => 'Bill To', 29 | 'shipping_to' => 'Shipping To', 30 | 31 | 'states' => [ 32 | 'draft' => 'Draft', 33 | 'pending' => 'Pending', 34 | 'paid' => 'Paid', 35 | 'refunded' => 'Refunded', 36 | ], 37 | 38 | 'types' => [ 39 | 'invoice' => 'Invoice', 40 | 'quote' => 'Quote', 41 | 'credit' => 'Credit note', 42 | 'proforma' => 'Proforma invoice', 43 | ], 44 | 45 | 'page' => 'Page', 46 | ]; 47 | -------------------------------------------------------------------------------- /resources/lang/fr/invoice.php: -------------------------------------------------------------------------------- 1 | 'Facture', 12 | 'serial_number' => 'Numéro de facture', 13 | 'due_at' => 'Due le', 14 | 'created_at' => 'Créée le', 15 | 'paid_at' => 'Payée le', 16 | 'description' => 'Description', 17 | 'total_amount' => 'Total', 18 | 'tax' => 'Tax', 19 | 'tax_label' => 'Tax', 20 | 'subtotal_amount' => 'Sous-total', 21 | 'subtotal_discounted_amount' => 'Sous-total après remise', 22 | 'amount' => 'Montant', 23 | 'unit_price' => 'Prix unitaire', 24 | 'quantity' => 'Qté', 25 | 'discount_name' => 'Remise', 26 | 27 | 'from' => 'De', 28 | 'to' => 'Pour', 29 | 'shipping_to' => 'Livré à', 30 | 31 | 'states' => [ 32 | 'draft' => 'Brouillon', 33 | 'pending' => 'En attente', 34 | 'paid' => 'Payée', 35 | 'refunded' => 'Remboursée', 36 | ], 37 | 38 | 'types' => [ 39 | 'invoice' => 'Facture', 40 | 'quote' => 'Devis', 41 | 'credit' => "Facture d'avoir", 42 | 'proforma' => 'Facture Proforma', 43 | ], 44 | 45 | 'page' => 'Page', 46 | ]; 47 | -------------------------------------------------------------------------------- /resources/lang/pt/invoice.php: -------------------------------------------------------------------------------- 1 | 'Fatura', 12 | 'serial_number' => 'Número da fatura', 13 | 'due_at' => 'Vencimento', 14 | 'created_at' => 'Criada em', 15 | 'paid_at' => 'Paga em', 16 | 'description' => 'Descrição', 17 | 'total_amount' => 'Total', 18 | 'tax' => 'Imposto', 19 | 'tax_label' => 'Imposto', 20 | 'subtotal_amount' => 'Subtotal', 21 | 'subtotal_discounted_amount' => 'Subtotal após o desconto', 22 | 'amount' => 'Montante', 23 | 'unit_price' => 'Preço unitário', 24 | 'quantity' => 'Qtd.', 25 | 'discount_name' => 'Desconto', 26 | 27 | 'from' => 'De', 28 | 'to' => 'Para', 29 | 'shipping_to' => 'Entregue em', 30 | 31 | 'states' => [ 32 | 'draft' => 'Rascunho', 33 | 'pending' => 'Pendente', 34 | 'paid' => 'Paga', 35 | 'refunded' => 'Reembolsada', 36 | ], 37 | 38 | 'types' => [ 39 | 'invoice' => 'Fatura', 40 | 'quote' => 'Orçamento', 41 | 'credit' => 'Nota de crédito', 42 | 'proforma' => 'Fatura Proforma', 43 | ], 44 | 45 | 'page' => 'Página', 46 | ]; 47 | -------------------------------------------------------------------------------- /resources/views/default/includes/address.blade.php: -------------------------------------------------------------------------------- 1 | @if ($address->company && $address->name) 2 |

{{ $address->company }}

3 |

{{ $address->name }}

4 | @elseif($address->company) 5 |

{{ $address->company }}

6 | @elseif ($address->name) 7 |

{{ $address->name }}

8 | @endif 9 | 10 | @if (is_array($address->street)) 11 | @foreach ($address->street as $line) 12 |

{{ $line }}

13 | @endforeach 14 | @elseif($address->street) 15 |

{{ $address->street }}

16 | @endif 17 | 18 | @if ($address->city) 19 |

20 | {{ $address->city }}, {{ $address->state }} {{ $address->postal_code }} 21 |

22 | @endif 23 | 24 | @if ($address->country) 25 |

{{ $address->country }}

26 | @endif 27 | 28 | @if ($address->fields) 29 | @foreach ($address->fields as $key => $value) 30 |

31 | @if (is_string($key)) 32 | {{ $key }} 33 | @endif 34 | {{ $value }} 35 |

36 | @endforeach 37 | @endif 38 | -------------------------------------------------------------------------------- /resources/views/default/includes/party.blade.php: -------------------------------------------------------------------------------- 1 | @if ($party->company && $party->name) 2 |

{{ $party->company }}

3 |

{{ $party->name }}

4 | @elseif($party->company) 5 |

{{ $party->company }}

6 | @elseif ($party->name) 7 |

{{ $party->name }}

8 | @endif 9 | 10 | @if ($party->address) 11 | @include('invoices::default.includes.address', [ 12 | 'address' => $party->address, 13 | ]) 14 | @endif 15 | 16 | @if ($party->email) 17 |

{{ $party->email }}

18 | @endif 19 | @if ($party->phone) 20 |

{{ $party->phone }}

21 | @endif 22 | @if ($party->tax_number) 23 |

{{ $party->tax_number }}

24 | @endif 25 | 26 | @if ($party->fields) 27 | @foreach ($party->fields as $key => $value) 28 |

29 | @if (is_string($key)) 30 | {{ $key }} 31 | @endif 32 | {{ $value }} 33 |

34 | @endforeach 35 | @endif 36 | -------------------------------------------------------------------------------- /resources/views/default/invoice.blade.php: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | 5 | 65 | @if ($invoice->logo) 66 | 69 | @endif 70 | 71 | 72 | 73 |
6 |

7 | {{ $invoice->type }} 8 |

9 |

10 | {{ $invoice->state }} 11 |

12 | 13 | 14 | 15 | 16 | 19 | 22 | 23 | 24 | 27 | 30 | 31 | @if ($invoice->due_at) 32 | 33 | 36 | 39 | 40 | @endif 41 | @if ($invoice->paid_at) 42 | 43 | 46 | 49 | 50 | @endif 51 | 52 | @foreach ($invoice->fields as $key => $value) 53 | 54 | 57 | 60 | 61 | @endforeach 62 | 63 |
17 | {{ __('invoices::invoice.serial_number') }} 18 | 20 | {{ $invoice->serial_number }} 21 |
25 | {{ __('invoices::invoice.created_at') }} 26 | 28 | {{ $invoice->created_at?->format(config('invoices.date_format')) }} 29 |
34 | {{ __('invoices::invoice.due_at') }} 35 | 37 | {{ $invoice->due_at->format(config('invoices.date_format')) }} 38 |
44 | {{ __('invoices::invoice.paid_at') }} 45 | 47 | {{ $invoice->paid_at->format(config('invoices.date_format')) }} 48 |
55 | {{ $key }} 56 | 58 | {{ $value }} 59 |
64 |
67 | logo 68 |
74 | 75 | 76 | 77 | 78 | 85 | 92 | 93 | @if ($invoice->buyer->shipping_address) 94 | 106 | @endif 107 | 108 | 109 | 110 |
79 |

{{ __('invoices::invoice.from') }}

80 | 81 | @include('invoices::default.includes.party', [ 82 | 'party' => $invoice->seller, 83 | ]) 84 |
86 |

{{ __('invoices::invoice.to') }}

87 | 88 | @include('invoices::default.includes.party', [ 89 | 'party' => $invoice->buyer, 90 | ]) 91 |
95 | 96 |

97 | {{ __('invoices::invoice.shipping_to') }} 98 |

99 | 100 | @if ($invoice->buyer->shipping_address) 101 | @include('invoices::default.includes.address', [ 102 | 'address' => $invoice->buyer->shipping_address, 103 | ]) 104 | @endif 105 |
111 | 112 | @php 113 | $hasTaxes = $invoice->tax_label || $invoice->totalTaxAmount()->isPositive(); 114 | @endphp 115 | 116 | 117 | 118 | 119 | 122 | 125 | 128 | 129 | @if ($hasTaxes) 130 | 133 | @else 134 | 135 | @endif 136 | 137 | 140 | 141 | 142 | 143 | @foreach ($invoice->items as $item) 144 | 145 | 151 | 154 | 157 | 158 | @if ($hasTaxes) 159 | 173 | @else 174 | 175 | @endif 176 | 177 | 180 | 181 | @endforeach 182 | 183 | 184 | {{-- empty space --}} 185 | 186 | 189 | 192 | 193 | 194 | @if ($invoice->discounts) 195 | @foreach ($invoice->discounts as $discount) 196 | 197 | {{-- empty space --}} 198 | 199 | 205 | 208 | 209 | @endforeach 210 | 211 | 212 | {{-- empty space --}} 213 | 214 | 217 | 220 | 221 | @endif 222 | 223 | 224 | 225 | @if ($hasTaxes) 226 | 227 | {{-- empty space --}} 228 | 229 | 232 | 235 | 236 | @endif 237 | 238 | 239 | {{-- empty space --}} 240 | 241 | 244 | 249 | 250 | 251 |
120 | {{ __('invoices::invoice.description') }} 121 | 123 | {{ __('invoices::invoice.quantity') }} 124 | 126 | {{ __('invoices::invoice.unit_price') }} 127 | 131 | {{ __('invoices::invoice.tax') }} 132 | 138 | {{ __('invoices::invoice.amount') }} 139 |
!$loop->last])> 146 |

{{ $item->label }}

147 | @if ($item->description) 148 |

{{ $item->description }}

149 | @endif 150 |
152 |

{{ $item->quantity }}

153 |
155 |

{{ $item->formatMoney($item->unit_price) }}

156 |
160 | @if ($item->unit_tax !== null && $item->tax_percentage !== null) 161 |

162 | {{ $item->formatMoney($item->unit_tax) }} 163 | ({{ $item->formatPercentage($item->tax_percentage) }}) 164 |

165 | @elseif ($item->unit_tax !== null) 166 |

{{ $item->formatMoney($item->unit_tax) }}

167 | @elseif($item->tax_percentage !== null) 168 |

{{ $item->formatPercentage($item->tax_percentage) }}

169 | @else 170 |

-

171 | @endif 172 |
178 |

{{ $item->formatMoney($item->totalAmount()) }}

179 |
187 | {{ __('invoices::invoice.subtotal_amount') }} 188 | 190 | {{ $invoice->formatMoney($invoice->subTotalAmount()) }} 191 |
200 | {{ __($discount->name) ?? __('invoices::invoice.discount_name') }} 201 | @if ($discount->percent_off) 202 | ({{ $discount->formatPercentage($discount->percent_off) }}) 203 | @endif 204 | 206 | {{ $invoice->formatMoney($discount->computeDiscountAmountOn($invoice->subTotalAmount())?->multipliedBy(-1)) }} 207 |
215 | {{ __('invoices::invoice.subtotal_discounted_amount') }} 216 | 218 | {{ $invoice->formatMoney($invoice->subTotalDiscountedAmount()) }} 219 |
230 | {{ $invoice->tax_label ?? __('invoices::invoice.tax_label') }} 231 | 233 | {{ $invoice->formatMoney($invoice->totalTaxAmount()) }} 234 |
242 | {{ __('invoices::invoice.total_amount') }} 243 | 245 | 246 | {{ $invoice->formatMoney($invoice->totalAmount()) }} 247 | 248 |
252 | 253 | @if ($invoice->description) 254 |

255 | {{ __('invoices::invoice.description') }} 256 |

257 |

{!! $invoice->description !!}

258 | @endif 259 | 260 | @if ($invoice->paymentInstructions) 261 |
262 | @foreach ($invoice->paymentInstructions as $paymentInstruction) 263 |
!$loop->last, 265 | '-ml-12 -mr-12 px-12 bg-zinc-100 py-6', 266 | ])> 267 | 268 | 269 | 270 | 271 | 303 | @if ($paymentInstruction->qrcode) 304 | 307 | @endif 308 | 309 | 310 |
272 | @if ($paymentInstruction->name) 273 |

274 | {!! $paymentInstruction->name !!} 275 |

276 | @endif 277 | 278 | @if ($paymentInstruction->description) 279 |

280 | {!! $paymentInstruction->description !!} 281 |

282 | @endif 283 | 284 | 285 | 286 | @foreach ($paymentInstruction->fields as $key => $value) 287 | 288 | @if (is_string($key)) 289 | 290 | 293 | @else 294 | 297 | @endif 298 | 299 | @endforeach 300 | 301 |
{{ $key }} 291 | {!! $value !!} 292 | 295 | {!! $value !!} 296 |
302 |
305 | 306 |
311 |
312 | @endforeach 313 |
314 | @endif 315 | 316 | 317 |
318 | -------------------------------------------------------------------------------- /resources/views/default/layout.blade.php: -------------------------------------------------------------------------------- 1 | @php 2 | $color = data_get($invoice->templateData, 'color'); 3 | @endphp 4 | 5 | 6 | 7 | 8 | 9 | {{ $invoice->serial_number }} 10 | 11 | 12 | @include('invoices::default.style') 13 | 14 | 15 | 16 | 17 |
18 |
19 |
20 | 21 |
22 | 23 | 24 | 25 | 28 | 31 | 32 | 33 |
26 | {{ $invoice->serial_number }} • {{ $invoice->formatMoney($invoice->totalAmount()) }} 27 | 29 |

{{ __('invoices::invoice.page') }}

30 |
34 |
35 | 36 | @include('invoices::default.invoice') 37 | 38 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /resources/views/default/style.blade.php: -------------------------------------------------------------------------------- 1 | 411 | -------------------------------------------------------------------------------- /src/Casts/Discounts.php: -------------------------------------------------------------------------------- 1 | > 14 | */ 15 | class Discounts implements CastsAttributes 16 | { 17 | /** 18 | * @param array $attributes 19 | * @return InvoiceDiscount[] 20 | */ 21 | public function get(Model $model, string $key, mixed $value, array $attributes): mixed 22 | { 23 | /** 24 | * @var null|array $data 31 | */ 32 | $data = Json::decode($attributes[$key] ?? ''); 33 | 34 | /** @var class-string $class */ 35 | $class = config()->string('invoices.discount_class'); 36 | 37 | if (! is_array($data)) { 38 | return []; 39 | } 40 | 41 | return array_map(fn ($item) => $class::fromArray($item), $data); 42 | } 43 | 44 | /** 45 | * Prepare the given value for storage. 46 | * 47 | * @param null|InvoiceDiscount[] $value 48 | * @param array $attributes 49 | * @return array 50 | */ 51 | public function set(Model $model, string $key, mixed $value, array $attributes): mixed 52 | { 53 | return [ 54 | $key => blank($value) ? null : Json::encode($value), 55 | ]; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/Commands/DenormalizeInvoicesCommand.php: -------------------------------------------------------------------------------- 1 | argument('ids'); 21 | 22 | /** 23 | * @var string $model 24 | */ 25 | $model = config('invoices.model_invoice'); 26 | 27 | /** @var Builder $query */ 28 | $query = $model::query(); 29 | 30 | $query 31 | ->with(['items']) 32 | ->when($ids, fn (Builder $q) => $q->whereIn('id', $ids)); 33 | 34 | /** @var int */ 35 | $total = $query->count(); 36 | 37 | $bar = $this->output->createProgressBar($total); 38 | 39 | $query 40 | ->chunk(2_000, function (Collection $invoices) use ($bar) { 41 | $invoices->each(function (Invoice $invoice) use ($bar) { 42 | $invoice->denormalize()->saveQuietly(); 43 | $bar->advance(); 44 | }); 45 | }); 46 | 47 | $bar->finish(); 48 | 49 | return self::SUCCESS; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/Concerns/FormatForPdf.php: -------------------------------------------------------------------------------- 1 | formatTo($locale ?? app()->getLocale())) : null; 15 | } 16 | 17 | public function formatPercentage(null|float|int $percentage, ?string $locale = null): string|false|null 18 | { 19 | if (! $percentage) { 20 | return null; 21 | } 22 | 23 | $formatter = new NumberFormatter($locale ?? app()->getLocale(), NumberFormatter::PERCENT); 24 | 25 | return $formatter->format(($percentage > 1) ? ($percentage / 100) : $percentage); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Contracts/GenerateSerialNumber.php: -------------------------------------------------------------------------------- 1 | __('invoices::invoice.states.draft'), 18 | self::Pending => __('invoices::invoice.states.pending'), 19 | self::Paid => __('invoices::invoice.states.paid'), 20 | self::Refunded => __('invoices::invoice.states.refunded'), 21 | }; 22 | } 23 | 24 | public function trans(): string 25 | { 26 | return $this->getLabel(); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/Enums/InvoiceType.php: -------------------------------------------------------------------------------- 1 | __('invoices::invoice.types.invoice'), 18 | self::Quote => __('invoices::invoice.types.quote'), 19 | self::Credit => __('invoices::invoice.types.credit'), 20 | self::Proforma => __('invoices::invoice.types.proforma'), 21 | }; 22 | } 23 | 24 | public function trans(): string 25 | { 26 | return $this->getLabel(); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/InvoiceDiscount.php: -------------------------------------------------------------------------------- 1 | 15 | * 16 | * @phpstan-consistent-constructor 17 | */ 18 | class InvoiceDiscount implements Arrayable, JsonSerializable 19 | { 20 | use FormatForPdf; 21 | 22 | public function __construct( 23 | public ?string $name = null, 24 | public ?string $code = null, 25 | public ?Money $amount_off = null, 26 | public ?float $percent_off = null, 27 | ) { 28 | // code... 29 | } 30 | 31 | public function computeDiscountAmountOn(Money $amout): Money 32 | { 33 | if ($this->amount_off) { 34 | return $this->amount_off; 35 | } 36 | 37 | if ($this->percent_off !== null) { 38 | return $amout->multipliedBy($this->percent_off / 100.0, RoundingMode::HALF_CEILING); 39 | } 40 | 41 | return Money::of(0, $amout->getCurrency()); 42 | } 43 | 44 | /** 45 | * @param null|array{ 46 | * name: ?string, 47 | * code: ?string, 48 | * currency: ?string, 49 | * amount_off: ?int, 50 | * percent_off: ?float, 51 | * } $array 52 | */ 53 | public static function fromArray(?array $array): static 54 | { 55 | $currency = $array['currency'] ?? config()->string('invoices.default_currency'); 56 | $amount_off = $array['amount_off'] ?? null; 57 | $percent_off = $array['percent_off'] ?? null; 58 | 59 | return new static( 60 | name: $array['name'] ?? '', 61 | code: $array['code'] ?? '', 62 | amount_off: $amount_off ? Money::ofMinor($amount_off, $currency) : null, 63 | percent_off: $percent_off ? (float) $percent_off : null 64 | ); 65 | } 66 | 67 | /** 68 | * @return array{ 69 | * name: ?string, 70 | * code: ?string, 71 | * amount_off: ?int, 72 | * currency: ?string, 73 | * percent_off: ?float, 74 | * } 75 | */ 76 | public function toArray(): array 77 | { 78 | return [ 79 | 'name' => $this->name, 80 | 'code' => $this->code, 81 | 'amount_off' => $this->amount_off?->getMinorAmount()->toInt(), 82 | 'currency' => $this->amount_off?->getCurrency()->getCurrencyCode(), 83 | 'percent_off' => $this->percent_off, 84 | ]; 85 | } 86 | 87 | /** 88 | * @return array{ 89 | * name: ?string, 90 | * code: ?string, 91 | * amount_off: ?int, 92 | * currency: ?string, 93 | * percent_off: ?float, 94 | * } 95 | */ 96 | public function jsonSerialize(): array 97 | { 98 | return $this->toArray(); 99 | } 100 | 101 | /** 102 | * @return array{ 103 | * name: ?string, 104 | * code: ?string, 105 | * amount_off: ?int, 106 | * currency: ?string, 107 | * percent_off: ?float, 108 | * } 109 | */ 110 | public function toLivewire() 111 | { 112 | return $this->toArray(); 113 | } 114 | 115 | /** 116 | * @param ?array{ 117 | * name: ?string, 118 | * code: ?string, 119 | * amount_off: ?int, 120 | * currency: ?string, 121 | * percent_off: ?float, 122 | * } $value 123 | */ 124 | // @phpstan-ignore-next-line 125 | public static function fromLivewire($value) 126 | { 127 | return static::fromArray($value); 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /src/InvoiceServiceProvider.php: -------------------------------------------------------------------------------- 1 | name('laravel-invoices') 24 | ->hasConfigFile() 25 | ->hasViews() 26 | ->hasTranslations() 27 | ->hasCommand(DenormalizeInvoicesCommand::class) 28 | ->hasMigration('create_invoices_table') 29 | ->hasMigration('create_invoice_items_table') 30 | ->hasMigration('add_discounts_column_to_invoices_table') 31 | ->hasMigration('add_type_column_to_invoices_table') 32 | ->hasMigration('add_denormalized_columns_to_invoices_table') 33 | ->hasMigration('add_serial_number_details_columns_to_invoices_table') 34 | ->hasMigration('migrate_serial_number_details_columns_to_invoices_table'); 35 | } 36 | 37 | public static function getSerialNumberPrefixConfiguration(null|string|InvoiceType $type): ?string 38 | { 39 | $value = $type instanceof InvoiceType ? $type->value : $type; 40 | 41 | /** @var string|array $prefixes */ 42 | $prefixes = config('invoices.serial_number.prefix', ''); 43 | 44 | if (is_string($prefixes)) { 45 | return $prefixes; 46 | } 47 | 48 | return $prefixes[$value] ?? null; 49 | } 50 | 51 | public static function getSerialNumberFormatConfiguration(null|string|InvoiceType $type): string 52 | { 53 | $value = $type instanceof InvoiceType ? $type->value : $type; 54 | 55 | /** @var string|array $formats */ 56 | $formats = config('invoices.serial_number.format') ?? ''; 57 | 58 | if (is_string($formats)) { 59 | return $formats; 60 | } 61 | 62 | /** @var ?string $format */ 63 | $format = $formats[$value] ?? null; 64 | 65 | if (! $format) { 66 | throw new Exception("No serial number format defined in config for type: {$value}."); 67 | } 68 | 69 | return $format; 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/Models/Invoice.php: -------------------------------------------------------------------------------- 1 | $seller_information 43 | * @property ?array $buyer_information 44 | * @property ?Carbon $due_at 45 | * @property ?string $tax_type 46 | * @property ?string $tax_exempt 47 | * @property Collection $items 48 | * @property ?Model $buyer 49 | * @property ?int $buyer_id 50 | * @property ?string $buyer_type 51 | * @property ?Model $seller 52 | * @property ?int $seller_id 53 | * @property ?string $seller_type 54 | * @property ?Model $invoiceable 55 | * @property ?int $invoiceable_id 56 | * @property ?string $invoiceable_type 57 | * @property Carbon $created_at 58 | * @property Carbon $updated_at 59 | * @property InvoiceDiscount[] $discounts 60 | * @property ?array $metadata 61 | * @property ?Money $subtotal_amount 62 | * @property ?Money $discount_amount 63 | * @property ?Money $tax_amount 64 | * @property ?Money $total_amount 65 | * @property ?string $currency 66 | * @property string $serial_number 67 | * @property string $serial_number_format 68 | * @property ?string $serial_number_prefix 69 | * @property ?int $serial_number_serie 70 | * @property ?int $serial_number_year 71 | * @property ?int $serial_number_month 72 | * @property int $serial_number_count 73 | */ 74 | class Invoice extends Model implements Attachable 75 | { 76 | /** 77 | * @use HasFactory 78 | */ 79 | use HasFactory; 80 | 81 | protected $attributes = [ 82 | 'type' => InvoiceType::Invoice->value, 83 | 'state' => InvoiceState::Draft->value, 84 | ]; 85 | 86 | protected $guarded = []; 87 | 88 | /** 89 | * @return array 90 | */ 91 | protected function casts(): array 92 | { 93 | return [ 94 | 'state_set_at' => 'datetime', 95 | 'due_at' => 'datetime', 96 | 'seller_information' => 'array', 97 | 'buyer_information' => 'array', 98 | 'metadata' => 'array', 99 | 'discounts' => Discounts::class, 100 | 'subtotal_amount' => MoneyCast::class.':currency', 101 | 'discount_amount' => MoneyCast::class.':currency', 102 | 'tax_amount' => MoneyCast::class.':currency', 103 | 'total_amount' => MoneyCast::class.':currency', 104 | ]; 105 | } 106 | 107 | public static function booted() 108 | { 109 | static::creating(function (Invoice $invoice) { 110 | if ( 111 | config('invoices.serial_number.auto_generate') && 112 | blank($invoice->serial_number) 113 | ) { 114 | $invoice->generateSerialNumber(); 115 | } else { 116 | $invoice->denormalizeSerialNumber(); 117 | } 118 | }); 119 | 120 | static::updating(function (Invoice $invoice) { 121 | $invoice->denormalize(); 122 | }); 123 | 124 | static::deleting(function (Invoice $invoice) { 125 | if (config('invoices.cascade_invoice_delete_to_invoice_items')) { 126 | $invoice->items()->delete(); 127 | } 128 | }); 129 | } 130 | 131 | /** 132 | * @return HasMany 133 | */ 134 | public function items(): HasMany 135 | { 136 | /** @var class-string */ 137 | $model = config()->string('invoices.model_invoice_item'); 138 | 139 | return $this->hasMany($model); 140 | } 141 | 142 | /** 143 | * Any model that is the "parent" of the invoice like a Mission, a Transaction, ... 144 | * 145 | * @return MorphTo 146 | **/ 147 | public function invoiceable(): MorphTo 148 | { 149 | return $this->morphTo(); 150 | } 151 | 152 | /** 153 | * Typically, the buyer is one of your users, teams or any other model. 154 | * When editing your invoice, you should not rely on the information of this relation as they can change in time and impact all buyer's invoices. 155 | * Instead you should store the buyer information in his property on the invoice creation/validation. 156 | * 157 | * @return MorphTo 158 | */ 159 | public function buyer(): MorphTo 160 | { 161 | return $this->morphTo(); 162 | } 163 | 164 | /** 165 | * In case, your application is a marketplace, you would also attach the invoice to the seller 166 | * When editing your invoice, you should not rely on the information of this relation as they can change in time and impact all seller's invoices. 167 | * Instead you should store the seller information in his property on the invoice creation/validation. 168 | * 169 | * @return MorphTo 170 | */ 171 | public function seller(): MorphTo 172 | { 173 | return $this->morphTo(); 174 | } 175 | 176 | /** 177 | * Invoice can be attached with another one 178 | * A Quote or a Credit can have another Invoice as parent. 179 | * Ex: $invoice = $quote->parent and $quote = $invoice->quote 180 | * 181 | * @return BelongsTo 182 | */ 183 | public function parent(): BelongsTo 184 | { 185 | return $this->belongsTo(Invoice::class); 186 | } 187 | 188 | /** 189 | * @return HasOne 190 | */ 191 | public function quote(): HasOne 192 | { 193 | return $this->hasOne(Invoice::class, 'parent_id')->where('type', InvoiceType::Quote); 194 | } 195 | 196 | /** 197 | * @return HasOne 198 | */ 199 | public function credit(): HasOne 200 | { 201 | return $this->hasOne(Invoice::class, 'parent_id')->where('type', InvoiceType::Credit); 202 | } 203 | 204 | /** 205 | * Generates a new serial number for an invoice. 206 | * 207 | * The count value for the new serial number is based on the previous serial number. 208 | * This function can be customized to determine what constitutes the previous invoice. 209 | */ 210 | public function getPreviousInvoice(): ?static 211 | { 212 | /** @var ?static $invoice */ 213 | $invoice = static::query() 214 | ->withoutGlobalScopes() 215 | ->where('serial_number_prefix', $this->serial_number_prefix) 216 | ->where('serial_number_serie', $this->serial_number_serie) 217 | ->where('serial_number_year', $this->serial_number_year) 218 | ->where('serial_number_month', $this->serial_number_month) 219 | ->latest('serial_number_count') 220 | ->first(); 221 | 222 | return $invoice; 223 | } 224 | 225 | public function setSerialNumberPrefix( 226 | ?string $value = null, 227 | bool $throw = true, 228 | ): static { 229 | 230 | if ($value === null) { 231 | $this->serial_number_prefix = null; 232 | } elseif ($length = mb_substr_count($this->serial_number_format, 'P')) { 233 | $this->serial_number_prefix = mb_substr($value, -$length); 234 | } elseif ($throw) { 235 | throw new Exception('The Serial Number Format does not contain a prefix.'); 236 | } 237 | 238 | return $this; 239 | } 240 | 241 | public function setSerialNumberSerie( 242 | null|int|string $value = null, 243 | bool $throw = true, 244 | ): static { 245 | 246 | if ($value === null) { 247 | $this->serial_number_serie = null; 248 | } elseif ($length = mb_substr_count($this->serial_number_format, 'S')) { 249 | $this->serial_number_serie = (int) mb_substr((string) $value, -$length); 250 | } elseif ($throw) { 251 | throw new Exception('The Serial Number Format does not contain a serie.'); 252 | } 253 | 254 | return $this; 255 | } 256 | 257 | public function setSerialNumberYear( 258 | null|int|string $value = null, 259 | bool $throw = true, 260 | ): static { 261 | 262 | if ($value === null) { 263 | $this->serial_number_year = null; 264 | } elseif ($length = mb_substr_count($this->serial_number_format, 'Y')) { 265 | $this->serial_number_year = (int) mb_substr((string) $value, -$length); 266 | } elseif ($throw) { 267 | throw new Exception('The Serial Number Format does not contain a year.'); 268 | } 269 | 270 | return $this; 271 | } 272 | 273 | public function setSerialNumberMonth( 274 | null|int|string $value = null, 275 | bool $throw = true, 276 | ): static { 277 | 278 | if ($value === null) { 279 | $this->serial_number_month = null; 280 | } elseif ($length = mb_substr_count($this->serial_number_format, 'M')) { 281 | $this->serial_number_month = (int) mb_substr((string) $value, -$length); 282 | } elseif ($throw) { 283 | throw new Exception('The Serial Number Format does not contain a month.'); 284 | } 285 | 286 | return $this; 287 | } 288 | 289 | public function configureSerialNumber( 290 | ?string $format = null, 291 | ?string $prefix = null, 292 | string|int|null $serie = null, 293 | string|int|null $year = null, 294 | string|int|null $month = null, 295 | bool $throw = false, 296 | ): static { 297 | $this->serial_number_format = $format ?? $this->serial_number_format ?? InvoiceServiceProvider::getSerialNumberFormatConfiguration($this->type); 298 | 299 | return $this 300 | ->setSerialNumberPrefix($prefix, $throw) 301 | ->setSerialNumberSerie($serie, $throw) 302 | ->setSerialNumberYear($year, $throw) 303 | ->setSerialNumberMonth($month, $throw); 304 | } 305 | 306 | public function generateSerialNumber(): static 307 | { 308 | $this->configureSerialNumber( 309 | format: $this->serial_number_format, 310 | prefix: $this->serial_number_prefix ?? InvoiceServiceProvider::getSerialNumberPrefixConfiguration($this->type), 311 | serie: $this->serial_number_serie, 312 | year: $this->serial_number_year ?? now()->format('Y'), 313 | month: $this->serial_number_month ?? now()->format('m'), 314 | ); 315 | 316 | $generator = new SerialNumberGenerator($this->serial_number_format); 317 | 318 | $previousCount = (int) $this->getPreviousInvoice()?->serial_number_count; 319 | 320 | $this->serial_number = $generator->generate( 321 | prefix: $this->serial_number_prefix, 322 | serie: $this->serial_number_serie, 323 | year: $this->serial_number_year, 324 | month: $this->serial_number_month, 325 | count: $previousCount + 1 326 | ); 327 | 328 | $this->denormalizeSerialNumber(); 329 | 330 | return $this; 331 | } 332 | 333 | /** 334 | * @return array{ 'prefix': ?string, 'serie': ?int, 'month': ?int, 'year': ?int, 'count': ?int} 335 | */ 336 | public function parseSerialNumber(): array 337 | { 338 | $format = $this->serial_number_format ?? InvoiceServiceProvider::getSerialNumberFormatConfiguration($this->type); 339 | 340 | $generator = new SerialNumberGenerator($format); 341 | 342 | return $generator->parse($this->serial_number); 343 | } 344 | 345 | public function denormalizeSerialNumber(): static 346 | { 347 | $this->serial_number_format ??= InvoiceServiceProvider::getSerialNumberFormatConfiguration($this->type); 348 | 349 | $values = $this->parseSerialNumber(); 350 | 351 | $this->serial_number_prefix = $values['prefix']; 352 | $this->serial_number_serie = $values['serie']; 353 | $this->serial_number_year = $values['year']; 354 | $this->serial_number_month = $values['month']; 355 | $this->serial_number_count = (int) $values['count']; 356 | 357 | return $this; 358 | } 359 | 360 | public function getTaxLabel(): ?string 361 | { 362 | return null; 363 | } 364 | 365 | /** 366 | * @return InvoiceDiscount[] 367 | */ 368 | public function getDiscounts(): array 369 | { 370 | return $this->discounts; 371 | } 372 | 373 | /** 374 | * Denormalize amounts computed from items to the invoice table 375 | * Allowing easier query 376 | */ 377 | public function denormalize(): static 378 | { 379 | $pdfInvoice = $this->toPdfInvoice(); 380 | $this->currency = $pdfInvoice->getCurrency(); 381 | $this->subtotal_amount = $pdfInvoice->subTotalAmount(); 382 | $this->discount_amount = $pdfInvoice->totalDiscountAmount(); 383 | $this->tax_amount = $pdfInvoice->totalTaxAmount(); 384 | $this->total_amount = $pdfInvoice->totalAmount(); 385 | 386 | return $this; 387 | } 388 | 389 | /** 390 | * @param Builder $query 391 | * @return Builder 392 | */ 393 | public function scopeInvoice(Builder $query): Builder 394 | { 395 | return $query->where('type', InvoiceType::Invoice); 396 | } 397 | 398 | /** 399 | * @param Builder $query 400 | * @return Builder 401 | */ 402 | public function scopeCredit(Builder $query): Builder 403 | { 404 | return $query->where('type', InvoiceType::Credit); 405 | } 406 | 407 | /** 408 | * @param Builder $query 409 | * @return Builder 410 | */ 411 | public function scopeQuote(Builder $query): Builder 412 | { 413 | return $query->where('type', InvoiceType::Quote); 414 | } 415 | 416 | /** 417 | * @param Builder $query 418 | * @return Builder 419 | */ 420 | public function scopePaid(Builder $query): Builder 421 | { 422 | return $query->where('state', InvoiceState::Paid); 423 | } 424 | 425 | /** 426 | * @param Builder $query 427 | * @return Builder 428 | */ 429 | public function scopeRefunded(Builder $query): Builder 430 | { 431 | return $query->where('state', InvoiceState::Refunded); 432 | } 433 | 434 | /** 435 | * @param Builder $query 436 | * @return Builder 437 | */ 438 | public function scopeDraft(Builder $query): Builder 439 | { 440 | return $query->where('state', InvoiceState::Draft); 441 | } 442 | 443 | /** 444 | * @param Builder $query 445 | * @return Builder 446 | */ 447 | public function scopePending(Builder $query): Builder 448 | { 449 | return $query->where('state', InvoiceState::Pending); 450 | } 451 | 452 | /** 453 | * Get the attachable representation of the model. 454 | */ 455 | public function toMailAttachment(): Attachment 456 | { 457 | return $this->toPdfInvoice()->toMailAttachment(); 458 | } 459 | 460 | /** 461 | * @return string|null A base64 encoded data url or a path to a local file 462 | */ 463 | public function getLogo(): ?string 464 | { 465 | return null; 466 | } 467 | 468 | public function getType(): string|InvoiceType 469 | { 470 | return InvoiceType::tryFrom($this->type) ?? $this->type; 471 | } 472 | 473 | public function getState(): string|InvoiceState 474 | { 475 | return InvoiceState::tryFrom($this->state) ?? $this->state; 476 | } 477 | 478 | public function toPdfInvoice(): PdfInvoice 479 | { 480 | return new PdfInvoice( 481 | type: $this->getType(), 482 | state: $this->getState(), 483 | serial_number: $this->serial_number, 484 | due_at: $this->due_at, 485 | created_at: $this->created_at, 486 | buyer: Buyer::fromArray($this->buyer_information ?? []), 487 | seller: Seller::fromArray($this->seller_information ?? []), 488 | description: $this->description, 489 | items: $this->items->map(fn ($item) => $item->toPdfInvoiceItem())->all(), 490 | tax_label: $this->getTaxLabel(), 491 | discounts: $this->getDiscounts(), 492 | logo: $this->getLogo(), 493 | ); 494 | } 495 | } 496 | -------------------------------------------------------------------------------- /src/Models/InvoiceItem.php: -------------------------------------------------------------------------------- 1 | $metadata 29 | * @property int $invoice_id 30 | * @property Carbon $created_at 31 | * @property Carbon $updated_at 32 | */ 33 | class InvoiceItem extends Model 34 | { 35 | /** 36 | * @use HasFactory 37 | */ 38 | use HasFactory; 39 | 40 | protected $guarded = []; 41 | 42 | /** 43 | * @return array 44 | */ 45 | protected function casts(): array 46 | { 47 | return [ 48 | 'unit_price' => MoneyCast::class.':currency', 49 | 'unit_tax' => MoneyCast::class.':currency', 50 | 'metadata' => AsArrayObject::class, 51 | 'tax_percentage' => 'float', 52 | ]; 53 | } 54 | 55 | /** 56 | * @return BelongsTo 57 | */ 58 | public function invoice(): BelongsTo 59 | { 60 | /** @var class-string */ 61 | $model = config()->string('invoices.model_invoice'); 62 | 63 | return $this->belongsTo($model); 64 | } 65 | 66 | public function toPdfInvoiceItem(): PdfInvoiceItem 67 | { 68 | return new PdfInvoiceItem( 69 | label: $this->label, 70 | quantity: $this->quantity ?? 1, 71 | quantity_unit: $this->quantity_unit, 72 | description: $this->description, 73 | unit_price: $this->unit_price, 74 | unit_tax: $this->unit_tax, 75 | tax_percentage: $this->tax_percentage, 76 | currency: $this->currency, 77 | ); 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/Pdf/PdfInvoice.php: -------------------------------------------------------------------------------- 1 | $fields Additianl fileds to display in the header 36 | * @param PdfInvoiceItem[] $items 37 | * @param InvoiceDiscount[] $discounts 38 | * @param PaymentInstruction[] $paymentInstructions 39 | * @param ?string $logo A local file path. The file must be accessible using file_get_contents. 40 | * @param array $templateData 41 | */ 42 | public function __construct( 43 | InvoiceType|string $type = InvoiceType::Invoice, 44 | InvoiceState|string $state = InvoiceState::Draft, 45 | public ?string $serial_number = null, 46 | public ?Carbon $created_at = null, 47 | public ?Carbon $due_at = null, 48 | public ?Carbon $paid_at = null, 49 | public array $fields = [], 50 | 51 | public Seller $seller = new Seller, 52 | public Buyer $buyer = new Buyer, 53 | public array $items = [], 54 | 55 | public ?string $description = null, 56 | public ?string $tax_label = null, 57 | public array $discounts = [], 58 | 59 | public array $paymentInstructions = [], 60 | 61 | ?string $template = null, 62 | public array $templateData = [], 63 | 64 | public ?string $logo = null, 65 | ) { 66 | $this->type = $type instanceof InvoiceType ? $type->getLabel() : $type; 67 | $this->state = $state instanceof InvoiceState ? $state->getLabel() : $state; 68 | 69 | // @phpstan-ignore-next-line 70 | $this->logo = $logo ?? config('invoices.pdf.logo') ?? config('invoices.default_logo'); 71 | // @phpstan-ignore-next-line 72 | $this->template = sprintf('invoices::%s', $template ?? config('invoices.pdf.template') ?? config('invoices.default_template')); 73 | // @phpstan-ignore-next-line 74 | $this->templateData = config('invoices.pdf.template_data') ?? []; 75 | } 76 | 77 | public function getFilename(): string 78 | { 79 | return str($this->serial_number) 80 | ->replace(['/', '\\'], '_') 81 | ->append('.pdf') 82 | ->value(); 83 | } 84 | 85 | public function getCurrency(): string 86 | { 87 | /** @var ?PdfInvoiceItem $firstItem */ 88 | $firstItem = Arr::first($this->items); 89 | 90 | return $firstItem?->currency->getCurrencyCode() ?? config()->string('invoices.default_currency'); 91 | } 92 | 93 | /** 94 | * Before discount and taxes 95 | */ 96 | public function subTotalAmount(): Money 97 | { 98 | return array_reduce( 99 | $this->items, 100 | fn ($total, $item) => $total->plus($item->subTotalAmount()), 101 | Money::of(0, $this->getCurrency()) 102 | ); 103 | } 104 | 105 | public function totalDiscountAmount(): Money 106 | { 107 | if (! $this->discounts) { 108 | return Money::of(0, $this->getCurrency()); 109 | } 110 | 111 | $amount = $this->subTotalAmount(); 112 | 113 | return array_reduce( 114 | $this->discounts, 115 | function ($total, $discount) use ($amount) { 116 | return $total->plus($discount->computeDiscountAmountOn($amount)); 117 | }, 118 | Money::of(0, $amount->getCurrency()) 119 | ); 120 | } 121 | 122 | public function subTotalDiscountedAmount(): Money 123 | { 124 | return $this->subTotalAmount()->minus($this->totalDiscountAmount()); 125 | } 126 | 127 | /** 128 | * After discount and taxes 129 | */ 130 | public function totalTaxAmount(): Money 131 | { 132 | $totalDiscount = $this->totalDiscountAmount(); 133 | 134 | /** 135 | * Taxes must be calculated on the discounted subtotal. 136 | * Since discounts apply at the invoice level and taxes at the item level, 137 | * we allocate the discount across items before computing taxes. 138 | */ 139 | $allocatedDiscounts = $totalDiscount->allocate(...array_map( 140 | fn ($item) => $item->subTotalAmount()->abs()->getMinorAmount()->toInt(), 141 | $this->items 142 | )); 143 | 144 | $totalTaxAmount = Money::of(0, $this->getCurrency()); 145 | 146 | foreach ($this->items as $index => $item) { 147 | 148 | if ($item->unit_tax) { 149 | /** 150 | * When unit_tax is defined, the amount is considered right 151 | * and the discount is not apply 152 | */ 153 | $itemTaxAmount = $item->unit_tax->multipliedBy($item->quantity); 154 | } elseif ($item->tax_percentage) { 155 | $itemDiscount = $allocatedDiscounts[$index]; 156 | 157 | $itemTaxAmount = $item->subTotalAmount() 158 | ->minus($itemDiscount) 159 | ->multipliedBy($item->tax_percentage / 100.0, roundingMode: RoundingMode::HALF_EVEN); 160 | } else { 161 | $itemTaxAmount = Money::of(0, $totalTaxAmount->getCurrency()); 162 | } 163 | 164 | $totalTaxAmount = $totalTaxAmount->plus($itemTaxAmount); 165 | 166 | } 167 | 168 | return $totalTaxAmount; 169 | } 170 | 171 | public function totalAmount(): Money 172 | { 173 | return $this->subTotalDiscountedAmount()->plus($this->totalTaxAmount()); 174 | } 175 | 176 | /** 177 | * @param array $options 178 | * @param array{ size?: string, orientation?: string } $paper 179 | */ 180 | public function pdf(array $options = [], array $paper = []): Dompdf 181 | { 182 | 183 | $pdf = new Dompdf(array_merge( 184 | // @phpstan-ignore-next-line 185 | config('invoices.pdf.options') ?? config('invoices.pdf_options') ?? [], 186 | $options, 187 | )); 188 | 189 | $pdf->setPaper( 190 | // @phpstan-ignore-next-line 191 | $paper['size'] ?? config('invoices.pdf.paper.size') ?? config('invoices.pdf.paper.paper') ?? config('invoices.paper_options.paper') ?? 'a4', 192 | // @phpstan-ignore-next-line 193 | $paper['orientation'] ?? config('invoices.pdf.paper.orientation') ?? config('invoices.paper_options.orientation') ?? 'portrait' 194 | ); 195 | 196 | $html = $this->view()->render(); 197 | 198 | $pdf->loadHtml($html); 199 | 200 | return $pdf; 201 | } 202 | 203 | public function getPdfOutput(): ?string 204 | { 205 | $pdf = $this->pdf(); 206 | 207 | $pdf->render(); 208 | 209 | return $pdf->output(); 210 | } 211 | 212 | public function stream(?string $filename = null): Response 213 | { 214 | $filename ??= $this->getFilename(); 215 | 216 | $output = $this->getPdfOutput(); 217 | 218 | return new Response($output, 200, [ 219 | 'Content-Type' => 'application/pdf', 220 | 'Content-Disposition' => HeaderUtils::makeDisposition('inline', $filename, Str::ascii($filename)), 221 | ]); 222 | } 223 | 224 | public function download(?string $filename = null): Response 225 | { 226 | $filename ??= $this->getFilename(); 227 | 228 | $output = $this->getPdfOutput(); 229 | 230 | return new Response($output, 200, [ 231 | 'Content-Type' => 'application/pdf', 232 | 'Content-Disposition' => HeaderUtils::makeDisposition('attachment', $filename, Str::ascii($filename)), 233 | 'Content-Length' => strlen($output ?? ''), 234 | ]); 235 | } 236 | 237 | public function toMailAttachment(?string $filename = null): Attachment 238 | { 239 | return Attachment::fromData(fn () => $this->getPdfOutput()) 240 | ->as($filename ?? $this->getFilename()) 241 | ->withMime('application/pdf'); 242 | } 243 | 244 | public function view(): \Illuminate\Contracts\View\View 245 | { 246 | // @phpstan-ignore-next-line 247 | return view($this->template, ['invoice' => $this]); 248 | } 249 | } 250 | -------------------------------------------------------------------------------- /src/Pdf/PdfInvoiceItem.php: -------------------------------------------------------------------------------- 1 | currency = $currency; 31 | } elseif ($currency) { 32 | $this->currency = Currency::of($currency); 33 | } elseif ($unit_price) { 34 | $this->currency = $unit_price->getCurrency(); 35 | } elseif ($unit_tax) { 36 | $this->currency = $unit_tax->getCurrency(); 37 | } else { 38 | $this->currency = Currency::of(config()->string('invoices.default_currency')); 39 | } 40 | 41 | if ($tax_percentage && ($tax_percentage > 100 || $tax_percentage < 0)) { 42 | throw new Exception("The tax_percentage parameter must be an integer between 0 and 100. {$tax_percentage} given."); 43 | } 44 | } 45 | 46 | public function subTotalAmount(): Money 47 | { 48 | if ($this->unit_price === null) { 49 | return Money::ofMinor(0, $this->currency); 50 | } 51 | 52 | return $this->unit_price->multipliedBy($this->quantity, RoundingMode::HALF_EVEN); 53 | } 54 | 55 | public function totalTaxAmount(): Money 56 | { 57 | if ($this->unit_tax) { 58 | return $this->unit_tax->multipliedBy($this->quantity, RoundingMode::HALF_EVEN); 59 | } 60 | 61 | if ($this->tax_percentage) { 62 | return $this->subTotalAmount()->multipliedBy($this->tax_percentage / 100.0, roundingMode: RoundingMode::HALF_EVEN); 63 | } 64 | 65 | return Money::ofMinor(0, $this->currency); 66 | } 67 | 68 | public function totalAmount(): Money 69 | { 70 | return $this->subTotalAmount()->plus($this->totalTaxAmount()); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/SerialNumberGenerator.php: -------------------------------------------------------------------------------- 1 | function ($matches) use ($serie) { 33 | $slot = (string) ($matches[0] ?? ''); 34 | $slotLength = mb_strlen($slot); 35 | $valueLength = mb_strlen($serie); 36 | 37 | if ($slotLength < 1) { 38 | return ''; 39 | } 40 | 41 | if (! $serie) { 42 | throw new \Exception("The serial Number format includes a {$slotLength} long Serie (S), but no serie has been specified."); 43 | } 44 | 45 | if ($valueLength > $slotLength) { 46 | throw new \Exception("The Serial Number can't be formatted: Serie ({$serie}) is {$valueLength} digits long while the format has only {$slotLength} slots."); 47 | } 48 | 49 | return mb_str_pad( 50 | $serie, 51 | $slotLength, 52 | '0', 53 | STR_PAD_LEFT 54 | ); 55 | }, 56 | '/M+/' => function ($matches) use ($month) { 57 | $slot = (string) ($matches[0] ?? ''); 58 | $slotLength = mb_strlen($slot); 59 | 60 | if ($slotLength < 1) { 61 | return ''; 62 | } 63 | 64 | if (! $month) { 65 | throw new \Exception("The serial Number format includes a {$slotLength} long Month (M), but no month has been specified."); 66 | } 67 | 68 | return mb_str_pad( 69 | mb_substr($month, -$slotLength), 70 | $slotLength, 71 | '0', 72 | STR_PAD_LEFT 73 | ); 74 | }, 75 | '/Y+/' => function ($matches) use ($year) { 76 | $slot = (string) ($matches[0] ?? ''); 77 | $slotLength = mb_strlen($slot); 78 | 79 | if ($slotLength < 1) { 80 | return ''; 81 | } 82 | 83 | if (! $year) { 84 | throw new \Exception("The serial Number format includes a {$slotLength} long Year (Y), but no year has been specified."); 85 | } 86 | 87 | return mb_str_pad( 88 | mb_substr($year, -$slotLength), 89 | $slotLength, 90 | '0', 91 | STR_PAD_LEFT 92 | ); 93 | }, 94 | '/C+/' => function ($matches) use ($count) { 95 | $slot = (string) ($matches[0] ?? ''); 96 | $slotLength = mb_strlen($slot); 97 | $valueLength = mb_strlen($count); 98 | 99 | if ($slotLength < 1) { 100 | return ''; 101 | } 102 | 103 | if (! $count) { 104 | throw new \Exception("The serial Number format includes a {$slotLength} long Count (C), but no count has been specified."); 105 | } 106 | 107 | if ($valueLength > $slotLength) { 108 | throw new \Exception("The Serial Number can't be formatted: Count ({$count}) is {$valueLength} digits long while the format has only {$slotLength} slots."); 109 | } 110 | 111 | return mb_str_pad( 112 | $count, 113 | $slotLength, 114 | '0', 115 | STR_PAD_LEFT 116 | ); 117 | }, 118 | // Must be kept last to avoid interfering with other callbacks 119 | '/P+/' => function ($matches) use ($prefix) { 120 | $slot = (string) ($matches[0] ?? ''); 121 | $slotLength = mb_strlen($slot); 122 | $valueLength = mb_strlen($prefix); 123 | 124 | if ($slotLength < 1) { 125 | return ''; 126 | } 127 | 128 | if (! $prefix) { 129 | throw new \Exception("The serial Number format includes a {$slotLength} long Prefix (S), but no prefix has been specified."); 130 | } 131 | 132 | if ($valueLength > $slotLength) { 133 | throw new \Exception("The Serial Number can't be formatted: Prefix ({$prefix}) is {$valueLength} digits long while the format has only {$slotLength} slots."); 134 | } 135 | 136 | return mb_str_pad( 137 | $prefix, 138 | $slotLength, 139 | '0', 140 | STR_PAD_LEFT 141 | ); 142 | }, 143 | ], 144 | $this->format 145 | ); 146 | 147 | return is_string($value) ? $value : ''; 148 | } 149 | 150 | /** 151 | * @return array{ 'prefix': ?string, 'serie': ?int, 'month': ?int, 'year': ?int, 'count': ?int} 152 | */ 153 | public function parse(string $serialNumber): array 154 | { 155 | preg_match("/{$this->formatToRegex()}/", $serialNumber, $matches); 156 | 157 | return [ 158 | 'prefix' => ($prefix = $matches['prefix'] ?? null) ? $prefix : null, 159 | 'serie' => ($serie = $matches['serie'] ?? null) ? (int) $serie : null, 160 | 'month' => ($month = $matches['month'] ?? null) ? (int) $month : null, 161 | 'year' => ($year = $matches['year'] ?? null) ? (int) $year : null, 162 | 'count' => ($count = $matches['count'] ?? null) ? (int) $count : null, 163 | ]; 164 | } 165 | 166 | protected function formatToRegex(): string 167 | { 168 | $value = preg_replace_callback_array( 169 | [ 170 | '/[^\w\s]/i' => fn ($matches) => "\\{$matches[0]}", 171 | '/P+/' => fn ($matches) => ($length = mb_strlen($matches[0])) ? "(?[a-zA-Z]{{$length}})" : '', 172 | '/S+/' => fn ($matches) => ($length = mb_strlen($matches[0])) ? "(?\d{{$length}})" : '', 173 | '/M+/' => fn ($matches) => ($length = mb_strlen($matches[0])) ? "(?\d{{$length}})" : '', 174 | '/Y+/' => fn ($matches) => ($length = mb_strlen($matches[0])) ? "(?\d{{$length}})" : '', 175 | '/C+/' => fn ($matches) => ($length = mb_strlen($matches[0])) ? "(?\d{{$length}})" : '', 176 | ], 177 | $this->format 178 | ); 179 | 180 | return is_string($value) ? $value : ''; 181 | } 182 | } 183 | -------------------------------------------------------------------------------- /src/Support/Address.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | class Address implements Arrayable 13 | { 14 | /** 15 | * @param null|string|string[] $street 16 | * @param array $fields 17 | */ 18 | public function __construct( 19 | public ?string $company = null, 20 | public ?string $name = null, 21 | public null|string|array $street = null, 22 | public ?string $state = null, 23 | public ?string $postal_code = null, 24 | public ?string $city = null, 25 | public ?string $country = null, 26 | public array $fields = [], 27 | ) { 28 | // code... 29 | } 30 | 31 | /** 32 | * @param array $values 33 | */ 34 | public static function fromArray(array $values): self 35 | { 36 | return new self( 37 | // @phpstan-ignore-next-line 38 | company: data_get($values, 'company'), 39 | // @phpstan-ignore-next-line 40 | name: data_get($values, 'name'), 41 | // @phpstan-ignore-next-line 42 | street: data_get($values, 'street'), 43 | // @phpstan-ignore-next-line 44 | state: data_get($values, 'state'), 45 | // @phpstan-ignore-next-line 46 | postal_code: data_get($values, 'postal_code'), 47 | // @phpstan-ignore-next-line 48 | city: data_get($values, 'city'), 49 | // @phpstan-ignore-next-line 50 | country: data_get($values, 'country'), 51 | // @phpstan-ignore-next-line 52 | fields: data_get($values, 'fields') ?? [], 53 | ); 54 | } 55 | 56 | /** 57 | * @return array{ 58 | * company: ?string, 59 | * name: ?string, 60 | * street: null|string|string[], 61 | * state: ?string, 62 | * postal_code: ?string, 63 | * city: ?string, 64 | * country: ?string, 65 | * fields: null|array, 66 | * } 67 | */ 68 | public function toArray(): array 69 | { 70 | return [ 71 | 'company' => $this->company, 72 | 'name' => $this->name, 73 | 'street' => $this->street, 74 | 'state' => $this->state, 75 | 'postal_code' => $this->postal_code, 76 | 'city' => $this->city, 77 | 'country' => $this->country, 78 | 'fields' => $this->fields, 79 | ]; 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/Support/Buyer.php: -------------------------------------------------------------------------------- 1 | > 11 | */ 12 | class Buyer implements Arrayable 13 | { 14 | /** 15 | * @param array $fields 16 | */ 17 | public function __construct( 18 | public ?string $company = null, 19 | public ?string $name = null, 20 | public ?Address $address = null, 21 | public ?Address $shipping_address = null, 22 | public ?string $tax_number = null, 23 | public ?string $email = null, 24 | public ?string $phone = null, 25 | public array $fields = [], 26 | ) { 27 | // code... 28 | } 29 | 30 | /** 31 | * @param array $values 32 | */ 33 | public static function fromArray(array $values): self 34 | { 35 | return new self( 36 | // @phpstan-ignore-next-line 37 | company: data_get($values, 'company'), 38 | // @phpstan-ignore-next-line 39 | name: data_get($values, 'name'), 40 | // @phpstan-ignore-next-line 41 | address: ($address = data_get($values, 'address')) ? Address::fromArray($address) : null, 42 | // @phpstan-ignore-next-line 43 | shipping_address: ($shipping_address = data_get($values, 'shipping_address')) ? Address::fromArray($shipping_address) : null, 44 | // @phpstan-ignore-next-line 45 | tax_number: data_get($values, 'tax_number'), 46 | // @phpstan-ignore-next-line 47 | email: data_get($values, 'email'), 48 | // @phpstan-ignore-next-line 49 | phone: data_get($values, 'phone'), 50 | // @phpstan-ignore-next-line 51 | fields: data_get($values, 'fields') ?? data_get($values, 'data') ?? [], 52 | ); 53 | } 54 | 55 | /** 56 | * @return array{ 57 | * company: ?string, 58 | * name: ?string, 59 | * address: null|array{ 60 | * company: ?string, 61 | * name: ?string, 62 | * street: null|string|string[], 63 | * state: ?string, 64 | * postal_code: ?string, 65 | * city: ?string, 66 | * country: ?string, 67 | * fields: null|array, 68 | * }, 69 | * shipping_address: null|array{ 70 | * company: ?string, 71 | * name: ?string, 72 | * street: null|string|string[], 73 | * state: ?string, 74 | * postal_code: ?string, 75 | * city: ?string, 76 | * country: ?string, 77 | * fields: null|array, 78 | * }, 79 | * tax_number: ?string, 80 | * email: ?string, 81 | * phone: ?string, 82 | * fields: null|array, 83 | * } 84 | */ 85 | public function toArray(): array 86 | { 87 | return [ 88 | 'company' => $this->company, 89 | 'name' => $this->name, 90 | 'address' => $this->address?->toArray(), 91 | 'shipping_address' => $this->shipping_address?->toArray(), 92 | 'tax_number' => $this->tax_number, 93 | 'email' => $this->email, 94 | 'phone' => $this->phone, 95 | 'fields' => $this->fields, 96 | ]; 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/Support/PaymentInstruction.php: -------------------------------------------------------------------------------- 1 | > 11 | */ 12 | class PaymentInstruction implements Arrayable 13 | { 14 | /** 15 | * @param array $fields 16 | */ 17 | public function __construct( 18 | public ?string $name = null, 19 | public ?string $description = null, 20 | public ?string $qrcode = null, 21 | public array $fields = [], 22 | ) { 23 | // code... 24 | } 25 | 26 | /** 27 | * @param array $values 28 | */ 29 | public static function fromArray(array $values): self 30 | { 31 | return new self( 32 | // @phpstan-ignore-next-line 33 | name: data_get($values, 'name'), 34 | // @phpstan-ignore-next-line 35 | description: data_get($values, 'description'), 36 | // @phpstan-ignore-next-line 37 | qrcode: data_get($values, 'qrcode'), 38 | // @phpstan-ignore-next-line 39 | fields: data_get($values, 'fields') ?? [], 40 | ); 41 | } 42 | 43 | /** 44 | * @return array{ 45 | * name: ?string, 46 | * description: ?string, 47 | * fields: null|array, 48 | * } 49 | */ 50 | public function toArray(): array 51 | { 52 | return [ 53 | 'name' => $this->name, 54 | 'description' => $this->description, 55 | 'qrcode' => $this->qrcode, 56 | 'fields' => $this->fields, 57 | ]; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/Support/Seller.php: -------------------------------------------------------------------------------- 1 | > 11 | */ 12 | class Seller implements Arrayable 13 | { 14 | /** 15 | * @param array $fields 16 | */ 17 | public function __construct( 18 | public ?string $company = null, 19 | public ?string $name = null, 20 | public ?Address $address = null, 21 | public ?string $tax_number = null, 22 | public ?string $email = null, 23 | public ?string $phone = null, 24 | public array $fields = [], 25 | ) { 26 | // code... 27 | } 28 | 29 | /** 30 | * @param array $values 31 | */ 32 | public static function fromArray(array $values): self 33 | { 34 | return new self( 35 | // @phpstan-ignore-next-line 36 | company: data_get($values, 'company'), 37 | // @phpstan-ignore-next-line 38 | name: data_get($values, 'name'), 39 | // @phpstan-ignore-next-line 40 | address: ($address = data_get($values, 'address')) ? Address::fromArray($address) : null, 41 | // @phpstan-ignore-next-line 42 | tax_number: data_get($values, 'tax_number'), 43 | // @phpstan-ignore-next-line 44 | email: data_get($values, 'email'), 45 | // @phpstan-ignore-next-line 46 | phone: data_get($values, 'phone'), 47 | // @phpstan-ignore-next-line 48 | fields: data_get($values, 'fields') ?? data_get($values, 'data') ?? [], 49 | ); 50 | } 51 | 52 | /** 53 | * @return array{ 54 | * company: ?string, 55 | * name: ?string, 56 | * address: null|array{ 57 | * company: ?string, 58 | * name: ?string, 59 | * street: null|string|string[], 60 | * state: ?string, 61 | * postal_code: ?string, 62 | * city: ?string, 63 | * country: ?string, 64 | * fields: null|array, 65 | * }, 66 | * tax_number: ?string, 67 | * email: ?string, 68 | * phone: ?string, 69 | * fields: null|array, 70 | * } 71 | */ 72 | public function toArray(): array 73 | { 74 | return [ 75 | 'company' => $this->company, 76 | 'name' => $this->name, 77 | 'address' => $this->address?->toArray(), 78 | 'tax_number' => $this->tax_number, 79 | 'email' => $this->email, 80 | 'phone' => $this->phone, 81 | 'fields' => $this->fields, 82 | ]; 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /workbench/resources/images/qrcode.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ElegantEngineeringTech/laravel-invoices/f8923d8a2ae14c84ac4b28b846440b0d6aa84be3/workbench/resources/images/qrcode.png -------------------------------------------------------------------------------- /workbench/resources/images/qrcode.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /workbench/resources/views/demo.blade.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 |
14 | 15 |
16 | 17 |
18 | View as PDF 19 |
20 | 21 |
22 | @include('invoices::default.invoice', [ 23 | 'invoice' => $invoice, 24 | ]) 25 |
26 | 27 |
28 | 29 |
30 | 31 | {{-- Must be added at the end to overwrite Tailwind --}} 32 | @include('invoices::default.style') 33 | 34 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /workbench/routes/web.php: -------------------------------------------------------------------------------- 1 | 'BD01-7659', 27 | ], 28 | logo: 'https://avatars.githubusercontent.com/u/170185760?s=400&u=becdedf9606e6a80ea4831e8fc5cac301763368a&v=4', 29 | seller: new Seller( 30 | company: 'Elegantly', 31 | address: new Address( 32 | street: "9 rue Geoffroy l'Angevin", 33 | postal_code: '75004', 34 | city: 'Paris', 35 | state: 'Île-de-France', 36 | country: 'France', 37 | ), 38 | email: 'support@example.com', 39 | phone: '069547XXXX', 40 | tax_number: 'FR88897962361', 41 | fields: [ 42 | 'SIREN' => '897962361', 43 | ], 44 | ), 45 | buyer: new Buyer( 46 | company: 'Company & Co', 47 | name: 'Wile E. Coyote', 48 | address: new Address( 49 | street : '8 Allée Du Sequoia', 50 | postal_code : '77400', 51 | city : 'Lagny-sur-Marne', 52 | country : 'France', 53 | ), 54 | shipping_address: new Address( 55 | company: 'Company & Co', 56 | name : 'John Doe', 57 | street : [ 58 | '8 Allée Du Sequoia', 59 | 'Apt 1.', 60 | ], 61 | postal_code : '77400', 62 | city : 'Lagny-sur-Marne', 63 | country : 'France', 64 | ), 65 | tax_number: 'FR15948344072', 66 | email: 'john.doe@example.com', 67 | ), 68 | items: [ 69 | new PdfInvoiceItem( 70 | label: 'Casting Pro', 71 | description: 'Feb 20 – Mar 20, 2025', 72 | currency: 'EUR', 73 | unit_price: Money::of(97, 'EUR'), 74 | quantity: 0.2, 75 | tax_percentage: 20, 76 | ), 77 | new PdfInvoiceItem( 78 | label: 'Casting Pro', 79 | description: 'Feb 20 – Mar 20, 2025', 80 | currency: 'EUR', 81 | unit_price: Money::of(97, 'EUR'), 82 | tax_percentage: 20, 83 | ), 84 | new PdfInvoiceItem( 85 | label: 'Casting Pro', 86 | description: 'Feb 20 – Mar 20, 2025', 87 | currency: 'EUR', 88 | unit_price: Money::of(97, 'EUR'), 89 | tax_percentage: 20, 90 | ), 91 | 92 | new PdfInvoiceItem( 93 | label: 'Casting Pro', 94 | description: 'Feb 20 – Mar 20, 2025', 95 | currency: 'EUR', 96 | unit_price: Money::of(97, 'EUR'), 97 | tax_percentage: 20, 98 | ), 99 | new PdfInvoiceItem( 100 | label: 'Casting Pro', 101 | description: 'Feb 20 – Mar 20, 2025', 102 | currency: 'EUR', 103 | unit_price: Money::of(97, 'EUR'), 104 | tax_percentage: 20, 105 | ), 106 | new PdfInvoiceItem( 107 | label: 'Casting Pro', 108 | description: 'Feb 20 – Mar 20, 2025', 109 | currency: 'EUR', 110 | unit_price: Money::of(97, 'EUR'), 111 | tax_percentage: 20, 112 | ), 113 | new PdfInvoiceItem( 114 | label: 'Casting Pro', 115 | description: 'Feb 20 – Mar 20, 2025', 116 | currency: 'EUR', 117 | unit_price: Money::of(97, 'EUR'), 118 | tax_percentage: 20, 119 | ), 120 | new PdfInvoiceItem( 121 | label: 'Casting Pro', 122 | description: 'Feb 20 – Mar 20, 2025', 123 | currency: 'EUR', 124 | unit_price: Money::of(97, 'EUR'), 125 | tax_percentage: 20, 126 | ), 127 | new PdfInvoiceItem( 128 | label: 'Casting Pro', 129 | description: 'Feb 20 – Mar 20, 2025', 130 | currency: 'EUR', 131 | unit_price: Money::of(97, 'EUR'), 132 | tax_percentage: 20, 133 | ), 134 | new PdfInvoiceItem( 135 | label: 'Casting Pro', 136 | description: 'Feb 20 – Mar 20, 2025', 137 | currency: 'EUR', 138 | unit_price: Money::of(97, 'EUR'), 139 | tax_percentage: 20, 140 | ), 141 | new PdfInvoiceItem( 142 | label: 'Casting Pro', 143 | description: 'Feb 20 – Mar 20, 2025', 144 | currency: 'EUR', 145 | unit_price: Money::of(97, 'EUR'), 146 | tax_percentage: 20, 147 | ), 148 | new PdfInvoiceItem( 149 | label: 'Casting Pro', 150 | description: 'Feb 20 – Mar 20, 2025', 151 | currency: 'EUR', 152 | unit_price: Money::of(97, 'EUR'), 153 | tax_percentage: 20, 154 | ), 155 | new PdfInvoiceItem( 156 | label: 'Casting Pro', 157 | description: 'Feb 20 – Mar 20, 2025', 158 | currency: 'EUR', 159 | unit_price: Money::of(97, 'EUR'), 160 | tax_percentage: 20, 161 | ), 162 | new PdfInvoiceItem( 163 | label: 'Casting Pro', 164 | description: 'Feb 20 – Mar 20, 2025', 165 | currency: 'EUR', 166 | unit_price: Money::of(97, 'EUR'), 167 | tax_percentage: 20, 168 | ), 169 | new PdfInvoiceItem( 170 | label: 'Casting Pro', 171 | description: 'Feb 20 – Mar 20, 2025', 172 | currency: 'EUR', 173 | unit_price: Money::of(97, 'EUR'), 174 | tax_percentage: 20, 175 | ), 176 | ], 177 | discounts: [ 178 | new InvoiceDiscount( 179 | name: 'Discount', 180 | code: 'AEX45', 181 | percent_off: 20 182 | ), 183 | ], 184 | tax_label: 'VAT (France)', 185 | description: 'A simple description', 186 | paymentInstructions: [ 187 | new PaymentInstruction( 188 | name: 'Bank Transfer', 189 | description: 'Make a direct bank transfer using the details below', 190 | qrcode: 'data:image/png;base64,'.base64_encode(file_get_contents(__DIR__.'/../resources/images/qrcode.png')), 191 | fields: [ 192 | 'Bank Name' => 'Acme Bank', 193 | 'Account Number' => '12345678', 194 | 'IBAN' => 'GB12ACME12345678123456', 195 | 'SWIFT/BIC' => 'ACMEGB2L', 196 | 'Reference' => 'INV-0032/001', 197 | 'Pay online', 198 | ], 199 | ), 200 | ] 201 | ); 202 | 203 | Route::get('/', function () use ($invoice) { 204 | return view('demo', [ 205 | 'invoice' => $invoice, 206 | ]); 207 | }); 208 | 209 | Route::get('/pdf', function () use ($invoice) { 210 | return $invoice->stream(); 211 | }); 212 | --------------------------------------------------------------------------------