# Runbook: reparo do saldo de reserva SPB (balance_positions/balance_movements)

Data: 2026-07-15. Executar SOMENTE em janela autorizada pelo dono, DEPOIS do deploy
do código com os fixes do motor de saldo (parsers R2, STR0016 fail-closed +
parser dedicado, unificação de grupo, self-heal STR0013R1). Aplica-se a PROD e,
com as mesmas queries, a HML.

## Contexto (provado em 15/07)

1. Parser STR0006R2 sem `VlrLanc` fez o ledger perder R$ 622.153,41 de créditos
   de reserva em 15/07 (2 STR0006 recebidas às 10:46 UTC). As operações existem
   corretas em `spb_operations`; só `balance_movements`/`balance_positions`
   ficaram cegos.
2. Broadcast STR0016 chegava ao handler com payload vazio: fechamentos de
   13/07 (R$ 113.187,94) e 14/07 (R$ 125.881,56) foram persistidos como ZERO,
   e a abertura de 15/07 ficou 0,00.
3. Dualismo de grupo: débito STR0010 de R$ 7.500,00 (15/07 13:11 UTC) postado
   no grupo "STR" em vez de "15"; em 13/07 uma saída de ~R$ 1,3 milhão foi
   postada no grupo "SME".
4. Delta conhecido de 14/07: fechamento BACEN 125.881,56 vs abertura+créditos
   rastreados 114.481,41 → R$ 11.400,15 de movimentos de reserva de 14/07 que
   o ledger não capturou (mesma família de causa: parsers R2 cegos). NÃO
   remendar em silêncio: registrar como delta de conciliação do dia 14/07.

## Passo 0 — snapshot de segurança (read-only)

```sql
SELECT group_id, position_date, opening_balance, credit_predicted, credit_confirmed,
       debit_predicted, debit_confirmed, closing_balance, is_closed
FROM balance_positions WHERE position_date >= '2026-07-12' ORDER BY position_date, group_id;

SELECT group_id, direction, phase, amount, operation_id, position_date
FROM balance_movements WHERE position_date >= '2026-07-13' ORDER BY inserted_at;
```
Guardar a saída no handoff da janela.

## Passo 1 — créditos STR0006 perdidos (15/07) entram no ledger

Idempotente (NOT EXISTS espelha o dedup por operação/fase/direção):

```sql
INSERT INTO balance_movements
  (operation_id, phase, direction, group_id, position_date, amount, inserted_at)
SELECT o.id, 'confirmed', 'credit', '15',
       COALESCE(o.settlement_date, (o.created_at AT TIME ZONE 'UTC' AT TIME ZONE 'America/Sao_Paulo')::date),
       o.amount, now()
FROM spb_operations o
WHERE o.message_type = 'STR0006' AND o.direction = 'inbound'
  AND o.state = 'r2_confirmed'
  AND o.created_at >= '2026-07-15' AND o.created_at < '2026-07-16'
  AND NOT EXISTS (
    SELECT 1 FROM balance_movements bm
    WHERE bm.operation_id = o.id AND bm.phase = 'confirmed' AND bm.direction = 'credit'
  );
-- Esperado em PROD: 2 linhas (187523.26 e 434630.15).
```

## Passo 2 — migrar movimentos de grupo sigla para o código canônico

```sql
-- STR0010 de 15/07 (debit predicted+confirmed 7500.00) e qualquer resíduo sigla.
UPDATE balance_movements SET group_id = '15' WHERE group_id = 'STR';
-- SME de 13/07: INVESTIGAR antes (qual operação? qual grp_saldo o contrato
-- legado dá para o message_type dela?):
SELECT o.message_type, o.amount, o.created_at, bm.direction, bm.phase
FROM balance_movements bm JOIN spb_operations o ON o.id = bm.operation_id
WHERE bm.group_id = 'SME';
-- Se o movimento de 13/07 for a saída real de R$ 1,3M da conta de reserva
-- (confere com a queda 1.413.187,94 -> 113.187,94 na timeline STR0013R1),
-- migrar para '15' TAMBÉM, senão para o grp_saldo do contrato da mensagem:
-- UPDATE balance_movements SET group_id = '15' WHERE group_id = 'SME';
```

## Passo 3 — recompor os acumuladores das posições a partir do ledger

ORDEM OBRIGATÓRIA: primeiro ZERAR os acumuladores das posições da janela
(inclusive as de sigla cujos movimentos migraram no passo 2 — sem isso o
UPDATE...FROM não as alcança, os valores antigos sobram na posição fantasma e
as telas que somam por grupo CONTAM EM DOBRO), depois aplicar o agregado:

