├── .editorconfig ├── .github └── workflows │ ├── php-cs-fixer.yml │ └── tests.yml ├── .gitignore ├── .php-cs-fixer.cache ├── .php-cs-fixer.dist.php ├── CHANGELOG.md ├── CONDUCT.md ├── CONTRIBUTING.md ├── ISSUE_TEMPLATE.md ├── LICENSE.md ├── PULL_REQUEST_TEMPLATE.md ├── README.md ├── composer.json ├── config └── invoicable.php ├── database └── migrations │ └── 2017_06_17_163005_create_invoices_tables.php ├── phpunit.xml ├── resources └── views │ └── receipt.blade.php ├── src ├── InvoicableServiceProvider.php ├── Invoice.php ├── InvoiceLine.php ├── InvoiceReferenceGenerator.php ├── IsInvoicable │ └── IsInvoicableTrait.php └── MoneyFormatter.php └── tests ├── AbstractTestCase.php ├── CreateTestModelsTable.php ├── Feature └── InvoiceTest.php ├── TestModel.php └── Unit ├── InvoiceReferenceTest.php └── MoneyFormatterTest.php /.editorconfig: -------------------------------------------------------------------------------- 1 | ; This file is for unifying the coding style for different editors and IDEs. 2 | ; More information at http://editorconfig.org 3 | 4 | root = true 5 | 6 | [*] 7 | charset = utf-8 8 | indent_size = 4 9 | indent_style = space 10 | end_of_line = lf 11 | insert_final_newline = true 12 | trim_trailing_whitespace = true 13 | 14 | [*.md] 15 | trim_trailing_whitespace = false 16 | -------------------------------------------------------------------------------- /.github/workflows/php-cs-fixer.yml: -------------------------------------------------------------------------------- 1 | name: Check & fix styling 2 | 3 | on: [push] 4 | 5 | jobs: 6 | php-cs-fixer: 7 | runs-on: ubuntu-latest 8 | 9 | steps: 10 | - name: Checkout code 11 | uses: actions/checkout@v2 12 | with: 13 | ref: ${{ github.head_ref }} 14 | 15 | - name: Run PHP CS Fixer 16 | uses: docker://oskarstark/php-cs-fixer-ga 17 | with: 18 | args: --config=.php-cs-fixer.dist.php --allow-risky=yes 19 | 20 | - name: Commit changes 21 | uses: stefanzweifel/git-auto-commit-action@v4 22 | with: 23 | commit_message: Fix styling 24 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: tests 2 | 3 | on: 4 | push: 5 | pull_request: 6 | schedule: 7 | - cron: '0 0 * * *' 8 | 9 | jobs: 10 | tests: 11 | 12 | runs-on: ubuntu-latest 13 | strategy: 14 | fail-fast: true 15 | matrix: 16 | php: [7.4, 8.0] 17 | laravel: [^7.0, ^8.0] 18 | 19 | name: P${{ matrix.php }} - L${{ matrix.laravel }} 20 | 21 | steps: 22 | - name: Checkout code 23 | uses: actions/checkout@v2 24 | 25 | - name: Setup PHP 26 | uses: shivammathur/setup-php@v2 27 | with: 28 | php-version: ${{ matrix.php }} 29 | extensions: curl, dom, intl, json, libxml, mbstring, openssl, zip 30 | tools: composer:v2 31 | coverage: xdebug 32 | - name: Install dependencies 33 | run: | 34 | composer require "illuminate/contracts=${{ matrix.laravel }}" --no-update 35 | composer update --prefer-dist --no-interaction --no-progress 36 | - name: Execute tests 37 | run: vendor/bin/phpunit --coverage-text 38 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | composer.lock 2 | vendor 3 | .phpunit.result.cache 4 | .php_cs.cache 5 | -------------------------------------------------------------------------------- /.php-cs-fixer.cache: -------------------------------------------------------------------------------- 1 | {"php":"8.0.5","version":"3.0.0","indent":" ","lineEnding":"\n","rules":{"blank_line_after_namespace":true,"braces":true,"class_definition":true,"constant_case":true,"elseif":true,"function_declaration":true,"indentation_type":true,"line_ending":true,"lowercase_keywords":true,"method_argument_space":{"on_multiline":"ensure_fully_multiline","keep_multiple_spaces_after_comma":true},"no_break_comment":true,"no_closing_tag":true,"no_spaces_after_function_name":true,"no_spaces_inside_parenthesis":true,"no_trailing_whitespace":true,"no_trailing_whitespace_in_comment":true,"single_blank_line_at_eof":true,"single_class_element_per_statement":{"elements":["property"]},"single_import_per_statement":true,"single_line_after_imports":true,"switch_case_semicolon_to_colon":true,"switch_case_space":true,"visibility_required":{"elements":["method","property"]},"encoding":true,"full_opening_tag":true,"array_syntax":{"syntax":"short"},"ordered_imports":{"sort_algorithm":"alpha"},"no_unused_imports":true,"not_operator_with_successor_space":true,"trailing_comma_in_multiline":true,"phpdoc_scalar":true,"unary_operator_spaces":true,"binary_operator_spaces":true,"blank_line_before_statement":{"statements":["break","continue","declare","return","throw","try"]},"phpdoc_single_line_var_spacing":true,"phpdoc_var_without_name":true,"single_trait_insert_per_statement":true},"hashes":{"src\/Invoice.php":2278568339,"src\/MoneyFormatter.php":1206229440,"src\/IsInvoicable\/IsInvoicableTrait.php":1957151012,"src\/InvoicableServiceProvider.php":672820863,"src\/InvoiceLine.php":3758312924,"src\/InvoiceReferenceGenerator.php":7251896,"config\/invoicable.php":47350322,"tests\/Feature\/InvoiceTest.php":1299334751,"tests\/AbstractTestCase.php":2619638986,"tests\/TestModel.php":482200789,"tests\/CreateTestModelsTable.php":2612826604,"tests\/Unit\/InvoiceReferenceTest.php":240943663,"tests\/Unit\/MoneyFormatterTest.php":1490742050,"database\/migrations\/2017_06_17_163005_create_invoices_tables.php":1842077571}} -------------------------------------------------------------------------------- /.php-cs-fixer.dist.php: -------------------------------------------------------------------------------- 1 | in([ 4 | __DIR__ . '/src', 5 | __DIR__ . '/config', 6 | __DIR__ . '/tests', 7 | __DIR__ . '/database', 8 | ]) 9 | ->name('*.php') 10 | ->notName('*.blade.php') 11 | ->ignoreDotFiles(true) 12 | ->ignoreVCS(true); 13 | 14 | return (new PhpCsFixer\Config()) 15 | ->setRules([ 16 | '@PSR2' => true, 17 | 'array_syntax' => ['syntax' => 'short'], 18 | 'ordered_imports' => ['sort_algorithm' => 'alpha'], 19 | 'no_unused_imports' => true, 20 | 'not_operator_with_successor_space' => true, 21 | 'trailing_comma_in_multiline' => true, 22 | 'phpdoc_scalar' => true, 23 | 'unary_operator_spaces' => true, 24 | 'binary_operator_spaces' => true, 25 | 'blank_line_before_statement' => [ 26 | 'statements' => ['break', 'continue', 'declare', 'return', 'throw', 'try'], 27 | ], 28 | 'phpdoc_single_line_var_spacing' => true, 29 | 'phpdoc_var_without_name' => true, 30 | 'method_argument_space' => [ 31 | 'on_multiline' => 'ensure_fully_multiline', 32 | 'keep_multiple_spaces_after_comma' => true, 33 | ], 34 | 'single_trait_insert_per_statement' => true, 35 | ]) 36 | ->setFinder($finder); 37 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All Notable changes to `laravel-invoicable` will be documented in this file. 4 | 5 | Updates should follow the [Keep a CHANGELOG](http://keepachangelog.com/) principles. 6 | 7 | ## 1.0.8 - 2018-03-27 8 | 9 | ### Added 10 | - Convenience method Invoice::findByReference($reference) 11 | - Convenience method Invoice::findByReferenceOrFail($reference) 12 | - Convenience method $invoice->invoicable() for accessing related model 13 | 14 | ## 1.0.7 - 2018-03-26 15 | 16 | ### Added 17 | - Now automatically registers ServiceProvider with Laravel 5.5 and up. (See issue #5) 18 | 19 | ### Fixed 20 | - Updated author E-mail and website addresses (.com instead of .nl) 21 | 22 | ## 2018-03-20 23 | ### Fixed 24 | - Laravel and phpunit version bumps 25 | - Readme file now explains how you to (manually) apply discounts 26 | 27 | ## 2017-06-21 28 | - First release 29 | 30 | # --- TEMPLATE BELOW --- 31 | 32 | ## NEXT - YYYY-MM-DD 33 | 34 | ### Added 35 | - Nothing 36 | 37 | ### Deprecated 38 | - Nothing 39 | 40 | ### Fixed 41 | - Nothing 42 | 43 | ### Removed 44 | - Nothing 45 | 46 | ### Security 47 | - Nothing 48 | -------------------------------------------------------------------------------- /CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to make participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at `info@sandervanhooft.com`. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at [http://contributor-covenant.org/version/1/4][version] 72 | 73 | [homepage]: http://contributor-covenant.org 74 | [version]: http://contributor-covenant.org/version/1/4/ 75 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Contributions are **welcome** and will be fully **credited**. 4 | 5 | We accept contributions via Pull Requests on [Github](https://github.com/sandervanhooft/laravel-invoicable). 6 | 7 | 8 | ## Pull Requests 9 | 10 | - **[PSR-2 Coding Standard](https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-2-coding-style-guide.md)** - Check the code style with ``$ composer check-style`` and fix it with ``$ composer fix-style``. 11 | 12 | - **Add tests!** - Your patch won't be accepted if it doesn't have tests. 13 | 14 | - **Document any change in behaviour** - Make sure the `README.md` and any other relevant documentation are kept up-to-date. 15 | 16 | - **Consider our release cycle** - We try to follow [SemVer v2.0.0](http://semver.org/). Randomly breaking public APIs is not an option. 17 | 18 | - **Create feature branches** - Don't ask us to pull from your master branch. 19 | 20 | - **One pull request per feature** - If you want to do more than one thing, send multiple pull requests. 21 | 22 | - **Send coherent history** - Make sure each individual commit in your pull request is meaningful. If you had to make multiple intermediate commits while developing, please [squash them](http://www.git-scm.com/book/en/v2/Git-Tools-Rewriting-History#Changing-Multiple-Commit-Messages) before submitting. 23 | 24 | 25 | ## Running Tests 26 | 27 | ``` bash 28 | $ composer test 29 | ``` 30 | 31 | 32 | **Happy coding**! 33 | -------------------------------------------------------------------------------- /ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Detailed description 4 | 5 | Provide a detailed description of the change or addition you are proposing. 6 | 7 | Make it clear if the issue is a bug, an enhancement or just a question. 8 | 9 | ## Context 10 | 11 | Why is this change important to you? How would you use it? 12 | 13 | How can it benefit other users? 14 | 15 | ## Possible implementation 16 | 17 | Not obligatory, but suggest an idea for implementing addition or change. 18 | 19 | ## Your environment 20 | 21 | Include as many relevant details about the environment you experienced the bug in and how to reproduce it. 22 | 23 | * Version used (e.g. PHP 5.6, HHVM 3): 24 | * Operating system and version (e.g. Ubuntu 16.04, Windows 7): 25 | * Link to your project: 26 | * ... 27 | * ... 28 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # The MIT License (MIT) 2 | 3 | Copyright (c) 2017 Sander van Hooft 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 | -------------------------------------------------------------------------------- /PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Description 4 | 5 | Describe your changes in detail. 6 | 7 | ## Motivation and context 8 | 9 | Why is this change required? What problem does it solve? 10 | 11 | If it fixes an open issue, please link to the issue here (if you write `fixes #num` 12 | or `closes #num`, the issue will be automatically closed when the pull is accepted.) 13 | 14 | ## How has this been tested? 15 | 16 | Please describe in detail how you tested your changes. 17 | 18 | Include details of your testing environment, and the tests you ran to 19 | see how your change affects other areas of the code, etc. 20 | 21 | ## Screenshots (if appropriate) 22 | 23 | ## Types of changes 24 | 25 | What types of changes does your code introduce? Put an `x` in all the boxes that apply: 26 | - [ ] Bug fix (non-breaking change which fixes an issue) 27 | - [ ] New feature (non-breaking change which adds functionality) 28 | - [ ] Breaking change (fix or feature that would cause existing functionality to change) 29 | 30 | ## Checklist: 31 | 32 | Go over all the following points, and put an `x` in all the boxes that apply. 33 | 34 | Please, please, please, don't send your pull request until all of the boxes are ticked. Once your pull request is created, it will trigger a build on our [continuous integration](http://www.phptherightway.com/#continuous-integration) server to make sure your [tests and code style pass](https://help.github.com/articles/about-required-status-checks/). 35 | 36 | - [ ] I have read the **[CONTRIBUTING](CONTRIBUTING.md)** document. 37 | - [ ] My pull request addresses exactly one patch/feature. 38 | - [ ] I have created a branch for this patch/feature. 39 | - [ ] Each individual commit in the pull request is meaningful. 40 | - [ ] I have added tests to cover my changes. 41 | - [ ] If my change requires a change to the documentation, I have updated it accordingly. 42 | 43 | If you're unsure about any of these, don't hesitate to ask. We're here to help! 44 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # laravel-invoicable 2 | 3 | [![Latest Version on Packagist][ico-version]][link-packagist] 4 | [![Software License][ico-license]](LICENSE.md) 5 | [![Github Actions][ico-tests]][link-tests] 6 | [![Total Downloads][ico-downloads]][link-downloads] 7 | 8 | Easy invoice creation for Laravel. Unlike Laravel Cashier, this package is payment gateway agnostic. 9 | 10 | If you're looking for Mollie payment processing, be sure to check out [laravel-payable-redirect-mollie](https://github.com/sandervanhooft/laravel-payable-redirect-mollie). 11 | 12 | ## Structure 13 | 14 | ``` 15 | database/ 16 | resources 17 | src/ 18 | tests/ 19 | vendor/ 20 | ``` 21 | 22 | ## Install 23 | 24 | Via Composer 25 | 26 | ``` bash 27 | $ composer require sander-van-hooft/laravel-invoicable 28 | ``` 29 | 30 | You can publish the migration with: 31 | 32 | ``` bash 33 | $ php artisan vendor:publish --provider="SanderVanHooft\Invoicable\InvoicableServiceProvider" --tag="migrations" 34 | ``` 35 | 36 | After the migration has been published you can create the invoices and invoice_lines tables by running the migrations: 37 | 38 | ``` bash 39 | $ php artisan migrate 40 | ``` 41 | 42 | Optionally, you can also publish the `invoicable.php` config file with: 43 | 44 | ``` bash 45 | $ php artisan vendor:publish --provider="SanderVanHooft\Invoicable\InvoicableServiceProvider" --tag="config" 46 | ``` 47 | 48 | This is what the default config file looks like: 49 | 50 | ``` php 51 | 52 | return [ 53 | 'default_currency' => 'EUR', 54 | 'default_status' => 'concept', 55 | 'locale' => 'nl_NL', 56 | ]; 57 | ``` 58 | 59 | If you'd like to override the design of the invoice blade view and pdf, publish the view: 60 | 61 | ``` bash 62 | $ php artisan vendor:publish --provider="SanderVanHooft\Invoicable\InvoicableServiceProvider" --tag="views" 63 | ``` 64 | 65 | You can now edit `receipt.blade.php` in `/resources/views/invoicable/receipt.blade.php` to match your style. 66 | 67 | 68 | ## Usage 69 | 70 | __Money figures are in cents!__ 71 | 72 | Add the invoicable trait to the Eloquent model which needs to be invoiced (typically an Order model): 73 | 74 | ``` php 75 | use Illuminate\Database\Eloquent\Model; 76 | use SanderVanHooft\Invoicable\IsInvoicable\IsInvoicableTrait; 77 | 78 | class Order extends Model 79 | { 80 | use IsInvoicableTrait; // enables the ->invoices() Eloquent relationship 81 | } 82 | ``` 83 | 84 | Now you can create invoices for an Order: 85 | 86 | ``` php 87 | $order = Order::first(); 88 | $invoice = $order->invoices()->create([]); 89 | 90 | // To add a line to the invoice, use these example parameters: 91 | // Amount: 92 | // 121 (€1,21) incl tax 93 | // 100 (€1,00) excl tax 94 | // Description: 'Some description' 95 | // Tax percentage: 0.21 (21%) 96 | $invoice = $invoice->addAmountInclTax(121, 'Some description', 0.21); 97 | $invoice = $invoice->addAmountExclTax(100, 'Some description', 0.21); 98 | 99 | // Invoice totals are now updated 100 | echo $invoice->total; // 242 101 | echo $invoice->tax; // 42 102 | 103 | // Set additional information (optional) 104 | $invoice->currency; // defaults to 'EUR' (see config file) 105 | $invoice->status; // defaults to 'concept' (see config file) 106 | $invoice->receiver_info; // defaults to null 107 | $invoice->sender_info; // defaults to null 108 | $invoice->payment_info; // defaults to null 109 | $invoice->note; // defaults to null 110 | 111 | // access individual invoice lines using Eloquent relationship 112 | $invoice->lines; 113 | $invoice->lines(); 114 | 115 | // Access as pdf 116 | $invoice->download(); // download as pdf (returns http response) 117 | $invoice->pdf(); // or just grab the pdf (raw bytes) 118 | 119 | // Handling discounts 120 | // By adding a line with a negative amount. 121 | $invoice = $invoice->addAmountInclTax(-121, 'A nice discount', 0.21); 122 | 123 | // Or by applying the discount and discribing the discount manually 124 | $invoice = $invoice->addAmountInclTax(121 * (1 - 0.30), 'Product XYZ incl 30% discount', 0.21); 125 | 126 | // Convenience methods 127 | Invoice::findByReference($reference); 128 | Invoice::findByReferenceOrFail($reference); 129 | $invoice->invoicable() // Access the related model 130 | ``` 131 | 132 | 133 | ## Change log 134 | 135 | Please see [CHANGELOG](CHANGELOG.md) for more information on what has changed recently. 136 | 137 | ## Testing 138 | 139 | ``` bash 140 | $ composer test 141 | ``` 142 | 143 | ## Contributing 144 | 145 | Please see [CONTRIBUTING](CONTRIBUTING.md) and [CONDUCT](CONDUCT.md) for details. 146 | 147 | ## Security 148 | 149 | If you discover any security related issues, please email info@sandervanhooft.com instead of using the issue tracker. 150 | 151 | ## Credits 152 | 153 | - [Sander van Hooft][link-author] 154 | - [All Contributors][link-contributors] 155 | - Inspired by [Laravel Cashier](https://github.com/laravel/cashier)'s invoices. 156 | 157 | ## License 158 | 159 | The MIT License (MIT). Please see [License File](LICENSE.md) for more information. 160 | 161 | [ico-version]: https://img.shields.io/packagist/v/sander-van-hooft/laravel-invoicable.svg?style=flat-square 162 | [ico-license]: https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat-square 163 | [ico-tests]: https://github.com/sandervanhooft/laravel-invoicable/workflows/tests/badge.svg 164 | [ico-downloads]: https://img.shields.io/packagist/dt/sander-van-hooft/laravel-invoicable.svg?style=flat-square 165 | 166 | [link-packagist]: https://packagist.org/packages/sander-van-hooft/laravel-invoicable 167 | [link-tests]: https://github.com/sandervanhooft/laravel-invoicable/actions 168 | [link-downloads]: https://packagist.org/packages/sander-van-hooft/laravel-invoicable 169 | [link-author]: https://github.com/sandervanhooft 170 | [link-contributors]: ../../contributors 171 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sander-van-hooft/laravel-invoicable", 3 | "type": "library", 4 | "description": "Easy invoice generation using Laravel Eloquent", 5 | "keywords": [ 6 | "Eloquent", 7 | "invoicable", 8 | "invoice", 9 | "Laravel", 10 | "laravel-invoicable", 11 | "payments", 12 | "sandervanhooft" 13 | ], 14 | "homepage": "https://github.com/SanderVanHooft/laravel-invoicable", 15 | "license": "MIT", 16 | "authors": [ 17 | { 18 | "name": "Sander van Hooft", 19 | "email": "info@sandervanhooft.com", 20 | "homepage": "http://www.sandervanhooft.com", 21 | "role": "Developer" 22 | } 23 | ], 24 | "require": { 25 | "php": "^7.4 || ^8.0", 26 | "ext-intl": "*", 27 | "dompdf/dompdf": "^1.0", 28 | "illuminate/support": "^7.0 || ^8.0", 29 | "nesbot/carbon": "^2.0" 30 | }, 31 | "require-dev": { 32 | "graham-campbell/testbench": "^5.6", 33 | "phpunit/phpunit": "^9.0", 34 | "squizlabs/php_codesniffer": "^3.4", 35 | "friendsofphp/php-cs-fixer": "^2.18" 36 | }, 37 | "autoload": { 38 | "psr-4": { 39 | "SanderVanHooft\\Invoicable\\": "src" 40 | } 41 | }, 42 | "autoload-dev": { 43 | "psr-4": { 44 | "SanderVanHooft\\Invoicable\\": "tests" 45 | } 46 | }, 47 | "scripts": { 48 | "test": "phpunit", 49 | "format": "./vendor/bin/php-cs-fixer fix --allow-risky=yes" 50 | }, 51 | "extra": { 52 | "branch-alias": { 53 | "dev-master": "1.0-dev" 54 | }, 55 | "laravel": { 56 | "providers": [ 57 | "SanderVanHooft\\Invoicable\\InvoicableServiceProvider" 58 | ] 59 | } 60 | }, 61 | "config": { 62 | "sort-packages": true 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /config/invoicable.php: -------------------------------------------------------------------------------- 1 | 'EUR', 5 | 'default_status' => 'concept', 6 | 'locale' => 'nl_NL', 7 | ]; 8 | -------------------------------------------------------------------------------- /database/migrations/2017_06_17_163005_create_invoices_tables.php: -------------------------------------------------------------------------------- 1 | increments('id'); 18 | $table->morphs('invoicable'); 19 | $table->integer('tax')->default(0)->description('in cents'); 20 | $table->integer('total')->default(0)->description('in cents'); 21 | $table->char('currency', 3); 22 | $table->char('reference', 17); 23 | $table->char('status', 16)->nullable(); 24 | $table->text('receiver_info')->nullable(); 25 | $table->text('sender_info')->nullable(); 26 | $table->text('payment_info')->nullable(); 27 | $table->text('note')->nullable(); 28 | $table->timestamps(); 29 | }); 30 | 31 | Schema::create('invoice_lines', function (Blueprint $table) { 32 | $table->increments('id'); 33 | $table->integer('amount')->default(0)->description('in cents, including tax'); 34 | $table->integer('tax')->default(0)->description('in cents'); 35 | $table->float('tax_percentage')->default(0); 36 | $table->integer('invoice_id')->unsigned(); 37 | $table->foreign('invoice_id')->references('id')->on('invoices'); 38 | $table->char('description', 255); 39 | $table->timestamps(); 40 | }); 41 | } 42 | 43 | /** 44 | * Reverse the migrations. 45 | * 46 | * @return void 47 | */ 48 | public function down() 49 | { 50 | Schema::dropIfExists('invoice_lines'); 51 | Schema::dropIfExists('invoices'); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 12 | 13 | 14 | ./tests/ 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /resources/views/receipt.blade.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Receipt 7 | 8 | 9 | 52 | 53 | 54 | 55 |
56 | 57 | 58 | 61 | 62 | 63 | 66 | 67 | 68 | 71 | 72 | 73 | 79 | 80 | 81 | 122 | 123 | 124 | 127 | 128 | 129 | 132 | 133 |
59 |   60 | 64 | {{ $invoice->sender_info }} 65 |
69 | Receipt 70 | 74 |

