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
25 changes: 24 additions & 1 deletion docs/NT009-CONTRACT-IMPLEMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ Restricted production instead lists
`esquemas-nfse-rtc-v1-01-20260727.zip`. They share a numeric schema
version but not necessarily the same content or deployment semantics.

At review time (2026-10-08), the RTC portal published NT009 v1.01 but no
At re-verification (2026-10-09), the RTC portal published NT009 v1.01 but no
NT009-specific XSD package or confirmed SEFIN deployment/effective date.
The NT009 text points to a separately published rollout schedule. **Do not
enable or claim production/homologation conformity for the draft NT009
Expand Down Expand Up @@ -135,6 +135,17 @@ It now includes explicitly typed, separately validated structural drafts for:
optional detail and discount records, with exact-cent reconciliation of
`gDetCobranca` to the charge and total charge values to `vServ`.

Additional optional, source-backed Annex VI groups are represented without
fiscal inference: `indZFMALC` (row 377, explicitly verified geography plus
published cIndOp whitelist), `tpOper`/`gRefNFSe` (rows 378-380, mandatory
reference for type 2/3), `tpEnteGov` (row 381, verified government purchase),
`indDoacao` (row 382, donation without consideration), `gTribRegular`
(rows 431-433) and `gDif` (rows 434-437). Existing callers are unaffected:
the `Nt009DpsPreviewData` constructor adds trailing optional arguments only.
The optional nested tax groups are rejected if the caller-verified
`ind_gIBSCBS` disallows `gIBSCBS`. Cross-domain applicability and
calculations remain caller responsibilities.

The preview also models `IBSCBS/valores/trib/gIBSCBS/gEstornoCred`
(Annex VI rows 438-440), containing both decimal `vIBSEstCred` and
`vCBSEstCred`. The caller must separately verify the official cClassTrib
Expand Down Expand Up @@ -255,6 +266,18 @@ php tools/bin/domains.php schema-packages --report=/tmp/nfse-xsd-comparison.json
These packages cannot independently prove an NT009 v1.04.01 rollout, which
is a separate contractual/effective-date gate.

## Downstream adoption boundary

`LibreCodeCoop/akaunting-nfse` currently pins
`librecodeoop/nfse-php: dev-main#f179d7f723bec80f5b19a25dfbc385a612c2447f`
in `3rdparty/composer.json`. The opt-in preview extends the constructor with
optional trailing parameters, leaving the current `DpsData` constructor,
`XmlBuilder::buildDps(DpsData)`, `DpsSchemaValidator` and
`NfseClient::emit()` unchanged. Existing consumers need no migration for
ordinary emission. **No NT009 commit should replace the consumer pin** until
an effective schema, correct environment and successful SEFIN contract tests
are evidenced. This library makes no downstream UI/persistence changes.

## Cross-publication discrepancies requiring official confirmation

