Tests around handling cancellations in verifactu

This commit is contained in:
David Bomba 2025-08-11 11:00:30 +10:00
parent f7961ecb61
commit a447b6a20b
7 changed files with 167 additions and 3 deletions

View File

@ -785,7 +785,7 @@ class InvoiceController extends BaseController
}
break;
case 'cancel':
$invoice = $invoice->service()->handleCancellation()->save();
$invoice = $invoice->service()->handleCancellation(request()->input('reason'))->save();
if (! $bulk) {
$this->itemResponse($invoice);
}

View File

@ -27,7 +27,7 @@ class RestoreDisabledRule implements ValidationRule
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if (empty($value) || $value != 'restore') {
if (empty($value) ||!in_array($value, ['delete', 'restore'])) {
return;
}
@ -36,9 +36,13 @@ class RestoreDisabledRule implements ValidationRule
$company = $user->company();
/** For verifactu, we do not allow restores of deleted invoices */
if($company->verifactuEnabled() && Invoice::withTrashed()->whereIn('id', $this->transformKeys(request()->ids))->where('company_id', $company->id)->where('is_deleted', true)->exists()) {
if($company->verifactuEnabled() && $value == 'restore' &&Invoice::withTrashed()->whereIn('id', $this->transformKeys(request()->ids))->where('company_id', $company->id)->where('is_deleted', true)->exists()) {
$fail(ctrans('texts.restore_disabled_verifactu'));
}
if ($company->verifactuEnabled() && $value == 'delete' && Invoice::withTrashed()->whereIn('id', $this->transformKeys(request()->ids))->where('company_id', $company->id)->where('status_id', Invoice::STATUS_CANCELLED)->exists()) {
$fail(ctrans('texts.delete_disabled_verifactu'));
}
}
}

View File

