# Dossie de paridade PROFUNDO — camt.054 / camt.060 / admi.002 (liquidacao e reconciliacao)

Auditoria READ-ONLY. Zero inferencia: cada afirmacao tem prova arquivo:linha, tabela.coluna ou log vivo.
Escopo: como o LEGADO (.NET SPI/Multiliquidacao) e o NOSSO (cabine PIX Elixir) tratam a
liquidacao e a reconciliacao de operacoes por camt.054 (notificacao de lancamento),
camt.060 (consulta de operacao) e admi.002 (rejeicao/nao-localizado).

Desenho de referencia (do proprio mandato, confirmado nos dois lados): a VERDADE de liquidacao
e o pacs.002 (ACSC/STLD) que sensibiliza e credita; a camt.060 reconcilia operacao presa sem
pacs.002; a camt.054 BOOK e fallback de ultimo recurso. Auditamos se o NOSSO codigo implementa
ESSA ordem SEM duplo-credito.

---

## 1. Comportamento do LEGADO (com prova)

### 1.1 Modelo de status (fonte da verdade da reconciliacao)

Tabela de referencia viva `crk_spi.SpiStatusOperacao` bate 1:1 com `enumStatusOperacao`
decompilado (`SPI.Core.General.decompiled.cs:7114`):

```
0 Entrada do registro | 1 Pendente Cripto | 2 Cripto OK | 4 Pendente de envio
5 Enviada | 7 Aguardando retorno | 8 Em processamento | 9 Efetivada | 10 Rejeitada
13 Entrada retorno Bacen | 15 Rejeitada-ADMI.002 | 37 Pendente de aprovacao
40 Pendente aprovacao restante lote
```
Prova viva: `docker exec monetarie-bak-mssql sqlcmd ... crk_spi.SpiStatusOperacao` (IdStatus 0..43).
Colunas de correlacao existem em `crk_spi.SpiMessage`: `CdMsg, EndToEndId, RtrId, DtHrOperacao,
IdStatus, JsonInput, EndToEndIdOrig` (verificado via sys.columns).

### 1.2 Dispatcher de retorno do BACEN

`SPI.Core.Worker.TratamentoRetornoApiBacen.Infrastructure.decompiled.cs:950-1046` roteia por
MsgDefIdr, exatamente nesta ordem:
- pacs.002 -> caminho de credito (`ValidaOperacaoACreditoRecebida`) + guarda "Operacao original
  nao encontrada na base -> reprocessamento" (`:958-963`);
- CAMT.014 -> `ImportaCAMT0014`; **CAMT.054 -> `ImportaCAMT0054`** (`:1005`);
  **ADMI.002 -> `ImportaADMI002`** (`:1009`); CAMT.055/PAIN.009/011/012/013/CAMT.025 -> handlers proprios.

### 1.3 camt.060 — EnvioConsultaOperacao (a "consulta aos 50 min")

Worker loop (`SPI.Core.Worker.EnvioConsultaOperacao.decompiled.cs:189-199`):
```
while (!stop) { num = ExecutaVerificacaoEEnvioOper();
                await Task.Delay((num > 60) ? 3600000 : (60000 * (num + 1))); }
```
Ciclo base = 60s quando nao ha pendentes. **Prova viva de producao**:
`LegadoPIX/Pix/SPI/worker.envioconsultaoperacao/logs/log2026-07-22.txt` — linhas
`Aguardando 60000 ms antes do proximo ciclo.` de 2 em 2 min o dia inteiro (22/07).