75 | To: {{ $invoice->receiver_info }} 76 |
77 | Date: {{ $invoice->created_at }} 78 |
82 | 83 |

84 | Invoice Reference: {{ $invoice->reference }}
85 |

86 | 87 |

88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | @foreach ($invoice->lines as $line) 100 | 101 | 102 | 103 | 104 | 105 | @endforeach 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 |
DescriptionDateAmountTax %
{{ $line->description }}{{ $moneyFormatter->format($line->amount) }}{{ $line->tax_percentage * 100 }}%
 Total{{ $moneyFormatter->format($invoice->total) }}
Included tax {{ $moneyFormatter->format($invoice->tax) }}
121 |
125 |   126 | 130 | {{ $invoice->note }} 131 |
134 |
135 | 136 | -------------------------------------------------------------------------------- /src/InvoicableServiceProvider.php: -------------------------------------------------------------------------------- 1 | loadViewsFrom($sourceViewsPath, 'invoicable'); 18 | 19 | $this->publishes([ 20 | $sourceViewsPath => resource_path('views/vendor/invoicable'), 21 | ], 'views'); 22 | 23 | // Publish a config file 24 | $this->publishes([ 25 | __DIR__.'/../config/invoicable.php' => config_path('invoicable.php'), 26 | ], 'config'); 27 | 28 | // Publish migrations 29 | $this->publishes([ 30 | __DIR__.'/../database/migrations/2017_06_17_163005_create_invoices_tables.php' 31 | => database_path('migrations/2017_06_17_163005_create_invoices_tables.php'), 32 | ], 'migrations'); 33 | } 34 | 35 | /** 36 | * Register any package services. 37 | * 38 | * @return void 39 | */ 40 | public function register() 41 | { 42 | $this->mergeConfigFrom(__DIR__.'/../config/invoicable.php', 'invoicable'); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/Invoice.php: -------------------------------------------------------------------------------- 1 | hasMany(InvoiceLine::class); 20 | } 21 | 22 | /** 23 | * Use this if the amount does not yet include tax. 24 | * @param Int $amount The amount in cents, excluding taxes 25 | * @param String $description The description 26 | * @param Float $taxPercentage The tax percentage (i.e. 0.21). Defaults to 0 27 | * @return Illuminate\Database\Eloquent\Model This instance after recalculation 28 | */ 29 | public function addAmountExclTax($amount, $description, $taxPercentage = 0) 30 | { 31 | $tax = $amount * $taxPercentage; 32 | $this->lines()->create([ 33 | 'amount' => $amount + $tax, 34 | 'description' => $description, 35 | 'tax' => $tax, 36 | 'tax_percentage' => $taxPercentage, 37 | ]); 38 | 39 | return $this->recalculate(); 40 | } 41 | 42 | /** 43 | * Use this if the amount already includes tax. 44 | * @param Int $amount The amount in cents, including taxes 45 | * @param String $description The description 46 | * @param Float $taxPercentage The tax percentage (i.e. 0.21). Defaults to 0 47 | * @return Illuminate\Database\Eloquent\Model This instance after recalculation 48 | */ 49 | public function addAmountInclTax($amount, $description, $taxPercentage = 0) 50 | { 51 | $this->lines()->create([ 52 | 'amount' => $amount, 53 | 'description' => $description, 54 | 'tax' => $amount - $amount / (1 + $taxPercentage), 55 | 'tax_percentage' => $taxPercentage, 56 | ]); 57 | 58 | return $this->recalculate(); 59 | } 60 | 61 | /** 62 | * Recalculates total and tax based on lines 63 | * @return Illuminate\Database\Eloquent\Model This instance 64 | */ 65 | public function recalculate() 66 | { 67 | $this->total = $this->lines()->sum('amount'); 68 | $this->tax = $this->lines()->sum('tax'); 69 | $this->save(); 70 | 71 | return $this; 72 | } 73 | 74 | /** 75 | * Get the View instance for the invoice. 76 | * 77 | * @param array $data 78 | * @return \Illuminate\View\View 79 | */ 80 | public function view(array $data = []) 81 | { 82 | return View::make('invoicable::receipt', array_merge($data, [ 83 | 'invoice' => $this, 84 | 'moneyFormatter' => new MoneyFormatter( 85 | $this->currency, 86 | config('invoicable.locale') 87 | ), 88 | ])); 89 | } 90 | 91 | /** 92 | * Capture the invoice as a PDF and return the raw bytes. 93 | * 94 | * @param array $data 95 | * @return string 96 | */ 97 | public function pdf(array $data = []) 98 | { 99 | if (! defined('DOMPDF_ENABLE_AUTOLOAD')) { 100 | define('DOMPDF_ENABLE_AUTOLOAD', false); 101 | } 102 | 103 | if (file_exists($configPath = base_path().'/vendor/dompdf/dompdf/dompdf_config.inc.php')) { 104 | require_once $configPath; 105 | } 106 | 107 | $dompdf = new Dompdf; 108 | $dompdf->loadHtml($this->view($data)->render()); 109 | $dompdf->render(); 110 | 111 | return $dompdf->output(); 112 | } 113 | 114 | /** 115 | * Create an invoice download response. 116 | * 117 | * @param array $data 118 | * @return \Symfony\Component\HttpFoundation\Response 119 | */ 120 | public function download(array $data = []) 121 | { 122 | $filename = $this->reference . '.pdf'; 123 | 124 | return new Response($this->pdf($data), 200, [ 125 | 'Content-Description' => 'File Transfer', 126 | 'Content-Disposition' => 'attachment; filename="'.$filename.'"', 127 | 'Content-Transfer-Encoding' => 'binary', 128 | 'Content-Type' => 'application/pdf', 129 | ]); 130 | } 131 | 132 | public static function findByReference($reference) 133 | { 134 | return static::where('reference', $reference)->first(); 135 | } 136 | 137 | public static function findByReferenceOrFail($reference) 138 | { 139 | return static::where('reference', $reference)->firstOrFail(); 140 | } 141 | 142 | public function invoicable() 143 | { 144 | return $this->morphTo(); 145 | } 146 | 147 | protected static function boot() 148 | { 149 | parent::boot(); 150 | 151 | static::creating(function ($model) { 152 | $model->currency = config('invoicable.default_currency', 'EUR'); 153 | $model->status = config('invoicable.default_status', 'concept'); 154 | $model->reference = InvoiceReferenceGenerator::generate(); 155 | }); 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /src/InvoiceLine.php: -------------------------------------------------------------------------------- 1 | belongsTo(Invoice::class); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/InvoiceReferenceGenerator.php: -------------------------------------------------------------------------------- 1 | format('Y-m-d') . '-' . self::generateRandomCode(); 14 | } 15 | 16 | protected static function generateRandomCode() 17 | { 18 | $pool = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; 19 | 20 | return substr(str_shuffle(str_repeat($pool, 6)), 0, 6); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/IsInvoicable/IsInvoicableTrait.php: -------------------------------------------------------------------------------- 1 | morphMany(Invoice::class, 'invoicable'); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/MoneyFormatter.php: -------------------------------------------------------------------------------- 1 | currency = $currency; 24 | $this->locale = $locale; 25 | } 26 | 27 | /** 28 | * Gets the amount formatted according the currency and locale 29 | * @param $amount The amount in cents(!) 30 | * @return String The current locale 31 | */ 32 | public function format($amount) 33 | { 34 | $formatter = new \NumberFormatter($this->locale, \NumberFormatter::CURRENCY); 35 | 36 | return (string) $formatter->formatCurrency($amount / 100, $this->currency); 37 | } 38 | 39 | /** 40 | * Gets the current locale 41 | * @return String The current locale 42 | */ 43 | public function getLocale() : string 44 | { 45 | return (string) $this->locale; 46 | } 47 | 48 | /** 49 | * Sets the current locale 50 | * @param $locale The locale (i.e. 'nl_NL') 51 | * @return Void 52 | */ 53 | public function setLocale(string $locale) 54 | { 55 | $this->locale = $locale; 56 | } 57 | 58 | /** 59 | * Gets the current currency 60 | * @return String The current currency 61 | */ 62 | public function getCurrency() 63 | { 64 | return (string) $this->currency; 65 | } 66 | 67 | /** 68 | * Sets the current currency 69 | * @param $currency The currency (i.e. 'EUR') 70 | * @return Void 71 | */ 72 | public function setCurrency(string $currency) 73 | { 74 | $this->currency = $currency; 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /tests/AbstractTestCase.php: -------------------------------------------------------------------------------- 1 | set('database.default', 'sqlite'); 15 | $app['config']->set('database.connections.sqlite', [ 16 | 'driver' => 'sqlite', 17 | 'database' => ':memory:', 18 | 'prefix' => '', 19 | ]); 20 | } 21 | 22 | /** 23 | * Get the service provider class. 24 | * @return string 25 | */ 26 | protected function getServiceProviderClass() 27 | { 28 | return InvoicableServiceProvider::class; 29 | } 30 | 31 | protected function setUp() : void 32 | { 33 | parent::setUp(); 34 | $this->withPackageMigrations(); 35 | } 36 | 37 | protected function withPackageMigrations() 38 | { 39 | include_once __DIR__.'/CreateTestModelsTable.php'; 40 | (new \SanderVanHooft\Invoicable\CreateTestModelsTable())->up(); 41 | include_once __DIR__.'/../database/migrations/2017_06_17_163005_create_invoices_tables.php'; 42 | (new \CreateInvoicesTables())->up(); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /tests/CreateTestModelsTable.php: -------------------------------------------------------------------------------- 1 | increments('id'); 20 | $table->timestamps(); 21 | }); 22 | } 23 | 24 | /** 25 | * Reverse the migrations. 26 | * 27 | * @return void 28 | */ 29 | public function down() 30 | { 31 | Schema::dropIfExists('test_models'); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /tests/Feature/InvoiceTest.php: -------------------------------------------------------------------------------- 1 | testModel = new TestModel(); 18 | $this->testModel->save(); 19 | $this->invoice = $this->testModel->invoices()->create([])->fresh(); 20 | } 21 | 22 | /** @test */ 23 | public function canCreateInvoice() 24 | { 25 | $this->invoice = $this->testModel->invoices()->create([])->fresh(); 26 | 27 | $this->assertEquals("0", (string) $this->invoice->total); 28 | $this->assertEquals("0", (string) $this->invoice->tax); 29 | $this->assertEquals("EUR", $this->invoice->currency); 30 | $this->assertEquals("concept", $this->invoice->status); 31 | $this->assertNotNull($this->invoice->reference); 32 | } 33 | 34 | /** @test */ 35 | public function canAddAmountExclTaxToInvoice() 36 | { 37 | $this->invoice = $this->testModel->invoices()->create([])->fresh(); 38 | 39 | $this->invoice->addAmountExclTax(100, 'Some description', 0.21); 40 | $this->invoice->addAmountExclTax(100, 'Some description', 0.21); 41 | 42 | $this->assertEquals("242", (string) $this->invoice->total); 43 | $this->assertEquals("42", (string) $this->invoice->tax); 44 | } 45 | 46 | /** @test */ 47 | public function canAddAmountInclTaxToInvoice() 48 | { 49 | $this->invoice = $this->testModel->invoices()->create([])->fresh(); 50 | 51 | $this->invoice->addAmountInclTax(121, 'Some description', 0.21); 52 | $this->invoice->addAmountInclTax(121, 'Some description', 0.21); 53 | 54 | $this->assertEquals("242", (string) $this->invoice->total); 55 | $this->assertEquals("42", (string) $this->invoice->tax); 56 | } 57 | 58 | /** @test */ 59 | public function canHandleNegativeAmounts() 60 | { 61 | $this->invoice = $this->testModel->invoices()->create([])->fresh(); 62 | 63 | $this->invoice->addAmountInclTax(121, 'Some description', 0.21); 64 | $this->invoice->addAmountInclTax(-121, 'Some negative amount description', 0.21); 65 | 66 | $this->assertEquals("0", (string) $this->invoice->total); 67 | $this->assertEquals("0", (string) $this->invoice->tax); 68 | } 69 | 70 | /** @test */ 71 | public function hasUniqueReference() 72 | { 73 | $references = array_map(function () { 74 | return $this->testModel->invoices()->create([])->reference; 75 | }, range(1, 100)); 76 | 77 | $this->assertCount(100, array_unique($references)); 78 | } 79 | 80 | /** @test */ 81 | public function canGetInvoiceView() 82 | { 83 | $this->invoice->addAmountInclTax(121, 'Some description', 0.21); 84 | $this->invoice->addAmountInclTax(121, 'Some description', 0.21); 85 | $view = $this->invoice->view(); 86 | $rendered = $view->render(); // fails if view cannot be rendered 87 | $this->assertTrue(true); 88 | } 89 | 90 | /** @test */ 91 | public function canGetInvoicePdf() 92 | { 93 | $this->invoice->addAmountInclTax(121, 'Some description', 0.21); 94 | $this->invoice->addAmountInclTax(121, 'Some description', 0.21); 95 | $pdf = $this->invoice->pdf(); // fails if pdf cannot be rendered 96 | $this->assertTrue(true); 97 | } 98 | 99 | /** @test */ 100 | public function canDownloadInvoicePdf() 101 | { 102 | $this->invoice->addAmountInclTax(121, 'Some description', 0.21); 103 | $this->invoice->addAmountInclTax(121, 'Some description', 0.21); 104 | $download = $this->invoice->download(); // fails if pdf cannot be rendered 105 | $this->assertTrue(true); 106 | } 107 | 108 | /** @test */ 109 | public function canFindByReference() 110 | { 111 | $invoice = $this->testModel->invoices()->create([]); 112 | $this->assertEquals($invoice->id, Invoice::findByReference($invoice->reference)->id); 113 | } 114 | 115 | /** @test */ 116 | public function canFindByReferenceOrFail() 117 | { 118 | $invoice = $this->testModel->invoices()->create([]); 119 | $this->assertEquals($invoice->id, Invoice::findByReferenceOrFail($invoice->reference)->id); 120 | } 121 | 122 | /** @test */ 123 | public function canFindByReferenceOrFailThrowsExceptionForNonExistingReference() 124 | { 125 | $this->expectException('Illuminate\Database\Eloquent\ModelNotFoundException'); 126 | Invoice::findByReferenceOrFail('non-existing-reference'); 127 | } 128 | 129 | /** @test */ 130 | public function canAccessInvoicable() 131 | { 132 | // Check if correctly set on invoice 133 | $this->assertEquals(TestModel::class, $this->invoice->invoicable_type); 134 | $this->assertEquals($this->testModel->id, $this->invoice->invoicable_id); 135 | 136 | // Check if invoicable is accessible 137 | $this->assertNotNull($this->invoice->invoicable); 138 | $this->assertEquals(TestModel::class, get_class($this->invoice->invoicable)); 139 | $this->assertEquals($this->testModel->id, $this->invoice->invoicable->id); 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /tests/TestModel.php: -------------------------------------------------------------------------------- 1 | reference = InvoiceReferenceGenerator::generate(); 18 | $this->date = Carbon::now(); 19 | } 20 | 21 | /** @test */ 22 | public function mustBe17CharactersLong() 23 | { 24 | $this->assertEquals(17, strlen($this->reference)); 25 | } 26 | 27 | /** @test */ 28 | public function mustMatchFormat() 29 | { 30 | // assert invoice reference matches format YYYY-MM-DD-XXXXXX (X = alphanumeric character) 31 | $list = explode('-', $this->reference); 32 | 33 | $this->assertEquals($list[0], $this->date->year); 34 | $this->assertEquals($list[1], $this->date->month); 35 | $this->assertEquals($list[2], $this->date->day); 36 | $this->assertEquals(6, strlen($list[3])); 37 | $this->assertMatchesRegularExpression('/^[A-Z0-9]+$/', $list[3]); 38 | } 39 | 40 | /** @test */ 41 | public function cannotContainAmbiguousCharacters() 42 | { 43 | $code = substr($this->reference, -6); 44 | 45 | $this->assertFalse(strpos($code, '1')); 46 | $this->assertFalse(strpos($code, 'I')); 47 | $this->assertFalse(strpos($code, '0')); 48 | $this->assertFalse(strpos($code, 'O')); 49 | } 50 | 51 | /** @test */ 52 | public function mustBeUnique() 53 | { 54 | $references = array_map(function () { 55 | return InvoiceReferenceGenerator::generate(); 56 | }, range(1, 100)); 57 | 58 | $this->assertCount(100, array_unique($references)); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /tests/Unit/MoneyFormatterTest.php: -------------------------------------------------------------------------------- 1 | formatter = new MoneyFormatter(); 14 | } 15 | 16 | /** @test */ 17 | public function canHandleNegativeValues() 18 | { 19 | $this->assertTrue(in_array($this->formatter->format(-123456), [ 20 | '€ -1.234,56', 21 | '€ 1.234,56-', 22 | ])); 23 | } 24 | 25 | /** @test */ 26 | public function canFormatMoney() 27 | { 28 | $this->assertEquals('€ 1.234,56', $this->formatter->format(123456)); 29 | } 30 | 31 | /** @test */ 32 | public function changingTheCurrencyChangesTheFormatting() 33 | { 34 | $this->formatter->setCurrency('USD'); 35 | $this->assertEquals('US$ 1.234,56', $this->formatter->format(123456)); 36 | } 37 | 38 | /** @test */ 39 | public function changingTheLocaleChangesTheFormatting() 40 | { 41 | $this->formatter->setLocale('en_US'); 42 | $this->assertEquals('€1,234.56', $this->formatter->format(123456)); 43 | } 44 | 45 | /** @test */ 46 | public function changingTheCurrencyAndLocaleChangesTheFormatting() 47 | { 48 | $this->formatter->setCurrency('USD'); 49 | $this->formatter->setLocale('en_US'); 50 | $this->assertEquals('$1,234.56', $this->formatter->format(123456)); 51 | } 52 | } 53 | --------------------------------------------------------------------------------