# Design: correções do saldo Conta PI e da tela CAMT (pix admin) - 5 defeitos

Data: 2026-07-15
Escopo: `pix/backend` (apps `spi_service`, `settlement_service`, `shared`) e `pix/frontend/admin`.
Método: investigação READ-ONLY no código, com evidência arquivo:linha. Onde a conclusão depende de dado vivo, o item está marcado como `PRECISA VALIDAR EM BANCO VIVO` com a query. Nenhuma linha de código foi alterada.
Referências BACEN: XSDs SPI v5.12.1 versionados em `pix/backend/apps/shared/priv/xsd/spi/v5.12.1/` (idênticos aos de `/Users/luizpenha/cecresa/md/spi.5.12.1/v5.12.1/xsd/`), exemplos oficiais em `/Users/luizpenha/cecresa/md/spi.5.12.1/v5.12.1/exemplos/camt060/`, Catálogo Vol VI v5.12 em `/Users/luizpenha/cecresa/md/Catalogo_de_Servicos_do_SFN_Volume_VI_Versao_512.md.STAGE`.

## Tabela-resumo

| # | Defeito | Causa raiz (síntese) | Evidência central | Correção (síntese) |
|---|---------|----------------------|-------------------|--------------------|
| 1 | Saldo "Confirmado BACEN" sobrescrito por consulta datada e carimbado como agora | `process_eod_statement` grava `confirmed` para TODA camt.053 recebida, sem gate de data da posição, e usa `Stmt/CreDtTm` (hora da RESPOSTA) como `confirmed_at` em vez de `Bal/Dt/DtTm` (hora da POSIÇÃO) | `inbound_processor.ex:2607-2631`; `camt053_parser.ex:62` vs `:78`; escritor secundário `balances.ex:911-921` | Gate de monotonicidade pela data DA POSIÇÃO dentro de `update_confirmed_balance/3` + `confirmed_at` = data do fio, nunca `now()` |
| 2 | Card "Débitos Hoje" R$ 0,00 com 3 pacs.008 OUTBOUND liquidadas (R$ 9.502,00) | Card lê `balance_history`, que só recebe débito pelo caminho de hold (`create_block`); o envio manual da tela debita no Core e não cria hold na Conta PI espelho | `balances.ex:676-705`, `:263-267`; `payment_controller.ex:106-138` (sem hold); `core_event_processor.ex:243` (único criador de hold no envio) | Compor Créditos/Débitos do dia da fonte canônica `monetarie_spi.messages`+`payments` via `SpiService.CanonicalTransactions` (status terminal, direção define o sinal) |
| 3 | admi.002 "Sem lançamento" para período com movimento; tela não explica | Montagem da camt.060 REL é XSD-válida e igual ao exemplo oficial; o motivo do admi.002 é descartado (só logado, nunca persistido/exibido); janela default colapsa `ToDt = FrDt` e horas são UTC fixas | `camt060.ex:44,236-240`; `spi_client.ex:547-567`; `camt060_requests.ex:158-184` (reason só no log); schema sem coluna de motivo | Persistir e exibir o motivo do admi.002 (`response_reason`); decisão sobre janela/fuso SÓ após ler o XML enviado e o admi.002 reais (BACEN é a verdade) |
| 4 | "Aguardando BACEN" eterno no demonstrativo-remuneração (camt.053/CRE) | Correlação é exclusiva por eco de `Stmt/Id`; a camt.053 de remuneração chega com `Stmt/Id` = 32 zeros (o próprio código a trata como não solicitada), então a requisição nunca vira `responded`; o watchdog de 30 min existe mas o estado vivo precisa ser conferido | `camt060_requests.ex:101-102`; `inbound_processor.ex:2292-2299, 2598-2602`; watchdog `camt060_requests.ex:194-205` + `stuck_outbound_checker.ex` | Fallback de correlação para camt.053 com `Bal[REMN]` fechar a requisição CRE pendente + garantir estado terminal (timeout) na tela |
| 5 | Devoluções (pacs.004) listadas sem Pagador/Recebedor (só ISPBs) | Serializer lê nomes/documentos só do `payments` da própria pacs.004, que o fio real não traz; a transação ORIGINAL (correlacionada por `original_end_to_end_id`) tem as partes e ninguém a resolve | `payment_controller.ex:1047-1084, 1119-1120`; `message_parser.ex:482-495`; `inbound_processor.ex:3339-3363` | Resolver as partes da devolução a partir da transação original (uma query em lote por `original_end_to_end_id`, com verificação de ISPB) em listagem e detalhe |

---

## DEFEITO 1 - CAMT060: saldo confirmado sobrescrito por consulta datada

### Causa raiz (arquivo:linha)

Escritores de `monetarie_spi.balances.confirmed/confirmed_at` (todos os callers de `update_confirmed_balance/3` em produção):

1. **`SpiService.Workers.InboundProcessor.process_eod_statement/2`** (o principal, e o culpado do incidente):
   - Dispatch: toda camt.053 inbound cai aqui (`inbound_processor.ex:207`: `"camt.053" -> process_eod_statement(message, metadata)`).
   - `inbound_processor.ex:2611-2619`: calcula `confirmed_amount = SADP + SABK` (camt.053 de saldo da Conta PI) ou `closing_balance` (CLBD, EOD regular).
   - `inbound_processor.ex:2621-2626`: chama `SpiService.Balances.update_confirmed_balance(statement.ispb, confirmed_amount, statement.created_at || DateTime.utc_now())` **incondicionalmente**, para qualquer camt.053 com valor. Não existe nenhum gate sobre a data da posição.
   - `statement.created_at` vem de `//Stmt/CreDtTm` (`camt053_parser.ex:62`), que é o instante de criação da MENSAGEM DE RESPOSTA (ou seja, "agora", quando o BACEN responde), e não a data da posição. A data da POSIÇÃO existe e já é extraída pelo parser: `position_at` = `Bal/Dt/DtTm` (ou `Bal/Dt/Dt`) do SADP/SABK (`camt053_parser.ex:73-78` e `:180-200`), mas `process_eod_statement` **não a usa** no write do confirmado.

2. **`SpiService.Balances.apply_bacen_position/5`, ramo stale** (`balances.ex:908-921`): quando a posição do BACEN é mais VELHA que o último movimento local, o saldo vivo é preservado (correto), mas a função grava `update_confirmed_balance(ispb, available, position_at)` de propósito, como "checkpoint de conciliação" (`balances.ex:911-913`). Dois problemas: (a) grava confirmado a partir de posição sabidamente datada; (b) passa só `available`, sem somar `blocked`, inconsistente com o cálculo `SADP + SABK` do item 1 (`inbound_processor.ex:2614-2619`). O carimbo, ao menos, é `position_at` (correto).