Selecao das operacoes a consultar (`...EnvioConsultaOperacao.Infrastructure.decompiled.cs:116-124`,
`MessageRepository.PagamentosPendentes`):
```
where DtMovto >= (hoje-1).Date
  and (CdMsg == "PACS.008" || CdMsg == "PACS.004")
  and IdStatus in (5,4,1,2,0,13,7)                 // pre-liquidacao: Enviada/PendEnvio/Cripto/Entrada/RetornoBacen/AguardRetorno
  and DtHrOperacao < now.AddMinutes(-50.0)          // >50 minutos presa
  and IspbIF == <nosso ISPB>
  and payment.TipoPrioridade == "PAGPRI"            // SO pagamento prioritario
select (CdMsg=="PACS.008") ? EndToEndId : RtrId
```
Montagem/despacho (`...EnvioConsultaOperacao.Application.decompiled.cs:264-283`): para cada id
monta `CAMT060ApiDetalhaLanctoDTO { IdEventoPesquisa = <E2E ou RtrId>, ... }` e envia via
`_dtoUseCase.InsereComConversao`. A resposta (camt.054 ou admi.002) volta pelo dispatcher acima.

### 1.4 camt.054 — ImportaCAMT0054 + AjustaStatusCAMT054

`Importa` (`...TratamentoRetornoApiBacen.Infrastructure.decompiled.cs:1068-1113`): classifica o
lancamento:
- `Sts/Cd == "BOOK"` -> `Efetivada`;
- senao, `AddtlNtryInf` nao vazio -> `Rejeitada`;
- senao -> `EntradaDoRegistro` -> **return (no-op)**.
Chave de correlacao: PACS008 -> `Refs/EndToEndId`; PACS004 -> `Refs/InstrId`. Outra coisa -> return.

`AjustaStatusCAMT054` (`:1967-2078`):
- Busca `MessageFull` por `CdMsg=="PACS.008" && EndToEndId==id` (ou `PACS.004 && RtrId==id`).
- **NAO ACHOU -> CRIA um novo MessageFull + Payment** reconstruido do XML da camt.054 (ISPB,
  DtMovto, Dbtr/Cdtr, Amt, TxId, RtrId, IdStatus = status classificado, DtHrLiquidacao/DtContabil
  se Efetivada). `:1976-2058`.
- ACHOU e `IdStatus != Efetivada` e status novo == Efetivada -> `IdStatus = Efetivada` `:2062`.
- ACHOU e `IdStatus != Rejeitada` e status novo == Rejeitada -> `IdStatus = Rejeitada` `:2068`.
Observacao: o handler ajusta o STATUS da mensagem; o credito/contabil do cliente segue a
integracao de contabil/liquidacao dirigida pelo status (nao re-credita aqui).

### 1.5 admi.002 — ImportaADMI002 + RejeitaPgtoPorADMI002

`Importa` (`:1115-1132`): so age quando
```
chaveMensagem != null
  and chaveMensagem.CdMsg == "CAMT.060"
  and TagValue(xml,"RsnDesc") == "Nao existe lancamento com o identificador da operacao solicitado"
-> RejeitaPgtoPorADMI002(chaveMensagem)
```
`Rejeita` (`:2079-2110`): le `JsonInput` da MessageFull por `IdOperacao`, desserializa
`CAMT060ApiDetalhaLanctoDTO`, pega `IdEventoPesquisa`; busca a operacao (pacs.008 por EndToEndId
se `IdEventoPesquisa` comeca com "E", senao pacs.004 por RtrId); e SE encontrada e
`IdStatus NOT IN {Efetivada, Rejeitada, PendenteAprovacao, PendenteAprovacaoRestanteLote}`
-> `IdStatus = Rejeitada`. Guarda explicita contra sobrescrever operacao ja liquidada/terminal.

Ou seja, no legado o admi.002 SO fecha a operacao presa quando ele e a resposta da NOSSA camt.060
dizendo "operacao nao existe" — e nesse caso REJEITA a operacao presa (libera o fluxo).

---

## 2. Comportamento do NOSSO codigo (com prova)

Todos os caminhos vivem em `pix/backend/apps/spi_service/lib/spi_service/`.

### 2.1 Dispatcher inbound