@ -13,6 +13,7 @@ namespace App\Services\Invoice;
use App\Events\Invoice\InvoiceWasCancelled;
use App\Models\Invoice;
use App\Repositories\InvoiceRepository;
use App\Services\AbstractService;
use App\Utils\Ninja;
use App\Utils\Traits\GeneratesCounter;
@ -33,6 +34,10 @@ class HandleCancellation extends AbstractService
return $this->invoice;
}
if($this->invoice->company->verifactuEnabled()) {
return $this->verifactuCancellation();
}
$adjustment = ($this->invoice->balance < 0) ? abs($this->invoice->balance) : $this->invoice->balance * -1;
$this->backupCancellation($adjustment);
@ -55,6 +60,59 @@ class HandleCancellation extends AbstractService
return $this->invoice;
}
private function verifactuCancellation(): Invoice
{
$replicated_invoice = $this->invoice->replicate();
$this->invoice = $this->invoice->service()->setStatus(Invoice::STATUS_CANCELLED)->save();
$this->invoice->service()->workFlow()->save();
$replicated_invoice->status_id = Invoice::STATUS_DRAFT;
$replicated_invoice->date = now()->format('Y-m-d');
$replicated_invoice->due_date = null;
$replicated_invoice->partial = 0;
$replicated_invoice->partial_due_date = null;
$replicated_invoice->number = null;
$replicated_invoice->amount = 0;
$replicated_invoice->balance = 0;
$replicated_invoice->paid_to_date = 0;
$items = $replicated_invoice->line_items;
foreach($items as &$item) {
$item->quantity = $item->quantity * -1;
}
$replicated_invoice->line_items = $items;
$backup = new \stdClass();
$backup->cancelled_invoice_id = $this->invoice->hashed_id;
$backup->cancelled_invoice_number = $this->invoice->number;
$backup->cancellation_reason = $this->reason ?? 'R3';
$replicated_invoice->backup = $backup;
$invoice_repository = new InvoiceRepository();
$replicated_invoice = $invoice_repository->save([], $replicated_invoice);
$replicated_invoice->service()->markSent()->sendVerifactu()->save();
$old_backup = new \stdClass();
$old_backup->credit_invoice_id = $replicated_invoice->hashed_id;
$old_backup->credit_invoice_number = $replicated_invoice->number;
$old_backup->cancellation_reason = $this->reason ?? 'R3';
$this->invoice->backup = $old_backup;
$this->invoice->saveQuietly();
$this->invoice->fresh();
event(new InvoiceWasCancelled($this->invoice, $this->invoice->company, Ninja::eventVars(auth()->user() ? auth()->user()->id : null)));
event('eloquent.updated: App\Models\Invoice', $this->invoice);
return $this->invoice;
}
public function reverse()
{
/* The stored cancelled object - contains the adjustment and status*/

View File

@ -674,6 +674,15 @@ class InvoiceService
}
//@todo - verifactu
public function sendVerifactu()
{
// if($this->invoice->company->verifactuEnabled()) {
// (new SendVerifactu($this->invoice))->handle();
// }
return $this;
}
/**
* Saves the invoice.
* @return Invoice object

View File

@ -17,6 +17,11 @@ trait ActionsInvoice
{
public function invoiceDeletable($invoice): bool
{
//Cancelled invoices are not deletable if verifactu is enabled
if($invoice->company->verifactuEnabled() && $invoice->status_id == Invoice::STATUS_CANCELLED) {
return false;
}
if ($invoice->status_id <= Invoice::STATUS_SENT &&
$invoice->is_deleted == false &&
$invoice->deleted_at == null &&

View File

@ -5574,6 +5574,7 @@ $lang = array(
'selected_products' => 'Selected Products',
'create_company_error_unauthorized' => 'You are not authorized to create a company. Only the account owner can create a company.',
'restore_disabled_verifactu' => 'You cannot restore an invoice once it has been deleted',
'delete_disabled_verifactu' => 'You cannot delete an invoice once it has been cancelled',
);
return $lang;

View File

@ -11,6 +11,7 @@
namespace Tests\Feature\EInvoice\Verifactu;
use App\DataMapper\InvoiceItem;
use Tests\TestCase;
use App\Models\Client;
use App\Models\Invoice;
@ -45,6 +46,92 @@ class VerifactuApiTest extends TestCase
$this->makeTestData();
}
public function test_cancel_invoice_response()
{
$item = new InvoiceItem();
$item->quantity = 1;
$item->product_key = 'product_1';
$item->notes = 'Product 1';
$item->cost = 100;
$item->discount = 0;
$item->tax_rate1 = 21;
$item->tax_name1 = 'IVA';
$invoice = Invoice::factory()->create([
'company_id' => $this->company->id,
'client_id' => $this->client->id,
'user_id' => $this->user->id,
'number' => 'INV-0001',
'date' => now()->format('Y-m-d'),
'due_date' => now()->addDays(100)->format('Y-m-d'),
'status_id' => Invoice::STATUS_DRAFT,
'is_deleted' => false,
'tax_rate1' => 0,
'tax_name1' => '',
'tax_rate2' => 0,
'tax_name2' => '',
'tax_rate3' => 0,
'tax_name3' => '',
'line_items' => [$item],
'discount' => 0,
'uses_inclusive_taxes' => false,
'exchange_rate' => 1,
'partial' => 0,
'partial_due_date' => null,
'footer' => '',
]);
$repo = new InvoiceRepository();
$invoice = $repo->save([], $invoice);
$invoice->service()->markSent()->save();
$this->assertEquals($invoice->status_id, Invoice::STATUS_SENT);
$this->assertEquals($invoice->balance, 121);
$this->assertEquals($invoice->amount, 121);
$settings = $this->company->settings;
$settings->e_invoice_type = 'verifactu';
$this->company->settings = $settings;
$this->company->save();
$data = [
'action' => 'cancel',
'ids' => [$invoice->hashed_id],
'reason' => 'R3'
];
$response = $this->withHeaders([
'X-API-SECRET' => config('ninja.api_secret'),
'X-API-TOKEN' => $this->token,
])->postJson('/api/v1/invoices/bulk', $data);
$response->assertStatus(200);
$arr = $response->json();
$this->assertEquals($arr['data'][0]['status_id'], Invoice::STATUS_CANCELLED);
$this->assertEquals($arr['data'][0]['balance'], 121);
$this->assertEquals($arr['data'][0]['amount'], 121);
$this->assertNotNull($arr['data'][0]['backup']['credit_invoice_id']);
$this->assertNotNull($arr['data'][0]['backup']['credit_invoice_number']);
$this->assertEquals($arr['data'][0]['backup']['cancellation_reason'], 'R3');
$credit_invoice = Invoice::find($this->decodePrimaryKey($arr['data'][0]['backup']['credit_invoice_id']));
nlog($credit_invoice->toArray());
$this->assertNotNull($credit_invoice);
$this->assertEquals($credit_invoice->status_id, Invoice::STATUS_SENT);
$this->assertEquals($credit_invoice->balance, -121);
$this->assertEquals($credit_invoice->amount, -121);
$this->assertEquals($credit_invoice->backup->cancelled_invoice_id, $invoice->hashed_id);
$this->assertEquals($credit_invoice->backup->cancelled_invoice_number, $invoice->number);
$this->assertEquals($credit_invoice->backup->cancellation_reason, 'R3');
}
public function test_restore_invoice_validation()
{