3. **`SettlementService.Reconciliation.fetch_bacen_records/1`** (`reconciliation.ex:436-441`): usa `statement.closing_balance` (CLBD) da camt.053 de EOD com `statement.created_at || DateTime.utc_now()` como carimbo. Mesmo defeito de carimbo do item 1 (CreDtTm da resposta em vez da data da posição), via o wrapper `reconciliation.ex:470-483`.

Sequência do incidente de PROD (consulta de 11/07 executada em 15/07):

- Aba "saldo-na-data" da tela CAMT envia camt.060 com `camt.053 + Prtry CSA + FrDt = 2026-07-11` (`camt060.ex:43` e `date_opts(:closed_day)` em `camt060.ex:232-234`; builder do XML em `spi_client.ex:547-567`).
- O BACEN devolve, corretamente, uma camt.053 com `Bal[SADP]` da posição histórica de 11/07 (R$ 77.453,82), com `Bal/Dt` = 11/07 e `Stmt/CreDtTm` = 15/07 (o momento em que respondeu).
- `process_eod_statement` roda `maybe_upsert_conta_pi_position` primeiro (`inbound_processor.ex:2604`), cujo `apply_bacen_position` detecta posição stale e PRESERVA `available/blocked` (esse gate existe e funciona, `balances.ex:908-921`), mas em seguida a linha 2621 sobrescreve `confirmed = 77.453,82` com `confirmed_at = CreDtTm ≈ 15/07 agora`. Resultado: posição histórica carimbada como "confirmada agora".

### Fluxo de dados (do dado ao card)

```
Tela CAMT, widget "Confirmado BACEN (camt.060)"
  Camt060ToolView.vue:156-186 (poll 10 s), :168-171 (confirmedValue), :569-575 (render de confirmed + confirmed_at)
  -> camt060Service.getPiPosition (services/camt060.ts:174-175): GET /api/v1/balance/positions
  -> gateway settlement_service (proxy) -> spi_service BalanceController.positions (balance_controller.ex:271-277)
  -> Balances.list_all_positions (balances.ex:604-639; campos confirmed/confirmed_at em :627-628)
  -> coluna monetarie_spi.balances.confirmed / confirmed_at
  <- escrita: Balances.update_confirmed_balance/3 (balances.ex:975-1021), callers acima
```

### Correção proposta (mínima, no padrão do app)

O padrão já existe no próprio módulo: `apply_bacen_position/5` protege `available/blocked` contra foto velha com um comparador de datas (`stale_position?/2`, `balances.ex:935-938`). A correção leva a MESMA disciplina para o confirmado, centralizada na fonte única `update_confirmed_balance/3` (assim os 3 callers ficam cobertos sem mudar de forma):

1. **Gate de monotonicidade pela data DA POSIÇÃO, dentro de `update_confirmed_balance/3`** (`balances.ex:975`): o terceiro argumento passa a ser, por contrato, a data da POSIÇÃO (do fio), e a função recusa retrocesso:
   - se `balance.confirmed_at` existe e `position_at < balance.confirmed_at`, NÃO grava; loga warning e retorna `{:ok, :stale_confirmed_ignored}` (mesmo estilo do `{:ok, :stale_ignored}` de `apply_bacen_position`).
   - caso contrário grava `confirmed = valor` e `confirmed_at = position_at` (nunca `DateTime.utc_now()`); remover o default `\\ DateTime.utc_now()` do terceiro argumento, para nenhum caller novo cair no carimbo errado por omissão.
   - Nota de design: a regra estrita "só grava se a posição for de hoje/agora" foi considerada e descartada porque quebraria o escritor legítimo de EOD (o CLBD do dia D chega em D+1 e É o checkpoint de conciliação correto; `reconciliation.ex:384-460`). A monotonicidade elimina exatamente o sintoma do incidente (consulta datada não retrocede o confirmado nem o carimbo) sem matar o EOD.

2. **`process_eod_statement` passa a data da posição** (`inbound_processor.ex:2621-2626`): trocar `statement.created_at || DateTime.utc_now()` por `statement.position_at || statement.created_at` (com warning quando cair no fallback; ver item 3). O gate central faz o resto.

3. **Parser: extrair a data da posição também para CLBD** (`camt053_parser.ex:78`): hoje `position_at` só olha `SADP`/`SABK`. Estender para `extract_balance_date(doc, "SADP") || extract_balance_date(doc, "SABK") || extract_balance_date(doc, "CLBD")`. O XSD garante que a data existe em todo `Bal`: `camt.053.spi.1.4.xsd`, complexType `CashBalance8` (linhas 141-148 do arquivo em `pix/backend/apps/shared/priv/xsd/spi/v5.12.1/camt.053.spi.1.4.xsd`) exige a sequência `Tp, Amt, CdtDbtInd, Dt` sem `minOccurs=0` no `Dt`. Ou seja, o fallback para `created_at` só cobre XML malformado.

4. **`apply_bacen_position`, ramo stale** (`balances.ex:913`): passar `Decimal.add(available, blocked)` em vez de só `available` (consistência com `SADP + SABK` do processador). A chamada continua existindo, mas o gate central do item 1 decide se grava (uma foto de 11/07 não sobrescreve um confirmado de 14/07; uma foto mais nova que o confirmado vigente pode avançá-lo com o carimbo verdadeiro).

5. **`reconciliation.ex:437-441`**: passar a data da posição do CLBD (`statement.position_at`, disponível após o item 3) em vez de `created_at || now()`.

Comportamento resultante para o incidente: a resposta da consulta de 11/07 chega, `available/blocked` seguem preservados (gate já existente), e o confirmado NÃO retrocede (gate novo); a linha de conciliação divergente continua sendo registrada por `record_position_check` (P10, `balances.ex:1000-1018`) apenas quando a escrita acontece; para escrita recusada, registrar log estruturado (follow-up opcional: estender `DailyReconciliation.record_position_check` com a data de referência da foto, fora do escopo mínimo).

### Plano de teste TDD (RED primeiro)

Arquivos-modelo: `apps/spi_service/test/spi_service/balances_stale_position_test.exs` e `apps/spi_service/test/spi_service/camt053_position_date_test.exs` (mesmo estilo: semeia `Balance`, processa XML sintético, afirma no banco).

