# S7: Contabilidade SPB separada-mas-integrada (mini-design para aprovação)

Data: 2026-07-04. Task S7 do plano `2026-07-04-paridade-legado-pix-spb-integracao.md`. Decisão do dono (gravada): contabilidade SEPARADA mas INTEGRADA; cada cabine mantém trilha contábil própria funcional standalone; integração com o Core por flag; PROIBIDO remover a trilha da cabine; PROIBIDO falha contábil silenciosa. Este design SUPERSEDE a decisão de 2026-06-06 "Core é dono do COSIF" registrada em `post_integration/dispatcher.ex:107` e `accounting/posting.ex:73-77`.

## Estado real aterrado (3 trilhas, nenhuma operante)

| Trilha | Código | Fonte de contas | Escreve em | Estado vivo |
|---|---|---|---|---|
| 1. AccountingEngine | `accounting/accounting_engine.ex:69` (post_operation); callers `lifecycle_engine.ex:598-599` (gate r1_confirmed), `str/inbound_processor.ex:997-1026`, `accounting/event_reprocessor.ex:297`, `accounting_controller.ex:223` | tabela `accounting_scripts` (migração `20260527153000`), **NUNCA semeada** (zero seeds; grep em `priv/` só acha a migração) | `accounting_entries` (via `PostingRecorder`) | Ligada mas INERTE: todo post cai em `:template_not_found` e vira `Logger.debug`. Contas de imposto fabricadas hardcoded (`:414-419`); `to_decimal` converte valor imparseável em **zero silencioso** (`:478,483`) |
| 2. STR.COSIFEngine | `str/cosif_engine.ex:94` (post_entry); child em `str/supervisor.ex:44` | 5 templates hardcoded (`:54-60`) + `cosif_chart_of_accounts`/`cosif_posting_templates` (seed `045_runtime_operational_defaults.exs:79-128`) | `cosif_entries` | GenServer roda, mas `post_entry` tem **ZERO callers** no código. Template `transfer_sent` DEBITA Reservas Bancárias no envio (`:55`, direção invertida). Zero testes. `store_reconciliation_result` termina em `rescue _ -> :ok` (`:605-607`) |
| 3. cosif_to_core (Roteiro+Posting+CoreNotifier) | `post_integration/dispatcher.ex:315-332` (maybe_emit_cosif), flag `system_config.cosif_to_core_enabled` default OFF (`:336-346`); resolução `accounting/posting.ex:40-70` (resolve_entry) sobre `accounting/roteiro.ex:71-99` (porte fiel de `spb_fc_get_evento_contabil`); emissão `nats/core_notifier.ex:125-147`, outbox durável (`nats/publisher.ex:196-212` enfileira em OutboxRepository) | `spb_cnt_roteiro` (303 eventos), `spb_cnt_roteiro_tags` (354 condições), `spb_cnt_contas` (714 contas), dados REAIS do LegadoSPB semeáveis por `release.ex:703-708` (seed_cnt_roteiro, CSVs em priv) | local: `accounting_entries` via `Posting.post_for_operation` (gate `cnt_ativa` default OFF, marcado DEPRECIADO em `posting.ex:73-77`); remoto: subject `monetarie.spb.cosif.entry` | Duplamente inerte: flag OFF e o Core **não tem consumer** do subject (`core/backend/.../consumers/spb_consumer.ex:31-34` assina messages/transactions/credits, não cosif.entry) |

Sobreposição real: trilhas 1 e 3-local escrevem na MESMA tabela `accounting_entries`; a diferença é a fonte do lançamento (`accounting_scripts` vazio vs roteiro legado real). A trilha 2 é ilha própria (`cosif_entries`). Testes: trilha 3 é a única com base validada (`test/bacen_gateway/accounting/roteiro_test.exs`, `posting_test.exs` e `posting_characterization_test.exs` com 951 linhas de caracterização contra o legado); trilha 1 tem testes de mecânica (`accounting_engine_test.exs`, `posting_recorder_test.exs`) mas sem dados; trilha 2 não tem nenhum.

Contexto Core (contrato da integração): `Cosif.create_journal_entry` (`core/backend/lib/monetarie/use_cases/cosif.ex:284-296`) insere com `on_conflict: :nothing` na unique parcial `(reference_type, reference_id, entry_date)`; plano de contas semeado (`cosif_accounts.exs` base ~44 contas + `scd_cosif_plan.exs` 222 contas SCD). O Core **JÁ materializa** um lançamento COSIF de negócio para cada SPB settled via `spb_handler.ex:159-279` (handle_transaction_ledger -> create_spb_journal_entry, reference_type "SPB").

