Files
sowai-fiscal-svc/docs/specs/2026-07-17-sowai-fiscal-svc-design.md
jonatanritter 923848af33 feat: service scaffold, k8s test infra, health
FastAPI + SQLAlchemy async + Alembic scaffold, src-layout (mirrors the
sowai-fiscal lib's own convention), pyproject wired to sowai-fiscal@v0.1.0
via git+https (uv.lock pins the commit). Makefile mirrors auto/Makefile's
k8s-test workflow: syncs into /app/fiscal-svc in the SAME auto-tests pod,
against a dedicated fiscal_svc_test database on the shared Postgres
sidecar, serialized by the SAME lock file the auto uses on purpose so the
two repos' test runs never race in the pod. Dockerfile installs git (the
git+https dependency needs it at uv sync time) and splits the dependency
layer from the project's own editable install for build caching.

GET /v1/health -> {"status": "ok"}, verified green via `make k8s-test`.
2026-07-22 16:12:11 -03:00

7.7 KiB

sowai-fiscal-svc — serviço fiscal compartilhado — Design

Data: 2026-07-17 · Decisão de origem: Estágio 2 / Caminho B (Jonatan, 2026-07-17). Onde este spec vive: no repo do auto (primeiro consumidor) até o repo do serviço existir; então é copiado para lá e este vira ponteiro.

Objetivo

Emissão fiscal (NF-e 55 agora; NFS-e Nacional e NFC-e 65 depois) como serviço compartilhado entre produtos SowAI, consumido via REST. A 1b.2 (transporte SEFAZ + DANFE + reconciliador) nasce dentro dele. Python permanente (a comunidade OCA mantém o leiaute NT — nfelib/erpbrasil; Go descartado para este serviço por decisão registrada).