1. **RED "consulta datada não altera confirmed"** (reproduz o incidente): semear `Balance` com `confirmed = 100.000,00`, `confirmed_at = ~U[2026-07-14 12:00:00Z]`; construir camt.053 sintética com `Bal[SADP] = 77.453,82`, `Bal/Dt/DtTm = 2026-07-11T23:59:59Z`, `Stmt/CreDtTm = agora`, `Stmt/Id` ecoando um MsgId qualquer; invocar o caminho de `process_eod_statement` (ou a unidade `update_confirmed_balance` com os argumentos que o processador passa hoje). Asserção: `confirmed` e `confirmed_at` INALTERADOS; retorno `{:ok, :stale_confirmed_ignored}`. Hoje esse teste FALHA (confirmed vira 77.453,82 e confirmed_at vira agora), provando o defeito.
2. **"posição corrente atualiza e carimba a data do fio"**: mesma camt.053 com `Bal/Dt/DtTm = T` (T > confirmed_at semeado). Asserção: `confirmed` atualizado e `confirmed_at == T` (não `now()`; comparar com tolerância zero, é o valor do fio).
3. **Parser CLBD**: XML EOD com `Bal[CLBD]` datado; `parse/1` devolve `position_at` = data do CLBD (estende `camt053_position_date_test.exs`).
4. **Primeira confirmação (confirmed_at nil)**: grava normalmente (sem regressão no bootstrap de homolog).
5. **Regressão do ramo stale de `apply_bacen_position`**: os 3 testes de `balances_stale_position_test.exs` continuam verdes; adicionar asserção de que o confirmado gravado no ramo stale usa `available + blocked`.

### Pontos a validar em banco vivo

- Estado contaminado atual: `PRECISA VALIDAR EM BANCO VIVO: SELECT ispb, available, blocked, confirmed, confirmed_at, as_of, source FROM monetarie_spi.balances WHERE ispb = '46026562';` (esperado pelo relato: `confirmed = 77453.82`, `confirmed_at` em 15/07).
- Carimbo real do fio na resposta datada: `PRECISA VALIDAR EM BANCO VIVO: SELECT message_id, receive_date, receive_time, xml_content FROM monetarie_spi.bacen_inbound WHERE message_type = 'camt.053' AND receive_date >= '2026-07-15' ORDER BY receive_time DESC LIMIT 5;` e conferir no XML `Stmt/CreDtTm` vs `Bal/Dt/DtTm` (zero inferência sobre o formato real do BACEN em PROD).
- Reparo do dado: após o deploy do fix, disparar uma consulta "saldo-atual" (sem RptgPrd, `camt060.ex:42` e `spi_client.ex:533-546`) e conferir que `confirmed/confirmed_at` regravam com a posição corrente. Se o carimbo contaminado (15/07 now) for MAIS NOVO que a posição corrente devolvida, o gate monotônico recusaria o reparo: nesse caso, UPDATE manual autorizado da linha (zerar `confirmed_at`) antes da consulta. `PRECISA VALIDAR EM BANCO VIVO` a ordem dos timestamps antes de escolher.

---

## DEFEITO 2 - "Débitos Hoje" R$ 0,00 apesar de 3 envios liquidados

### Causa raiz (arquivo:linha)

Fato provado em PROD (dado pelo operador): 3 pacs.008 OUTBOUND liquidadas hoje (`status_id = 4`, `system_id = 1`), R$ 2,00 + R$ 2.500,00 + R$ 7.000,00 = R$ 9.502,00, com **0** linhas em `balance_blocks` e **0** débitos em `balance_history`.

1. O card lê `Balances.get_intraday_movements/2` (`balances.ex:676-705`), que consulta **exclusivamente** `monetarie_spi.balance_history` e agrega por hora (`credits`/`debits` pelo campo `direction` da linha de histórico).
2. `balance_history` só ganha linhas por `record_balance_change/4` (`balances.ex:820-837`), chamado em exatamente dois lugares:
   - `update_balance/2` (`balances.ex:132`), que desde o P12 NÃO é mais chamado no envio ("create_block passou a ser o ÚNICO ponto de saída de available no PIX-OUT", comentário em `balances.ex:118-121` e `:213-216`); serve a crédito (inbound/devolução recebida/remuneração) e ajustes admin;
   - `create_block/2` (`balances.ex:263-267`), a hold do Debit-Then-Send, com `reference_type "spi_outbound_hold"`.
   `confirm_block/1` (liquidação da hold) NÃO grava histórico (`balances.ex:362-398`, por design: o débito já aconteceu na hold).
3. Quem cria a hold no envio: somente o caminho Core -> PIX `payment_request` (`core_event_processor.ex:243` -> `check_and_block_balance` `:1517-1547` -> `Balances.create_block` `:1570-1578`), além da devolução enviada (`return_processor.ex:141`) e recorrência (`recurrences/execution_worker.ex:182`).
4. O **envio manual da tela** (`PaymentController.create`, `payment_controller.ex:106-138`) registra o débito no conta-corrente do CORE (P11, `register_debtor_debit`, fail-CLOSED) e **não cria hold** na Conta PI espelho da cabine. Consequência: a pacs.008 nasce, liquida (ACSC no `messages`), e nenhuma linha de débito jamais entra em `balance_history` -> card R$ 0,00.

A fonte autoritativa do movimento liquidado é `monetarie_spi.messages` + `monetarie_spi.payments` (`Shared.Schemas.Spi.Transaction`; gotcha 10 do `pix/CLAUDE.md`: "Two Transaction schemas"; valor real em `payments.amount`, join `payments.message_id == messages.id`). É de lá que o extrato oficial já lê, via `SpiService.CanonicalTransactions` (`canonical_transactions.ex:1-50`).

### Fluxo de dados (do dado ao card)

```
Cards "Créditos Hoje"/"Débitos Hoje"
  BalanceDashboardView.vue:26-36 (totalCreditsToday/totalDebitsToday = reduce no array movements;
    o summary calculado no backend NÃO é usado pelos cards)
  <- stores/balance.ts:56 (intradayMovements)
  <- services/balance.ts:279-283: GET /api/v1/balance/intraday
  <- gateway: router.ex:512 + SpiProxyController.balance_intraday (spi_proxy_controller.ex:296-300)
  <- spi_service: router.ex:99 + BalanceController.intraday (balance_controller.ex:286-298;
     summary em :330-345)
  <- Balances.get_intraday_movements (balances.ex:676-705)
  <- monetarie_spi.balance_history   <-- fonte errada para "movimento do dia"
```

### Correção proposta (mínima, no padrão do app)

Compor Créditos/Débitos do dia da fonte canônica, reutilizando as regras já provadas de `SpiService.CanonicalTransactions` (o mesmo módulo que salvou o extrato e o saldo projetado da tabela paralela vazia):

