diff --git a/app/Models/Company.php b/app/Models/Company.php index dba5e3d72f..ff78383555 100644 --- a/app/Models/Company.php +++ b/app/Models/Company.php @@ -428,6 +428,11 @@ class Company extends BaseModel return $this->hasMany(Scheduler::class); } + public function verifactu_logs(): HasMany + { + return $this->hasMany(VerifactuLog::class); + } + public function task_schedulers(): HasMany { return $this->hasMany(Scheduler::class); diff --git a/app/Models/Invoice.php b/app/Models/Invoice.php index 247a9ec349..ec6073f84c 100644 --- a/app/Models/Invoice.php +++ b/app/Models/Invoice.php @@ -400,6 +400,11 @@ class Invoice extends BaseModel return $this->hasMany(Credit::class); } + public function verifactu_logs(): \Illuminate\Database\Eloquent\Relations\HasMany + { + return $this->hasMany(VerifactuLog::class); + } + public function tasks(): \Illuminate\Database\Eloquent\Relations\HasMany { return $this->hasMany(Task::class); diff --git a/app/Services/EDocument/Standards/Verifactu/AeatAuthority.php b/app/Services/EDocument/Standards/Verifactu/AeatAuthority.php new file mode 100644 index 0000000000..0fe8ce091e --- /dev/null +++ b/app/Services/EDocument/Standards/Verifactu/AeatAuthority.php @@ -0,0 +1,100 @@ +base_url = $this->sandbox_url; + + return $this; + } + + public function run(string $client_nif): bool + { + + $sender_nif = config('services.verifactu.sender_nif'); + $certificate = config('services.verifactu.certificate'); + $ssl_key = config('services.verifactu.ssl_key'); + + $xml = << + + + + + + {$sender_nif} + + + {$client_nif} + + LGTINVDI + + + +XML; + + $signingService = new \App\Services\EDocument\Standards\Verifactu\Signing\SigningService($xml, file_get_contents($ssl_key), file_get_contents($certificate)); + $soapXml = $signingService->sign(); + + $response = Http::withHeaders([ + 'Content-Type' => 'text/xml; charset=utf-8', + 'SOAPAction' => '', + ]) + ->withOptions([ + 'cert' => $certificate, + 'ssl_key' => $ssl_key, + 'verify' => false, + 'timeout' => 30, + ]) + ->withBody($soapXml, 'text/xml') + ->post($this->base_url); + + $success = $response->successful(); + + $responseProcessor = new ResponseProcessor(); + + $parsedResponse = $responseProcessor->processResponse($response->body()); + nlog($response->body()); + nlog($parsedResponse); + + return $parsedResponse['success']; + + } +} \ No newline at end of file diff --git a/app/Services/EDocument/Standards/Verifactu/AeatClient.php b/app/Services/EDocument/Standards/Verifactu/AeatClient.php new file mode 100644 index 0000000000..0a72641e6f --- /dev/null +++ b/app/Services/EDocument/Standards/Verifactu/AeatClient.php @@ -0,0 +1,119 @@ +init(); + } + + /** + * initialize the certificates + * + * @return self + */ + private function init(): self + { + $this->certificate = $this->certificate ?? file_get_contents(config('services.verifactu.certificate')); + $this->ssl_key = $this->ssl_key ?? file_get_contents(config('services.verifactu.ssl_key')); + + return $this; + } + + /** + * setTestMode + * + * @return self + */ + public function setTestMode(): self + { + $this->base_url = $this->sandbox_url; + + return $this; + } + + /** + * run + * + * @param mixed $entity + * @return void + */ + public function run($entity): void + { + // build the payload + + // harvest any previous hashes + + // send the payload to the AEAT + + // await the response and insert new row into the verifactu_logs table + + // write an activity (success or failure) + + // on success, add a reference to invoice->backup->guid + } + + + private function buildPayload($entity): string + { + return ''; + } + + private function harvestPreviousHashes($entity): array + { + return []; + } + + private function send($xml) + { + + $response = Http::withHeaders([ + 'Content-Type' => 'text/xml; charset=utf-8', + 'SOAPAction' => '', + ]) + ->withOptions([ + 'cert' => $this->certificate, + 'ssl_key' => $this->ssl_key, + 'verify' => false, + 'timeout' => 30, + ]) + ->withBody($xml, 'text/xml') + ->post($this->base_url); + + $success = $response->successful(); + + $responseProcessor = new ResponseProcessor(); + + $parsedResponse = $responseProcessor->processResponse($response->body()); + + nlog($parsedResponse); + + if($parsedResponse['success']){ + + //write the success activity + } + else { + //handle the failure + } + + + } +} \ No newline at end of file diff --git a/app/Services/EDocument/Standards/Verifactu/Response/ResponseProcessor.php b/app/Services/EDocument/Standards/Verifactu/Response/ResponseProcessor.php deleted file mode 100644 index d6196a0903..0000000000 --- a/app/Services/EDocument/Standards/Verifactu/Response/ResponseProcessor.php +++ /dev/null @@ -1,331 +0,0 @@ -dom = new DOMDocument(); - } - - /** - * Process AEAT XML response and return structured array - */ - public function processResponse(string $xmlResponse): array - { - try { - $this->loadXml($xmlResponse); - - return [ - 'success' => $this->isSuccessful(), - 'status' => $this->getStatus(), - 'errors' => $this->getErrors(), - 'warnings' => $this->getWarnings(), - 'data' => $this->getResponseData(), - 'metadata' => $this->getMetadata(), - 'raw_response' => $xmlResponse - ]; - } catch (Exception $e) { - Log::error('Error processing AEAT response', [ - 'error' => $e->getMessage(), - 'xml' => $xmlResponse - ]); - - return [ - 'success' => false, - 'error' => 'Failed to process response: ' . $e->getMessage(), - 'raw_response' => $xmlResponse - ]; - } - } - - /** - * Load XML into DOM - */ - private function loadXml(string $xml): void - { - libxml_use_internal_errors(true); - libxml_clear_errors(); - - if (!$this->dom->loadXML($xml)) { - $errors = libxml_get_errors(); - libxml_clear_errors(); - throw new Exception('Invalid XML: ' . ($errors[0]->message ?? 'Unknown error')); - } - - $this->root = $this->dom->documentElement; - } - - /** - * Check if response indicates success - */ - private function isSuccessful(): bool - { - $estadoEnvio = $this->getElementText('//tikR:EstadoEnvio'); - return $estadoEnvio === 'Correcto'; - } - - /** - * Get response status - */ - private function getStatus(): string - { - return $this->getElementText('//tikR:EstadoEnvio') ?? 'Unknown'; - } - - /** - * Get all errors from response - */ - private function getErrors(): array - { - $errors = []; - - // Check for SOAP faults - $fault = $this->getElementText('//env:Fault/faultstring'); - if ($fault) { - $errors[] = [ - 'type' => 'SOAP_Fault', - 'code' => $this->getElementText('//env:Fault/faultcode'), - 'message' => $fault, - 'details' => $this->getElementText('//env:Fault/detail/callstack') - ]; - } - - // Check for business logic errors - $respuestaLineas = $this->dom->getElementsByTagNameNS( - 'https://www2.agenciatributaria.gob.es/static_files/common/internet/dep/aplicaciones/es/aeat/tike/cont/ws/RespuestaSuministro.xsd', - 'RespuestaLinea' - ); - - foreach ($respuestaLineas as $linea) { - $estadoRegistro = $this->getElementText('.//tikR:EstadoRegistro', $linea); - - if ($estadoRegistro === 'Incorrecto') { - $errors[] = [ - 'type' => 'Business_Error', - 'code' => $this->getElementText('.//tikR:CodigoErrorRegistro', $linea), - 'message' => $this->getElementText('.//tikR:DescripcionErrorRegistro', $linea), - 'invoice_data' => $this->getInvoiceData($linea) - ]; - } - } - - return $errors; - } - - /** - * Get warnings from response - */ - private function getWarnings(): array - { - $warnings = []; - - // Check for subsanacion (correction) messages - $subsanacion = $this->getElementText('//tikR:RespuestaLinea/tikR:Subsanacion'); - if ($subsanacion) { - $warnings[] = [ - 'type' => 'Subsanacion', - 'message' => $subsanacion - ]; - } - - return $warnings; - } - - /** - * Get response data - */ - private function getResponseData(): array - { - $data = []; - - // Get header information - $cabecera = $this->getElement('//tikR:Cabecera'); - if ($cabecera) { - $data['header'] = [ - 'obligado_emision' => [ - 'nombre_razon' => $this->getElementText('.//tik:NombreRazon', $cabecera), - 'nif' => $this->getElementText('.//tik:NIF', $cabecera) - ] - ]; - } - - // Get processing informationp - $data['processing'] = [ - 'tiempo_espera_envio' => $this->getElementText('//tikR:TiempoEsperaEnvio'), - 'estado_envio' => $this->getElementText('//tikR:EstadoEnvio') - ]; - - // Get invoice responses - $data['invoices'] = $this->getInvoiceResponses(); - - return $data; - } - - /** - * Get metadata from response - */ - private function getMetadata(): array - { - return [ - 'request_id' => $this->getElementText('//tikR:RespuestaLinea/tikR:IDFactura/tik:IDEmisorFactura'), - 'invoice_series' => $this->getElementText('//tikR:RespuestaLinea/tikR:IDFactura/tik:NumSerieFactura'), - 'invoice_date' => $this->getElementText('//tikR:RespuestaLinea/tikR:IDFactura/tik:FechaExpedicionFactura'), - 'operation_type' => $this->getElementText('//tikR:RespuestaLinea/tikR:Operacion/tik:TipoOperacion'), - 'external_reference' => $this->getElementText('//tikR:RespuestaLinea/tikR:RefExterna') - ]; - } - - /** - * Get invoice responses - */ - private function getInvoiceResponses(): array - { - $invoices = []; - - $respuestaLineas = $this->dom->getElementsByTagNameNS( - 'https://www2.agenciatributaria.gob.es/static_files/common/internet/dep/aplicaciones/es/aeat/tike/cont/ws/RespuestaSuministro.xsd', - 'RespuestaLinea' - ); - - foreach ($respuestaLineas as $linea) { - $invoices[] = [ - 'id_emisor' => $this->getElementText('.//tikR:IDFactura/tik:IDEmisorFactura', $linea), - 'num_serie' => $this->getElementText('.//tikR:IDFactura/tik:NumSerieFactura', $linea), - 'fecha_expedicion' => $this->getElementText('.//tikR:IDFactura/tik:FechaExpedicionFactura', $linea), - 'tipo_operacion' => $this->getElementText('.//tikR:Operacion/tik:TipoOperacion', $linea), - 'ref_externa' => $this->getElementText('.//tikR:RefExterna', $linea), - 'estado_registro' => $this->getElementText('.//tikR:EstadoRegistro', $linea), - 'codigo_error' => $this->getElementText('.//tikR:CodigoErrorRegistro', $linea), - 'descripcion_error' => $this->getElementText('.//tikR:DescripcionErrorRegistro', $linea), - 'subsanacion' => $this->getElementText('.//tikR:Subsanacion', $linea) - ]; - } - - return $invoices; - } - - /** - * Get invoice data from response line - */ - private function getInvoiceData(DOMElement $linea): array - { - return [ - 'id_emisor' => $this->getElementText('.//tikR:IDFactura/tik:IDEmisorFactura', $linea), - 'num_serie' => $this->getElementText('.//tikR:IDFactura/tik:NumSerieFactura', $linea), - 'fecha_expedicion' => $this->getElementText('.//tikR:IDFactura/tik:FechaExpedicionFactura', $linea), - 'tipo_operacion' => $this->getElementText('.//tikR:Operacion/tik:TipoOperacion', $linea), - 'ref_externa' => $this->getElementText('.//tikR:RefExterna', $linea) - ]; - } - - /** - * Get element text by XPath - */ - private function getElementText(string $xpath, ?DOMElement $context = null): ?string - { - $xpathObj = new \DOMXPath($this->dom); - - // Register namespaces - $xpathObj->registerNamespace('env', 'http://schemas.xmlsoap.org/soap/envelope/'); - $xpathObj->registerNamespace('tikR', 'https://www2.agenciatributaria.gob.es/static_files/common/internet/dep/aplicaciones/es/aeat/tike/cont/ws/RespuestaSuministro.xsd'); - $xpathObj->registerNamespace('tik', 'https://www2.agenciatributaria.gob.es/static_files/common/internet/dep/aplicaciones/es/aeat/tike/cont/ws/SuministroInformacion.xsd'); - - $nodeList = $context ? $xpathObj->query($xpath, $context) : $xpathObj->query($xpath); - - if ($nodeList && $nodeList->length > 0) { - return trim($nodeList->item(0)->nodeValue); - } - - return null; - } - - /** - * Get element by XPath - */ - private function getElement(string $xpath): ?DOMElement - { - $xpathObj = new \DOMXPath($this->dom); - - // Register namespaces - $xpathObj->registerNamespace('env', 'http://schemas.xmlsoap.org/soap/envelope/'); - $xpathObj->registerNamespace('tikR', 'https://www2.agenciatributaria.gob.es/static_files/common/internet/dep/aplicaciones/es/aeat/tike/cont/ws/RespuestaSuministro.xsd'); - $xpathObj->registerNamespace('tik', 'https://www2.agenciatributaria.gob.es/static_files/common/internet/dep/aplicaciones/es/aeat/tike/cont/ws/SuministroInformacion.xsd'); - - $nodeList = $xpathObj->query($xpath); - - if ($nodeList && $nodeList->length > 0) { - return $nodeList->item(0); - } - - return null; - } - - /** - * Check if response has errors - */ - public function hasErrors(): bool - { - return !empty($this->getErrors()); - } - - /** - * Get first error message - */ - public function getFirstError(): ?string - { - $errors = $this->getErrors(); - return $errors[0]['message'] ?? null; - } - - /** - * Get error codes - */ - public function getErrorCodes(): array - { - $codes = []; - $errors = $this->getErrors(); - - foreach ($errors as $error) { - if (isset($error['code'])) { - $codes[] = $error['code']; - } - } - - return $codes; - } - - /** - * Check if specific error code exists - */ - public function hasErrorCode(string $code): bool - { - return in_array($code, $this->getErrorCodes()); - } - - /** - * Get summary of response - */ - public function getSummary(): array - { - return [ - 'success' => $this->isSuccessful(), - 'status' => $this->getStatus(), - 'error_count' => count($this->getErrors()), - 'warning_count' => count($this->getWarnings()), - 'invoice_count' => count($this->getInvoiceResponses()), - 'first_error' => $this->getFirstError(), - 'error_codes' => $this->getErrorCodes() - ]; - } -} diff --git a/app/Services/EDocument/Standards/Verifactu/Validation/InvoiceValidator.php b/app/Services/EDocument/Standards/Verifactu/Validation/InvoiceValidator.php new file mode 100644 index 0000000000..ac514e5467 --- /dev/null +++ b/app/Services/EDocument/Standards/Verifactu/Validation/InvoiceValidator.php @@ -0,0 +1,237 @@ +validateNif($invoice)); + + // Validate date formats + $errors = array_merge($errors, $this->validateDates($invoice)); + + // Validate invoice numbers + $errors = array_merge($errors, $this->validateInvoiceNumbers($invoice)); + + // Validate amounts + $errors = array_merge($errors, $this->validateAmounts($invoice)); + + // Validate tax rates + $errors = array_merge($errors, $this->validateTaxRates($invoice)); + + // Validate business logic + $errors = array_merge($errors, $this->validateBusinessLogic($invoice)); + + return $errors; + } + + /** + * Validate NIF format (Spanish tax identification) + */ + private function validateNif(Invoice $invoice): array + { + $errors = []; + + // Check emitter NIF + if ($invoice->getTercero() && $invoice->getTercero()->getNif()) { + $nif = $invoice->getTercero()->getNif(); + if (!$this->isValidNif($nif)) { + $errors[] = "Invalid emitter NIF format: {$nif}"; + } + } + + // Check system NIF + if ($invoice->getSistemaInformatico() && $invoice->getSistemaInformatico()->getNif()) { + $nif = $invoice->getSistemaInformatico()->getNif(); + if (!$this->isValidNif($nif)) { + $errors[] = "Invalid system NIF format: {$nif}"; + } + } + + return $errors; + } + + /** + * Validate date formats + */ + private function validateDates(Invoice $invoice): array + { + $errors = []; + + // Validate FechaHoraHusoGenRegistro format (YYYY-MM-DDTHH:MM:SS+HH:MM) + $fechaHora = $invoice->getFechaHoraHusoGenRegistro(); + if ($fechaHora && !preg_match('/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[+-]\d{2}:\d{2}$/', $fechaHora)) { + $errors[] = "Invalid FechaHoraHusoGenRegistro format. Expected: YYYY-MM-DDTHH:MM:SS+HH:MM, Got: {$fechaHora}"; + } + + // Validate FechaExpedicionFactura format (YYYY-MM-DD) + if ($invoice->getIdFactura() && method_exists($invoice->getIdFactura(), 'getFechaExpedicionFactura')) { + $fecha = $invoice->getIdFactura()->getFechaExpedicionFactura(); + if ($fecha && !preg_match('/^\d{4}-\d{2}-\d{2}$/', $fecha)) { + $errors[] = "Invalid FechaExpedicionFactura format. Expected: YYYY-MM-DD, Got: {$fecha}"; + } + } + + return $errors; + } + + /** + * Validate invoice numbers + */ + private function validateInvoiceNumbers(Invoice $invoice): array + { + $errors = []; + + if ($invoice->getIdFactura() && method_exists($invoice->getIdFactura(), 'getNumSerieFactura')) { + $numero = $invoice->getIdFactura()->getNumSerieFactura(); + + // Check for common problematic patterns + if (str_contains($numero, 'TEST') && strlen($numero) < 10) { + $errors[] = "Test invoice numbers should be at least 10 characters long"; + } + + // Check for special characters that might cause issues + if (preg_match('/[^A-Za-z0-9\-_]/', $numero)) { + $errors[] = "Invoice number contains invalid characters. Only letters, numbers, hyphens and underscores allowed"; + } + } + + return $errors; + } + + /** + * Validate amounts + */ + private function validateAmounts(Invoice $invoice): array + { + $errors = []; + + // Validate total amounts + if ($invoice->getImporteTotal() <= 0) { + $errors[] = "ImporteTotal must be greater than 0"; + } + + if ($invoice->getCuotaTotal() < 0) { + $errors[] = "CuotaTotal cannot be negative (use rectification invoice for negative amounts)"; + } + + // Validate decimal places (AEAT expects 2 decimal places) + if (fmod($invoice->getImporteTotal() * 100, 1) !== 0.0) { + $errors[] = "ImporteTotal must have maximum 2 decimal places"; + } + + if (fmod($invoice->getCuotaTotal() * 100, 1) !== 0.0) { + $errors[] = "CuotaTotal must have maximum 2 decimal places"; + } + + return $errors; + } + + /** + * Validate tax rates + */ + private function validateTaxRates(Invoice $invoice): array + { + $errors = []; + + // Check if desglose exists and has valid tax rates + if ($invoice->getDesglose()) { + $desglose = $invoice->getDesglose(); + + // Validate tax rates are standard Spanish rates + $validRates = [0, 4, 10, 21]; + + // This would need to be implemented based on your Desglose structure + // $taxRate = $desglose->getTipoImpositivo(); + // if (!in_array($taxRate, $validRates)) { + // $errors[] = "Invalid tax rate: {$taxRate}. Valid rates are: " . implode(', ', $validRates); + // } + } + + return $errors; + } + + /** + * Validate business logic rules + */ + private function validateBusinessLogic(Invoice $invoice): array + { + $errors = []; + + // Check for required fields based on invoice type + if ($invoice->getTipoFactura() === 'R1' && !$invoice->getTipoRectificativa()) { + $errors[] = "Rectification invoices (R1) must specify TipoRectificativa"; + } + + // Check for simplified invoice requirements + if ($invoice->getTipoFactura() === 'F2' && !$invoice->getFacturaSimplificadaArt7273()) { + $errors[] = "Simplified invoices (F2) must specify FacturaSimplificadaArt7273"; + } + + // Check for system information requirements + if (!$invoice->getSistemaInformatico()) { + $errors[] = "SistemaInformatico is required for all invoices"; + } + + // Check for encadenamiento requirements + if (!$invoice->getEncadenamiento()) { + $errors[] = "Encadenamiento is required for all invoices"; + } + + return $errors; + } + + /** + * Check if NIF format is valid for Spanish tax identification + */ + private function isValidNif(string $nif): bool + { + // Basic format validation for Spanish NIFs + // Company NIFs: Letter + 8 digits (e.g., B12345678) + // Individual NIFs: 8 digits + letter (e.g., 12345678A) + + $pattern = '/^([A-Z]\d{8}|\d{8}[A-Z])$/'; + return preg_match($pattern, $nif) === 1; + } + + /** + * Get validation rules as array for documentation + */ + public function getValidationRules(): array + { + return [ + 'nif' => [ + 'format' => 'Company: Letter + 8 digits (B12345678), Individual: 8 digits + letter (12345678A)', + 'required' => true + ], + 'dates' => [ + 'FechaHoraHusoGenRegistro' => 'YYYY-MM-DDTHH:MM:SS+HH:MM', + 'FechaExpedicionFactura' => 'YYYY-MM-DD' + ], + 'amounts' => [ + 'decimal_places' => 'Maximum 2 decimal places', + 'positive' => 'ImporteTotal must be positive', + 'tax_rates' => 'Valid rates: 0%, 4%, 10%, 21%' + ], + 'invoice_numbers' => [ + 'min_length' => 'Test numbers should be at least 10 characters', + 'characters' => 'Only letters, numbers, hyphens, underscores' + ], + 'business_logic' => [ + 'R1_invoices' => 'Must specify TipoRectificativa', + 'F2_invoices' => 'Must specify FacturaSimplificadaArt7273', + 'required_fields' => 'SistemaInformatico and Encadenamiento are required' + ] + ]; + } +} \ No newline at end of file diff --git a/config/services.php b/config/services.php index 681e4fc0d2..b5953f9374 100644 --- a/config/services.php +++ b/config/services.php @@ -156,4 +156,9 @@ return [ 'quickbooks_webhook' => [ 'verifier_token' => env('QUICKBOOKS_VERIFIER_TOKEN', false), ], + 'verifactu' => [ + 'sender_nif' => env('VERIFACTU_SENDER_NIF', ''), + 'certificate' => env('VERIFACTU_CERTIFICATE', ''), + 'ssl_key' => env('VERIFACTU_SSL_KEY', ''), + ], ]; \ No newline at end of file diff --git a/tests/Feature/EInvoice/Verifactu/Models/WSTest.php b/tests/Feature/EInvoice/Verifactu/Models/WSTest.php index f49d7b309c..04d5cc50e8 100644 --- a/tests/Feature/EInvoice/Verifactu/Models/WSTest.php +++ b/tests/Feature/EInvoice/Verifactu/Models/WSTest.php @@ -5,6 +5,7 @@ namespace Tests\Feature\EInvoice\Verifactu\Models; use Tests\TestCase; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Http; +use App\Services\EDocument\Standards\Verifactu\AeatAuthority; use App\Services\EDocument\Standards\Verifactu\Models\Invoice; use App\Services\EDocument\Standards\Verifactu\Models\Desglose; use App\Services\EDocument\Standards\Verifactu\Models\Encadenamiento; @@ -16,21 +17,44 @@ use App\Services\EDocument\Standards\Verifactu\Models\PersonaFisicaJuridica; class WSTest extends TestCase { + protected function setUp(): void + { + parent::setUp(); + + if (config('ninja.is_travis')) { + $this->markTestSkipped('No credentials to test Verifactu'); + } + + } + + //@todo - need to test that the user has granted power of attorney to the system + //@todo - data must be written to the database to confirm this. + public function test_verifactu_authority() + { + $authority = new AeatAuthority(); + $authority->setTestMode(); + $success = $authority->run('A39200019'); + + $this->assertTrue($success); + } + + //@todo - need to confirm that building the xml and sending works. public function test_generated_invoice_xml_can_send_to_web_service() { - -// Generate current timestamp in the correct format -$currentTimestamp = now()->setTimezone('Europe/Madrid')->format('Y-m-d\TH:i:s'); + + // Generate current timestamp in the correct format + $currentTimestamp = now()->setTimezone('Europe/Madrid')->format('Y-m-d\TH:i:s'); -// $currentTimestamp = \Carbon\Carbon::parse('2023-01-01')->format('Y-m-d\TH:i:s'); -// $currentTimestamp = now()->subDays(5)->format('Y-m-d\TH:i:s'); + // $currentTimestamp = \Carbon\Carbon::parse('2023-01-01')->format('Y-m-d\TH:i:s'); + // $currentTimestamp = now()->subDays(5)->format('Y-m-d\TH:i:s'); + + nlog($currentTimestamp); - nlog($currentTimestamp); $invoice = new Invoice(); $invoice ->setIdVersion('1.0') ->setIdFactura('FAC2023002') - ->setFechaExpedicionFactura('2023-01-02') + ->setFechaExpedicionFactura('02-01-2025') ->setRefExterna('REF-123') ->setNombreRazonEmisor('Empresa Ejemplo SL') ->setTipoFactura('F1') @@ -91,152 +115,148 @@ $currentTimestamp = now()->setTimezone('Europe/Madrid')->format('Y-m-d\TH:i:s'); $this->assertNotNull($soapXml); + $correctHash = $this->calculateVerifactuHash( + $invoice->getTercero()->getNif(), // IDEmisorFactura + $invoice->getIdFactura(), // NumSerieFactura + $invoice->getFechaHoraHusoGenRegistro(), // FechaExpedicionFactura + $invoice->getTipoFactura(), // TipoFactura + $invoice->getCuotaTotal(), // CuotaTotal + $invoice->getImporteTotal(), // ImporteTotal + '', // Huella (empty for first calculation) + $currentTimestamp // FechaHoraHusoGenRegistro (current time) + ); + // Replace the placeholder with the correct hash + $soapXml = str_replace('PLACEHOLDER_HUELLA', $correctHash, $soapXml); -$correctHash = $this->calculateVerifactuHash( - $invoice->getTercero()->getNif(), // IDEmisorFactura - $invoice->getIdFactura(), // NumSerieFactura - $invoice->getFechaHoraHusoGenRegistro(), // FechaExpedicionFactura - $invoice->getTipoFactura(), // TipoFactura - $invoice->getCuotaTotal(), // CuotaTotal - $invoice->getImporteTotal(), // ImporteTotal - '', // Huella (empty for first calculation) - $currentTimestamp // FechaHoraHusoGenRegistro (current time) -); + nlog("test_generated_invoice_xml_can_send_to_web_service"); + nlog('Calculated hash for XML: ' . $correctHash); -// Replace the placeholder with the correct hash -$soapXml = str_replace('PLACEHOLDER_HUELLA', $correctHash, $soapXml); + // Sign the XML before sending + $certPath = storage_path('aeat-cert5.pem'); + $keyPath = storage_path('aeat-key5.pem'); + $signingService = new \App\Services\EDocument\Standards\Verifactu\Signing\SigningService($soapXml, file_get_contents($keyPath), file_get_contents($certPath)); + $soapXml = $signingService->sign(); -nlog("test_generated_invoice_xml_can_send_to_web_service"); -nlog('Calculated hash for XML: ' . $correctHash); - -// Sign the XML before sending -$certPath = storage_path('aeat-cert5.pem'); -$keyPath = storage_path('aeat-key5.pem'); -$signingService = new \App\Services\EDocument\Standards\Verifactu\Signing\SigningService($soapXml, file_get_contents($keyPath), file_get_contents($certPath)); -$soapXml = $signingService->sign(); - -// Try direct HTTP approach instead of SOAP client -$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($soapXml, 'text/xml') - ->post('https://prewww1.aeat.es/wlpl/TIKE-CONT/ws/SistemaFacturacion/VerifactuSOAP'); - -nlog('Request with AEAT official test data:'); -nlog($soapXml); -nlog('Response with AEAT official test data:'); -nlog('Response Status: ' . $response->status()); -nlog('Response Headers: ' . json_encode($response->headers())); -nlog('Response Body: ' . $response->body()); - -if (!$response->successful()) { - \Log::error('Request failed with status: ' . $response->status()); - \Log::error('Response body: ' . $response->body()); -} - -$this->assertTrue($response->successful()); + // Try direct HTTP approach instead of SOAP client + $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($soapXml, 'text/xml') + ->post('https://prewww1.aeat.es/wlpl/TIKE-CONT/ws/SistemaFacturacion/VerifactuSOAP'); + nlog('Request with AEAT official test data:'); + nlog($soapXml); + nlog('Response with AEAT official test data:'); + nlog('Response Status: ' . $response->status()); + nlog('Response Headers: ' . json_encode($response->headers())); + nlog('Response Body: ' . $response->body()); + if (!$response->successful()) { + \Log::error('Request failed with status: ' . $response->status()); + \Log::error('Response body: ' . $response->body()); + } + $this->assertTrue($response->successful()); } + //Confirmed, this works. requires us to track the previous hash for each company to be used in subsequent calls. public function test_send_aeat_example_to_verifactu() { // Generate current timestamp in the correct format // $currentTimestamp = date('Y-m-d\TH:i:sP'); - -$currentTimestamp = now()->setTimezone('Europe/Madrid')->format('Y-m-d\TH:i:sP'); -$invoice_number = 'TEST0033343443'; -$previous_invoice_number = 'TEST0033343442'; -$invoice_date = '02-07-2025'; -$previous_hash = '10C643EDC7DC727FAC6BAEBAAC7BEA67B5C1369A5A5ED74E5AD3149FC30A3C8C'; -$nif = 'A39200019'; + + $currentTimestamp = now()->setTimezone('Europe/Madrid')->format('Y-m-d\TH:i:sP'); + $invoice_number = 'TEST0033343443'; + $previous_invoice_number = 'TEST0033343442'; + $invoice_date = '02-07-2025'; + $previous_hash = '10C643EDC7DC727FAC6BAEBAAC7BEA67B5C1369A5A5ED74E5AD3149FC30A3C8C'; + $nif = 'A39200019'; - $soapXml = << - - - - - - - - CERTIFICADO FISICA PRUEBAS - {$nif} - - - - - 1.0 - - - {$nif} - {$invoice_number} - {$invoice_date} - - - CERTIFICADO FISICA PRUEBAS - F1 - Test invoice submitted by computer system on behalf of business - - - Test Recipient Company - A39200019 - - - - - 01 - S1 - 21 - 100.00 - 21.00 - - - 21.00 - 121.00 - - - - {$nif} - {$previous_invoice_number} - 02-07-2025 - {$previous_hash} - - - - - Sistema de Facturación - A39200019 - InvoiceNinja - 77 - 1.0.03 - 383 - N - S - S - - {$currentTimestamp} - 01 - PLACEHOLDER_HUELLA - - - - - -XML; + $soapXml = << + + + + + + + + CERTIFICADO FISICA PRUEBAS + {$nif} + + + + + 1.0 + + + {$nif} + {$invoice_number} + {$invoice_date} + + + CERTIFICADO FISICA PRUEBAS + F1 + Test invoice submitted by computer system on behalf of business + + + Test Recipient Company + A39200019 + + + + + 01 + S1 + 21 + 100.00 + 21.00 + + + 21.00 + 121.00 + + + + {$nif} + {$previous_invoice_number} + 02-07-2025 + {$previous_hash} + + + + + Sistema de Facturación + A39200019 + InvoiceNinja + 77 + 1.0.03 + 383 + N + S + S + + {$currentTimestamp} + 01 + PLACEHOLDER_HUELLA + + + + + + XML; // Calculate the correct hash using AEAT's specified format $correctHash = $this->calculateVerifactuHash( @@ -290,168 +310,171 @@ XML; $this->assertTrue($response->successful()); -$responseProcessor = new ResponseProcessor(); -$responseProcessor->processResponse($response->body()); + $responseProcessor = new ResponseProcessor(); + $responseProcessor->processResponse($response->body()); -nlog($responseProcessor->getSummary()); + nlog($responseProcessor->getSummary()); -$this->assertTrue($responseProcessor->getSummary()['success']); + $this->assertTrue($responseProcessor->getSummary()['success']); } + //@todo - need to test that cancelling an invoice works. + public function test_cancel_existing_invoice() + { + //@todo - need to test that cancelling an invoice works. + } -public function test_cancel_and_modify_existing_invoice() -{ + //@todo - Need to test that modifying an invoice works. + public function test_cancel_and_modify_existing_invoice() + { -$currentTimestamp = now()->setTimezone('Europe/Madrid')->format('Y-m-d\TH:i:sP'); -$invoice_number = 'TEST0033343436'; -$invoice_date = '02-07-2025'; -$calc_hash = 'A0B4D14E6F7769860C8A4EAFFA3EEBF52B7044685BD69D1DB5BBD68EA0E2BA21'; -$nif = '99999910G'; + $currentTimestamp = now()->setTimezone('Europe/Madrid')->format('Y-m-d\TH:i:sP'); + $invoice_number = 'TEST0033343436'; + $invoice_date = '02-07-2025'; + $calc_hash = 'A0B4D14E6F7769860C8A4EAFFA3EEBF52B7044685BD69D1DB5BBD68EA0E2BA21'; + $nif = '99999910G'; - $soapXml = << - - - - A39200019 - Sistema de Facturación - - + $soapXml = << + + + + A39200019 + Sistema de Facturación + + - - + + - - - 99999910G - TEST0033343436 - 02-07-2025 - - 1 - + + + 99999910G + TEST0033343436 + 02-07-2025 + + 1 + - - - 1.0 - - - 99999910G - {$invoice_number} - {$invoice_date} - - - CERTIFICADO FISICA PRUEBAS - F1 - Test invoice submitted by computer system on behalf of business - - - Test Recipient Company - A39200019 - - - - - 01 - S1 - 21 - 100.00 - 21.00 - - - 21.00 - 121.00 - - - N - - - - Sistema de Facturación - A39200019 - InvoiceNinja - 77 - 1.0.03 - 383 - N - S - S - - {$currentTimestamp} - 01 - PLACEHOLDER_HUELLA + + 1.0 + + + 99999910G + {$invoice_number} + {$invoice_date} + + + CERTIFICADO FISICA PRUEBAS + F1 + Test invoice submitted by computer system on behalf of business + + + Test Recipient Company + A39200019 + + + + + 01 + S1 + 21 + 100.00 + 21.00 + + + 21.00 + 121.00 + + + N + + + + Sistema de Facturación + A39200019 + InvoiceNinja + 77 + 1.0.03 + 383 + N + S + S + + {$currentTimestamp} + 01 + PLACEHOLDER_HUELLA + - + - - - -XML; + + + + XML; -// Calculate the correct hash using AEAT's specified format -$correctHash = $this->calculateVerifactuHash( - $nif, // IDEmisorFactura - $invoice_number, // NumSerieFactura - $invoice_date, // FechaExpedicionFactura - 'F1', // TipoFactura - '21.00', // CuotaTotal - '121.00', // ImporteTotal - '', // Huella (empty for first calculation) - $currentTimestamp // FechaHoraHusoGenRegistro (current time) -); + // Calculate the correct hash using AEAT's specified format + $correctHash = $this->calculateVerifactuHash( + $nif, // IDEmisorFactura + $invoice_number, // NumSerieFactura + $invoice_date, // FechaExpedicionFactura + 'F1', // TipoFactura + '21.00', // CuotaTotal + '121.00', // ImporteTotal + '', // Huella (empty for first calculation) + $currentTimestamp // FechaHoraHusoGenRegistro (current time) + ); -// Replace the placeholder with the correct hash -$soapXml = str_replace('PLACEHOLDER_HUELLA', $correctHash, $soapXml); + // Replace the placeholder with the correct hash + $soapXml = str_replace('PLACEHOLDER_HUELLA', $correctHash, $soapXml); -nlog('Calculated hash for XML: ' . $correctHash); + nlog('Calculated hash for XML: ' . $correctHash); -// Sign the XML before sending -$certPath = storage_path('aeat-cert5.pem'); -$keyPath = storage_path('aeat-key5.pem'); -$signingService = new \App\Services\EDocument\Standards\Verifactu\Signing\SigningService($soapXml, file_get_contents($keyPath), file_get_contents($certPath)); -$soapXml = $signingService->sign(); + // Sign the XML before sending + $certPath = storage_path('aeat-cert5.pem'); + $keyPath = storage_path('aeat-key5.pem'); + $signingService = new \App\Services\EDocument\Standards\Verifactu\Signing\SigningService($soapXml, file_get_contents($keyPath), file_get_contents($certPath)); + $soapXml = $signingService->sign(); -// Try direct HTTP approach instead of SOAP client -$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($soapXml, 'text/xml') - ->post('https://prewww1.aeat.es/wlpl/TIKE-CONT/ws/SistemaFacturacion/VerifactuSOAP'); + // Try direct HTTP approach instead of SOAP client + $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($soapXml, 'text/xml') + ->post('https://prewww1.aeat.es/wlpl/TIKE-CONT/ws/SistemaFacturacion/VerifactuSOAP'); -nlog('Request with AEAT official test data:'); -nlog($soapXml); -nlog('Response with AEAT official test data:'); -nlog('Response Status: ' . $response->status()); -nlog('Response Headers: ' . json_encode($response->headers())); -nlog('Response Body: ' . $response->body()); + nlog('Request with AEAT official test data:'); + nlog($soapXml); + nlog('Response with AEAT official test data:'); + nlog('Response Status: ' . $response->status()); + nlog('Response Headers: ' . json_encode($response->headers())); + nlog('Response Body: ' . $response->body()); -if (!$response->successful()) { - \Log::error('Request failed with status: ' . $response->status()); - \Log::error('Response body: ' . $response->body()); -} + if (!$response->successful()) { + \Log::error('Request failed with status: ' . $response->status()); + \Log::error('Response body: ' . $response->body()); + } -$this->assertTrue($response->successful()); + $this->assertTrue($response->successful()); -$responseProcessor = new ResponseProcessor(); -$responseProcessor->processResponse($response->body()); - -nlog($responseProcessor->getSummary()); - -$this->assertTrue($responseProcessor->getSummary()['success']); - + $responseProcessor = new ResponseProcessor(); + $responseProcessor->processResponse($response->body()); + nlog($responseProcessor->getSummary()); + $this->assertTrue($responseProcessor->getSummary()['success']); }