The live, checksum-pinned XSD ZIP comparison on 2026-10-08 (Brazil local
Expand Down
21 changes: 21 additions & 0 deletions src/Dto/Nt009DeferralData.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?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 Annex VI rows 434-437. Exact textual percentages, not calculated.
*/
final readonly class Nt009DeferralData
{
public function __construct(
public string $percentualUf,
public string $percentualMunicipio,
public string $percentualCbs,
) {
}
}
19 changes: 19 additions & 0 deletions src/Dto/Nt009DpsPreviewData.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
* @param list<Nt009LinkedPaymentData> $pagamentosVinculados
* @param list<Nt009MovableAssetData> $bensMoveis
* @param list<string> $notasPagamentoAntecipado
* @param list<string> $notasFiscaisReferenciadas
*/
public function __construct(
/** finNFSe: 0 regular, 1 credit, 2 debit. */
Expand Down Expand Up @@ -68,6 +69,24 @@ public function __construct(
public ?string $valorEstornoCbs = null,
/** 1-99 prior NFS-e payment-reference keys, 50 characters each. */
public array $notasPagamentoAntecipado = [],
/** indZFMALC, present only for the officially enumerated operations. */
public ?int $indicadorZfmAlc = null,
/** Caller confirmed the location requirements in Annex VI row 377. */
public ?bool $elegibilidadeZfmAlcConfirmada = null,
/** tpOper 1-5; externally verified applicable transaction. */
public ?int $tipoOperacao = null,
/** Prior NFS-e references (gRefNFSe), 1-99 keys of 50 characters. */
public array $notasFiscaisReferenciadas = [],
/** tpEnteGov 1-4, only in externally confirmed government purchases. */
public ?int $tipoEnteGovernamental = null,
/** Whether the caller confirmed this is a government purchase. */
public ?bool $compraGovernamentalConfirmada = null,
/** Presence means donor declares a transaction without consideration. */
public ?bool $doacaoSemContraprestacao = null,
/** Explicit optional regular taxation group, not a tax calculation. */
public ?Nt009RegularTaxData $tributacaoRegular = null,
/** Explicit optional deferral percentages, not tax calculation. */
public ?Nt009DeferralData $diferimento = null,
) {
}
}
20 changes: 20 additions & 0 deletions src/Dto/Nt009RegularTaxData.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?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 Annex VI rows 431-433. The caller selects the classification.
*/
final readonly class Nt009RegularTaxData
{
public function __construct(
public string $cst,
public string $classificacao,
) {
}
}
120 changes: 115 additions & 5 deletions src/Xml/Nt009DpsPreviewBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,27 @@ public function build(DpsData $dps, Nt009DpsPreviewData $preview): Nt009DpsPrevi
if ($preview->codigoIndicadorOperacao !== null) {
$ibs->appendChild($document->createElement('cIndOp', $preview->codigoIndicadorOperacao));
}
if ($preview->indicadorZfmAlc !== null) {
$ibs->appendChild($document->createElement('indZFMALC', (string) $preview->indicadorZfmAlc));
}
if ($preview->tipoOperacao !== null) {
$ibs->appendChild($document->createElement('tpOper', (string) $preview->tipoOperacao));
}
if ($preview->notasFiscaisReferenciadas !== []) {
$references = $document->createElement('gRefNFSe');
foreach ($preview->notasFiscaisReferenciadas as $key) {
$ref = $document->createElement('refNFSe');
$ref->appendChild($document->createTextNode($key));
$references->appendChild($ref);
}
$ibs->appendChild($references);
}
if ($preview->tipoEnteGovernamental !== null) {
$ibs->appendChild($document->createElement('tpEnteGov', (string) $preview->tipoEnteGovernamental));
}
if ($preview->doacaoSemContraprestacao === true) {
$ibs->appendChild($document->createElement('indDoacao', '1'));
}
if ($preview->imovel !== null) {
$ibs->appendChild(
(new Nt009RealEstatePreviewGroup())->build(
Expand Down Expand Up @@ -153,6 +174,22 @@ public function build(DpsData $dps, Nt009DpsPreviewData $preview): Nt009DpsPrevi
$adjustment->appendChild($document->createElement('vCBS', (string) $preview->valorAjusteCbs));
$group->appendChild($adjustment);
}
if ($preview->tributacaoRegular !== null) {
$regular = $document->createElement('gTribRegular');
$regular->appendChild($document->createElement('CSTReg', $preview->tributacaoRegular->cst));
$regular->appendChild($document->createElement(
'cClassTribReg',
$preview->tributacaoRegular->classificacao
));
$group->appendChild($regular);
}
if ($preview->diferimento !== null) {
$deferral = $document->createElement('gDif');
$deferral->appendChild($document->createElement('pDifUF', $preview->diferimento->percentualUf));
$deferral->appendChild($document->createElement('pDifMun', $preview->diferimento->percentualMunicipio));
$deferral->appendChild($document->createElement('pDifCBS', $preview->diferimento->percentualCbs));
$group->appendChild($deferral);
}
if ($preview->valorEstornoIbs !== null) {
// Annex VI rows 438-440: allowed/required exclusively
// by the caller-verified cClassTrib.ind_gEstornoCred.
Expand Down Expand Up @@ -232,6 +269,13 @@ private function validatePreview(Nt009DpsPreviewData $preview): void
|| $preview->valorEstornoIbs !== null
|| $preview->valorEstornoCbs !== null
|| $preview->notasPagamentoAntecipado !== []
|| $preview->indicadorZfmAlc !== null
|| $preview->tipoOperacao !== null
|| $preview->notasFiscaisReferenciadas !== []
|| $preview->tipoEnteGovernamental !== null
|| $preview->doacaoSemContraprestacao === true
|| $preview->tributacaoRegular !== null
|| $preview->diferimento !== null
|| $preview->exigeGrupoIbsCbs !== null
|| $preview->bensMoveis !== []
|| $preview->pagamentosVinculados !== []
Expand Down Expand Up @@ -267,7 +311,9 @@ private function validatePreview(Nt009DpsPreviewData $preview): void
|| $preview->valorAjusteIbs !== null || $preview->valorAjusteCbs !== null
|| $preview->exigeEstornoCredito === true
|| $preview->valorEstornoIbs !== null || $preview->valorEstornoCbs !== null
|| $preview->notasPagamentoAntecipado !== [])) {
|| $preview->notasPagamentoAntecipado !== []
|| $preview->tributacaoRegular !== null
|| $preview->diferimento !== null)) {
throw new \InvalidArgumentException('NT009 forbids gIBSCBS children when ind_gIBSCBS is false');
}
if ($preview->codigoCreditoPresumido !== null
Expand Down Expand Up @@ -303,12 +349,76 @@ private function validatePreview(Nt009DpsPreviewData $preview): void
throw new \InvalidArgumentException('NT009 gEstornoCred requires decimal vIBSEstCred and vCBSEstCred strings');
}
}
if (count($preview->notasPagamentoAntecipado) > 99) {
throw new \InvalidArgumentException('NT009 gPagAntecipado allows at most 99 refNFSe keys');
$this->validateInvoiceKeys($preview->notasPagamentoAntecipado, 'gPagAntecipado');

if ($preview->indicadorZfmAlc !== null) {
// Annex VI row 377: both operation-code whitelist and geographic
// requirements must hold; location is verified by the caller.
$eligibleOperations = [
'010101', '010102', '010103', '010106', '020101', '020201',
'020301', '030101', '030102', '050101', '050102', '050201',
'060101', '070101', '070102', '100301', '100302', '100401',
'100501', '100502', '100601',
];
if (!in_array($preview->indicadorZfmAlc, [0, 1], true)
|| !in_array($preview->codigoIndicadorOperacao, $eligibleOperations, true)
|| $preview->elegibilidadeZfmAlcConfirmada !== true) {
throw new \InvalidArgumentException(
'NT009 indZFMALC requires a published eligible cIndOp and caller-verified ZFM/ALC location'
);
}
}
if ($preview->tipoOperacao !== null
&& !in_array($preview->tipoOperacao, [1, 2, 3, 4, 5], true)) {
throw new \InvalidArgumentException('NT009 tpOper must be 1-5');
}
if (in_array($preview->tipoOperacao, [2, 3], true)
&& $preview->notasFiscaisReferenciadas === []) {
throw new \InvalidArgumentException('NT009 tpOper 2 or 3 requires gRefNFSe');
}
$this->validateInvoiceKeys($preview->notasFiscaisReferenciadas, 'gRefNFSe');
if ($preview->tipoEnteGovernamental !== null
&& (!in_array($preview->tipoEnteGovernamental, [1, 2, 3, 4], true)
|| $preview->compraGovernamentalConfirmada !== true)) {
throw new \InvalidArgumentException('NT009 tpEnteGov requires a government purchase and code 1-4');
}
foreach ($preview->notasPagamentoAntecipado as $key) {
if ($preview->doacaoSemContraprestacao === false) {
throw new \InvalidArgumentException(
'NT009 indDoacao must be omitted for transactions with consideration'
);
}
if ($preview->tributacaoRegular !== null) {
if (preg_match('/^[0-9]{3}$/D', $preview->tributacaoRegular->cst) !== 1
|| preg_match('/^[0-9]{6}$/D', $preview->tributacaoRegular->classificacao) !== 1) {
throw new \InvalidArgumentException('NT009 gTribRegular requires CSTReg (3) and cClassTribReg (6)');
}
}
if ($preview->diferimento !== null) {
foreach ([
$preview->diferimento->percentualUf,
$preview->diferimento->percentualMunicipio,
$preview->diferimento->percentualCbs,
] as $percentual) {
if (preg_match('/^[0-9]{1,3}\.[0-9]{2}$/D', $percentual) !== 1) {
throw new \InvalidArgumentException('NT009 gDif requires three percentage strings (1-3V2)');
}
}
}
}

/**
* @param array<array-key, mixed> $keys
*/
private function validateInvoiceKeys(array $keys, string $group): void
{
if (count($keys) > 99) {
throw new \InvalidArgumentException('NT009 ' . $group . ' allows at most 99 refNFSe keys');
}
foreach ($keys as $key) {
if (!is_string($key) || mb_strlen($key) !== 50 || preg_match('/\p{Cc}/u', $key) !== 0) {
throw new \InvalidArgumentException('NT009 gPagAntecipado/refNFSe must contain 50 valid characters');
throw new \InvalidArgumentException(
'NT009 ' . $group . '/refNFSe must contain 50 valid characters'
);
}
}
}
Expand Down
Loading
Loading