1. **Nova função `CanonicalTransactions.intraday_settled_movements(ispb, date)`** (mesmo shape de query de `list_statement_movements/4`, `canonical_transactions.ex:124-157`):
   - `Transaction` left join `Payment` (`p.message_id == t.id`), `message_code in ~w(pacs.008 pacs.004)` (`@money_message_codes`, `:53`);
   - janela do dia sobre `operation_time` (00:00:00.000000 a 23:59:59.999999, padrão da `list_statement_movements`);
   - filtro liquidada = a semântica única `settled?/1` (`canonical_transactions.ex:182-187`): `status_id` em `success_ids() (4 ACSC / 7 STLD) ++ RTRN (10)`, mais `ACCC (3)` quando `direction == "INBOUND"` (P0 8d1769cc);
   - `select` incluindo `t.operation_time` (para o agrupamento por hora) e o sinal pelo critério já canônico `credit_debit: creditor_ispb == ispb -> :credit, senão :debit` (`to_movement/4`, `:239`). Isso resolve pacs.004 por direção automaticamente: devolução recebida (INBOUND, somos creditor) = crédito; devolução enviada (OUTBOUND, somos debtor) = débito;
   - `limit` explícito (gotcha 26 do `pix/CLAUDE.md`).
   - Nota de decisão (documentada, não inferida): a janela usa `operation_time` para manter a MESMA semântica do extrato canônico (`list_statement_movements`). Alternativa `settlement_time` ("liquidou hoje") muda a semântica de todo o produto e fica registrada como decisão do dono, fora do escopo mínimo.
2. **`Balances.get_intraday_movements/2`** (`balances.ex:676-705`) passa a agregar por hora a partir de `intraday_settled_movements/2` (créditos, débitos, contagens), preservando EXATAMENTE o contrato de saída atual (`hour`, `hour_label`, `credits`, `debits`, `credit_count`, `debit_count`, `closing_balance`), para que gateway e frontend não mudem. Regra anti-duplicidade: a fonte dos valores passa a ser SOMENTE o canônico; `balance_history` NÃO é somado junto (a mesma transação com hold + liquidação contaria duas vezes; um crédito inbound existe nas duas fontes). `closing_balance` da hora continua vindo de `balance_history` quando houver linha na hora (é um snapshot de `available_after`, não um total), e `nil`/último conhecido quando não houver; documentar no @doc.
3. Nenhuma mudança no controller (`balance_controller.ex:286-298`), no gateway, no store nem na view: os cards somam `movements`, que passam a carregar os débitos reais. O `summary` do endpoint (`:330-345`) passa a bater com os cards por consequência.

O precedente do padrão é literal: `calculate_pending_amounts/1` já foi migrado da tabela paralela para `CanonicalTransactions.pending_totals(ispb)` pelo mesmo motivo (`balances.ex:839-845`).

### Plano de teste TDD (RED primeiro)

Modelos: `apps/spi_service/test/spi_service/statements_canonical_read_test.exs` (semeia messages+payments canônicos) e `balances_outbound_hold_test.exs`.

1. **RED reproduzindo os 3 envios**: semear 3 `Transaction` pacs.008 OUTBOUND com `status_id = 4`, `debtor_ispb = nosso`, `operation_time = hoje`, `Payment.amount` 2,00 / 2.500,00 / 7.000,00; ZERO `balance_blocks`/`balance_history`. Chamar `Balances.get_intraday_movements(ispb, hoje)`. Asserção: soma de `debits` = 9.502,00 e `debit_count` = 3. Hoje devolve tudo zero (prova o defeito).
2. **Créditos**: pacs.008 INBOUND liquidada (status 4) + pacs.004 INBOUND (devolução recebida, `creditor_ispb = nosso`) somam em `credits`; pacs.004 OUTBOUND soma em `debits`.
3. **Não liquidada não conta**: pacs.008 OUTBOUND com status 1 (PDNG) / 2 (ACSP) / 8 (RJCT) fora dos totais; INBOUND+ACCC (3) conta como liquidada (regra P0 8d1769cc).
4. **Sem duplicidade**: transação liquidada COM hold correspondente em `balance_history` (caminho Core) conta UMA vez nos débitos.
5. **Contrato do endpoint**: teste de controller no `GET /api/v1/balance/intraday` verificando que `summary.total_debits` reflete os mesmos 9.502,00 (o front soma `movements`; o teste prende o contrato).

### Pontos a validar em banco vivo

- Confirmar os 3 envios e o caminho de origem de cada um: `PRECISA VALIDAR EM BANCO VIVO: SELECT t.id, t.end_to_end_id, t.direction, t.status_id, p.amount, t.operation_time, t.settlement_time FROM monetarie_spi.messages t JOIN monetarie_spi.payments p ON p.message_id = t.id WHERE t.message_code = 'pacs.008' AND t.direction = 'OUTBOUND' AND t.operation_time >= '2026-07-15' ORDER BY t.operation_time;`
- Confirmar ausência de hold/histórico: `PRECISA VALIDAR EM BANCO VIVO: SELECT count(*) FROM monetarie_spi.balance_blocks WHERE inserted_at >= '2026-07-15'; SELECT direction, count(*), sum(change_amount) FROM monetarie_spi.balance_history WHERE recorded_at >= '2026-07-15' GROUP BY direction;`
- Se os 3 envios NÃO forem do caminho manual (`PaymentController.create`), identificar qual produtor pulou a hold antes de assumir a narrativa acima (a correção proposta independe disso, mas o registro do incidente deve ser exato).

---

## DEFEITO 3 - admi.002 "Sem lançamento" em período que teve lançamentos

### Causa raiz (arquivo:linha)

**Parte A: a montagem da camt.060 lista-lançamentos é estruturalmente válida.** Verificado elemento a elemento contra o XSD oficial e o exemplo do catálogo:

- Spec da ação: `camt060.ex:44`: `"lista-lanctos" => %{reqd: "camt.052", prtry: "REL", date: :day_range}`. O tipo REL é fixado no backend (a tela removeu o dropdown; comentário em `Camt060ToolView.vue:53-54`: "a camt.052 de período exige balance_type=REL", regra provada vs BACEN).
- Janela: `date_opts(:day_range)` (`camt060.ex:236-240`): `from_date` = o que a tela mandou (ex.: `2026-07-11`); **`to_date` default = `from_date`** (um único dia); `from_time`/`to_time` **fixos** em `"00:00:00.000Z"`/`"23:59:59.999Z"` (dia cheio em UTC), independentemente da tela.
- XML: `spi_client.ex:547-567` monta `<RptgPrd><FrToDt><FrDt>..</FrDt><ToDt>..</ToDt></FrToDt><FrToTm><FrTm>..</FrTm><ToTm>..</ToTm></FrToTm><Tp>ALLL</Tp></RptgPrd>` e `spi_client.ex:577-578` o `<ReqdBalTp><CdOrPrtry><Prtry>REL</Prtry>`; ISPB em `AcctOwnr/Agt/FinInstnId/ClrSysMmbId/MmbId` (`spi_client.ex:606`); destinatário fixo BACEN `00038166` (`:592`).
- XSD `camt.060.spi.1.9.xsd` (v5.12.1): `RptgPrd` e `ReqdBalTp` opcionais (minOccurs=0); `FrDt` ISODate (date-only); `FrToTm` exige `FrTm` E `ToTm` no padrão `HH:MM:SS.mmmZ`; `Tp` só admite `ALLL`; universo do `Prtry` = `{CRE, CSA, REL, TRD, TRT}`. O que enviamos satisfaz tudo.
- Exemplo oficial `camt.060_SOLIC_REL_LANCAMENTO_msg.xml` (`/Users/luizpenha/cecresa/md/spi.5.12.1/v5.12.1/exemplos/camt060/`, linhas 33-59): mesma forma, com range multi-dia e horas parciais.

