Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions src/Dto/DeductionDocumentData.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

// SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
// SPDX-License-Identifier: AGPL-3.0-or-later

declare(strict_types=1);

namespace LibreCodeCoop\NfsePHP\Dto;

final readonly class DeductionDocumentData
{
public function __construct(
public DeductionDocumentReferenceData $reference,
public int $type,
public string $issuedAt,
public string $deductibleValue,
public string $deductionValue,
public string $otherDescription = '',
public ?DeductionSupplierData $supplier = null,
) {
}
}
58 changes: 58 additions & 0 deletions src/Dto/DeductionDocumentReferenceData.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
<?php

// SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
// SPDX-License-Identifier: AGPL-3.0-or-later

declare(strict_types=1);

namespace LibreCodeCoop\NfsePHP\Dto;

/**
* One of the mutually exclusive document identities accepted by TCDocDedRed.
*/
final readonly class DeductionDocumentReferenceData
{
private function __construct(
public string $type,
public array $values,
) {
}

public static function nfse(string $accessKey): self
{
return new self('nfse', ['access_key' => $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]);
}
}
4 changes: 4 additions & 0 deletions src/Dto/DeductionReductionData.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,13 @@
*/
final readonly class DeductionReductionData
{
/**
* @param list<DeductionDocumentData> $documentos
*/
public function __construct(
public string $percentual = '',
public string $valor = '',
public array $documentos = [],
) {
}
}
27 changes: 27 additions & 0 deletions src/Dto/DeductionSupplierData.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?php

// SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
// SPDX-License-Identifier: AGPL-3.0-or-later

declare(strict_types=1);

namespace LibreCodeCoop\NfsePHP\Dto;

/**
* Optional supplier identity used by a document-backed deduction.
*
* Address data is intentionally omitted because TCInfoPessoa makes it optional.
*/
final readonly class DeductionSupplierData
{
public function __construct(
public string $identityType,
public string $identity,
public string $name,
public string $caepf = '',
public string $municipalRegistration = '',
public string $phone = '',
public string $email = '',
) {
}
}
178 changes: 175 additions & 3 deletions src/Xml/XmlBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -271,10 +271,12 @@ private function buildDeductionReduction(\DOMDocument $doc, DpsData $dps): \DOME

$hasPercentage = $deduction->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.',
);
}

Expand All @@ -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<string, mixed> $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<string, mixed> $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;
Expand Down
26 changes: 26 additions & 0 deletions tests/Unit/Xml/OfficialDpsSchemaValidationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading