diff --git a/src/Helpers/HashHelper.php b/src/Helpers/HashHelper.php index f48676e..74ef78e 100644 --- a/src/Helpers/HashHelper.php +++ b/src/Helpers/HashHelper.php @@ -18,23 +18,27 @@ class HashHelper ]; /** - * Generates the hash for an invoice record. + * Generates the hash for an invoice record according to AEAT specifications. + * + * CRITICAL: Field names MUST match the official AEAT XML field names as per + * "Detalle de las especificaciones técnicas para generación de la huella o hash + * de los registros de facturación" v0.1.2 (27/08/2024), page 6. * - * @param array $data Invoice record data in the correct order. + * @param array $data Invoice record data with snake_case keys (for compatibility). * @return array ['hash' => string, 'inputString' => string] */ public static function generateInvoiceHash(array $data): array { - self::validateData(self::$invoiceRequiredFields, $data); - $inputString = self::field('issuer_tax_id', $data['issuer_tax_id']); - $inputString .= self::field('invoice_number', $data['invoice_number']); - $inputString .= self::field('issue_date', $data['issue_date']); - $inputString .= self::field('invoice_type', $data['invoice_type']); - $inputString .= self::field('total_tax', $data['total_tax']); - $inputString .= self::field('total_amount', $data['total_amount']); - $inputString .= self::field('previous_hash', $data['previous_hash']); - $inputString .= self::field('generated_at', $data['generated_at'], false); - $hash = strtoupper(hash('sha256', $inputString, false)); + self::validateData(self::$invoiceRequiredFields, $data); + $inputString = self::field('IDEmisorFactura', $data['issuer_tax_id']); + $inputString .= self::field('NumSerieFactura', $data['invoice_number']); + $inputString .= self::field('FechaExpedicionFactura', $data['issue_date']); + $inputString .= self::field('TipoFactura', $data['invoice_type']); + $inputString .= self::field('CuotaTotal', $data['total_tax']); + $inputString .= self::field('ImporteTotal', $data['total_amount']); + $inputString .= self::field('Huella', $data['previous_hash']); + $inputString .= self::field('FechaHoraHusoGenRegistro', $data['generated_at'], false); + $hash = strtoupper(hash('sha256', $inputString, false)); return ['hash' => $hash, 'inputString' => $inputString]; } diff --git a/tests/Unit/HashHelperAeatComplianceTest.php b/tests/Unit/HashHelperAeatComplianceTest.php new file mode 100644 index 0000000..064ea54 --- /dev/null +++ b/tests/Unit/HashHelperAeatComplianceTest.php @@ -0,0 +1,156 @@ + '89890001K', + 'invoice_number' => '12345678/G33', + 'issue_date' => '01-01-2024', + 'invoice_type' => 'F1', + 'total_tax' => '12.35', + 'total_amount' => '123.45', + 'previous_hash' => '', // Empty for first invoice + 'generated_at' => '2024-01-01T19:20:30+01:00', + ]; + + $result = HashHelper::generateInvoiceHash($data); + + // Verify hash format (64 chars, uppercase hex) + $this->assertMatchesRegularExpression('/^[A-F0-9]{64}$/', $result['hash']); + + // Verify hash matches AEAT expected value + $this->assertEquals( + '3C464DAF61ACB827C65FDA19F352A4E3BDC2C640E9E9FC4CC058073F38F12F60', + $result['hash'], + 'Hash does not match AEAT official example for first invoice' + ); + + // Verify input string uses official AEAT field names + $expectedInputString = 'IDEmisorFactura=89890001K&NumSerieFactura=12345678/G33&' + . 'FechaExpedicionFactura=01-01-2024&TipoFactura=F1&CuotaTotal=12.35&' + . 'ImporteTotal=123.45&Huella=&FechaHoraHusoGenRegistro=2024-01-01T19:20:30+01:00'; + + $this->assertEquals($expectedInputString, $result['inputString']); + } + + /** + * Test Case 2 from AEAT spec (page 11): + * Second invoice with previous hash. + * + * Expected hash: F7B94CFD8924EDFF273501B01EE5153E4CE8F259766F88CF6ACB8935802A2B97 + */ + public function test_second_invoice_hash_matches_aeat_specification(): void + { + $data = [ + 'issuer_tax_id' => '89890001K', + 'invoice_number' => '12345679/G34', + 'issue_date' => '01-01-2024', + 'invoice_type' => 'F1', + 'total_tax' => '12.35', + 'total_amount' => '123.45', + 'previous_hash' => '3C464DAF61ACB827C65FDA19F352A4E3BDC2C640E9E9FC4CC058073F38F12F60', + 'generated_at' => '2024-01-01T19:20:35+01:00', + ]; + + $result = HashHelper::generateInvoiceHash($data); + + $this->assertEquals( + 'F7B94CFD8924EDFF273501B01EE5153E4CE8F259766F88CF6ACB8935802A2B97', + $result['hash'], + 'Hash does not match AEAT official example for second invoice' + ); + } + + /** + * Test that hash output is always uppercase (page 9 of spec). + */ + public function test_hash_output_is_uppercase(): void + { + $data = [ + 'issuer_tax_id' => '89890001K', + 'invoice_number' => 'TEST-001', + 'issue_date' => '01-01-2024', + 'invoice_type' => 'F1', + 'total_tax' => '10.00', + 'total_amount' => '100.00', + 'previous_hash' => '', + 'generated_at' => '2024-01-01T12:00:00+01:00', + ]; + + $result = HashHelper::generateInvoiceHash($data); + + $this->assertMatchesRegularExpression( + '/^[A-F0-9]{64}$/', + $result['hash'], + 'Hash must be 64 uppercase hexadecimal characters' + ); + + $this->assertEquals( + strtoupper($result['hash']), + $result['hash'], + 'Hash must be in uppercase' + ); + } + + /** + * Test whitespace trimming (page 6 of spec): + * Values with leading/trailing spaces should be trimmed. + */ + public function test_whitespace_trimming(): void + { + $dataWithSpaces = [ + 'issuer_tax_id' => '89890001K', + 'invoice_number' => ' 12345678 / G33 ', // Spaces inside preserved + 'issue_date' => '01-01-2024', + 'invoice_type' => 'F1', + 'total_tax' => '12.35', + 'total_amount' => '123.45', + 'previous_hash' => '', + 'generated_at' => '2024-01-01T19:20:30+01:00', + ]; + + $dataWithoutSpaces = [ + 'issuer_tax_id' => '89890001K', + 'invoice_number' => '12345678 / G33', // Trimmed + 'issue_date' => '01-01-2024', + 'invoice_type' => 'F1', + 'total_tax' => '12.35', + 'total_amount' => '123.45', + 'previous_hash' => '', + 'generated_at' => '2024-01-01T19:20:30+01:00', + ]; + + $hashWithSpaces = HashHelper::generateInvoiceHash($dataWithSpaces)['hash']; + $hashWithoutSpaces = HashHelper::generateInvoiceHash($dataWithoutSpaces)['hash']; + + $this->assertEquals( + $hashWithSpaces, + $hashWithoutSpaces, + 'Leading/trailing spaces should be trimmed but internal spaces preserved' + ); + } +} +