diff --git a/src/Dto/DeductionDocumentData.php b/src/Dto/DeductionDocumentData.php new file mode 100644 index 0000000..a8be218 --- /dev/null +++ b/src/Dto/DeductionDocumentData.php @@ -0,0 +1,22 @@ + $accessKey]); + } + + public static function nfe(string $accessKey): self + { + return new self('nfe', ['access_key' => $accessKey]); + } + + public static function legacyNfse(string $municipioIbge, string $numero, string $codigoVerificacao): self + { + return new self('legacy_nfse', [ + 'municipio_ibge' => $municipioIbge, + 'numero' => $numero, + 'codigo_verificacao' => $codigoVerificacao, + ]); + } + + public static function legacyNfNfs(string $numero, string $modelo, string $serie): self + { + return new self('legacy_nf_nfs', [ + 'numero' => $numero, + 'modelo' => $modelo, + 'serie' => $serie, + ]); + } + + public static function fiscalDocument(string $numero): self + { + return new self('fiscal_document', ['numero' => $numero]); + } + + public static function document(string $numero): self + { + return new self('document', ['numero' => $numero]); + } +} diff --git a/src/Dto/DeductionReductionData.php b/src/Dto/DeductionReductionData.php index 433959f..40d97d7 100644 --- a/src/Dto/DeductionReductionData.php +++ b/src/Dto/DeductionReductionData.php @@ -15,9 +15,13 @@ */ final readonly class DeductionReductionData { + /** + * @param list $documentos + */ public function __construct( public string $percentual = '', public string $valor = '', + public array $documentos = [], ) { } } diff --git a/src/Dto/DeductionSupplierData.php b/src/Dto/DeductionSupplierData.php new file mode 100644 index 0000000..d398a50 --- /dev/null +++ b/src/Dto/DeductionSupplierData.php @@ -0,0 +1,27 @@ +percentual !== ''; $hasValue = $deduction->valor !== ''; + $hasDocuments = $deduction->documentos !== []; + $modeCount = (int) $hasPercentage + (int) $hasValue + (int) $hasDocuments; - if ($hasPercentage === $hasValue) { + if ($modeCount !== 1) { throw new \InvalidArgumentException( - 'Deduction/reduction must provide exactly one of percentual (pDR) or valor (vDR).', + 'Deduction/reduction must provide exactly one of percentual (pDR), valor (vDR) or documentos.', ); } @@ -283,14 +285,184 @@ private function buildDeductionReduction(\DOMDocument $doc, DpsData $dps): \DOME if ($hasPercentage) { $this->assertDecimal($deduction->percentual, 3, 'Deduction/reduction percentage'); $vDedRed->appendChild($doc->createElement('pDR', $deduction->percentual)); - } else { + + return $vDedRed; + } + + if ($hasValue) { $this->assertDecimal($deduction->valor, 15, 'Deduction/reduction value'); $vDedRed->appendChild($doc->createElement('vDR', $deduction->valor)); + + return $vDedRed; + } + + if (count($deduction->documentos) > 1000) { + throw new \InvalidArgumentException('Deduction/reduction supports at most 1000 documents.'); } + $documents = $doc->createElement('documentos'); + + foreach ($deduction->documentos as $document) { + $documents->appendChild($this->buildDeductionDocument($doc, $document)); + } + + $vDedRed->appendChild($documents); + return $vDedRed; } + private function buildDeductionDocument( + \DOMDocument $doc, + \LibreCodeCoop\NfsePHP\Dto\DeductionDocumentData $document, + ): \DOMElement { + if (!in_array($document->type, [1, 2, 3, 4, 5, 6, 7, 8, 9, 99], true)) { + throw new \InvalidArgumentException('Deduction document type must be 1-9 or 99.'); + } + + if ($document->type === 99 && trim($document->otherDescription) === '') { + throw new \InvalidArgumentException('Other deduction type (99) requires a description.'); + } + + if ($document->type !== 99 && $document->otherDescription !== '') { + throw new \InvalidArgumentException('Other deduction description is only allowed for type 99.'); + } + + $date = \DateTimeImmutable::createFromFormat('!Y-m-d', $document->issuedAt); + if ($date === false || $date->format('Y-m-d') !== $document->issuedAt) { + throw new \InvalidArgumentException('Deduction document issue date must use YYYY-MM-DD.'); + } + + $this->assertDecimal($document->deductibleValue, 15, 'Deductible/reducible document value'); + $this->assertDecimal($document->deductionValue, 15, 'Applied deduction/reduction value'); + + if ((float) $document->deductionValue > (float) $document->deductibleValue) { + throw new \InvalidArgumentException( + 'Applied deduction/reduction value cannot exceed the deductible/reducible document value.', + ); + } + + $element = $doc->createElement('docDedRed'); + $element->appendChild($this->buildDeductionDocumentReference($doc, $document->reference)); + $element->appendChild($doc->createElement('tpDedRed', (string) $document->type)); + + if ($document->otherDescription !== '') { + $element->appendChild( + $doc->createElement('xDescOutDed', htmlspecialchars($document->otherDescription, ENT_XML1)), + ); + } + + $element->appendChild($doc->createElement('dtEmiDoc', $document->issuedAt)); + $element->appendChild($doc->createElement('vDedutivelRedutivel', $document->deductibleValue)); + $element->appendChild($doc->createElement('vDeducaoReducao', $document->deductionValue)); + + if ($document->supplier !== null) { + $element->appendChild($this->buildDeductionSupplier($doc, $document->supplier)); + } + + return $element; + } + + private function buildDeductionDocumentReference( + \DOMDocument $doc, + \LibreCodeCoop\NfsePHP\Dto\DeductionDocumentReferenceData $reference, + ): \DOMElement { + return match ($reference->type) { + 'nfse' => $doc->createElement('chNFSe', (string) ($reference->values['access_key'] ?? '')), + 'nfe' => $doc->createElement('chNFe', (string) ($reference->values['access_key'] ?? '')), + 'legacy_nfse' => $this->buildLegacyNfseReference($doc, $reference->values), + 'legacy_nf_nfs' => $this->buildLegacyNfNfsReference($doc, $reference->values), + 'fiscal_document' => $doc->createElement('nDocFisc', (string) ($reference->values['numero'] ?? '')), + 'document' => $doc->createElement('nDoc', (string) ($reference->values['numero'] ?? '')), + default => throw new \InvalidArgumentException('Unsupported deduction document reference type.'), + }; + } + + /** + * @param array $values + */ + private function buildLegacyNfseReference(\DOMDocument $doc, array $values): \DOMElement + { + $group = $doc->createElement('NFSeMun'); + $group->appendChild($doc->createElement('cMunNFSeMun', (string) ($values['municipio_ibge'] ?? ''))); + $group->appendChild($doc->createElement('nNFSeMun', (string) ($values['numero'] ?? ''))); + $group->appendChild($doc->createElement('cVerifNFSeMun', (string) ($values['codigo_verificacao'] ?? ''))); + + return $group; + } + + /** + * @param array $values + */ + private function buildLegacyNfNfsReference(\DOMDocument $doc, array $values): \DOMElement + { + $group = $doc->createElement('NFNFS'); + $group->appendChild($doc->createElement('nNFS', (string) ($values['numero'] ?? ''))); + $group->appendChild($doc->createElement('modNFS', (string) ($values['modelo'] ?? ''))); + $group->appendChild($doc->createElement('serieNFS', (string) ($values['serie'] ?? ''))); + + return $group; + } + + private function buildDeductionSupplier( + \DOMDocument $doc, + \LibreCodeCoop\NfsePHP\Dto\DeductionSupplierData $supplier, + ): \DOMElement { + if (trim($supplier->name) === '') { + throw new \InvalidArgumentException('Deduction supplier name is required.'); + } + + $element = $doc->createElement('fornec'); + + switch ($supplier->identityType) { + case 'cnpj': + if (preg_match('/^[A-Z0-9]{12}\d{2}$/', strtoupper($supplier->identity)) !== 1) { + throw new \InvalidArgumentException('Deduction supplier CNPJ must contain 14 valid-format characters.'); + } + $element->appendChild($doc->createElement('CNPJ', strtoupper($supplier->identity))); + break; + case 'cpf': + if (preg_match('/^\d{11}$/', $supplier->identity) !== 1) { + throw new \InvalidArgumentException('Deduction supplier CPF must contain 11 digits.'); + } + $element->appendChild($doc->createElement('CPF', $supplier->identity)); + break; + case 'nif': + if ($supplier->identity === '') { + throw new \InvalidArgumentException('Deduction supplier NIF cannot be empty.'); + } + $element->appendChild($doc->createElement('NIF', $supplier->identity)); + break; + case 'no_nif': + if (!in_array($supplier->identity, ['0', '1', '2'], true)) { + throw new \InvalidArgumentException('Deduction supplier cNaoNIF must be 0, 1 or 2.'); + } + $element->appendChild($doc->createElement('cNaoNIF', $supplier->identity)); + break; + default: + throw new \InvalidArgumentException('Unsupported deduction supplier identity type.'); + } + + if ($supplier->caepf !== '') { + $element->appendChild($doc->createElement('CAEPF', $supplier->caepf)); + } + + if ($supplier->municipalRegistration !== '') { + $element->appendChild($doc->createElement('IM', $supplier->municipalRegistration)); + } + + $element->appendChild($doc->createElement('xNome', htmlspecialchars($supplier->name, ENT_XML1))); + + if ($supplier->phone !== '') { + $element->appendChild($doc->createElement('fone', $supplier->phone)); + } + + if ($supplier->email !== '') { + $element->appendChild($doc->createElement('email', htmlspecialchars($supplier->email, ENT_XML1))); + } + + return $element; + } + private function buildMunicipalBenefit(\DOMDocument $doc, DpsData $dps): \DOMElement { $benefit = $dps->beneficioMunicipal; diff --git a/tests/Unit/Xml/OfficialDpsSchemaValidationTest.php b/tests/Unit/Xml/OfficialDpsSchemaValidationTest.php index b72e665..310d6af 100644 --- a/tests/Unit/Xml/OfficialDpsSchemaValidationTest.php +++ b/tests/Unit/Xml/OfficialDpsSchemaValidationTest.php @@ -7,7 +7,10 @@ namespace LibreCodeCoop\NfsePHP\Tests\Unit\Xml; +use LibreCodeCoop\NfsePHP\Dto\DeductionDocumentData; +use LibreCodeCoop\NfsePHP\Dto\DeductionDocumentReferenceData; use LibreCodeCoop\NfsePHP\Dto\DeductionReductionData; +use LibreCodeCoop\NfsePHP\Dto\DeductionSupplierData; use LibreCodeCoop\NfsePHP\Dto\DpsData; use LibreCodeCoop\NfsePHP\Dto\MunicipalBenefitData; use LibreCodeCoop\NfsePHP\Dto\SubstitutionData; @@ -53,6 +56,29 @@ public function testStandardDeductionAndMunicipalBenefitMatchOfficialSchema(): v self::assertSame([], $this->validator->validate($xml)); } + public function testDocumentBackedDeductionMatchesOfficialSchema(): void + { + $xml = $this->builder->buildDps($this->makeDps( + deducaoReducao: new DeductionReductionData(documentos: [ + new DeductionDocumentData( + reference: DeductionDocumentReferenceData::document('DOC-2026-001'), + type: 99, + issuedAt: '2026-09-30', + deductibleValue: '500.00', + deductionValue: '125.00', + otherDescription: 'Despesa comprovada', + supplier: new DeductionSupplierData( + identityType: 'cnpj', + identity: '11222333000181', + name: 'Fornecedor Exemplo', + ), + ), + ]), + )); + + self::assertSame([], $this->validator->validate($xml)); + } + public function testValidatorRejectsUnsupportedSchemaVersionExplicitly(): void { $this->expectException(\InvalidArgumentException::class); diff --git a/tests/Unit/Xml/XmlBuilderTest.php b/tests/Unit/Xml/XmlBuilderTest.php index f7a7e85..48da978 100644 --- a/tests/Unit/Xml/XmlBuilderTest.php +++ b/tests/Unit/Xml/XmlBuilderTest.php @@ -7,7 +7,10 @@ namespace LibreCodeCoop\NfsePHP\Tests\Unit\Xml; +use LibreCodeCoop\NfsePHP\Dto\DeductionDocumentData; +use LibreCodeCoop\NfsePHP\Dto\DeductionDocumentReferenceData; use LibreCodeCoop\NfsePHP\Dto\DeductionReductionData; +use LibreCodeCoop\NfsePHP\Dto\DeductionSupplierData; use LibreCodeCoop\NfsePHP\Dto\DpsData; use LibreCodeCoop\NfsePHP\Dto\MunicipalBenefitData; use LibreCodeCoop\NfsePHP\Dto\SubstitutionData; @@ -817,6 +820,74 @@ public function testBuildDpsRejectsInvalidMunicipalBenefitIdentifier(): void )); } + public function testBuildDpsEmitsDocumentBackedDeductionWithSupplier(): void + { + $document = new DeductionDocumentData( + reference: DeductionDocumentReferenceData::document('DOC-2026-001'), + type: 99, + issuedAt: '2026-09-30', + deductibleValue: '500.00', + deductionValue: '125.00', + otherDescription: 'Despesa comprovada', + supplier: new DeductionSupplierData( + identityType: 'cnpj', + identity: '11222333000181', + name: 'Fornecedor Exemplo', + ), + ); + + $xml = $this->builder->buildDps($this->makeDps( + deducaoReducao: new DeductionReductionData(documentos: [$document]), + )); + $compactXml = preg_replace('/>\s+<', $xml) ?? $xml; + + self::assertStringContainsString( + 'DOC-2026-00199' + . 'Despesa comprovada2026-09-30' + . '500.00' + . '125.00' + . '11222333000181Fornecedor Exemplo' + . '', + $compactXml, + ); + } + + public function testBuildDpsRejectsAppliedDeductionAboveDocumentValue(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('cannot exceed'); + + $this->builder->buildDps($this->makeDps( + deducaoReducao: new DeductionReductionData(documentos: [ + new DeductionDocumentData( + reference: DeductionDocumentReferenceData::fiscalDocument('NF-123'), + type: 2, + issuedAt: '2026-09-30', + deductibleValue: '100.00', + deductionValue: '101.00', + ), + ]), + )); + } + + public function testBuildDpsRejectsOtherDeductionWithoutDescription(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('requires a description'); + + $this->builder->buildDps($this->makeDps( + deducaoReducao: new DeductionReductionData(documentos: [ + new DeductionDocumentData( + reference: DeductionDocumentReferenceData::document('DOC-1'), + type: 99, + issuedAt: '2026-09-30', + deductibleValue: '100.00', + deductionValue: '50.00', + ), + ]), + )); + } + private function makeDps( string $cnpjPrestador = '11222333000181', string $municipioIbge = '3303302',