**Parte B: o que É nosso defeito, provado no código: o motivo do admi.002 é jogado fora.**

- Inbound: `admi.002` cai em `process_system_event` (`inbound_processor.ex:210`, `:1656-1690`) e `maybe_correlate_admi002_camt060` (`:1737-1765`) extrai os MsgIds ecoados no XML e o motivo (`body[:reason_description] || body[:rejecting_reason] || message["reason_description"]`).
- `Camt060Requests.correlate_not_found/3` (`camt060_requests.ex:158-184`) grava `status = "not_found"`, `response_type = "admi.002"`, `responded_at`, `response_msg_id`... e o `reason` **aparece apenas no `Logger.info` da linha 176-178**. O schema `Shared.Schemas.Spi.Camt060Request` (`camt060_request.ex:22-34`) e a migration `20260704170000_create_camt060_requests.exs` não têm NENHUMA coluna de motivo/descrição.
- A tela então só consegue exibir um rótulo fixo: `Camt060ToolView.vue:448-449` ("Sem lançamento (admi.002)") e banner `:876-880` ("O BACEN respondeu que não existe lançamento para esta consulta"). O texto real do BACEN (que distingue "não há lançamento no período" de "janela/tipo inválido") nunca chega ao operador.

**Parte C: hipóteses para o "sem lançamento" em si (não decidíveis pelo código; BACEN é a verdade).** O Catálogo Vol VI v5.12 só diz que "as requisições efetuadas pelas mensagens CAMT.060 podem, em caso de inconsistências negociais, ser respondidas com mensagens ADMI.002" (`Catalogo_de_Servicos_do_SFN_Volume_VI_Versao_512.md.STAGE:586-587`), sem enumerar motivos. Três hipóteses ordenadas, cada uma com o teste de verificação:

1. **Resposta legítima do BACEN**: a consulta foi para 11/07 (FrDt=ToDt=2026-07-11) e o movimento citado no relato (inbound de 20k) é de **14/07**, fora da janela. Se as devoluções/REMN do período também não caíram em 11/07, "sem lançamento" está certo e o defeito é só de comunicação (Parte B).
2. **Default surpresa `ToDt = FrDt`** (`camt060.ex:238`): o operador informa só a data inicial esperando "de 11/07 até hoje", e o backend colapsa a consulta para UM dia em silêncio. É montagem nossa discutível (a tela mostra o eco `sent_query`, `camt060.ex:267-282`, mas o operador precisa notar).
3. **Janela de horas em UTC fixo** (`camt060.ex:239`): `00:00Z-23:59Z` de 11/07 cobre 10/07 21:00 a 11/07 20:59 em Brasília. Se o BACEN interpreta a janela na grade local, movimentos de 11/07 21:00-23:59 BRT ficam fora. O XSD só fixa o formato com `Z`; a semântica de fuso não é decidível pelo código nem pelo XSD.

### Fluxo de dados

```
Aba "lista-lançamentos" (Camt060ToolView.vue:52-60; payload só com campos preenchidos :313-320)
  -> POST /api/v1/camt060/lista-lanctos (services/camt060.ts:118,131-134)
  -> spi_service router.ex:135 -> Camt060Controller.lista_lanctos (camt060_controller.ex:48,54-57)
  -> Camt060.query -> send_camt060 (camt060.ex:171-214; registra MsgId em camt060_requests ANTES do POST)
  -> Shared.Bacen.SpiClient.get_balance -> build_camt060_request (spi_client.ex:523-612) -> BACEN
  <- admi.002 -> InboundProcessor.process_system_event (:210, :1656-1690)
  -> maybe_correlate_admi002_camt060 (:1737-1765) -> Camt060Requests.correlate_not_found (:158-184)
  -> monetarie_spi.camt060_requests.status = "not_found" (motivo SÓ no log)
  <- tela: GET /api/v1/camt060/requests -> chip "Sem lançamento (admi.002)" (Camt060ToolView.vue:448-449)
```

### Correção proposta (mínima, no padrão do app)

1. **Persistir o motivo do admi.002**: migration forward adicionando `response_reason :text` a `monetarie_spi.camt060_requests` (padrão das migrations idempotentes do repo); `correlate_not_found/3` grava `response_reason: reason` no mesmo `update_all` (`camt060_requests.ex:165-173`); campo no schema (`camt060_request.ex`) e no `request_json` do controller (`camt060_controller.ex`, função que hoje expõe `pending`/`response_type`).
2. **Exibir o motivo na tela**: no chip do histórico e no banner de resultado (`Camt060ToolView.vue:448-449` e `:876-880`), mostrar `response_reason` quando presente ("Sem lançamento (admi.002): <texto do BACEN>"). Sem motivo persistido (acervo antigo), mantém o rótulo atual.
3. **Montagem: NÃO mudar janela/fuso agora.** Qualquer mudança de `ToDt` default ou da janela de horas só depois de ler o XML realmente enviado e o texto real do admi.002 (validação abaixo). Se a hipótese 2 se confirmar como causa recorrente de confusão operacional, a mudança mínima é a TELA pré-preencher `to_date` com `from_date` visível (o operador vê e altera), sem tocar no backend.

### Plano de teste TDD (RED primeiro)

Modelo: `apps/spi_service/test/spi_service/camt060_requests_test.exs` e `camt060_test.exs` (que já prendem `request_opts` por ação, incluindo `lista-lanctos` com `00:00:00.000Z`/`23:59:59.999Z`, `camt060_test.exs:149-156`).

1. **RED persistência do motivo**: `record_sent` + `correlate_not_found(msg_id, "Não existem lançamentos para o período informado", rmid)`; asserção: a linha tem `status = "not_found"` E `response_reason` com o texto. Hoje falha (campo não existe).
2. **Fluxo inbound**: admi.002 sintética (XML com o MsgId ecoado e `Rsn/Desc`) por `process_system_event`; asserção: linha `not_found` com `response_reason` extraído (prende `maybe_correlate_admi002_camt060` -> `correlate_not_found` com o reason do parser).
3. **Idempotência preservada**: segundo admi.002 não sobrescreve `responded`/`not_found` (comportamento atual de `:163`).
4. **Controller**: `GET /api/v1/camt060/requests` expõe `response_reason`.
5. **Front (vitest)**: label do histórico renderiza o motivo quando presente.