`workers/inbound_processor.ex:184-224` (`dispatch_message`): `pacs.002 -> process_status_report`,
`camt.052 -> process_account_report`, `camt.054 -> process_notification`,
`admi.002/admi.004 -> process_system_event`, etc. Fail-CLOSED de assinatura/XSD antes do dispatch.

### 2.2 Verdade = pacs.002 (ACSC/STLD)

`process_status_report` (`:2348-2530`) + `map_pacs002_status` (`:2568-2574`):
```
ACSP->"processing" | ACCC/ACSC/STLD->"settled" | RJCT->"rejected" | CANC->"cancelled" | _->nil
```
Correlaciona a operacao (RtrId "D..." para devolucao / E2E / message_ref / bacen_outbound) e
publica `status_update`. `StatusUpdater.settle_payment_line` (`workers/status_updater.ex:267-340`)
grava `status_id = 4 (ACSC, terminal)` com `guard_not_settled` + `guard_not_terminal`, publica
`monetarie.spi.transaction.settled` UMA vez (idempotente). Mapa canonico `Shared.Spi.StatusCodes`
(`apps/shared/lib/shared/spi/status_codes.ex`): terminais [4 ACSC, 8 RJCT, 9 CANC, 10 RTRN],
liquidada=4, sucesso [4,7].

### 2.3 camt.060 reconciliacao — PaymentStatusReconciler (nosso "EnvioConsultaOperacao")

`workers/payment_status_reconciler.ex` (arquivo inteiro): tick 30s; alvo = pacs.008 OUTBOUND
(e pacs.004 INBOUND por RtrId) em `status_id in [1 PDNG, 2 ACSP]` com `operation_time < now-90s`;
lote 10; backoff 5min por E2E via `camt060_requests action="op_status:"<>e2e` (`recently_queried?`).
Envia camt.060 de consulta de operacao (`ReqdMsgNmId=camt.054`, EvtId=E2E/RtrId) por
`Shared.Bacen.SpiClient.get_balance(... event_id: e2e ...)`. A camt.054 de resposta e resolvida
no InboundProcessor (2.4). ISPB do remetente = SEMPRE a instituicao (nunca o creditor/debtor;
comentario `:120-128` cita o incidente 14/07).

### 2.4 camt.054 — process_notification + resolve_outbound_from_camt054

`process_notification` (`:1436-1517`): correlaciona `Ntfctn/Id` (echo da nossa camt.060) via
`Camt060Requests.correlate_response`; enriquecimento MED advisory; **`resolve_outbound_from_camt054`**;
registra em `bacen_inbound` (audit). Fail-safe: entrada do BACEN nunca derruba o worker.

`resolve_outbound_from_camt054` (`:1531-1600`) + `classify_camt054_entry` (`:1607-1640`):
- `Sts BOOK + CdtDbtInd DBIT` -> `settled`;
- `Sts INFO + codigo de rejeicao conhecido (RejectCodes)` -> `rejected`;
- caso contrario -> `:inconclusive` (no-op, NUNCA inventa status).
Resolve SOMENTE tx `direction=="OUTBOUND" message_code=="pacs.008"` existente por E2E
(`outbound_tx_by_e2e :1731`). Devolucao recebida pendente (pacs.004 INBOUND por RtrId) -> 
`resolve_return_entry` (`:1660-1729`) com guarda terminal `terminal_status?(...) or status_id==7 (STLD)`;
BOOK -> marca STLD + espelha credito na Conta PI + publica `returned`; INFO+codigo -> RJCT sem credito.
Publica via `publish_status_update_for_tx` (MESMO caminho da pacs.002).

### 2.5 Guarda out-of-order (anti duplo-credito)