## Decisão 1: trilha canônica da cabine = Roteiro+Posting (trilha 3-local, promovida)

Critério: é a única com aderência ao COSIF real (roteiro legado com 303 eventos condicionais e 714 contas, portado e caracterizado por teste), e é a MESMA resolução usada pela emissão ao Core, ou seja, por construção não há drift entre o que a cabine lança local e o que ela emite. A trilha 1 nunca teve template semeado e fabrica contas; a trilha 2 nunca foi chamada e tem direção D/C invertida no envio.

O que acontece com as outras: **aposentar sem migração de dados**.
- AccountingEngine/accounting_scripts: remover os call sites (`lifecycle_engine.ex:598-599/2521`, `inbound_processor.ex:221/997`, `event_reprocessor`) apontando-os para `Posting.post_for_operation`; congelar a tabela `accounting_scripts` (não dropar). Não há dado a migrar: escreve na mesma `accounting_entries` e, sem templates, nunca escreveu (aceite exige `SELECT count(*)` vivo antes). O `reverse_posting` (estorno) migra de módulo para `Posting`.
- STR.COSIFEngine: remover o child de `str/supervisor.ex:44` e o módulo; congelar `cosif_entries` (verificar vazia em HML antes). A geração de arquivo COSIF posicional, se vier a ser exigida, será follow-up lendo `accounting_entries` (registrar, não implementar).
- `Posting.post_for_operation` deixa de ser DEPRECIADO: vira a trilha oficial; `cnt_ativa` passa a default ON (a flag continua existindo para desligar em contingência).
- Higiene: seed `025_accounting_seeds.exs` popula `spb_chart_of_accounts`/`spb_accounting_events` com ZERO referências no código; congelar e registrar para limpeza.

## Decisão 2: contrato da integração por flag (quem é o oficial em cada modo)

A cabine **SEMPRE lança local** em `accounting_entries` (fonte standalone). Quando `cosif_to_core=true` ela ADICIONALMENTE emite `monetarie.spb.cosif.entry` (outbox durável, já existe). O Core ganha o consumer que falta e materializa o evento como **espelho de conciliação**, NÃO como segundo lançamento no balancete:

- Modo white-label standalone (sem Core): a cabine é a oficial; `accounting_entries` + AccountingController (`accounting_controller.ex:30-222`: entries, balance, daily_summary, reverse) são o produto contábil.
- Modo integrado (Core presente): o **Core é o oficial regulatório** (balancete/CADOC continuam vindo do journal de negócio que o spb_handler já grava dos settled). O evento `cosif.entry` é materializado numa tabela espelho no Core (`spb_cosif_mirror`, nova) com dedup determinístico `(operation_id, cd_evento)`, alimentando relatório de conciliação cabine x Core por (operation_id, valor, dt_movto).
- Por que espelho e não journal: (a) o Core já lança o mesmo fato econômico dos settled; gravar o roteiro no mesmo journal DUPLICARIA o balancete; (b) as contas do roteiro são o plano legado (714 contas), não o plano SCD do Core; materializar direto repetiria o defeito do precedente PIX (conta não encontrada -> skip silencioso, `pix/backend/apps/settlement_service/lib/settlement_service/accounting.ex:200`).
- Dedup: o espelho usa unique em `(operation_id, cd_evento)`; se um dia promovermos espelho a journal, a chave do Core já existe (`reference_type="SPB_COSIF"`, `reference_id=operation_id:cd_evento`, unique parcial de `cosif.ex:294-295`).

## Decisão 3: fail-fast (fim do silencioso)

Novo alert_type `contabil` em `Alerts.OperationalAlerts` (`alerts/operational_alerts.ex:35` @alert_types, `:138` raise_alert, dedupe por operation_id). Falha contábil NÃO derruba o fluxo da operação (o dinheiro já se moveu no BACEN), mas vira **Logger.error + alerta roteado + telemetry**, nunca warning/debug. Sites a corrigir (grep aterrado):

