Validation rules for modification invoice for Verifactu

This commit is contained in:
David Bomba 2025-08-12 09:17:57 +10:00
parent 6a0fff10ae
commit 47f33c8691
5 changed files with 240 additions and 57 deletions

View File

@ -11,12 +11,13 @@
namespace App\Http\Requests\Invoice; namespace App\Http\Requests\Invoice;
use App\Http\Requests\Request;
use App\Http\ValidationRules\Project\ValidProjectForClient;
use App\Models\Invoice; use App\Models\Invoice;
use App\Utils\Traits\CleanLineItems; use App\Http\Requests\Request;
use App\Utils\Traits\MakesHash; use App\Utils\Traits\MakesHash;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
use App\Utils\Traits\CleanLineItems;
use App\Http\ValidationRules\Project\ValidProjectForClient;
use App\Http\ValidationRules\Invoice\CanGenerateModificationInvoice;
class StoreInvoiceRequest extends Request class StoreInvoiceRequest extends Request
{ {
@ -89,6 +90,9 @@ class StoreInvoiceRequest extends Request
$rules['custom_surcharge4'] = ['sometimes', 'nullable', 'bail', 'numeric', 'max:99999999999999']; $rules['custom_surcharge4'] = ['sometimes', 'nullable', 'bail', 'numeric', 'max:99999999999999'];
$rules['location_id'] = ['nullable', 'sometimes','bail', Rule::exists('locations', 'id')->where('company_id', $user->company()->id)->where('client_id', $this->client_id)]; $rules['location_id'] = ['nullable', 'sometimes','bail', Rule::exists('locations', 'id')->where('company_id', $user->company()->id)->where('client_id', $this->client_id)];
$rules['verifactu_modified'] = ['bail', 'boolean', 'required_with:modified_invoice_id'];
$rules['modified_invoice_id'] = ['bail', 'required_with:verifactu_modified', new CanGenerateModificationInvoice()];
return $rules; return $rules;
} }

View File

@ -0,0 +1,63 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2025. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\Http\ValidationRules\Invoice;
use Closure;
use App\Models\Invoice;
use App\Utils\Traits\MakesHash;
use Illuminate\Contracts\Validation\ValidationRule;
/**
* Class CanGenerateModificationInvoice.
*/
class CanGenerateModificationInvoice implements ValidationRule
{
use MakesHash;
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if (empty($value)) {
return;
}
$user = auth()->user();
$company = $user->company();
/** For verifactu, we do not allow restores of deleted invoices */
if (!$company->verifactuEnabled())
$fail("Verifactu is not enabled for this company");
$invoice = Invoice::withTrashed()->find($this->decodePrimaryKey($value));
if (is_null($invoice)) {
$fail("Invoice not found.");
}elseif($invoice->is_deleted) {
$fail("Cannot create a modification invoice for a deleted invoice.");
} elseif($invoice->status_id === Invoice::STATUS_DRAFT){
$fail("Cannot create a modification invoice for a draft invoice.");
} elseif(in_array($invoice->status_id, [Invoice::STATUS_PARTIAL, Invoice::STATUS_PAID])) {
$fail("Cannot create a modification invoice where a payment has been made.");
} elseif($invoice->status_id === Invoice::STATUS_CANCELLED ) {
$fail("Cannot create a modification invoice for a cancelled invoice.");
} elseif($invoice->status_id === Invoice::STATUS_REPLACED) {
$fail("Cannot create a modification invoice for a replaced invoice.");
} elseif($invoice->status_id === Invoice::STATUS_REVERSED) {
$fail("Cannot create a modification invoice for a reversed invoice.");
} elseif ($invoice->status_id !== Invoice::STATUS_SENT) {
$fail("Cannot create a modification invoice.");
}
}
}

View File

@ -243,9 +243,9 @@ class Invoice extends BaseModel
public const STATUS_REVERSED = 6; public const STATUS_REVERSED = 6;
public const STATUS_OVERDUE = -1; //status < 4 || < 3 && !is_deleted && !trashed() && due_date < now() public const STATUS_OVERDUE = -1; // status < 4 || < 3 && !is_deleted && !trashed() && due_date < now()
public const STATUS_UNPAID = -2; //status < 4 || < 3 && !is_deleted && !trashed() public const STATUS_UNPAID = -2; // status < 4 || < 3 && !is_deleted && !trashed()
public const STATUS_REPLACED = 7; // handle the case where the invoice is replaced by another invoice. public const STATUS_REPLACED = 7; // handle the case where the invoice is replaced by another invoice.