`publish_status_update_for_tx` (`:4160-4238`): **se `terminal_status?(tx.status_id)` [4,8,9,10] ->
ignora** ("pacs.002 fora de ordem"). Logo:
- pacs.002 ACSC liquida -> status 4 -> camt.054 BOOK posterior IGNORADA;
- camt.054 BOOK liquida primeiro -> status 4 -> pacs.002 ACSC posterior IGNORADA (evento publicado 1x).
Reforco no consumidor: `StatusUpdater.guard_not_settled/guard_not_terminal` (`:959-982`) +
`:already_settled` idempotente (`:314`). Sequencia pacs.002 <-> camt.054 <-> admi.002 nao duplica.

### 2.6 admi.002 — process_system_event

`process_system_event` (`:1777-1810`): audita e chama tres correlacoes:
- `maybe_publish_admi002_rejection` (`:1965-2054`): admi.002 como MessageReject de mensagem NOSSA;
  correlaciona a tx por `RltdRef/Ref` (`find_correlated_transaction original_message_id/resource_id`)
  e publica `rejected` (guardado por terminal). Fan-out de lote por `OutboundSendClaim` (`:1868`).
- `maybe_correlate_admi002_camt060` (`:1856-1888`): extrai refs `M<ISPB>...` do XML e chama
  `Camt060Requests.correlate_not_found` -> marca a requisicao camt.060 como `not_found` (terminal).
- `maybe_correlate_admi002_reda` (`:1826-1854`): espelha a rejeicao no participante indireto REDA.

### 2.7 Rastreio da camt.060 — Camt060Requests

`camt060_requests.ex` (inteiro): estados `sent -> responded` (echo camt.052/053/054),
`sent -> not_found` (admi.002), `sent -> timeout` (30min), `sent -> send_failed`.
`correlate_response` (UPDATE condicional `status != responded`, idempotente),
`correlate_not_found` (`status not in [responded, not_found]`, camt.054 sempre vence),
`sweep_timeouts` (watchdog 30min). NUNCA altera `messages.status_id`.

### 2.8 OperationQuery (consulta sincrona por E2E)

`operation_query.ex` (inteiro): dispara camt.060 detalha-lancto e faz POLL de `camt060_requests`
ate `responded`/`not_found`/deadline; le o corpo em `bacen_inbound` e parseia com `Camt054Parser`.
Fail-closed; guarda money-adjacent de E2E divergente (`:finalize_body`); desfechos
ok/not_found/timeout/error/invalid_e2e. Usado por MED (InfractionEnricher) e comprovante — NUNCA credita.

### 2.9 StuckOutboundChecker (deteccao pura)

`stuck_outbound_checker.ex` (inteiro): 10min/30min, loga 1 WARNING agregado das pacs.008 OUTBOUND
presas em PDNG/ACSP e faz `sweep_timeouts` das camt.060. Moduledoc: "NUNCA altera status: status
final so com evidencia do BACEN (pacs.002 / admi.002 / camt.053)". Supervisionado
(`workers/supervisor.ex:25`).

### 2.10 PixInOrphanReconciliation (verdade inbound = camt.054 CRDT)

`workers/pix_in_orphan_reconciliation.ex` (moduledoc `:1-70`): Oban cron (`:92-93`). Modelo canonico
inbound (participante DIRETO): BACEN liquida ANTES de entregar a pacs.008 e o recebedor NAO recebe
pacs.002; a `camt.054 CRDT` no banco E a prova de liquidacao. Detecta orfaos
(`no_message` / `message_not_credited` / `no_outbox_event`); por DEFAULT so ALERTA
(`Logger.error` + telemetria + `pix_in_orphans`); auto-republica so atras de flag
`PIX_IN_ORPHAN_AUTO_REPUBLISH` (default OFF) e so para `no_outbox_event`; documenta o risco de
duplo-credito.

---

## 3. GAPS e itens cobertos

### GAP g1 — admi.002 "nao existe lancamento" NAO encerra a operacao presa (divergencia, risco MEDIO)
- Legado: `RejeitaPgtoPorADMI002` (`...TratamentoRetornoApiBacen.Infrastructure.decompiled.cs:2079-2110`)
  seta `IdStatus = Rejeitada` na pacs.008/004 presa quando o admi.002 e resposta da camt.060 e o
  RsnDesc e "Nao existe lancamento..." (guardado por status nao-terminal). Isso ENCERRA a operacao.