### Pontos a validar em banco vivo

- O que foi realmente enviado: `PRECISA VALIDAR EM BANCO VIVO: SELECT message_id, xml_content FROM monetarie_spi.bacen_outbound WHERE message_type = 'camt.060' AND created_at >= '2026-07-15' ORDER BY created_at DESC LIMIT 5;` (conferir `RptgPrd` real: FrDt/ToDt/FrToTm). (Se a coluna de data tiver outro nome no acervo, ajustar; a tabela é `monetarie_spi.bacen_outbound`, usada pela correlação admi.002 do ETL.)
- O que o BACEN respondeu: `PRECISA VALIDAR EM BANCO VIVO: SELECT message_id, receive_time, xml_content FROM monetarie_spi.bacen_inbound WHERE message_type = 'admi.002' AND receive_date >= '2026-07-15' ORDER BY receive_time DESC LIMIT 5;` e ler o `Rsn`/descrição literal. É esse texto que decide entre as hipóteses 1/2/3 da Parte C.
- Estado da requisição: `PRECISA VALIDAR EM BANCO VIVO: SELECT msg_id, action, prtry, status, response_type, sent_at, responded_at FROM monetarie_spi.camt060_requests WHERE action = 'lista-lanctos' ORDER BY sent_at DESC LIMIT 10;`
- Onde marcar o motivo no acervo já ocorrido: o texto vive apenas em `bacen_inbound.xml_content` (e nos logs); backfill do `response_reason` a partir do XML arquivado é possível e fica como decisão operacional.

---

## DEFEITO 4 - "Aguardando BACEN" eterno no Demonstrativo Remuneração

### Causa raiz (arquivo:linha)

MsgId do relato (`M4602656278da9f129f628d8e8a44011`) casa com o formato `MsgIdType` gerado por `Camt060Requests.generate_msg_id/1` (`camt060_requests.ex:48-52`; regex do schema `camt060_request.ex`). A tabela de tracking é `monetarie_spi.camt060_requests`; **não existe** uso de `query_correlations` para camt.060 (a única referência a `query_correlations` no backend é uma migration de checks do `monetarie_dict`, `20260612400000_add_cnpj_alfa_checks.exs:62`, tabela de outro domínio).

1. **Criação**: a aba envia camt.060 com `camt.053 + Prtry CRE + FrDt = dia fechado` (`camt060.ex:48`; `date_opts(:closed_day)` `:232-234`); o MsgId é persistido com `status = "sent"` ANTES do POST (`camt060.ex:171-187`; `record_sent` `camt060_requests.ex:60-72`).
2. **Correlação**: a ÚNICA forma de fechar a requisição de camt.053 é o eco do MsgId em `Stmt/Id` (`process_eod_statement` -> `correlate_camt060_response(statement.statement_id, "camt.053", ...)`, `inbound_processor.ex:2595-2602`; `correlate_response` `camt060_requests.ex:104-143`). Não existe correlação por `RltdMsg`/`OrgnlBizQry`/`NewQryNm` em lugar nenhum (grep repo-wide sem hits), e o XSD `camt.053.spi.1.4.xsd` nem tem esses elementos: `Stmt/Id` é `MsgIdOr32ZerosType` (padrão `[M][0-9A-Z]{8}[a-zA-Z0-9]{23}|[0]{32}`), ou seja, o fio permite eco OU 32 zeros.
3. **O curto-circuito**: `correlate_response(@unsolicited_id, ...)` devolve `{:ok, :unsolicited}` SEM tocar linha nenhuma (`camt060_requests.ex:37, 101-102`). E o próprio código afirma e implementa que a camt.053 de remuneração chega com `Stmt/Id` = 32 zeros: `maybe_credit_remuneration/2` SÓ credita o `Bal[REMN]` quando `stmt_id == @unsolicited_stmt_id` (`inbound_processor.ex:2280-2299`, comentário: "O BACEN entrega uma camt.053 NÃO SOLICITADA (`Stmt/Id` = 32 zeros)... o LegadoPIX (`Handlers.CAMT053/v12.cs:30-39`) lê esse Bal[REMN]"). Contradição interna: a spec da aba trata a resposta como correlacionável por eco; o handler de remuneração trata a mesma camt.053 como não solicitada. Nos dois cenários possíveis do fio, o resultado observado é o mesmo se o BACEN mandar 32 zeros: o dinheiro é creditado, mas a linha do tracking fica `sent` para sempre pelo caminho de correlação.
4. **Timeout existe, mas é condicionado**: `sweep_timeouts/1` marca `sent` -> `timeout` após 30 min (`camt060_requests.ex:38, 194-205`), acionado pelo `SpiService.Workers.StuckOutboundChecker` a cada 10 min (`stuck_outbound_checker.ex:36, 128-152`). Esse worker vive sob `SpiService.Workers.Supervisor`, que só sobe com `NATS_ENABLED=true` (`spi_service/application.ex`). "Aguardando BACEN" ETERNO implica `status = "sent"` antigo no banco, o que exige verificação viva: ou o watchdog não está rodando em PROD, ou a linha é recente a cada consulta. A tela mostra "Aguardando BACEN" exatamente enquanto `status == "sent"` (`historyStatusLabel`, `Camt060ToolView.vue:440-455`; `pending: req.status in ["sent"]`, `camt060_controller.ex:77`); o poll do resultado desiste em 45 s sem mutar nada (`waitForBacen`, `Camt060ToolView.vue:397-422`).
5. **Entrega**: a camt.053 chega pelo stream ICOM assíncrono (`SpiService.Icom.NatsBridge` -> `monetarie.spi.inbound.camt.053` -> `process_eod_statement`); não é síncrona nem via arquivo STA para este caso (o caminho de arquivo camt.053 existe no `FileImporter` do settlement, mas o handler de remuneração cabeado é o do stream, `inbound_processor.ex:2604-2605`).

### Fluxo de dados

```
Aba "demonstrativo-remuneracao" -> POST /api/v1/camt060/demonstrativo-remuneracao
  -> Camt060.query (spec :48: camt.053 + CRE + dia fechado)
  -> camt060_requests: linha status="sent" (record_sent, ANTES do POST)
  -> BACEN (ACK síncrono; relatório assíncrono via ICOM)
  <- camt.053 com Bal[REMN] -> process_eod_statement
       correlate_camt060_response(Stmt/Id, ...) -> se Stmt/Id = 32 zeros: {:ok, :unsolicited}, linha NÃO fecha
       maybe_credit_remuneration: credita o REMN (dedup por "REMN-"<message_id>), sem fechar tracking
  <- tela: GET /api/v1/camt060/requests/:msg_id -> pending=true enquanto status="sent"
       chip "Aguardando BACEN" (Camt060ToolView.vue:444-445), para sempre
```

