diff --git a/src/Models/Records/RegistrationRecord.php b/src/Models/Records/RegistrationRecord.php index 54509c7..1301d4b 100644 --- a/src/Models/Records/RegistrationRecord.php +++ b/src/Models/Records/RegistrationRecord.php @@ -13,6 +13,14 @@ * @field RegistroAlta */ class RegistrationRecord extends Record { + private const REGIMES_WITHOUT_TOTAL_CHECK = [ + RegimeType::C03, + RegimeType::C05, + RegimeType::C06, + RegimeType::C08, + RegimeType::C09, + ]; + /** * Indicador de subsanación de un registro de facturación de alta previamente generado * @@ -208,6 +216,10 @@ final public function validateTotals(ExecutionContextInterface $context): void { ->addViolation(); } + if ($this->hasRegimeWithoutTotalCheck()) { + return; + } + $isValidTotalAmount = false; $bestTotalAmount = number_format($expectedTotalBaseAmount + $expectedTotalTaxAmount, 2, '.', ''); foreach ([0, -0.01, 0.01, -0.02, 0.02] as $tolerance) { @@ -224,6 +236,22 @@ final public function validateTotals(ExecutionContextInterface $context): void { } } + /** + * AEAT does not cross-check ImporteTotal against the breakdown when any + * line is under these keys: the total legitimately differs from base plus + * quota (the margin schemes tax only the margin, travel agencies and + * VAT groups declare amounts that are not the invoiced price). + * Source: AEAT "Validaciones y errores" for VERI*FACTU, ImporteTotal. + */ + private function hasRegimeWithoutTotalCheck(): bool { + foreach ($this->breakdown as $details) { + if (isset($details->regimeType) && in_array($details->regimeType, self::REGIMES_WITHOUT_TOTAL_CHECK, true)) { + return true; + } + } + return false; + } + #[Assert\Callback] final public function validateRecipients(ExecutionContextInterface $context): void { if (!isset($this->invoiceType)) { diff --git a/tests/Models/Records/RegistrationRecordTest.php b/tests/Models/Records/RegistrationRecordTest.php index 0626dc8..1e789e9 100644 --- a/tests/Models/Records/RegistrationRecordTest.php +++ b/tests/Models/Records/RegistrationRecordTest.php @@ -195,6 +195,54 @@ public function testValidatesTotalAmounts(): void { } } + public function testSkipsTotalAmountCheckForSpecialRegimes(): void { + $record = new RegistrationRecord(); + $record->invoiceId = new InvoiceIdentifier(); + $record->invoiceId->issuerId = 'A00000000'; + $record->invoiceId->invoiceNumber = 'REBU-1'; + $record->invoiceId->issueDate = new DateTimeImmutable('2025-06-01'); + $record->issuerName = 'Perico de los Palotes, S.A.'; + $record->invoiceType = InvoiceType::Simplificada; + $record->description = 'Venta de terminal usado en REBU'; + $record->breakdown[0] = new BreakdownDetails(); + $record->breakdown[0]->taxType = TaxType::IVA; + $record->breakdown[0]->regimeType = RegimeType::C03; + $record->breakdown[0]->operationType = OperationType::Subject; + $record->breakdown[0]->baseAmount = '82.64'; + $record->breakdown[0]->taxRate = '21.00'; + $record->breakdown[0]->taxAmount = '17.36'; + $record->totalTaxAmount = '17.36'; + $record->totalAmount = '300.00'; + $record->previousInvoiceId = null; + $record->previousHash = null; + $record->hashedAt = new DateTimeImmutable('2025-06-01T20:30:40+02:00'); + $record->hash = $record->calculateHash(); + + // The price is the full sale price, not base plus quota of the margin + $record->validate(); + + // The quota is still checked + $record->totalTaxAmount = '18.00'; + $record->hash = $record->calculateHash(); + try { + $record->validate(); + $this->fail('Did not throw exception for total tax amount validation'); + } catch (InvalidModelException $e) { + $this->assertStringContainsString('Expected total tax amount of 17.36, got 18.00', $e->getMessage()); + } + + // A general line keeps the check + $record->totalTaxAmount = '17.36'; + $record->breakdown[0]->regimeType = RegimeType::C01; + $record->hash = $record->calculateHash(); + try { + $record->validate(); + $this->fail('Did not throw exception for total amount validation'); + } catch (InvalidModelException $e) { + $this->assertStringContainsString('Expected total amount of 100.00, got 300.00', $e->getMessage()); + } + } + public function testValidatesRecipients(): void { $record = new RegistrationRecord(); $record->invoiceId = new InvoiceIdentifier();