| Site | Hoje | Vira |
|---|---|---|
| `dispatcher.ex:322-323` (`_ -> :ok` no resolve_entry) | engole tudo | distinguir `:no_event` legítimo (telemetry count) de exceção (error+alerta) |
| `dispatcher.ex:328-331` (rescue/catch -> warning) | fail-soft mudo | error + alerta `contabil` |
| `lifecycle_engine.ex:2551-2561` ({:error} e rescue -> warning) | warning | error + alerta |
| `inbound_processor.ex:1017-1026` (idem) | warning | error + alerta |
| `posting.ex:66-70` (resolve_entry rescue -> :no_event) | mascara exceção como "sem evento" | retorna `{:error, ...}` e caller alerta |
| `roteiro.ex:45-47` (`rescue _ -> []`) e `:214-223` (load falho -> warning) | roteiro indisponível = zero contabilização invisível | error + alerta (roteiro é infraestrutura da contabilidade) |
| `core_notifier.ex:143-147` (rescue -> :ok) | evento de integração perdido mudo | error + alerta |
| `accounting_engine.ex:478,483` (valor imparseável -> Decimal 0) | lançamento de valor ZERO silencioso | morre com a trilha 1 (o `abs_dec` de `posting.ex:197-201` levanta exceção em binário inválido, correto) |
| `cosif_engine.ex:605-607` (`rescue _ -> :ok`) | silencioso | morre com a trilha 2 |

Defeito real encontrado: `accounting_entries` NÃO tem unique constraint (migração `20260527153000:31-52` só cria indexes simples), logo o tratamento "unique_violation = idempotência" do engine é código morto e o único guard (`Posting.already_posted?`, `posting.ex:177-185`) é check-then-insert com corrida. Correção: colunas `cd_msg`/`cd_evento`/`leg` + unique `(operation_id, cd_evento, leg)` (aditiva).

(`store_closing_balance` `inbound_processor.ex:693-695` já está registrado como S2-FU1; fora do escopo S7.)

## Decisão 4: seeds

Plano de contas e roteiro da cabine EXISTEM e têm seeder idempotente (`Release.seed_cnt_roteiro`, `release.ex:703-708`: 714 contas, 303 eventos, 354 tags, de CSVs do legado). Falta: (a) conferir contagens no Aurora HML vivo (podem estar zeradas; a carga documentada foi local); (b) rodar o seeder em HML se vazio; (c) aposentar os seeds mortos (`045` templates da trilha 2; `025` tabelas sem referência). O Core não precisa de seed novo (plano base + SCD já semeados).

## Simetria com P11 (cabine PIX)

A cabine PIX tem o mesmo padrão de defeito (journal silenciosamente pulado quando a conta COSIF não existe, `accounting.ex:200 _ -> :ok`, com COSIF próprio semeado). P11 aplicará ESTE mesmo desenho: trilha local canônica fail-fast + plano semeado + integração por flag com espelho/conciliação, usando o AlertEngine da cabine PIX (P8) como o SPB usa OperationalAlerts.

## Plano de execução (após aprovação)

1. **S7a Consolidação da trilha canônica** — **FEITO** (commit `b29f7ea2`): redirecionar `lifecycle_engine`/`inbound_processor`/`event_reprocessor` para `Posting.post_for_operation`; mover estorno; remover COSIFEngine do supervisor; migração aditiva (colunas + unique); `cnt_ativa` default ON. Aceites: `count(accounting_entries)` antes/depois conferido vivo; operação STR liquidada em teste de integração gera partida dobrada balanceada; zero referências a AccountingEngine/COSIFEngine fora de testes congelados; suíte verde.
2. **S7b Fail-fast** — **FEITO** (commit `6c860ec2`): alert_type `contabil` + correção dos 7 sites vivos da tabela acima + telemetry. Aceites: teste provando que falha de gravação gera alerta e log ERROR; grep sem `_ -> :ok`/rescue blanket nos módulos contábeis (exceto idempotência tipada).
3. **S7c Consumer no Core** — **FEITO** (commit `94b55ada`): subject `monetarie.spb.cosif.entry` no `spb_consumer`, tabela espelho `spb_cosif_mirror` com unique `(operation_id, cd_evento)`, endpoint/consulta de conciliação cabine x Core. Aceites: replay do mesmo evento = 1 linha; evento com conta desconhecida NÃO é descartado silenciosamente (fica no espelho com flag de não-mapeado).
4. **S7d Seeds + validação viva HML** — **FEITO (código/seeder); validação viva em HML pendente de OPS**: conferir/semear roteiro no Aurora HML; smoke: 1 operação real -> partida em `accounting_entries`; ligar `cosif_to_core` em HML -> evento materializado no espelho do Core; queries e screenshots (regra 11). Flag permanece OFF por default em código.
5. **S7e** — **PARCIALMENTE FEITO** (código fechado; ver abaixo o que sobrou como decisão do dono / OPS).