### Correção proposta (mínima, no padrão do app)

1. **Fallback de correlação para a remuneração**: em `process_eod_statement`, quando a camt.053 tiver `Bal[REMN]` (o parser já expõe `remuneration_balance`, `camt053_parser.ex:79`) e `Stmt/Id` for 32 zeros, fechar a requisição CRE pendente: nova função `Camt060Requests.correlate_unsolicited_remuneration(ispb, response_msg_id)` que faz `UPDATE ... SET status='responded', response_type='camt.053', responded_at=now` na linha `status = 'sent' AND action = 'demonstrativo-remuneracao' AND ispb = ?` mais recente (e SOMENTE se existir exatamente uma pendente OU limitada à mais recente por `sent_at`, decisão documentada no @doc: fallback heurístico, janela curta, nunca rebaixa `responded`). Se o `Stmt/Id` vier ecoado (o XSD permite), o caminho atual já fecha e o fallback não roda. Mesmo padrão fail-safe do `correlate_camt060_response` (`inbound_processor.ex:2552-2564`: tracking nunca derruba o processamento).
2. **Estado terminal garantido na tela**: nada a criar; `timeout` já é terminal e rotulado ("Sem resposta no prazo", `Camt060ToolView.vue:446-447`). O que falta é OPERACIONAL: confirmar que o `StuckOutboundChecker` roda em PROD e varre a fila (validação abaixo). Se a validação mostrar linhas `sent` velhas com o worker no ar, investigar o supervisor antes de qualquer código novo.
3. **Não tocar** no crédito da remuneração (`maybe_credit_remuneration`): já é idempotente por `reference_id` e é money-path validado.

### Plano de teste TDD (RED primeiro)

Modelo: `camt060_requests_test.exs` + testes de inbound existentes.

1. **RED**: `record_sent(msg_id, ispb, action: "demonstrativo-remuneracao", ...)`; processar camt.053 sintética com `Stmt/Id = 32 zeros` e `Bal[REMN] = 40,69` por `process_eod_statement`. Asserções: (a) linha vira `responded` com `response_type = "camt.053"`; (b) o crédito REMN acontece UMA vez (`balance_history` com `reference_id "REMN-"...`). Hoje (a) falha: a linha fica `sent`.
2. **Eco explícito**: camt.053 com `Stmt/Id = msg_id` fecha pelo caminho atual (regressão do `correlate_response`).
3. **Duas pendentes CRE**: comportamento definido do fallback (fecha só a mais recente; a outra segue para o watchdog) - prende a decisão.
4. **Nunca rebaixa**: fallback não altera linha `responded`/`not_found`.
5. **Watchdog**: caso de `sweep_timeouts` para `sent` > 30 min de `demonstrativo-remuneracao` (se ainda não coberto).

### Pontos a validar em banco vivo

- Estado da linha do relato: `PRECISA VALIDAR EM BANCO VIVO: SELECT msg_id, action, status, sent_at, responded_at, response_type FROM monetarie_spi.camt060_requests WHERE msg_id = 'M4602656278da9f129f628d8e8a44011';`
- O que o BACEN realmente devolve para a CRE (eco ou 32 zeros; o XSD permite ambos): `PRECISA VALIDAR EM BANCO VIVO: SELECT message_id, receive_time, xml_content FROM monetarie_spi.bacen_inbound WHERE message_type = 'camt.053' AND xml_content LIKE '%REMN%' ORDER BY receive_time DESC LIMIT 5;` (conferir `Stmt/Id` no XML). Se o BACEN ecoar o MsgId, o fallback vira rede de segurança e o defeito real passa a ser "resposta nunca chegou": conferir se existe camt.053 REMN recebida após o `sent_at` da linha.
- Watchdog vivo: `PRECISA VALIDAR EM BANCO VIVO: SELECT count(*) FROM monetarie_spi.camt060_requests WHERE status = 'sent' AND sent_at < now() - interval '30 minutes';` (> 0 com o serviço no ar = varredura não está rodando; conferir `NATS_ENABLED` e o log do `StuckOutboundChecker` na task do pix-api).

---

## DEFEITO 5 - Devoluções sem Pagador/Recebedor na lista de transações

### Causa raiz (arquivo:linha)

1. **Endpoint da lista**: gateway `router.ex:495` (`GET /transactions` -> `SpiProxyController.list_transactions` -> `list_payments`, `spi_proxy_controller.ex:130,69`) proxy para o spi_service `GET /api/v1/payments`, atendido por `SpiServiceWeb.PaymentController.index` (`spi_service/router.ex:58,70`; `payment_controller.ex:23-61`): query em `Shared.Schemas.Spi.Transaction` (canônica, `monetarie_spi.messages`) com left join `Payment` (`:42`).
2. **Serializer**: `serialize_transaction/1` (`payment_controller.ex:1047-1084`): ISPBs saem da própria linha de `messages` (`debtor_ispb :1059`, `creditor_ispb :1064`, com nome da instituição resolvido pelo diretório de participantes), mas **nome/documento/conta saem exclusivamente do `payments` da própria transação** (`get_in_payment(payment, :debtor_name)` `:1061`, `:creditor_name` `:1066`, documentos `:1063/:1068`; `get_in_payment(nil,_) -> nil`, `:1119-1120`).
3. **A pacs.004 recebida não traz as partes**: o registro nasce em `do_process_incoming_return` (`inbound_processor.ex:1203-1313`), com `original_end_to_end_id` gravado (`:1218-1219`) e payments via `payment_attrs/3` (`:3339-3363`), que tenta `//Dbtr/Nm`/`//Cdtr/Nm` do corpo; o parser documenta que no fio real esses campos vêm ausentes (`message_parser.ex:482-483`: "Campos ausentes (ex.: Cdtr sem <Nm> no fio real) retornam nil sem quebrar"). ISPBs da pacs.004 vêm de `debtor_agent_ispb`/`creditor_agent_ispb` com fallback pelo E2E (`build_transaction_attrs`, `:3175-3197`). Resultado: linha com ISPBs e partes nulas -> tela mostra "-". (O fix de 15/07 que criou a linha de payments da devolução, comentário em `:1234-1238`, resolveu o "R$ 0,00" mas não as partes, que o fio não traz.)
4. **A transação ORIGINAL tem as partes** (`debtor_*`/`creditor_*` completos no seu payments) e está correlacionada por `original_end_to_end_id` (`transaction.ex:63`; índice parcial em `20260714211000_add_messages_original_e2e_index.exs:27-28`), mas nem `index` nem `show` a resolvem: `show` (`payment_controller.ex:87-101`) só preloada `:payment` (`:1125-1126`). A única correlação existente é a do timeline (`:643-707`, `where: t.end_to_end_id == ^e2e or t.original_end_to_end_id == ^e2e`, `:655`).
5. **Frontend**: `TransactionListView.vue`: fallback "-" em `partyMain` (`:139-141`, `name || doc || '-'`) e `partySub` (`:143-152`); células Pagador/Recebedor (`:474-495`); detecção de devolução `isReturnRow` (`:234-236`, `message_type === 'pacs.004' && return_id`); exibição "Original: E..." (`:438-447`, `returnOriginalE2e :242-247`). Detalhe: `TransactionDetailView.vue:533-549` (blocos das partes, `|| '-'`), `:586-588` (original E2E), sem nenhum fetch da original (`:87-95`).

