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.
- pynfsenacional/__init__.py +26 -0
- pynfsenacional/assinatura.py +46 -0
- pynfsenacional/certificado.py +149 -0
- pynfsenacional/cli.py +83 -0
- pynfsenacional/config.py +229 -0
- pynfsenacional/danfse.py +34 -0
- pynfsenacional/documentos.py +43 -0
- pynfsenacional/dps.py +136 -0
- pynfsenacional/emissor.py +150 -0
- pynfsenacional/relogio.py +41 -0
- pynfsenacional/sefin.py +181 -0
- pynfsenacionalgt-0.1.0.dist-info/METADATA +301 -0
- pynfsenacionalgt-0.1.0.dist-info/RECORD +16 -0
- pynfsenacionalgt-0.1.0.dist-info/WHEEL +4 -0
- pynfsenacionalgt-0.1.0.dist-info/entry_points.txt +2 -0
- pynfsenacionalgt-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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())
|
pynfsenacional/config.py
ADDED
|
@@ -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
|
pynfsenacional/danfse.py
ADDED
|
@@ -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)
|