From 8bc151359155fb08e02e35b7cf6adc367b0fdbc3 Mon Sep 17 00:00:00 2001 From: David Bomba Date: Wed, 13 Aug 2025 10:28:10 +1000 Subject: [PATCH] Wire up AEAT for processing --- .../Validation/Verifactu/InvoiceValidator.php | 4 +- .../EDocument/Standards/Verifactu.php | 36 +- .../Standards/Verifactu/Models/Invoice.php | 6 +- .../Standards/Verifactu/RegistroAlta.php | 35 +- .../Standards/Verifactu/SendToAeat.php | 33 +- composer.lock | 12 +- .../Verifactu/VerifactuFeatureTest.php | 315 ++++++++++++------ 7 files changed, 298 insertions(+), 143 deletions(-) diff --git a/app/Services/EDocument/Standards/Validation/Verifactu/InvoiceValidator.php b/app/Services/EDocument/Standards/Validation/Verifactu/InvoiceValidator.php index 427151a766..3462fd10c3 100644 --- a/app/Services/EDocument/Standards/Validation/Verifactu/InvoiceValidator.php +++ b/app/Services/EDocument/Standards/Validation/Verifactu/InvoiceValidator.php @@ -139,8 +139,8 @@ class InvoiceValidator $errors = []; // Check for required fields based on invoice type - if ($invoice->getTipoFactura() === 'R1' && !$invoice->getTipoRectificativa()) { - $errors[] = "Rectification invoices (R1) must specify TipoRectificativa"; + if ($invoice->getTipoFactura() === 'R2' && !$invoice->getTipoRectificativa()) { + $errors[] = "Rectification invoices (R2) must specify TipoRectificativa"; } // Check for simplified invoice requirements diff --git a/app/Services/EDocument/Standards/Verifactu.php b/app/Services/EDocument/Standards/Verifactu.php index 6f55a7561a..d73d5fe816 100644 --- a/app/Services/EDocument/Standards/Verifactu.php +++ b/app/Services/EDocument/Standards/Verifactu.php @@ -62,26 +62,29 @@ class Verifactu extends AbstractService $i_logs = $this->invoice->verifactu_logs; - if($i_logs->count() >= 1){ - $document = (new RegistroAlta($this->invoice))->run()->setRectification()->getInvoice(); - } - else{ - $document = (new RegistroAlta($this->invoice))->run()->getInvoice(); - } + // if($i_logs->count() >= 1){ + // $document = (new RegistroAlta($this->invoice))->run()->setRectification()->getInvoice(); + // } + // else{ + $document = (new RegistroAlta($this->invoice))->run(); + + if($this->invoice->amount < 0) { + $document = $document->setRectification(); + } + + $document = $document->getInvoice(); + // } //keep this state for logging later on successful send $this->_document = $document; $this->_previous_huella = ''; - //1. new => RegistraAlta if($v_logs->count() >= 1){ $v_log = $v_logs->first(); $this->_previous_huella = $v_log->hash; } - //3. cancelled => RegistroAnulacion - $this->_huella = $this->calculateHash($document, $this->_previous_huella); // careful with this! we'll need to reference this later $document->setHuella($this->_huella); @@ -90,7 +93,20 @@ class Verifactu extends AbstractService return $this; } - + + /** + * setHuella + * We need this for cancellation documents. + * + * @param string $huella + * @return self + */ + public function setHuella(string $huella): self + { + $this->_huella = $huella; + return $this; + } + public function getInvoice() { return $this->_document; diff --git a/app/Services/EDocument/Standards/Verifactu/Models/Invoice.php b/app/Services/EDocument/Standards/Verifactu/Models/Invoice.php index 2dc94e40a8..db42539c59 100644 --- a/app/Services/EDocument/Standards/Verifactu/Models/Invoice.php +++ b/app/Services/EDocument/Standards/Verifactu/Models/Invoice.php @@ -20,7 +20,7 @@ class Invoice extends BaseXmlModel implements XmlModelInterface { // Constants for invoice types public const TIPO_FACTURA_NORMAL = 'F1'; - public const TIPO_FACTURA_RECTIFICATIVA = 'R1'; + public const TIPO_FACTURA_RECTIFICATIVA = 'R2'; public const TIPO_FACTURA_SUSTITUIDA = 'F3'; // Constants for rectification types @@ -599,11 +599,11 @@ class Invoice extends BaseXmlModel implements XmlModelInterface throw new \InvalidArgumentException('DescripcionOperacion is required'); } - if ($this->cuotaTotal < 0) { + if ($this->tipoFactura !== self::TIPO_FACTURA_RECTIFICATIVA && $this->cuotaTotal < 0) { throw new \InvalidArgumentException('CuotaTotal must be a positive number'); } - if ($this->importeTotal < 0) { + if ($this->tipoFactura !== self::TIPO_FACTURA_RECTIFICATIVA && $this->importeTotal < 0) { throw new \InvalidArgumentException('ImporteTotal must be a positive number'); } diff --git a/app/Services/EDocument/Standards/Verifactu/RegistroAlta.php b/app/Services/EDocument/Standards/Verifactu/RegistroAlta.php index 7df104dd8a..b2f85749b8 100644 --- a/app/Services/EDocument/Standards/Verifactu/RegistroAlta.php +++ b/app/Services/EDocument/Standards/Verifactu/RegistroAlta.php @@ -29,11 +29,13 @@ use App\Services\EDocument\Standards\Verifactu\Models\SistemaInformatico; use App\Services\EDocument\Standards\Verifactu\Models\PersonaFisicaJuridica; use App\Services\EDocument\Standards\Verifactu\Models\Invoice as VerifactuInvoice; use App\Models\VerifactuLog; +use App\Utils\Traits\MakesHash; class RegistroAlta { - use Taxer; + use Taxer; use NumberFormatter; + use MakesHash; private Company $company; @@ -212,27 +214,36 @@ class RegistroAlta public function setRectification(): self { - $this->v_invoice->setTipoFactura('R1'); - $this->v_invoice->setTipoRectificativa('S'); // S for substitutive rectification + $this->v_invoice->setTipoFactura('R2'); + $this->v_invoice->setTipoRectificativa('I'); // S for substitutive rectification + + //need to harvest the parent invoice!! + + $_i = Invoice::withTrashed()->find($this->decodePrimaryKey($this->invoice->backup->parent_invoice_id)); + + if(!$_i) { + throw new \Exception('Parent invoice not found'); + } // Set up rectified invoice information $facturasRectificadas = [ [ 'IDEmisorFactura' => $this->company->settings->vat_number, - 'NumSerieFactura' => $this->invoice->number, - 'FechaExpedicionFactura' => \Carbon\Carbon::parse($this->invoice->date)->format('d-m-Y') + 'NumSerieFactura' => $_i->number, + 'FechaExpedicionFactura' => \Carbon\Carbon::parse($_i->date)->format('d-m-Y') ] ]; $this->v_invoice->setFacturasRectificadas($facturasRectificadas); - // Set up rectification amounts - $importeRectificacion = [ - 'BaseRectificada' => $this->calc->getNetSubtotal(), - 'CuotaRectificada' => $this->invoice->total_taxes, - 'CuotaRecargoRectificado' => 0.00 - ]; - $this->v_invoice->setRectificationAmounts($importeRectificacion); + // // Set up rectification amounts + // $importeRectificacion = [ + // 'BaseRectificada' => $this->calc->getNetSubtotal(), + // 'CuotaRectificada' => $this->invoice->total_taxes, + // 'CuotaRecargoRectificado' => 0.00 + // ]; + + // $this->v_invoice->setRectificationAmounts($importeRectificacion); return $this; } diff --git a/app/Services/EDocument/Standards/Verifactu/SendToAeat.php b/app/Services/EDocument/Standards/Verifactu/SendToAeat.php index e72828d48b..c2a452ef0b 100644 --- a/app/Services/EDocument/Standards/Verifactu/SendToAeat.php +++ b/app/Services/EDocument/Standards/Verifactu/SendToAeat.php @@ -82,17 +82,48 @@ class SendToAeat implements ShouldQueue } } - + + /** + * modifyInvoice + * + * Two code paths here: + * 1. F3 - we are replacing the invoice with a new one: ie. invoice->amount >=0 + * 2. R2 - we are modifying the invoice with a negative amount: ie. invoice->amount < 0 + * @param Invoice $invoice + * @return void + */ public function modifyInvoice(Invoice $invoice) { + $verifactu = new Verifactu($invoice); $verifactu->run(); + + $envelope = $verifactu->getEnvelope(); + + $response = $verifactu->send($envelope); + + nlog($response); + + // if($invoice->amount >= 0) { + // $document = (new RegistroAlta($invoice))->run()->getInvoice(); + // } + // else { + // $document = (new RegistroRectificacion($invoice))->run()->getInvoice(); + // } + } public function createInvoice(Invoice $invoice) { $verifactu = new Verifactu($invoice); $verifactu->run(); + + $envelope = $verifactu->getEnvelope(); + + $response = $verifactu->send($envelope); + + nlog($response); + } public function cancelInvoice(Invoice $invoice) diff --git a/composer.lock b/composer.lock index 3cd151045c..8e9c736dc2 100644 --- a/composer.lock +++ b/composer.lock @@ -19149,16 +19149,16 @@ }, { "name": "phpunit/phpunit", - "version": "11.5.31", + "version": "11.5.32", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "fc44414e0779e94640663b809557b0b599548260" + "reference": "101e132dcf9e74a1eb3a309b4f686114ae8f7f36" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/fc44414e0779e94640663b809557b0b599548260", - "reference": "fc44414e0779e94640663b809557b0b599548260", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/101e132dcf9e74a1eb3a309b4f686114ae8f7f36", + "reference": "101e132dcf9e74a1eb3a309b4f686114ae8f7f36", "shasum": "" }, "require": { @@ -19230,7 +19230,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.31" + "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.32" }, "funding": [ { @@ -19254,7 +19254,7 @@ "type": "tidelift" } ], - "time": "2025-08-11T05:27:39+00:00" + "time": "2025-08-12T07:32:49+00:00" }, { "name": "react/cache", diff --git a/tests/Feature/EInvoice/Verifactu/VerifactuFeatureTest.php b/tests/Feature/EInvoice/Verifactu/VerifactuFeatureTest.php index 5699ceb776..2deaa2bd26 100644 --- a/tests/Feature/EInvoice/Verifactu/VerifactuFeatureTest.php +++ b/tests/Feature/EInvoice/Verifactu/VerifactuFeatureTest.php @@ -13,10 +13,12 @@ use App\Models\CompanyToken; use App\Models\VerifactuLog; use App\Models\ClientContact; use App\DataMapper\InvoiceItem; +use App\Factory\InvoiceFactory; use App\DataMapper\ClientSettings; use App\DataMapper\CompanySettings; use App\Factory\CompanyUserFactory; use Illuminate\Support\Facades\Http; +use App\Repositories\InvoiceRepository; use App\Services\EDocument\Standards\Verifactu; use App\Services\EDocument\Standards\Verifactu\RegistroAlta; use App\Services\EDocument\Standards\Verifactu\Models\Desglose; @@ -52,9 +54,15 @@ class VerifactuFeatureTest extends TestCase $this->faker = Faker::create(); - $this->markTestSkipped('not now'); + // $this->markTestSkipped('not now'); } - + + /** + * Helper to stub test data. + * + * @param mixed $settings + * @return Invoice $invoice + */ private function buildData($settings = null) { /** @var Account $a */ @@ -93,11 +101,13 @@ class VerifactuFeatureTest extends TestCase $settings->name = $this->nombre_razon; } - $this->company = Company::factory()->create([ + /** @var Company $company */ + $company = Company::factory()->create([ 'account_id' => $this->account->id, 'settings' => $settings, ]); + $this->company = $company; $this->company->settings = $settings; $this->company->save(); @@ -122,7 +132,8 @@ class VerifactuFeatureTest extends TestCase $client_settings = ClientSettings::defaults(); $client_settings->currency_id = '3'; - $this->client = Client::factory()->create([ + /** @var Client $client */ + $client = Client::factory()->create([ 'user_id' => $this->user->id, 'company_id' => $this->company->id, 'is_deleted' => 0, @@ -138,6 +149,8 @@ class VerifactuFeatureTest extends TestCase 'settings' => $client_settings, ]); + $this->client = $client; + ClientContact::factory()->create([ 'user_id' => $this->user->id, 'client_id' => $this->client->id, @@ -194,7 +207,13 @@ class VerifactuFeatureTest extends TestCase return $invoice; } - + + /** + * test_construction_and_validation + * + * tests building / validating / sending a NEW invoice in a chain + * @return void + */ public function test_construction_and_validation() { // - current previous hash - 10C643EDC7DC727FAC6BAEBAAC7BEA67B5C1369A5A5ED74E5AD3149FC30A3C8C @@ -208,6 +227,7 @@ class VerifactuFeatureTest extends TestCase $this->assertNotNull($invoice); + /** @var Invoice $_inv */ $_inv = Invoice::factory()->create([ 'user_id' => $invoice->user_id, 'company_id' => $invoice->company_id, @@ -240,7 +260,6 @@ class VerifactuFeatureTest extends TestCase if (!empty($errors)) { nlog('Verifactu Validation Errors:'); - nlog($xml); nlog($errors); } @@ -269,7 +288,14 @@ class VerifactuFeatureTest extends TestCase $xx->forceDelete(); } - + + /** + * testBuildInvoiceCancellation + * + * test cancellation of an invoice and sending to AEAT + * + * @return void + */ public function testBuildInvoiceCancellation() { $invoice = $this->buildData(); @@ -332,44 +358,97 @@ class VerifactuFeatureTest extends TestCase $xx->forceDelete(); - } - private function cancellationHash($document, $huella) + + + /** + * test_invoice_modification_validation + * + * Test that the modified invoice passes the validation rules + * @return void + */ + public function test_invoice_modification_validation() { - $idEmisorFacturaAnulada = $document->getIdFactura()->getIdEmisorFactura(); - $numSerieFacturaAnulada = $document->getIdFactura()->getNumSerieFactura(); - $fechaExpedicionFacturaAnulada = $document->getIdFactura()->getFechaExpedicionFactura(); - $fechaHoraHusoGenRegistro = $document->getFechaHoraHusoGenRegistro(); - - $hashInput = "IDEmisorFacturaAnulada={$idEmisorFacturaAnulada}&" . - "NumSerieFacturaAnulada={$numSerieFacturaAnulada}&" . - "FechaExpedicionFacturaAnulada={$fechaExpedicionFacturaAnulada}&" . - "Huella={$huella}&" . - "FechaHoraHusoGenRegistro={$fechaHoraHusoGenRegistro}"; - - nlog("Cancellation Huella: " . $hashInput); - - return strtoupper(hash('sha256', $hashInput)); - - - - } - - - public function test_invoice_invoice_modification() - { $invoice = $this->buildData(); - $invoice->number = 'TEST0033343460-R2'; + + /** @var Invoice $_invoice */ + $_invoice = Invoice::factory()->create([ + 'user_id' => $invoice->user_id, + 'company_id' => $invoice->company_id, + 'client_id' => $invoice->client_id, + 'date' => '2025-08-10', + 'status_id' => Invoice::STATUS_SENT, + 'uses_inclusive_taxes' => false, + 'number' => 'Replaceable Invoice #'.rand(1000000000, 9999999999), + ]); + + $invoice->number = 'TEST0033343460-R4'; + $invoice->status_id = Invoice::STATUS_DRAFT; + $invoice->backup->parent_invoice_id = $_invoice->hashed_id; + + $items = $invoice->line_items; + + foreach($items as &$item) { + $item->quantity = -1; + } + + $invoice->line_items = $items; + + $repo = new InvoiceRepository(); + $invoice = $repo->save($invoice->toArray(), $invoice); + $invoice = $invoice->service()->markSent()->save(); + + $previous_huella = 'E5A23515881D696FCD1CA8EE4902632BFC6D892BA8EB79CB656A5F84963079D3'; + + $verifactu2 = new Verifactu($invoice); + $document2 = $verifactu2->setTestMode() + ->setPreviousHash($previous_huella) + ->run() + ->getInvoice(); + + $soapXml = $document2->toSoapEnvelope(); + + $this->assertNotNull($document2->getHuella()); + + nlog("huella: " . $document2->getHuella()); + + nlog($soapXml); + + $xslt = new VerifactuDocumentValidator($soapXml); + $xslt->validate(); + $errors = $xslt->getVerifactuErrors(); + + if (count($errors) > 0) { + nlog('Errors:'); + nlog($errors); + nlog('Errors:'); + } + + $this->assertCount(0, $errors); + + } + + /** + * test_invoice_invoice_modification + * Creates a new invoice and sends to AEAT, follows with a matching credit note that is then sent to AEAT + * + * @return void + */ + public function test_invoice_invoice_modification_and_create_cancellation_of_rectification_invoice() + { + // New Invoice + $invoice = $this->buildData(); + $invoice->number = 'TEST0033343460-R13'; $invoice->save(); - $previous_huella = '1FB6B4EF72DD2A07CC23B3F9D74EE5749C8E86B34B9B1DFFFC8C3E46ACA87E21'; + $previous_huella = 'FDC8D47AC4BE81237A6A2FC21F854C824618805DB684F6B28053AC62AB8C86EB'; $xx = VerifactuLog::create([ 'invoice_id' => $invoice->id, 'company_id' => $invoice->company_id, - 'invoice_number' => 'TEST0033343459', + 'invoice_number' => 'TEST0033343460-C9', 'date' => '2025-08-10', 'hash' => $previous_huella, 'nif' => 'A39200019', @@ -389,6 +468,77 @@ class VerifactuFeatureTest extends TestCase $this->assertNotNull($response); $this->assertArrayHasKey('success', $response); $this->assertTrue($response['success']); + + // Credit Note + $invoice2 = $invoice->replicate(); + $invoice2->number = 'TEST0033343460-C10'; + $invoice2->status_id = Invoice::STATUS_DRAFT; + $invoice2->backup->parent_invoice_id = $invoice->hashed_id; + + $items = $invoice2->line_items; + + foreach($items as &$item) { + $item->quantity = -1; + } + + $invoice2->line_items = $items; + + $invoice2->save(); + + $data = $invoice2->toArray(); + $data['client_id'] = $invoice->client_id; + unset($data['id']); + + $repo = new InvoiceRepository(); + $invoice2 = $repo->save($data, $invoice2); + $invoice2 = $invoice2->service()->markSent()->save(); + + $this->assertEquals(-121, $invoice2->amount); + + $verifactu2 = new Verifactu($invoice2); + $document2 = $verifactu2->setTestMode() + ->setPreviousHash($document->getHuella()) + ->run() + ->getInvoice(); + + nlog($document2->toSoapEnvelope()); + + $response = $verifactu2->send($document2->toSoapEnvelope()); + + $this->assertNotNull($response); + $this->assertArrayHasKey('success', $response); + $this->assertTrue($response['success']); + +//Lets try and cancel the credit note now - we should fail!! + $verifactu = new Verifactu($invoice2); + $document = (new RegistroAlta($invoice2))->run()->getInvoice(); + $huella = $this->cancellationHash($document, $document2->getHuella()); + + $cancellation = $document->createCancellation(); + $cancellation->setHuella($huella); + + $soapXml = $cancellation->toSoapEnvelope(); + + nlog($soapXml); + + $response = $verifactu->setTestMode() + ->setInvoice($document) + ->setHuella($huella) + ->setPreviousHash($document2->getHuella()) + ->send($soapXml); + + nlog("CANCELLATION RESPONSE"); + nlog($response); + + $this->assertNotNull($response); + $this->assertArrayHasKey('success', $response); + $this->assertTrue($response['success']); + + $xx->forceDelete(); + + VerifactuLog::query()->where('id', $invoice2->id)->forceDelete(); + VerifactuLog::query()->where('id', $invoice->id)->forceDelete(); + } public function test_rectification_invoice() @@ -511,81 +661,6 @@ class VerifactuFeatureTest extends TestCase } - public function testInvoiceCancellation() - { - // Create a sample invoice - $invoice = $this->buildData(); - - // Create cancellation from invoice - $cancellation = \App\Services\EDocument\Standards\Verifactu\Models\InvoiceCancellation::fromInvoice( - $invoice, - 'ABCD1234EF5678901234567890ABCDEF1234567890ABCDEF1234567890ABCDEF12' - ); - - // Set custom cancellation details - $cancellation->setEstado('02') // 02 = Invoice cancelled - ->setDescripcionEstado('Factura anulada por error'); - - // Generate XML - $xmlString = $cancellation->toXmlString(); - - // Verify XML structure - $this->assertNotEmpty($xmlString); - $this->assertStringContainsString('SuministroLRFacturas', $xmlString); - $this->assertStringContainsString('LRFacturaEntrada', $xmlString); - $this->assertStringContainsString('IDFactura', $xmlString); - $this->assertStringContainsString('EstadoFactura', $xmlString); - $this->assertStringContainsString('Estado', $xmlString); - $this->assertStringContainsString('02', $xmlString); // Cancelled status - - // Generate SOAP envelope - $soapEnvelope = $cancellation->toSoapEnvelope(); - - // Verify SOAP structure - $this->assertNotEmpty($soapEnvelope); - $this->assertStringContainsString('soapenv:Envelope', $soapEnvelope); - $this->assertStringContainsString('RegFactuSistemaFacturacion', $soapEnvelope); - - // Test serialization - $serialized = $cancellation->serialize(); - $this->assertNotEmpty($serialized); - - // Test deserialization - $deserialized = \App\Services\EDocument\Standards\Verifactu\Models\InvoiceCancellation::unserialize($serialized); - $this->assertEquals($cancellation->getNumSerieFacturaEmisor(), $deserialized->getNumSerieFacturaEmisor()); - $this->assertEquals($cancellation->getEstado(), $deserialized->getEstado()); - - // Test from XML - $fromXml = \App\Services\EDocument\Standards\Verifactu\Models\InvoiceCancellation::fromXml($xmlString); - $this->assertEquals($cancellation->getNumSerieFacturaEmisor(), $fromXml->getNumSerieFacturaEmisor()); - $this->assertEquals($cancellation->getEstado(), $fromXml->getEstado()); - - $response = Http::withHeaders([ - 'Content-Type' => 'text/xml; charset=utf-8', - 'SOAPAction' => '', - ]) - ->withOptions([ - 'cert' => storage_path('aeat-cert5.pem'), - 'ssl_key' => storage_path('aeat-key5.pem'), - 'verify' => false, - 'timeout' => 30, - ]) - ->withBody($soapEnvelope, 'text/xml') - ->post('https://prewww1.aeat.es/wlpl/TIKE-CONT/ws/SistemaFacturacion/VerifactuSOAP'); - - nlog('Request with AEAT official test data:'); - nlog($soapEnvelope); - nlog('Response with AEAT official test data:'); - nlog('Response Status: ' . $response->status()); - nlog('Response Headers: ' . json_encode($response->headers())); - nlog('Response Body: ' . $response->body()); - - $r = new ResponseProcessor(); - $rx = $r->processResponse($response->body()); - - nlog($rx); - - } /** * Test that R1 invoice XML structure is exactly as expected with proper element order @@ -754,4 +829,26 @@ class VerifactuFeatureTest extends TestCase $this->assertStringContainsString('', $soapXml); $this->assertStringContainsString('', $soapXml); } + + +//////////////////////////////////////////////// + private function cancellationHash($document, $huella) + { + + $idEmisorFacturaAnulada = $document->getIdFactura()->getIdEmisorFactura(); + $numSerieFacturaAnulada = $document->getIdFactura()->getNumSerieFactura(); + $fechaExpedicionFacturaAnulada = $document->getIdFactura()->getFechaExpedicionFactura(); + $fechaHoraHusoGenRegistro = $document->getFechaHoraHusoGenRegistro(); + + $hashInput = "IDEmisorFacturaAnulada={$idEmisorFacturaAnulada}&" . + "NumSerieFacturaAnulada={$numSerieFacturaAnulada}&" . + "FechaExpedicionFacturaAnulada={$fechaExpedicionFacturaAnulada}&" . + "Huella={$huella}&" . + "FechaHoraHusoGenRegistro={$fechaHoraHusoGenRegistro}"; + + nlog("Cancellation Huella: " . $hashInput); + + return strtoupper(hash('sha256', $hashInput)); + + } } \ No newline at end of file