PyNFSeNacionalGT 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,26 @@
1
+ """PyNFSeNacional — Emissão de NFS-e pela API SEFIN Nacional (DPS) + DANFSe local.
2
+
3
+ API pública:
4
+ from pynfsenacional import Emissor
5
+ emissor = Emissor("config/cliente.json")
6
+ nota = emissor.emitir(valor=150.00, descricao="...", tomador={...})
7
+ nota.danfse("nota.pdf")
8
+
9
+ Estado: alpha (pipeline completo). Ver docs/roadmap.md para o escopo e
10
+ docs/conhecimento-fiscal.md para a regra fiscal (DPS, E-codes, numeração, DANFSe).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from importlib.metadata import PackageNotFoundError, version
16
+
17
+ from .config import ClienteConfig
18
+ from .emissor import Emissor, Nota
19
+
20
+ try:
21
+ # Fonte única da versão: o metadado do pacote instalado (pyproject.toml).
22
+ __version__ = version("PyNFSeNacionalGT")
23
+ except PackageNotFoundError: # pragma: no cover - rodando do source, sem instalar
24
+ __version__ = "0.0.0+unknown"
25
+
26
+ __all__ = ["Emissor", "Nota", "ClienteConfig", "__version__"]
@@ -0,0 +1,46 @@
1
+ """Assinatura digital XMLDSIG da DPS. Lib: `erpbrasil.assinatura` (signxml).
2
+
3
+ ★ Era o ponto que parecia o "nó" — resolvido por biblioteca madura (em produção no
4
+ ecossistema Odoo Brasil). Assinatura *enveloped* sobre o nó `infDPS`, com `Reference`
5
+ apontando para o `Id`. Validado contra a referência PHP: o `DigestValue` produzido aqui
6
+ é **byte-a-byte idêntico** ao do gabarito (RSA-SHA1 + transforms enveloped/c14n) — ver
7
+ tests/golden. A `<Signature>` entra como último filho do `<DPS>`, depois do `infDPS`.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from lxml import etree
13
+
14
+ NS = "http://www.sped.fazenda.gov.br/nfse"
15
+
16
+
17
+ def assinar_dps(xml_dps: str, *, cert_pfx_path: str, senha: str) -> str:
18
+ """Recebe o XML da DPS (não assinado) e devolve o XML assinado (XMLDSIG enveloped).
19
+
20
+ Delegado a `erpbrasil.assinatura`: assina apontando o `Reference` para o `Id` do
21
+ `infDPS`. RSA-SHA1 (algoritmo do padrão NFe/NFSe).
22
+ """
23
+ from erpbrasil.assinatura.assinatura import Assinatura
24
+ from erpbrasil.assinatura.certificado import Certificado
25
+
26
+ raiz = etree.fromstring(xml_dps.encode("utf-8"))
27
+ inf = raiz.find(f"{{{NS}}}infDPS")
28
+ if inf is None:
29
+ raise ValueError("DPS sem nó infDPS — nada para assinar")
30
+ reference = inf.get("Id")
31
+ if not reference:
32
+ raise ValueError("infDPS sem atributo Id — assinatura precisa do Reference")
33
+
34
+ cert = Certificado(cert_pfx_path, senha)
35
+ assinado = Assinatura(cert).assina_xml2(raiz, reference)
36
+
37
+ # Reserializa com o prolog `<?xml version='1.0' encoding='UTF-8'?>` — sem ele o SEFIN
38
+ # rejeita com E1229 ("Xml não está utilizando codificação UTF-8"). O prolog fica FORA
39
+ # do infDPS/Signature, então não altera o DigestValue nem a assinatura já calculados.
40
+ if isinstance(assinado, str):
41
+ elem = etree.fromstring(assinado.encode("utf-8"))
42
+ elif isinstance(assinado, bytes):
43
+ elem = etree.fromstring(assinado)
44
+ else:
45
+ elem = assinado
46
+ return str(etree.tostring(elem, encoding="UTF-8", xml_declaration=True).decode("utf-8"))
@@ -0,0 +1,149 @@
1
+ """Leitura do certificado A1 (.pfx / PKCS#12). Lib: `cryptography`.
2
+
3
+ ⚠️ Gotcha ICP-Brasil (docs/conhecimento-fiscal.md §9): certificados A1 ICP-Brasil
4
+ usam cifras legadas (RC2/3DES) que o OpenSSL 3 desabilita por padrão. Se a leitura
5
+ falhar, **NÃO assuma "senha errada"** — pode ser o bloqueio legado. Aqui:
6
+ 1. tenta ler direto com `cryptography` (cobre a maioria);
7
+ 2. se falhar, tenta um fallback best-effort via `openssl pkcs12 -legacy` (quando o
8
+ binário existe) — se ISSO funciona, era cifra legada (e recuperamos);
9
+ 3. senão, levanta `CertificadoError` deixando as DUAS causas possíveis explícitas.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import shutil
15
+ import subprocess
16
+ import tempfile
17
+ from datetime import datetime
18
+ from pathlib import Path
19
+
20
+ from cryptography import x509
21
+ from cryptography.hazmat.primitives import serialization
22
+ from cryptography.hazmat.primitives.asymmetric import rsa
23
+ from cryptography.hazmat.primitives.serialization import pkcs12
24
+ from cryptography.hazmat.primitives.serialization.pkcs12 import PKCS12KeyAndCertificates
25
+ from cryptography.x509.oid import NameOID
26
+
27
+ from .config import Certificado as CertConfig
28
+
29
+
30
+ class CertificadoError(Exception):
31
+ """Falha ao abrir/usar o certificado A1."""
32
+
33
+
34
+ class Certificado:
35
+ """Abre o .pfx e expõe chave privada + certificado para assinatura e mTLS."""
36
+
37
+ def __init__(self, cfg: CertConfig):
38
+ self.cfg = cfg
39
+ self._carregado: PKCS12KeyAndCertificates | None = None
40
+ self.legado: bool = False # True se foi preciso o fallback de cifra legada
41
+
42
+ # ── leitura ─────────────────────────────────────────────────────────────
43
+ def carregar(self) -> Certificado:
44
+ """Lê o .pfx. Idempotente. Ver gotcha legado acima."""
45
+ if self._carregado is not None:
46
+ return self
47
+ caminho = Path(self.cfg.path)
48
+ if not caminho.exists():
49
+ raise CertificadoError(f"certificado não encontrado: {caminho}")
50
+ data = caminho.read_bytes()
51
+ senha = self.cfg.senha.encode()
52
+ try:
53
+ self._carregado = pkcs12.load_pkcs12(data, senha)
54
+ except ValueError as e:
55
+ recuperado = self._tentar_legado(data, senha)
56
+ if recuperado is None:
57
+ raise CertificadoError(
58
+ "falha ao abrir o .pfx. Pode ser (a) senha incorreta OU (b) cifra "
59
+ "legada ICP-Brasil (RC2/3DES) bloqueada pelo OpenSSL — NÃO é "
60
+ "necessariamente senha errada (conhecimento-fiscal §9). "
61
+ f"Detalhe: {e}"
62
+ ) from e
63
+ self._carregado = recuperado
64
+ self.legado = True
65
+ if self._carregado.cert is None or self._carregado.cert.certificate is None:
66
+ raise CertificadoError("o .pfx não contém um certificado")
67
+ return self
68
+
69
+ def _tentar_legado(self, data: bytes, senha: bytes) -> PKCS12KeyAndCertificates | None:
70
+ """Best-effort: usa o `openssl` do sistema com o provedor `-legacy` para
71
+ reembalar o .pfx num formato que a `cryptography` lê. Devolve None se não der."""
72
+ openssl = shutil.which("openssl")
73
+ if not openssl:
74
+ return None
75
+ with tempfile.TemporaryDirectory() as tmp:
76
+ pfx = Path(tmp) / "in.pfx"
77
+ pem = Path(tmp) / "out.pem"
78
+ pfx.write_bytes(data)
79
+ try:
80
+ # legacy → PEM (cert+chave), depois recarrega na cryptography
81
+ subprocess.run(
82
+ [openssl, "pkcs12", "-legacy", "-in", str(pfx), "-out", str(pem),
83
+ "-nodes", "-passin", f"pass:{senha.decode()}"],
84
+ check=True, capture_output=True, timeout=30,
85
+ )
86
+ pem_bytes = pem.read_bytes()
87
+ except (subprocess.CalledProcessError, subprocess.TimeoutExpired, OSError):
88
+ return None
89
+ key = serialization.load_pem_private_key(pem_bytes, password=None)
90
+ if not isinstance(key, rsa.RSAPrivateKey): # A1 ICP-Brasil é sempre RSA
91
+ return None
92
+ cert = x509.load_pem_x509_certificate(pem_bytes)
93
+ # reembala num PKCS12 moderno em memória p/ uniformizar o tipo de retorno
94
+ novo = pkcs12.serialize_key_and_certificates(
95
+ b"pynfse", key, cert, None, serialization.BestAvailableEncryption(senha),
96
+ )
97
+ return pkcs12.load_pkcs12(novo, senha)
98
+
99
+ # ── acesso ────────────────────────────────────────────────────────────────
100
+ def _exigir(self) -> PKCS12KeyAndCertificates:
101
+ if self._carregado is None:
102
+ self.carregar()
103
+ assert self._carregado is not None
104
+ return self._carregado
105
+
106
+ @property
107
+ def certificado_x509(self) -> x509.Certificate:
108
+ cert = self._exigir().cert
109
+ assert cert is not None
110
+ return cert.certificate
111
+
112
+ @property
113
+ def validade(self) -> datetime:
114
+ """Fim da validade (UTC) do certificado."""
115
+ return self.certificado_x509.not_valid_after_utc
116
+
117
+ @property
118
+ def titular(self) -> str:
119
+ """Common Name do titular (em A1 ICP-Brasil costuma ser 'NOME:CNPJ')."""
120
+ attrs = self.certificado_x509.subject.get_attributes_for_oid(NameOID.COMMON_NAME)
121
+ return str(attrs[0].value) if attrs else ""
122
+
123
+ @property
124
+ def cnpj(self) -> str | None:
125
+ """CNPJ/CPF do titular, extraído do CN ICP-Brasil ('NOME:DOC'), se houver."""
126
+ cn = self.titular
127
+ if ":" in cn:
128
+ doc = "".join(ch for ch in cn.rsplit(":", 1)[1] if ch.isdigit())
129
+ return doc or None
130
+ return None
131
+
132
+ def como_pem(self, destino_dir: str | Path) -> tuple[str, str]:
133
+ """Exporta (cert_pem_path, key_pem_path) para o mTLS via `requests`.
134
+ Arquivos temporários — nunca versionar (ficam em `destino_dir`)."""
135
+ p12 = self._exigir()
136
+ assert p12.cert is not None and p12.key is not None
137
+ destino = Path(destino_dir)
138
+ destino.mkdir(parents=True, exist_ok=True)
139
+ cert_path = destino / "cert.pem"
140
+ key_path = destino / "key.pem"
141
+ cert_path.write_bytes(p12.cert.certificate.public_bytes(serialization.Encoding.PEM))
142
+ key_path.write_bytes(
143
+ p12.key.private_bytes(
144
+ encoding=serialization.Encoding.PEM,
145
+ format=serialization.PrivateFormat.PKCS8,
146
+ encryption_algorithm=serialization.NoEncryption(),
147
+ )
148
+ )
149
+ return str(cert_path), str(key_path)
pynfsenacional/cli.py ADDED
@@ -0,0 +1,83 @@
1
+ """CLI turnkey: `nfse-nacional emitir config/cliente.json --valor 150,00 ...`.
2
+
3
+ Pensada para o contador: aponta para o config (certificado + prestador) e emite.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import argparse
9
+ import logging
10
+ import sys
11
+
12
+ from .config import ConfigError
13
+ from .emissor import Emissor
14
+
15
+
16
+ def _to_float(s: str) -> float:
17
+ """Aceita '1.262,90', '1262.90', '100'. Padrão pt-BR."""
18
+ s = s.strip()
19
+ if "," in s:
20
+ s = s.replace(".", "").replace(",", ".")
21
+ return float(s)
22
+
23
+
24
+ def _cmd_emitir(args: argparse.Namespace) -> int:
25
+ tomador = None
26
+ if args.tomador_doc or args.tomador_nome:
27
+ tomador = {"cpf_cnpj": args.tomador_doc, "nome": args.tomador_nome}
28
+ try:
29
+ emissor = Emissor(args.config, ambiente=args.ambiente)
30
+ nota = emissor.emitir(
31
+ valor=_to_float(args.valor),
32
+ descricao=args.descricao,
33
+ tomador=tomador,
34
+ numero=args.numero,
35
+ serie=args.serie,
36
+ )
37
+ except ConfigError as e: # config/dados inválidos — antes de tocar o SEFIN
38
+ print(f"❌ config inválida:\n{e}", file=sys.stderr)
39
+ return 2
40
+
41
+ if nota.emitida:
42
+ marca = "(já emitida)" if nota.status == "ja_emitida" else ""
43
+ print(f"✅ emitida {marca}— nNFSe {nota.numero_nfse} · chave {nota.chave_acesso}")
44
+ if args.xml_out and nota.nfse_xml:
45
+ with open(args.xml_out, "w", encoding="utf-8") as fh:
46
+ fh.write(nota.nfse_xml)
47
+ print(" XML:", args.xml_out)
48
+ if args.pdf:
49
+ print(" DANFSe:", nota.danfse(args.pdf))
50
+ return 0
51
+ print(f"❌ não emitida: {nota.erro_msg}", file=sys.stderr)
52
+ return 1
53
+
54
+
55
+ def main(argv: list[str] | None = None) -> int:
56
+ p = argparse.ArgumentParser(prog="nfse-nacional",
57
+ description="Emite NFS-e pela API SEFIN Nacional.")
58
+ p.add_argument("-v", "--verbose", action="store_true", help="mostra o log do processo")
59
+ sub = p.add_subparsers(dest="cmd", required=True)
60
+
61
+ e = sub.add_parser("emitir", help="emite uma NFS-e")
62
+ e.add_argument("config", help="caminho do config/<cliente>.json")
63
+ e.add_argument("--valor", required=True, help="valor do serviço (ex.: 150,00)")
64
+ e.add_argument("--descricao", required=True)
65
+ e.add_argument("--tomador-doc", dest="tomador_doc")
66
+ e.add_argument("--tomador-nome", dest="tomador_nome")
67
+ e.add_argument("--numero", help="nDPS (default: contador do ambiente)")
68
+ e.add_argument("--serie", default="1")
69
+ e.add_argument("--ambiente", choices=["homologacao", "producao"],
70
+ help="homologacao = TESTE (sem validade) · producao = nota real")
71
+ e.add_argument("--pdf", help="gera o DANFSe neste caminho")
72
+ e.add_argument("--xml-out", dest="xml_out", help="salva o XML da NFS-e neste caminho")
73
+ e.set_defaults(func=_cmd_emitir)
74
+
75
+ args = p.parse_args(argv)
76
+ if args.verbose:
77
+ logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
78
+ codigo: int = args.func(args)
79
+ return codigo
80
+
81
+
82
+ if __name__ == "__main__":
83
+ sys.exit(main())
@@ -0,0 +1,229 @@
1
+ """Configuração do cliente/prestador — carrega o JSON descrito em
2
+ docs/conhecimento-fiscal.md §3 em dataclasses tipadas.
3
+
4
+ Duas responsabilidades:
5
+ • **Carregar/representar** o JSON em dataclasses (`from_dict`/`from_json`), com erro
6
+ de schema amigável (`ConfigError`) em vez de `TypeError` cru.
7
+ • **Validar fiscalmente** (`validar`) — os E-codes que dão para pegar ANTES de tocar o
8
+ SEFIN (cTribNac 6 díg, cTribMun presente, pTotTribSN se Simples, CNPJ/CPF mód-11…).
9
+ A montagem do XML da DPS fica em dps.py.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import dataclasses
15
+ import json
16
+ from dataclasses import dataclass, field
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+ from . import documentos
21
+
22
+ _AMBIENTES = ("homologacao", "producao")
23
+
24
+
25
+ class ConfigError(ValueError):
26
+ """Config malformada (bloco ausente, campo desconhecido, tipo errado)."""
27
+
28
+
29
+ def _construir(tipo: type, dados: dict[str, Any], contexto: str,
30
+ ignorar: tuple[str, ...] = ()) -> Any:
31
+ """Constrói uma dataclass a partir de um dict, com erro de schema amigável.
32
+
33
+ Campos em `ignorar` são descartados (ex.: endereço/nome do prestador, que não
34
+ são enviados — E0128/E0121). Chave desconhecida ou faltando → `ConfigError` com
35
+ contexto, em vez do `TypeError` cru do `**splat`.
36
+ """
37
+ if not isinstance(dados, dict):
38
+ raise ConfigError(f"{contexto}: esperado objeto JSON, recebido {type(dados).__name__}")
39
+ dados = {k: v for k, v in dados.items() if k not in ignorar}
40
+ campos = {f.name for f in dataclasses.fields(tipo)}
41
+ desconhecidos = set(dados) - campos
42
+ if desconhecidos:
43
+ raise ConfigError(
44
+ f"{contexto}: campo(s) não reconhecido(s): {sorted(desconhecidos)}. "
45
+ f"Válidos: {sorted(campos)}"
46
+ )
47
+ try:
48
+ return tipo(**dados)
49
+ except TypeError as e: # campo obrigatório ausente
50
+ raise ConfigError(f"{contexto}: {e}") from e
51
+
52
+
53
+ @dataclass
54
+ class Certificado:
55
+ """Certificado A1 ICP-Brasil do PRÓPRIO prestador (.pfx). NUNCA versionar."""
56
+ path: str
57
+ senha: str
58
+
59
+
60
+ @dataclass
61
+ class Regime:
62
+ """Regime tributário do prestador (ver §3)."""
63
+ opSimpNac: int # 1=Não Optante · 2=MEI · 3=Optante ME/EPP (Simples)
64
+ regApTribSN: int = 1 # apuração do SN (se optante)
65
+ regEspTrib: int = 0 # regime especial (0=Nenhum)
66
+
67
+
68
+ @dataclass
69
+ class Prestador:
70
+ cnpj: str # 14 dígitos
71
+ codigo_municipio: str # IBGE 7 díg do emitente
72
+ regime: Regime
73
+ uf: str | None = None
74
+ inscricao_municipal: str | None = None
75
+ # Nome/endereço do prestador NÃO são enviados quando ele é o emitente (E0128/E0121).
76
+
77
+
78
+ @dataclass
79
+ class Servico:
80
+ codigo_municipio_prestacao: str # IBGE do local da prestação
81
+ cTribNac: str # 6 dígitos (LC116)
82
+ cTribMun: str # desdobro municipal
83
+ descricao_padrao: str = "Serviço Prestado"
84
+ tributos: dict[str, Any] = field(default_factory=dict) # ex.: {"pTotTribSN": "6.00"}
85
+
86
+
87
+ @dataclass
88
+ class ClienteConfig:
89
+ id: str
90
+ nome_cliente: str
91
+ certificado: Certificado
92
+ prestador: Prestador
93
+ servico: Servico
94
+ ambiente: str = "homologacao"
95
+ dados_nota: dict[str, Any] = field(default_factory=dict)
96
+
97
+ # ── carregamento ──────────────────────────────────────────────────────────
98
+ @classmethod
99
+ def from_dict(cls, d: dict[str, Any]) -> ClienteConfig:
100
+ if not isinstance(d, dict):
101
+ raise ConfigError(f"config: esperado objeto JSON, recebido {type(d).__name__}")
102
+ for chave in ("id", "nome_cliente", "certificado", "prestador", "servico"):
103
+ if chave not in d:
104
+ raise ConfigError(f"config: bloco/campo obrigatório ausente: '{chave}'")
105
+
106
+ cert = _construir(Certificado, d["certificado"], "certificado")
107
+ prest_raw = dict(d["prestador"])
108
+ if "regime" not in prest_raw:
109
+ raise ConfigError("prestador: bloco 'regime' obrigatório ausente")
110
+ regime = _construir(Regime, prest_raw.pop("regime"), "prestador.regime")
111
+ # endereço/telefone/email do prestador NÃO vão na DPS (E0128/E0121): descartados.
112
+ prestador = _construir(
113
+ Prestador, {**prest_raw, "regime": regime}, "prestador",
114
+ ignorar=("endereco", "telefone", "email"),
115
+ )
116
+ servico = _construir(Servico, d["servico"], "servico")
117
+ return cls(
118
+ id=str(d["id"]),
119
+ nome_cliente=d["nome_cliente"],
120
+ certificado=cert,
121
+ prestador=prestador,
122
+ servico=servico,
123
+ ambiente=d.get("ambiente", "homologacao"),
124
+ dados_nota=d.get("dados_nota", {}),
125
+ )
126
+
127
+ @classmethod
128
+ def from_json(cls, path: str | Path) -> ClienteConfig:
129
+ raw = Path(path).read_text(encoding="utf-8")
130
+ try:
131
+ dados = json.loads(raw)
132
+ except json.JSONDecodeError as e:
133
+ raise ConfigError(
134
+ f"{path}: JSON inválido ({e}). O config real é JSON puro — comentários "
135
+ f"// só existem no exemplo anotado .jsonc, não num config carregável."
136
+ ) from e
137
+ return cls.from_dict(dados)
138
+
139
+ # ── validação fiscal ──────────────────────────────────────────────────────
140
+ def validar(self) -> list[str]:
141
+ """Lista os problemas fiscais (vazia = ok). Pega, ANTES do SEFIN, os E-codes
142
+ que são determinísticos a partir da config. Ver docs/conhecimento-fiscal.md §4.
143
+ """
144
+ problemas: list[str] = []
145
+ p, s, r = self.prestador, self.servico, self.prestador.regime
146
+
147
+ # Prestador (emitente)
148
+ if not documentos.cnpj_valido(p.cnpj):
149
+ problemas.append(
150
+ f"CNPJ do prestador inválido (14 dígitos + dígito verificador): {p.cnpj!r}"
151
+ )
152
+ if not (p.codigo_municipio.isdigit() and len(p.codigo_municipio) == 7):
153
+ problemas.append(
154
+ f"código IBGE do município do prestador deve ter 7 dígitos: {p.codigo_municipio!r}"
155
+ )
156
+ if r.opSimpNac not in (1, 2, 3):
157
+ problemas.append(
158
+ f"regime.opSimpNac deve ser 1 (Não Optante), 2 (MEI) ou 3 (ME/EPP); "
159
+ f"recebido {r.opSimpNac!r}"
160
+ )
161
+
162
+ # Serviço / códigos de tributação
163
+ if not (s.cTribNac.isdigit() and len(s.cTribNac) == 6):
164
+ problemas.append(
165
+ f"E1235: cTribNac deve ter exatamente 6 dígitos (TSCodTribNac); "
166
+ f"recebido {s.cTribNac!r}"
167
+ )
168
+ if not (s.cTribMun and str(s.cTribMun).strip()):
169
+ problemas.append(
170
+ "E0312: cTribMun é obrigatório (o município administra a combinação "
171
+ "cTribNac+cTribMun; meio-par é rejeitado)"
172
+ )
173
+ if not (s.codigo_municipio_prestacao.isdigit()
174
+ and len(s.codigo_municipio_prestacao) == 7):
175
+ problemas.append(
176
+ f"código IBGE do local da prestação deve ter 7 dígitos: "
177
+ f"{s.codigo_municipio_prestacao!r}"
178
+ )
179
+
180
+ # Tributos: Simples (MEI/ME-EPP) exige pTotTribSN (E0712/E1235)
181
+ if r.opSimpNac in (2, 3):
182
+ ptot = s.tributos.get("pTotTribSN")
183
+ if ptot is None or str(ptot).strip() == "":
184
+ problemas.append(
185
+ f"E0712/E1235: prestador é Simples (opSimpNac={r.opSimpNac}) → "
186
+ f"'pTotTribSN' é obrigatório em servico.tributos"
187
+ )
188
+ else:
189
+ try:
190
+ float(str(ptot).replace(",", "."))
191
+ except ValueError:
192
+ problemas.append(f"servico.tributos.pTotTribSN não é numérico: {ptot!r}")
193
+
194
+ # Ambiente
195
+ if self.ambiente not in _AMBIENTES:
196
+ problemas.append(
197
+ f"ambiente inválido: {self.ambiente!r} (use 'homologacao' ou 'producao')"
198
+ )
199
+ return problemas
200
+
201
+
202
+ def validar_tomador(tomador: dict[str, Any] | None) -> list[str]:
203
+ """Valida o bloco do tomador da emissão (E1235 §7). Tomador `None`/sem doc =
204
+ consumidor final (omite <toma>) → sem problemas. Com documento, EXIGE nome e
205
+ documento válido (mód-11)."""
206
+ if not tomador:
207
+ return []
208
+ doc = documentos.digitos(tomador.get("cpf_cnpj") or tomador.get("cpf") or tomador.get("cnpj"))
209
+ if not doc:
210
+ return [] # sem documento → tratado como não identificado (omite <toma>)
211
+
212
+ problemas: list[str] = []
213
+ nome = (tomador.get("nome") or tomador.get("xNome") or "").strip()
214
+ if not nome:
215
+ problemas.append(
216
+ "E1235: tomador identificado EXIGE nome (xNome) — a API não resolve pela "
217
+ "Receita (§7); informe o nome ou resolva por CNPJ (BrasilAPI)"
218
+ )
219
+ if len(doc) == 14:
220
+ if not documentos.cnpj_valido(doc):
221
+ problemas.append(f"CNPJ do tomador inválido: {doc!r}")
222
+ elif len(doc) == 11:
223
+ if not documentos.cpf_valido(doc):
224
+ problemas.append(f"CPF do tomador inválido: {doc!r}")
225
+ else:
226
+ problemas.append(
227
+ f"documento do tomador deve ser CPF (11) ou CNPJ (14 dígitos); recebido {doc!r}"
228
+ )
229
+ return problemas
@@ -0,0 +1,34 @@
1
+ """Geração local do DANFSe (PDF) a partir do XML da NFS-e. Lib: `BrazilFiscalReport`.
2
+
3
+ Necessário porque a API de PDF do governo (ADN) é descontinuada em 01/07/2026
4
+ (NT-008/2026) — ver docs/conhecimento-fiscal.md §1. Geramos o PDF localmente, a
5
+ partir do XML devolvido pelo SEFIN.
6
+
7
+ Os 2 gotchas do §6 (rótulo "SEM VALIDADE JURÍDICA" por `tpAmb` ≠ `ambGer`; UF do
8
+ local da prestação resolvida pelo IBGE) **já vêm corrigidos upstream** na
9
+ `BrazilFiscalReport` ≥ 1.0 — não precisamos repetir os patches da referência PHP.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from pathlib import Path
15
+
16
+
17
+ def render(nfse_xml: str, destino: str | Path) -> str:
18
+ """Gera o PDF do DANFSe a partir do XML da NFS-e; devolve o caminho do PDF.
19
+
20
+ `nfse_xml`: o XML da NFS-e devolvido pelo SEFIN (não o da DPS).
21
+
22
+ Requer o extra opcional: `pip install PyNFSeNacionalGT[danfse]`.
23
+ """
24
+ try:
25
+ from brazilfiscalreport.danfse import Danfse
26
+ except ImportError as e:
27
+ raise ImportError(
28
+ "geração do DANFSe requer o extra opcional: pip install PyNFSeNacionalGT[danfse]"
29
+ ) from e
30
+
31
+ destino = Path(destino)
32
+ destino.parent.mkdir(parents=True, exist_ok=True)
33
+ Danfse(xml=nfse_xml).output(str(destino))
34
+ return str(destino)
@@ -0,0 +1,43 @@
1
+ """Validação de CPF/CNPJ (dígito verificador mód-11) — stdlib pura.
2
+
3
+ Usado pela validação fiscal (`config.validar`, tomador da DPS). Isolado aqui para
4
+ ser testável de forma independente (inclusive property-based) e reutilizável.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+
11
+ _NAO_DIGITO = re.compile(r"\D")
12
+
13
+
14
+ def digitos(s: str | None) -> str:
15
+ """Remove tudo que não for dígito (espelha `gt_digitos` do PHP)."""
16
+ return _NAO_DIGITO.sub("", s or "")
17
+
18
+
19
+ def _mod11_dv(base: str, pesos: list[int]) -> int:
20
+ """Dígito verificador mód-11 da Receita: resto<2 → 0, senão 11−resto."""
21
+ soma = sum(int(d) * p for d, p in zip(base, pesos, strict=True))
22
+ resto = soma % 11
23
+ return 0 if resto < 2 else 11 - resto
24
+
25
+
26
+ def cpf_valido(valor: str | None) -> bool:
27
+ """True se `valor` (com ou sem máscara) é um CPF válido (11 díg + mód-11)."""
28
+ cpf = digitos(valor)
29
+ if len(cpf) != 11 or cpf == cpf[0] * 11: # rejeita repetidos (00000000000…)
30
+ return False
31
+ dv1 = _mod11_dv(cpf[:9], list(range(10, 1, -1)))
32
+ dv2 = _mod11_dv(cpf[:10], list(range(11, 1, -1)))
33
+ return cpf[9] == str(dv1) and cpf[10] == str(dv2)
34
+
35
+
36
+ def cnpj_valido(valor: str | None) -> bool:
37
+ """True se `valor` (com ou sem máscara) é um CNPJ válido (14 díg + mód-11)."""
38
+ cnpj = digitos(valor)
39
+ if len(cnpj) != 14 or cnpj == cnpj[0] * 14:
40
+ return False
41
+ dv1 = _mod11_dv(cnpj[:12], [5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2])
42
+ dv2 = _mod11_dv(cnpj[:13], [6, 5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2])
43
+ return cnpj[12] == str(dv1) and cnpj[13] == str(dv2)