Wire up AEAT for processing

This commit is contained in:
David Bomba 2025-08-13 10:28:10 +10:00
parent 63e6f75a24
commit 8bc1513591
7 changed files with 298 additions and 143 deletions

View File

@ -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

View File

@ -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);
@ -91,6 +94,19 @@ class Verifactu extends AbstractService
}
/**
* 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;

View File

@ -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');
}

View File

@ -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 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;
}

View File

@ -83,16 +83,47 @@ 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)

12
composer.lock generated
View File

@ -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",

View File

@ -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,
@ -195,6 +208,12 @@ 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);
}
@ -270,6 +289,13 @@ 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)
{
$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()
/**
* test_invoice_modification_validation
*
* Test that the modified invoice passes the validation rules
* @return void
*/
public function test_invoice_modification_validation()
{
$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('</soapenv:Body>', $soapXml);
$this->assertStringContainsString('</soapenv:Envelope>', $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));
}
}