### Fluxo de dados

```
TransactionListView.vue (células :474-495, fallback '-' :139-152)
  <- GET /api/v1/transactions (gateway router.ex:495 -> SpiProxyController -> /api/v1/payments do spi)
  <- PaymentController.index (payment_controller.ex:23-61) + serialize_transaction (:1047-1084)
  <- monetarie_spi.messages (ISPBs, original_end_to_end_id) + monetarie_spi.payments (partes: NULAS na pacs.004)
  [a original, com partes completas, está a um join de distância por original_end_to_end_id e não é lida]
```

### Correção proposta (mínima, no padrão do app)

Resolver as partes no BACKEND (uma query em lote, sem N+1), preenchendo o MESMO contrato de resposta já consumido pela tela (nenhum campo novo obrigatório no front):

1. **`index`**: após materializar as linhas, coletar `original_end_to_end_id` não nulos das linhas pacs.004 e buscar as ORIGINAIS em uma query (`where: t.end_to_end_id in ^e2es and t.message_code != "pacs.004"`, padrão de `find_tx_by_e2e/1`, `inbound_processor.ex:3812-3829`, e do timeline `:655`) com join em payments; montar mapa `e2e -> {tx_original, payment_original}`.
2. **Preenchimento com verificação de papel (sem inferência)**: numa devolução, quem paga é o recebedor original e quem recebe é o pagador original. A própria pacs.004 já carrega os ISPBs invertidos, o que permite VERIFICAR o mapeamento antes de preencher: se `pacs004.debtor_ispb == original.creditor_ispb`, então `debtor_name/document/account` da devolução := `creditor_*` do payments original; simetricamente para o creditor (`pacs004.creditor_ispb == original.debtor_ispb` -> `debtor_*` originais). Se os ISPBs não casarem, NÃO preencher (mantém "-", honesto). Preencher apenas campos nulos: o que a pacs.004 trouxe no fio vence.
3. **`show`**: mesma resolução para a transação única (reusar a função de resolução do item 2 com lista de 1). Opcional de produto (documentado, não obrigatório): expor `parties_source: "original_transaction"` para a tela sinalizar a proveniência; a exibição em listagem e detalhe já funciona sem mudança de front porque `partyMain`/blocos do detalhe consomem os mesmos campos.
4. Nenhuma migration: o índice parcial de `original_end_to_end_id` (20260714211000) já serve a busca em lote.

### Plano de teste TDD (RED primeiro)

Testes de controller do spi_service (padrão dos testes de `PaymentController` existentes; semear canônico `messages` + `payments`).

1. **RED lista**: semear pacs.008 original liquidada (payments com `debtor_name "Fulano"/debtor_cpf_cnpj`, `creditor_name "Gabriel"/creditor_cpf_cnpj`, `debtor_ispb A`, `creditor_ispb B`) e pacs.004 INBOUND com `original_end_to_end_id` apontando para ela, `debtor_ispb B`, `creditor_ispb A`, payments SEM nomes. `GET /api/v1/payments`: a linha da devolução deve trazer `debtor_name == "Gabriel"` (recebedor original devolvendo) e `creditor_name == "Fulano"`. Hoje vem nil (prova o defeito).
2. **Detalhe**: `GET /api/v1/payments/:id` da devolução com as mesmas asserções.
3. **ISPB não casa**: original com ISPBs que não batem com os da pacs.004 -> campos permanecem nil (nunca preencher com verificação falha).
4. **Fio vence**: pacs.004 cujo payments JÁ tem `debtor_name` -> valor do fio não é sobrescrito.
5. **Sem original**: pacs.004 com `original_end_to_end_id` inexistente na base -> resposta igual à atual (sem erro, partes "-").
6. **Sem N+1**: teste com 3 devoluções afirmando o preenchimento (a garantia de query única fica no code review; opcionalmente prender com contador de queries se o harness suportar).

### Pontos a validar em banco vivo

- Confirmar o estado das devoluções do acervo: `PRECISA VALIDAR EM BANCO VIVO: SELECT t.id, t.end_to_end_id, t.original_end_to_end_id, t.debtor_ispb, t.creditor_ispb, p.debtor_name, p.creditor_name FROM monetarie_spi.messages t LEFT JOIN monetarie_spi.payments p ON p.message_id = t.id WHERE t.message_code = 'pacs.004' ORDER BY t.id DESC LIMIT 10;` (esperado: nomes nulos, original preenchido).
- Confirmar que as originais correspondentes têm payments com partes completas (mesma query trocando o filtro para os `end_to_end_id` originais). Se alguma original também estiver sem partes (ex.: acervo importado), o preenchimento simplesmente não acontece para ela (comportamento definido no teste 5).
- Conferir se algum fio real de pacs.004 traz `Dbtr/Nm`/`Cdtr/Nm` (para o teste 4 refletir a realidade): ler `xml_content` de uma pacs.004 em `monetarie_spi.bacen_inbound`.

---

## Observações finais

- Ordem sugerida de implementação: 1 (money-adjacente, corrige mentira na tela de conciliação), 2 (card financeiro), 5 (tela operacional), 4 e 3 (tracking/UX de consulta). Cada item é independente e pode virar PR próprio com o respectivo bloco de testes.
- Todos os fixes respeitam os padrões vigentes: fonte canônica `Shared.Schemas.Spi.Transaction` (gotcha 10), `Repo.all` com limit (gotcha 26), tracking fail-safe que nunca derruba processamento inbound, dinheiro em reais nas tabelas de saldo da cabine (comentário em `BalanceDashboardView.vue:51-54`).
- Nenhum item exige mudança de contrato entre gateway e frontend; os defeitos 3 e 4 adicionam UMA coluna nova (`response_reason`) e uma transição de status já existente.