- Nosso: `maybe_correlate_admi002_camt060` (`inbound_processor.ex:1856-1888`) so marca a linha de
  `camt060_requests` como `not_found`; `maybe_publish_admi002_rejection` correlaciona por RltdRef/Ref
  que, neste caso, e o MsgId da PROPRIA camt.060 (M<ISPB>...), NAO o message_id da pacs.008 -> nenhuma
  tx e encontrada (`find_tx_by_message_ref` `:4326-4341`) -> NAO rejeita. A pacs.008 fica PDNG/ACSP;
  o `PaymentStatusReconciler` re-consulta a cada 5min (a linha `not_found` sai do filtro
  `recently_queried?` que exige status in ["sent","responded"]) e o hold no Core nunca e liberado.
  Confirmado por busca: nenhum consumidor de `not_found` rejeita tx (grep em spi/settlement/shared = 0).
- Nota de desenho: nossa filosofia (StuckOutboundChecker moduledoc) e nao auto-rejeitar sem decisao;
  mas aqui HA evidencia BACEN definitiva (admi.002 not_found da nossa consulta) e nao a aplicamos.

### GAP g2 — camt.054 nao RECONSTRÓI operacao ausente (divergencia/parcial, risco BAIXO)
- Legado: `AjustaStatusCAMT054` CRIA MessageFull+Payment do XML quando a operacao nao existe local
  (`:1976-2058`).
- Nosso: `resolve_outbound_from_camt054` so atualiza tx OUTBOUND existente; miss -> `_ -> :ok`
  (`:1594`). Para INBOUND ausente o caso e COBERTO (e mais seguro) por `PixInOrphanReconciliation`
  (camt.054 CRDT como verdade, ALERTA + operador). Residual: nao ha reconstrucao automatica de uma
  saida (OUTBOUND) que a cabine nunca registrou. Na pratica a cabine sempre registra o que origina,
  entao o impacto e restrito a acervo/perda total do registro local.

### GAP g3 — Janela e escopo de reconciliacao divergentes (divergencia, risco INFO)
- Legado: consulta camt.060 apos 50 min, statuses pre-liquidacao {0,1,2,4,5,7,13}, filtro
  `TipoPrioridade == "PAGPRI"` (so prioritario).
- Nosso: consulta apos 90s (ordem do dono 14/07 "aguardando retorno nao pode ser eterno"), apenas
  PDNG/ACSP, TODAS as prioridades. Nao e defeito (mais agressivo e mais amplo), mas e uma diferenca
  de comportamento observavel; modelos de status distintos justificam a divergencia de conjunto.

### GAP g4 — settle_payment_line nao e CAS como settle_outbound_return_line (parcial, risco BAIXO)
- `status_updater.ex`: a linha de DEVOLUCAO usa CAS atomico (`transition_status` `:984-1000`,
  UPDATE ... WHERE status_id IN allowed_from). A linha de PAGAMENTO usa `guard_not_settled`/
  `guard_not_terminal` (checagem em memoria) + `update_transaction_status` (update simples, `:1026-1042`).
  Dois eventos "settled" concorrentes (pacs.002 + camt.054 em pods diferentes) poderiam ambos passar as
  guardas e publicar `transaction.settled` 2x -> depende da idempotencia do Core. A guarda primaria
  `publish_status_update_for_tx` (terminal) torna a janela estreita (os eventos chegam temporalmente
  distantes: pacs.002 em segundos, camt.054 >90s depois). Recomendacao: usar `transition_status` (CAS)
  tambem na linha de pagamento.