```sql
UPDATE balance_positions SET
  credit_predicted = 0, credit_confirmed = 0,
  debit_predicted = 0, debit_confirmed = 0,
  updated_at = now()
WHERE position_date >= '2026-07-13';

UPDATE balance_positions p SET
  credit_predicted = COALESCE(m.cp, 0),
  credit_confirmed = COALESCE(m.cc, 0),
  debit_predicted  = COALESCE(m.dp, 0),
  debit_confirmed  = COALESCE(m.dc, 0),
  updated_at = now()
FROM (
  SELECT group_id, position_date,
         SUM(amount) FILTER (WHERE direction='credit' AND phase='predicted') AS cp,
         SUM(amount) FILTER (WHERE direction='credit' AND phase='confirmed') AS cc,
         SUM(amount) FILTER (WHERE direction='debit'  AND phase='predicted') AS dp,
         SUM(amount) FILTER (WHERE direction='debit'  AND phase='confirmed') AS dc
  FROM balance_movements
  WHERE position_date >= '2026-07-13'
  GROUP BY group_id, position_date
) m
WHERE p.group_id = m.group_id AND p.position_date = m.position_date;

-- Garantir que a posicao (15, 2026-07-15) existe (se o INSERT do passo 1 criou
-- movimento sem posicao, criar):
INSERT INTO balance_positions (group_id, position_date, opening_balance, inserted_at, updated_at)
SELECT '15', '2026-07-15', 0, now(), now()
WHERE NOT EXISTS (SELECT 1 FROM balance_positions WHERE group_id='15' AND position_date='2026-07-15');
```

## Passo 4 — fechamentos/aberturas autoritativos (STR0016)

```sql
-- Fechamento 13/07 (STR0016 de 13/07 21:55/22:30 UTC: SldRB_CL=113187.94)
UPDATE balance_positions SET closing_balance = 113187.94, updated_at = now()
WHERE group_id = '15' AND position_date = '2026-07-13';

-- 14/07: abertura = fechamento de 13/07; fechamento = STR0016 de 14/07 (125881.56)
UPDATE balance_positions SET opening_balance = 113187.94, closing_balance = 125881.56, updated_at = now()
WHERE group_id = '15' AND position_date = '2026-07-14';

-- 15/07: abertura = fechamento de 14/07
UPDATE balance_positions SET opening_balance = 125881.56, updated_at = now()
WHERE group_id = '15' AND position_date = '2026-07-15';

-- Abertura de 13/07 permanece desconhecida (sem STR0016 de 12/07 no acervo da
-- janela analisada) — ACEITO e documentado; não inventar valor.

-- Remover posicoes-fantasma de sigla depois da migracao (se zeradas):
DELETE FROM balance_positions
WHERE group_id IN ('STR', 'SME')
  AND credit_predicted = 0 AND credit_confirmed = 0
  AND debit_predicted = 0 AND debit_confirmed = 0
  AND opening_balance = 0 AND closing_balance = 0;
```

ATENÇÃO: os UPDATEs acima usam os valores das STR0016 REAIS de PROD. Em HML,
buscar os valores próprios:
```sql
SELECT message_type, inserted_at, content->>'sld_rb_cl'
FROM bacen_messages WHERE message_type='STR0016' AND inserted_at >= '2026-07-12'
ORDER BY inserted_at;
```

## Passo 5 — reiniciar a task do spb-api (OBRIGATÓRIO)

O tracker mantém posições em ETS e `get_balance/2` lê o ETS PRIMEIRO (só vai ao
banco quando a chave grupo/data não existe no cache). Os UPDATEs SQL NÃO
aparecem no card até o restart. Reciclar a task do spb-api depois dos passos
1-4 é obrigatório; sem isso o card fica desatualizado indefinidamente num dia
sem movimento novo.

## Passo 6 — verificação

```sql
-- O card deve fechar com: abertura 125881.56 + creditos do dia - debitos do dia
SELECT group_id, position_date, opening_balance, credit_confirmed, debit_confirmed,
       opening_balance + credit_confirmed - debit_confirmed AS saldo_vivo
FROM balance_positions WHERE group_id='15' AND position_date='2026-07-15';
```
Conferir contra a STR0013R1 mais recente do dia (botão ATUALIZAR SALDO na tela)
ao centavo, considerando movimentos posteriores à consulta. O self-heal novo
NÃO atuará (abertura != 0), o que é o esperado.

## Delta conhecido que o reparo NÃO cobre

R$ 11.400,15 de movimentos de reserva de 14/07 invisíveis ao ledger (fechamento
BACEN 125.881,56 vs 113.187,94+1.293,47 rastreados +10,00 do fim do dia).
Levantar na janela com STR0014 (extrato de 14/07) e decidir se entra como
movimento retroativo ou só como nota de conciliação. Com os parsers corrigidos
isso não se repete para dias novos.