119
composer.lock generated
View File

@ -1819,33 +1819,32 @@
}, },
{ {
"name": "doctrine/inflector", "name": "doctrine/inflector",
"version": "2.0.10", "version": "2.1.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/doctrine/inflector.git", "url": "https://github.com/doctrine/inflector.git",
"reference": "5817d0659c5b50c9b950feb9af7b9668e2c436bc" "reference": "6d6c96277ea252fc1304627204c3d5e6e15faa3b"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/doctrine/inflector/zipball/5817d0659c5b50c9b950feb9af7b9668e2c436bc", "url": "https://api.github.com/repos/doctrine/inflector/zipball/6d6c96277ea252fc1304627204c3d5e6e15faa3b",
"reference": "5817d0659c5b50c9b950feb9af7b9668e2c436bc", "reference": "6d6c96277ea252fc1304627204c3d5e6e15faa3b",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
"php": "^7.2 || ^8.0" "php": "^7.2 || ^8.0"
}, },
"require-dev": { "require-dev": {
"doctrine/coding-standard": "^11.0", "doctrine/coding-standard": "^12.0 || ^13.0",
"phpstan/phpstan": "^1.8", "phpstan/phpstan": "^1.12 || ^2.0",
"phpstan/phpstan-phpunit": "^1.1", "phpstan/phpstan-phpunit": "^1.4 || ^2.0",
"phpstan/phpstan-strict-rules": "^1.3", "phpstan/phpstan-strict-rules": "^1.6 || ^2.0",
"phpunit/phpunit": "^8.5 || ^9.5", "phpunit/phpunit": "^8.5 || ^12.2"
"vimeo/psalm": "^4.25 || ^5.4"
}, },
"type": "library", "type": "library",
"autoload": { "autoload": {
"psr-4": { "psr-4": {
"Doctrine\\Inflector\\": "lib/Doctrine/Inflector" "Doctrine\\Inflector\\": "src"
} }
}, },
"notification-url": "https://packagist.org/downloads/", "notification-url": "https://packagist.org/downloads/",
@ -1890,7 +1889,7 @@
], ],
"support": { "support": {
"issues": "https://github.com/doctrine/inflector/issues", "issues": "https://github.com/doctrine/inflector/issues",
"source": "https://github.com/doctrine/inflector/tree/2.0.10" "source": "https://github.com/doctrine/inflector/tree/2.1.0"
}, },
"funding": [ "funding": [
{ {
@ -1906,7 +1905,7 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2024-02-18T20:23:39+00:00" "time": "2025-08-10T19:31:58+00:00"
}, },
{ {
"name": "doctrine/instantiator", "name": "doctrine/instantiator",
@ -3034,7 +3033,7 @@
}, },
{ {
"name": "google/apiclient-services", "name": "google/apiclient-services",
"version": "v0.406.0", "version": "v0.407.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/googleapis/google-api-php-client-services.git", "url": "https://github.com/googleapis/google-api-php-client-services.git",
@ -3072,7 +3071,7 @@
], ],
"support": { "support": {
"issues": "https://github.com/googleapis/google-api-php-client-services/issues", "issues": "https://github.com/googleapis/google-api-php-client-services/issues",
"source": "https://github.com/googleapis/google-api-php-client-services/tree/v0.406.0" "source": "https://github.com/googleapis/google-api-php-client-services/tree/v0.407.0"
}, },
"time": "2025-06-04T17:28:44+00:00" "time": "2025-06-04T17:28:44+00:00"
}, },
@ -9556,16 +9555,16 @@
}, },
{ {
"name": "phpoffice/phpspreadsheet", "name": "phpoffice/phpspreadsheet",
"version": "2.3.10", "version": "2.4.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/PHPOffice/PhpSpreadsheet.git", "url": "https://github.com/PHPOffice/PhpSpreadsheet.git",
"reference": "22058ce75b774bf40ceefcadd090a424d558f1ca" "reference": "3a3cad86101a77019eb2fc693aab1a8c11b18b94"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/22058ce75b774bf40ceefcadd090a424d558f1ca", "url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/3a3cad86101a77019eb2fc693aab1a8c11b18b94",
"reference": "22058ce75b774bf40ceefcadd090a424d558f1ca", "reference": "3a3cad86101a77019eb2fc693aab1a8c11b18b94",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@ -9655,9 +9654,9 @@
], ],
"support": { "support": {
"issues": "https://github.com/PHPOffice/PhpSpreadsheet/issues", "issues": "https://github.com/PHPOffice/PhpSpreadsheet/issues",
"source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/2.3.10" "source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/2.4.0"
}, },
"time": "2025-07-23T04:43:28+00:00" "time": "2025-08-10T06:45:13+00:00"
}, },
{ {
"name": "phpoption/phpoption", "name": "phpoption/phpoption",
@ -17968,16 +17967,16 @@
}, },
{ {
"name": "filp/whoops", "name": "filp/whoops",
"version": "2.18.3", "version": "2.18.4",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/filp/whoops.git", "url": "https://github.com/filp/whoops.git",
"reference": "59a123a3d459c5a23055802237cb317f609867e5" "reference": "d2102955e48b9fd9ab24280a7ad12ed552752c4d"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/filp/whoops/zipball/59a123a3d459c5a23055802237cb317f609867e5", "url": "https://api.github.com/repos/filp/whoops/zipball/d2102955e48b9fd9ab24280a7ad12ed552752c4d",
"reference": "59a123a3d459c5a23055802237cb317f609867e5", "reference": "d2102955e48b9fd9ab24280a7ad12ed552752c4d",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@ -18027,7 +18026,7 @@
], ],
"support": { "support": {
"issues": "https://github.com/filp/whoops/issues", "issues": "https://github.com/filp/whoops/issues",
"source": "https://github.com/filp/whoops/tree/2.18.3" "source": "https://github.com/filp/whoops/tree/2.18.4"
}, },
"funding": [ "funding": [
{ {
@ -18035,7 +18034,7 @@
"type": "github" "type": "github"
} }
], ],
"time": "2025-06-16T00:02:10+00:00" "time": "2025-08-08T12:00:00+00:00"
}, },
{ {
"name": "friendsofphp/php-cs-fixer", "name": "friendsofphp/php-cs-fixer",
@ -19150,16 +19149,16 @@
}, },
{ {
"name": "phpunit/phpunit", "name": "phpunit/phpunit",
"version": "11.5.28", "version": "11.5.31",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/sebastianbergmann/phpunit.git", "url": "https://github.com/sebastianbergmann/phpunit.git",
"reference": "93f30aa3889e785ac63493d4976df0ae9fdecb60" "reference": "fc44414e0779e94640663b809557b0b599548260"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/93f30aa3889e785ac63493d4976df0ae9fdecb60", "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/fc44414e0779e94640663b809557b0b599548260",
"reference": "93f30aa3889e785ac63493d4976df0ae9fdecb60", "reference": "fc44414e0779e94640663b809557b0b599548260",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@ -19169,7 +19168,7 @@
"ext-mbstring": "*", "ext-mbstring": "*",
"ext-xml": "*", "ext-xml": "*",
"ext-xmlwriter": "*", "ext-xmlwriter": "*",
"myclabs/deep-copy": "^1.13.3", "myclabs/deep-copy": "^1.13.4",
"phar-io/manifest": "^2.0.4", "phar-io/manifest": "^2.0.4",
"phar-io/version": "^3.2.1", "phar-io/version": "^3.2.1",
"php": ">=8.2", "php": ">=8.2",
@ -19180,13 +19179,13 @@
"phpunit/php-timer": "^7.0.1", "phpunit/php-timer": "^7.0.1",
"sebastian/cli-parser": "^3.0.2", "sebastian/cli-parser": "^3.0.2",
"sebastian/code-unit": "^3.0.3", "sebastian/code-unit": "^3.0.3",
"sebastian/comparator": "^6.3.1", "sebastian/comparator": "^6.3.2",
"sebastian/diff": "^6.0.2", "sebastian/diff": "^6.0.2",
"sebastian/environment": "^7.2.1", "sebastian/environment": "^7.2.1",
"sebastian/exporter": "^6.3.0", "sebastian/exporter": "^6.3.0",
"sebastian/global-state": "^7.0.2", "sebastian/global-state": "^7.0.2",
"sebastian/object-enumerator": "^6.0.1", "sebastian/object-enumerator": "^6.0.1",
"sebastian/type": "^5.1.2", "sebastian/type": "^5.1.3",
"sebastian/version": "^5.0.2", "sebastian/version": "^5.0.2",
"staabm/side-effects-detector": "^1.0.5" "staabm/side-effects-detector": "^1.0.5"
}, },
@ -19231,7 +19230,7 @@
"support": { "support": {
"issues": "https://github.com/sebastianbergmann/phpunit/issues", "issues": "https://github.com/sebastianbergmann/phpunit/issues",
"security": "https://github.com/sebastianbergmann/phpunit/security/policy", "security": "https://github.com/sebastianbergmann/phpunit/security/policy",
"source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.28" "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.31"
}, },
"funding": [ "funding": [
{ {
@ -19255,7 +19254,7 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2025-07-31T07:10:28+00:00" "time": "2025-08-11T05:27:39+00:00"
}, },
{ {
"name": "react/cache", "name": "react/cache",
@ -19955,16 +19954,16 @@
}, },
{ {
"name": "sebastian/comparator", "name": "sebastian/comparator",
"version": "6.3.1", "version": "6.3.2",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/sebastianbergmann/comparator.git", "url": "https://github.com/sebastianbergmann/comparator.git",
"reference": "24b8fbc2c8e201bb1308e7b05148d6ab393b6959" "reference": "85c77556683e6eee4323e4c5468641ca0237e2e8"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/24b8fbc2c8e201bb1308e7b05148d6ab393b6959", "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/85c77556683e6eee4323e4c5468641ca0237e2e8",
"reference": "24b8fbc2c8e201bb1308e7b05148d6ab393b6959", "reference": "85c77556683e6eee4323e4c5468641ca0237e2e8",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@ -20023,15 +20022,27 @@
"support": { "support": {
"issues": "https://github.com/sebastianbergmann/comparator/issues", "issues": "https://github.com/sebastianbergmann/comparator/issues",
"security": "https://github.com/sebastianbergmann/comparator/security/policy", "security": "https://github.com/sebastianbergmann/comparator/security/policy",
"source": "https://github.com/sebastianbergmann/comparator/tree/6.3.1" "source": "https://github.com/sebastianbergmann/comparator/tree/6.3.2"
}, },
"funding": [ "funding": [
{ {
"url": "https://github.com/sebastianbergmann", "url": "https://github.com/sebastianbergmann",
"type": "github" "type": "github"
},
{
"url": "https://liberapay.com/sebastianbergmann",
"type": "liberapay"
},
{
"url": "https://thanks.dev/u/gh/sebastianbergmann",
"type": "thanks_dev"
},
{
"url": "https://tidelift.com/funding/github/packagist/sebastian/comparator",
"type": "tidelift"
} }
], ],
"time": "2025-03-07T06:57:01+00:00" "time": "2025-08-10T08:07:46+00:00"
}, },
{ {
"name": "sebastian/complexity", "name": "sebastian/complexity",
@ -20612,16 +20623,16 @@
}, },
{ {
"name": "sebastian/type", "name": "sebastian/type",
"version": "5.1.2", "version": "5.1.3",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/sebastianbergmann/type.git", "url": "https://github.com/sebastianbergmann/type.git",
"reference": "a8a7e30534b0eb0c77cd9d07e82de1a114389f5e" "reference": "f77d2d4e78738c98d9a68d2596fe5e8fa380f449"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/sebastianbergmann/type/zipball/a8a7e30534b0eb0c77cd9d07e82de1a114389f5e", "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/f77d2d4e78738c98d9a68d2596fe5e8fa380f449",
"reference": "a8a7e30534b0eb0c77cd9d07e82de1a114389f5e", "reference": "f77d2d4e78738c98d9a68d2596fe5e8fa380f449",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@ -20657,15 +20668,27 @@
"support": { "support": {
"issues": "https://github.com/sebastianbergmann/type/issues", "issues": "https://github.com/sebastianbergmann/type/issues",
"security": "https://github.com/sebastianbergmann/type/security/policy", "security": "https://github.com/sebastianbergmann/type/security/policy",
"source": "https://github.com/sebastianbergmann/type/tree/5.1.2" "source": "https://github.com/sebastianbergmann/type/tree/5.1.3"
}, },
"funding": [ "funding": [
{ {
"url": "https://github.com/sebastianbergmann", "url": "https://github.com/sebastianbergmann",
"type": "github" "type": "github"
},
{
"url": "https://liberapay.com/sebastianbergmann",
"type": "liberapay"
},
{
"url": "https://thanks.dev/u/gh/sebastianbergmann",
"type": "thanks_dev"
},
{
"url": "https://tidelift.com/funding/github/packagist/sebastian/type",
"type": "tidelift"
} }
], ],
"time": "2025-03-18T13:35:50+00:00" "time": "2025-08-09T06:55:48+00:00"
}, },
{ {
"name": "sebastian/version", "name": "sebastian/version",

View File

@ -27,6 +27,7 @@ use Illuminate\Support\Facades\Config;
use App\Repositories\InvoiceRepository; use App\Repositories\InvoiceRepository;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Session; use Illuminate\Support\Facades\Session;
use Illuminate\Support\Str;
use Illuminate\Foundation\Testing\DatabaseTransactions; use Illuminate\Foundation\Testing\DatabaseTransactions;
class VerifactuApiTest extends TestCase class VerifactuApiTest extends TestCase
@ -46,7 +47,7 @@ class VerifactuApiTest extends TestCase
$this->makeTestData(); $this->makeTestData();
} }
public function test_cancel_invoice_response() private function buildData()
{ {
$item = new InvoiceItem(); $item = new InvoiceItem();
@ -57,12 +58,13 @@ class VerifactuApiTest extends TestCase
$item->discount = 0; $item->discount = 0;
$item->tax_rate1 = 21; $item->tax_rate1 = 21;
$item->tax_name1 = 'IVA'; $item->tax_name1 = 'IVA';
/** @var \App\Models\Invoice $invoice */
$invoice = Invoice::factory()->create([ $invoice = Invoice::factory()->create([
'company_id' => $this->company->id, 'company_id' => $this->company->id,
'client_id' => $this->client->id, 'client_id' => $this->client->id,
'user_id' => $this->user->id, 'user_id' => $this->user->id,
'number' => 'INV-0001', 'number' => Str::random(32),
'date' => now()->format('Y-m-d'), 'date' => now()->format('Y-m-d'),
'due_date' => now()->addDays(100)->format('Y-m-d'), 'due_date' => now()->addDays(100)->format('Y-m-d'),
'status_id' => Invoice::STATUS_DRAFT, 'status_id' => Invoice::STATUS_DRAFT,
@ -81,10 +83,101 @@ class VerifactuApiTest extends TestCase
'partial_due_date' => null, 'partial_due_date' => null,
'footer' => '', 'footer' => '',
]); ]);
$repo = new InvoiceRepository(); $repo = new InvoiceRepository();
$invoice = $repo->save([], $invoice); $invoice = $repo->save([], $invoice);
return $invoice;
}
public function test_create_modification_invoice_validation_fails()
{
$invoice = $this->buildData();;
$data = $invoice->toArray();
$data['verifactu_modified'] = true;
$response = $this->withHeaders([
'X-API-SECRET' => config('ninja.api_secret'),
'X-API-TOKEN' => $this->token,
])->postJson('/api/v1/invoices', $data);
$response->assertStatus(422);
}
public function test_create_modification_invoice_validation_fails2()
{
$invoice = $this->buildData();;
$data = $invoice->toArray();
$data['verifactu_modified'] = true;
$data['modified_invoice_id'] = "XXX";
$response = $this->withHeaders([
'X-API-SECRET' => config('ninja.api_secret'),
'X-API-TOKEN' => $this->token,
])->postJson('/api/v1/invoices', $data);
$response->assertStatus(422);
}
public function test_create_modification_invoice_validation_fails3()
{
$invoice = $this->buildData();;
$invoice2 = $this->buildData();
$invoice2->service()->markPaid()->save();
$data = $invoice->toArray();
$data['verifactu_modified'] = true;
$data['modified_invoice_id'] = $invoice2->hashed_id;
$response = $this->withHeaders([
'X-API-SECRET' => config('ninja.api_secret'),
'X-API-TOKEN' => $this->token,
])->postJson('/api/v1/invoices', $data);
$response->assertStatus(422);
}
public function test_create_modification_invoice_validation_fails4()
{
$settings = $this->company->settings;
$settings->e_invoice_type = 'verifactu';
$this->company->settings = $settings;
$this->company->save();
$invoice = $this->buildData();;
$invoice2 = $this->buildData();
$invoice2->service()->markSent()->save();
$data = $invoice->toArray();
$data['verifactu_modified'] = true;
$data['modified_invoice_id'] = $invoice2->hashed_id;
$data['client_id'] = $this->client->hashed_id;
$data['number'] = null;
$response = $this->withHeaders([
'X-API-SECRET' => config('ninja.api_secret'),
'X-API-TOKEN' => $this->token,
])->postJson('/api/v1/invoices', $data);
$response->assertStatus(200);
}
public function test_cancel_invoice_response()
{
$invoice = $this->buildData();
$invoice->service()->markSent()->save(); $invoice->service()->markSent()->save();
$this->assertEquals($invoice->status_id, Invoice::STATUS_SENT); $this->assertEquals($invoice->status_id, Invoice::STATUS_SENT);