### GAP g5 — @terminal_statuses hardcoded duplica StatusCodes (info, risco BAIXO)
- `inbound_processor.ex:4235` define `@terminal_statuses [4, 8, 9, 10]` local, enquanto
  `Shared.Spi.StatusCodes.terminal_ids()` e a fonte unica (o proprio moduledoc do StatusCodes manda
  delegar). Valores coincidem hoje; risco e de drift futuro.

### COBERTO c1 — pacs.002 = verdade de liquidacao, ordem correta
`process_status_report` + `map_pacs002_status` (ACSC/STLD/ACCC->settled) -> StatusUpdater grava
ACSC(4). Prova: `inbound_processor.ex:2348-2574`, `status_updater.ex:267-340`.

### COBERTO c2 — anti duplo-credito na sequencia pacs.002 <-> camt.054 <-> admi.002
Guarda out-of-order em DOIS pontos: `publish_status_update_for_tx` (`:4160-4173`) e
`StatusUpdater.guard_not_settled/terminal` + `:already_settled` (`:959-982`, `:314`). camt.054 BOOK e
o MESMO caminho de status da pacs.002, entao a que chegar segundo vira no-op. Sem re-credito.

### COBERTO c3 — camt.060 op-status reconciliation espelha EnvioConsultaOperacao
`PaymentStatusReconciler` com backoff/dedup por E2E e envio camt.060 (EvtId=E2E/RtrId,
ReqdMsgNmId=camt.054). Cadencia legada confirmada viva (60s, log 22/07).

### COBERTO c4 — classificacao camt.054 alinhada ao legado
BOOK -> liquidada; rejeicao por informacao adicional -> rejeitada; resto -> no-op. Nosso
`classify_camt054_entry` (`:1607-1640`) espelha (mais conservador: exige codigo conhecido).

### COBERTO c5 — admi.002 correlacao camt.060->not_found terminal + fan-out lote + REDA
`Camt060Requests.correlate_not_found` fecha a consulta (fim do "timeout eterno"); fan-out de lote
por `OutboundSendClaim`; state machine REDA. Cobertura MAIOR que a do legado (que so tratava a
rejeicao de operacao). Prova: `inbound_processor.ex:1826-2054`, `camt060_requests.ex`.

### COBERTO c6 — orfao inbound (camt.054 CRDT truth) alert-first
`PixInOrphanReconciliation` usa a camt.054 CRDT do banco como prova de liquidacao inbound e ALERTA
por default (auto-republica so atras de flag OFF). Substitui, de forma mais segura, o
create-from-camt.054 do legado para inbound perdido.

### COBERTO c7 — OperationQuery fail-closed com guarda de E2E divergente
`operation_query.ex` nunca fabrica ok; recusa camt.054 de outro E2E. Usado por MED/comprovante,
nao credita.

### COBERTO c8 — StuckOutboundChecker so detecta
`stuck_outbound_checker.ex`: status final so muda por evidencia BACEN; watchdog de camt.060.

---

## 4. Sintese

A ordem canonica (pacs.002 = verdade; camt.060 reconcilia presa; camt.054 BOOK = fallback) esta
IMPLEMENTADA no nosso codigo SEM duplo-credito: a guarda out-of-order terminal em dois pontos e a
idempotencia do StatusUpdater garantem que a camt.054 (fallback) nunca re-liquida/re-credita uma
operacao ja fechada por pacs.002, e vice-versa. O inbound e coberto por um modelo mais seguro
(orfao por camt.054 CRDT, alert-first).

O gap material e o **g1**: quando a NOSSA camt.060 recebe admi.002 "nao existe lancamento", o
legado REJEITA a operacao presa (liberando o fluxo/hold) e nos apenas marcamos a consulta como
`not_found`, deixando a pacs.008 presa em PDNG/ACSP indefinidamente e re-consultando a cada 5min.
Os demais (g2 reconstrucao camt.054, g3 janela 90s vs 50min, g4 CAS na linha de pagamento, g5
duplicacao de terminais) sao divergencias de baixo risco ou de desenho deliberado.