## S7e — fechamento (2026-07-05)

**Código FEITO nesta task** (escopo `spb/services/bacen_gateway`):

- **Balancete com nomes de `spb_cnt_contas`**: `AccountingController.balance` e `.daily_summary` agora fazem LEFT JOIN em `spb_cnt_contas` (`cd_conta = account_code`), devolvendo `account_name` (`ds_conta`). Conta não mapeada permanece no balancete com nome nulo (não some). Teste: `accounting_controller_balancete_test.exs`.
- **`reverse_posting` concorrente endurecido** (nota menor do S7a): as pernas de estorno (`leg IN ('RD','RC')`) ficavam FORA da unique parcial do S7a (que só cobria `reversal_of_id IS NULL`), então dois estornos concorrentes duplicariam pernas. Nova unique parcial `accounting_entries_reversal_uidx` `(reversal_of_id) WHERE reversal_of_id IS NOT NULL AND leg IN ('RD','RC')` (migração `20260705130000`, aditiva) + `ON CONFLICT DO NOTHING` no INSERT de `Posting.reverse_posting` (reporta as pernas efetivamente inseridas; `:already_reversed` se a corrida concorrente inseriu todas). Isola do soft-mark do EventReprocessor (`reversal_of_id = id`, `leg` 'D'/'C') e de estornos legados (`leg` NULL). Testes em `posting_test.exs`.
- **Limpeza dos seeds mortos 025/045**: `025_accounting_seeds.exs` (populava `spb_chart_of_accounts`/`spb_accounting_events`, SEM callers no código) virou stub `@deprecated` no-op e saiu de `run_all_seeds.exs` (lista + contador + cabeçalho); o bloco trilha-2 de `045_runtime_operational_defaults.exs` (`cosif_chart_of_accounts`/`cosif_posting_templates`, alimentava só a `STR.COSIFEngine` aposentada) foi removido com nota `@deprecated`, preservando os blocos vivos (STR status_de_para e message_splits).

**Residual — NÃO é código (decisão do dono / OPS), registrado como pendência:**

- **Arquivo COSIF posicional** (formato posicional do legado `spb_cnt_*`): DECISÃO DO DONO antes de implementar — (a) é exigido? (b) vai ao BACEN ou é interno? (c) é responsabilidade da cabine standalone ou do Core (regulatório)? Existe um gerador dormente em `STR.COSIFEngine.generate_cosif_file` (lê `cosif_entries`, trilha 2 aposentada); se exigido, o follow-up é reescrever lendo `accounting_entries`. Não implementado por design ("registrar, não implementar", linha 23).
- **Drop das tabelas congeladas após 1 ciclo** (OPS/DBA): `accounting_scripts`, `cosif_entries`, `spb_chart_of_accounts`, `spb_accounting_events` — dropar em HML só após 1 ciclo de operação da trilha canônica, com `SELECT count(*)` vivo confirmando que seguem sem uso. Não é código.
- **Remoção do módulo `STR.COSIFEngine`**: a Decisão 1 pediu remover o child do supervisor **e** o módulo; o S7a removeu o child mas o módulo-arquivo permanece (dead code, não supervisionado, único caller do `cosif_*`). Remoção adiada para o mesmo ciclo do drop das tabelas (evita mexer no `str_supervisor_children_test.exs` antes de decidir o arquivo COSIF posicional, que poderia reaproveitar o gerador).

## Riscos

- **Dupla contabilização no Core**: mitigada por construção (espelho separado do journal; o journal oficial do Core continua único, vindo dos settled). Nunca materializar cosif.entry no journal sem antes desligar o caminho do spb_handler para a mesma operação.
- **Drift cabine vs Core**: planos de contas diferentes (legado 714 vs SCD do Core); conciliar por (operation_id, valor, data), nunca por conta contábil.
- **Ordem de deploy**: (1) migração unique, (2) deploy SPB com cnt_ativa ON, (3) deploy Core com consumer, (4) só então ligar `cosif_to_core`. Ligar a flag antes do consumer não perde evento (outbox durável) mas acumula; monitorar o outbox.
- **Retroatividade**: operações liquidadas antes do corte ficam sem partida local; backfill existe (`Posting.post_for_date`, `posting.ex:110`) mas SÓ roda com aprovação explícita do dono e data de corte definida.