Decisões batidas (2026-07-17 — não re-litigar)

  1. Dois repos novos (Gitea): sowai-fiscal (lib — núcleo puro extraído do auto: resolver, xml_builder, chave_acesso, domains, presets, tpag; versionada SemVer; EMENDA F1 2026-07-17: consumida via git+https@tag — pinada por hash no lock — em vez de registry PyPI do Gitea; o registry fica como opção futura se o nº de consumidores crescer. Consequência operacional: git precisa existir nas imagens que rodam uv sync — Dockerfile do auto ajustado) e sowai-fiscal-svc (FastAPI + SQLAlchemy async + Alembic + uv, gabarito e convenções do auto — incluindo lock de pod de teste e teste de migration real).
  2. Banco: schema próprio fiscal_svc na MESMA instância Postgres do cluster (dev); instância separada é decisão de produção futura. fiscal_documents, fiscal_certificates, fiscal_document_series mudam de dono: nascem no serviço via migrations dele; os dados de teste do auto migram por script one-shot; as tabelas do auto são dropadas ao fim do corte.
  3. O motor de regras FICA no produto. TaxRule/TaxProfile/resolver rodam no auto (config de imposto é do produto); o serviço recebe o FiscalResult já resolvido dentro do DadosEmissao. O serviço valida coerência estrutural (somas, campos obrigatórios do leiaute), nunca recalcula imposto.
  4. Tenancy: (product_id, tenant_ref, branch_ref) — refs OPACAS (o serviço não conhece o modelo de org de nenhum produto). O vínculo forte é o CNPJ da filial, validado contra o certificado no upload e contra o emitente na emissão.
  5. Auth: API key por produto (X-Api-Key, hash argon2/bcrypt no banco, uma por product_id), sem ingress público — ClusterIP + NetworkPolicy no namespace. Webhooks assinados com HMAC (segredo por produto).
  6. Idempotência: Idempotency-Key obrigatória no POST /v1/emissoes (o produto usa sale_id + tentativa). Chave repetida → devolve o documento existente (200), nunca emite duas vezes.
  7. Os 3 seguros de portabilidade fazem parte do "pronto" de toda fase: (a) suíte de contrato HTTP language-agnostic (bate na API, não no interior); (b) corpus golden DadosEmissao (JSON) → XML PRÉ-ASSINATURA esperado como fixtures (EMENDA F1: pré-assinatura para determinismo byte a byte com dh_emi/cnf pinados. GAP REGISTRADO: o caminho de ASSINATURA fica sem seguro golden — a F2 DEVE acrescentar goldens assinados com certificado de TESTE fixo do repo, viável porque RSA PKCS#1v1.5 é determinístico dado input+chave iguais); (c) OpenAPI versionado como fonte de verdade do contrato.
  8. Invariantes herdados do 1b.1 permanecem intactos DENTRO do serviço: outbox (alocar nNF + persistir documento num commit), cNF persistido (anti-539), chave módulo-11, certificado Fernet decifrado só em memória, um certificado vivo por branch_ref (índice parcial único), imutabilidade pós-ASSINADO, fail-closed com 409 estruturado.
  9. Transmissão (1b.2, dentro do serviço): síncrona indSinc=1 (SVRS rejeita lote assíncrono de 1 — Rej. 452), timeout → retransmitir o MESMO XML → 204 → NfeConsultaProtocolo; reconciliador = CronJob k8s → endpoint interno varrendo TRANSMITINDO/PENDENTE_CONSULTA. Homologação primeiro, com o A1 da própria SowAI (CNPJ 63329985000113; senha só o Jonatan digita, no upload).
  10. Auto consome via port FiscalEmitter: duas implementações — InProcessEmitter (a atual, morre no corte) e HttpEmitter (client do serviço). Corte por flag/env; rollback = voltar a flag.

API v1 (contrato)

Rota O quê
POST /v1/emissoes DadosEmissao completo + Idempotency-Key. Fluxo: valida → aloca → monta → assina → persiste (ASSINADO) → transmite (1b.2) → devolve estado final (AUTORIZADA/REJEITADA) ou PENDENTE_CONSULTA (timeout). Erros de config → 409 estruturado (fiscal_config_missing etc., os mesmos códigos do 1b.1).
GET /v1/documentos/{id} estado + metadados (chave, número, protocolo, rejeição).
GET /v1/documentos/{id}/xml · /danfe XML assinado/autorizado · DANFE PDF (1b.2).
GET /v1/documentos?tenant_ref=&branch_ref=&status= listagem paginada.
POST /v1/certificados (por branch_ref) · GET (metadados) · DELETE ciclo do A1 (as validações do 1b.1: senha/CNPJ/validade).
POST /v1/series · GET · PATCH numeração por branch_ref+modelo (guard retroativo next_number > max(numero) vem junto).
POST /v1/webhooks registro de callback por produto; eventos documento.status_changed com HMAC; retry com backoff; o produto mantém polling de reconciliação como fallback.

DadosEmissao v1 = o dataclass do 1b.1 congelado (emitente, destinatário, itens+FiscalResult por item, pagamento, ambiente) + product_id/tenant_ref/branch_ref. Congelar nomes = contrato; mudanças = v2.

Sequência de entrega (cada fase = plano próprio, ciclo completo com Fable)

  1. F1 — lib sowai-fiscal: extração do núcleo puro (o guard test_fiscal_core_purity do auto garante que continua extraível) + goldens gerados do 1b.1 atual (N casos: Padrão intra/inter, ST, devolução, com/sem UB) + publicação no registry. Auto passa a depender da lib (deleta o código duplicado).
  2. F2 — serviço com paridade 1b.1 (pronto inclui: OpenAPI commitado como artefato versionado — o seguro (c) — e os goldens ASSINADOS do gap da emenda 7b; ver_proc vira parâmetro por produto no contrato, não default da lib): repo, schema fiscal_svc, migrations, tenancy, API v1 (sem transmissão), certificados, séries. Prova de paridade: os goldens do F1 passam byte a byte. Suíte de contrato HTTP nasce aqui.
  3. F3 — corte do auto: HttpEmitter atrás do port, migração one-shot dos dados de teste, flag virada, tabelas fiscais do auto dropadas (migration), frontend intocado (o contrato do auto com o frontend não muda — o auto proxia).
  4. F4 — 1b.2 no serviço: transporte SEFAZ homologação (SVRS), estados AUTORIZADA/REJEITADA/PENDENTE_CONSULTA, reconciliador CronJob, DANFE, webhooks. Validação de ponta: primeira nota AUTORIZADA em homologação com o A1 da SowAI.
  5. F5 — NFS-e Nacional no serviço (spec próprio; deadline do cliente 01/09).

Fora de escopo (deste spec)

NFC-e/contingência (1b.4), eventos (1b.3), cálculo IBS/CBS (R1 — fica no motor do produto), produção SEFAZ (após homologação validada), multi-região.

Riscos e mitigações

  • Paridade de XML na extração → goldens byte a byte (F1/F2) — regressão fiscal é impossível de passar silenciosa.
  • Corte do auto → port + flag + rollback trivial; dados de teste migrados por script idempotente conferido por contagem+chaves.
  • Webhook perdido → polling de reconciliação no produto (o status cacheado na venda nunca é a fonte de verdade; a fonte é o serviço).
  • Reforma/NTs → a lib versionada absorve upgrades de nfelib; produtos sobem a lib, o serviço sobe primeiro.