# Devolução de PIX iniciada pelo cliente (trilho verdadeiro) - Plano de Implementação

> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans para implementar tarefa a tarefa.
> Convenções DESTE repo que prevalecem: sem worktree (main direto), commit + push a cada tarefa concluída,
> TDD com RED primeiro, dinheiro com TigerBeetle real, revisão adversarial ao final, deploy só com OK do dono.

**Goal:** devolução de PIX iniciada pelo cliente (Partner API e IB) chega ao BACEN pelo trilho verdadeiro (ReturnProcessor), com validação da pacs.008 original, parciais com teto, trilha completa e RtrId (e2e iniciado por D) estruturalmente único.

**Architecture:** o `handle_return_request` da cabine deixa de criar linha fantasma e passa a derivar a verdade da pacs.008 INBOUND original e publicar `RETURN_CREATED` com `origin=client`; o `ReturnProcessor` (único emissor de pacs.004) ganha queima estrutural de RtrId no `return_id_registry`; o Core pula a materialização para `origin=client` (hold já feito no edge) e ganha desfechos terminais (return.settled efetiva o débito, return.rejected libera o hold), com webhooks `pix.refund.completed`/`pix.refund.failed`/`pix.payout.returned`.

**Tech Stack:** Elixir/Phoenix (core/backend + pix/backend umbrella), NATS JetStream + outbox Oban (ObanRouting), TigerBeetle, PostgreSQL particionado.

**Design:** `docs/plans/2026-07-17-devolucao-pix-fluxo-cliente-design.md` (validado com o dono).

**Pré-requisitos de ambiente:** containers `monetarie-pg` (:15432) e `monetarie-tb-test` de pé (conferido nesta sessão). Testes da cabine: `cd pix/backend && mix test apps/<app>/test/...`. Testes do Core: `cd core/backend && mix test test/...`.

---

## Fatos do código que o executor precisa saber (verificados em 2583e365)

1. Trilho verdadeiro: `RETURN_CREATED` em `monetarie.spi.return.created` -> `ReturnProcessor` (`pix/backend/apps/spi_service/lib/spi_service/workers/return_processor.ex`). Contrato do evento: `return_id` (opcional, revalidado), `original_end_to_end_id` (obrigatório), `amount`, `original_amount`, `original_date`, `reason_code`, `reason_description`, `debtor_ispb`/`creditor_ispb`, `institution_id`/`branch_id`/`system_id`.
2. RtrId: regex `^D[0-9A-Z]{8}[0-9]{12}[a-zA-Z0-9]{11}$` (`return_processor.ex:37`), gerador único `MessageBuilder.generate_rtr_id/1` (`message_builder.ex:1664`).
3. `monetarie_spi.return_id_registry` existe no squash `20260101000000` (schema `Shared.Schemas.Spi.IdRegistry` em `shared/lib/shared/schemas/spi/id_registry.ex`: PK `return_id` max 35 + `ispb`), ZERO chamadores hoje.
4. Padrão de queima: `Shared.E2eBurn` (`shared/lib/shared/e2e_burn.ex`) - ON CONFLICT DO NOTHING + num_rows, opção `:repo` para participar do rollback da transação envolvente (lição do item B).
5. Trilho fantasma a matar: `core_event_processor.ex:841-926` (`handle_return_request`) cria linha pacs.004 PDNG sem RtrId/XML e publica `monetarie.spi.transaction.returned`, que NINGUÉM envia (StatusUpdater sem cláusula `TRANSACTION_RETURNED`, catch-all `status_updater.ex:122`; Core trata como audit-only `pix_handler.ex:230`).
6. Chamadores do trilho fantasma (edge): IB `core/backend/lib/monetarie_web/controllers/v2/pix_controller.ex:547` (`return_pix`) e Partner `core/backend/lib/monetarie_web/controllers/partner_v1/pix_controller.ex:1050-1134` (`refund`, cego: não valida a original). Ambos mandam `"event" => "return_request"` e hold no edge.
7. Liquidação da NOSSA pacs.004 hoje NÃO gera evento ao Core: `status_updater.ex:225-247` suprime `transaction.settled` para `outbound_return?` e só loga. Rejeição gera `monetarie.spi.return.rejected` (`:456-470`) consumido por `PixHandler.handle_return_rejected` (`pix_handler.ex:1040`, dedup SHA-256, estorna materialização).
8. Materialização do débito (devolução iniciada pela cabine): Core `PixHandler.handle_return` (`pix_handler.ex:970`, consome `return.created`): journal COSIF + TB + fee.
9. Devolução RECEBIDA de PIX que enviamos: `handle_transaction(payload, "returned")` (`pix_handler.ex:209`) credita o pagador original com teto e dedup por RtrId; webhook `pix.payout.returned` só existe no TbFirst (flag proibida) - ligar no caminho legado.
10. Catálogo de webhooks: `core/backend/lib/monetarie/schemas/webhooks/webhook.ex:33-49` (`@valid_events`). Existem `pix.refund.requested` e `pix.refund.completed` (produtor em `pix_handler.ex:407-410`); NÃO existe `pix.refund.failed`.
11. Falha de submissão cabine->Core usa `monetarie.spi.transaction.error` (classificador do incidente 15/07; Core falha a PaymentTransactions + libera hold + webhook). Reusar para "original não encontrada/não liquidada".
12. Unidades: Partner fala CENTAVOS; Core opera base_units (`MoneyUnit.from_cents`); a cabine publica reais no fio. Conferir `Monetarie.Util.MoneyUnit` em toda fronteira.

---

### Task 0: Baseline verde

**Step 1:** `cd pix/backend && mix test apps/spi_service/test/spi_service/workers/return_processor_*.exs apps/spi_service/test/spi_service/workers/status_updater*.exs` (usar os arquivos que existirem; descobrir com `ls`). Expected: 0 failures.
**Step 2:** `cd core/backend && mix test test/monetarie_web/controllers/partner_v1/` Expected: 0 failures (anotar contagem).
**Step 3:** Sem commit (nada mudou). Se algo falhar na baseline, PARAR e provar pré-existência com stash antes de seguir.

### Task 1: `Shared.ReturnIdBurn` (queima estrutural do RtrId)

**Files:**
- Create: `pix/backend/apps/shared/lib/shared/return_id_burn.ex`
- Test: `pix/backend/apps/shared/test/shared/return_id_burn_test.exs`

**Step 1: teste RED** com 3 casos:
```elixir
test "burn/2 registra RtrId inedito" do
  assert :ok = ReturnIdBurn.burn("D460265622026071712" <> unique11(), "46026562")
end

test "burn/2 do mesmo RtrId duas vezes retorna already_burned" do
  rtr = "D46026562202607171200" <> ...  # 32 chars validos
  assert :ok = ReturnIdBurn.burn(rtr, "46026562")
  assert {:error, :already_burned} = ReturnIdBurn.burn(rtr, "46026562")
end

test "burn participa do rollback da transacao envolvente (opcao :repo)" do
  rtr = ...
  Shared.Repo.transaction(fn ->
    :ok = ReturnIdBurn.burn(rtr, "46026562", repo: Shared.Repo)
    Shared.Repo.rollback(:abort)
  end)
  assert :ok = ReturnIdBurn.burn(rtr, "46026562")  # nada ficou queimado
end
```
**Step 2:** `cd pix/backend && mix test apps/shared/test/shared/return_id_burn_test.exs` Expected: FAIL (módulo não existe).
**Step 3: implementação mínima** espelhando `Shared.E2eBurn` (mesma estrutura, mesmos comentários de atomicidade), inserindo `Shared.Schemas.Spi.IdRegistry` com `on_conflict: :nothing` e decidindo por num_rows. API: `burn(return_id, ispb, opts \\ [])` com `opts[:repo]` (default `Shared.Repo`).
**Step 4:** rodar de novo. Expected: PASS.
**Step 5:** commit `feat(pix): Shared.ReturnIdBurn — queima estrutural do RtrId no return_id_registry (espelho do E2eBurn)` + push.

### Task 2: ReturnProcessor queima o RtrId (colisão regenera, redelivery idempotente)

**Files:**
- Modify: `pix/backend/apps/spi_service/lib/spi_service/workers/return_processor.ex` (`process_message` RETURN_CREATED, `queue_outbound_return`)
- Test: `pix/backend/apps/spi_service/test/spi_service/workers/return_processor_rtr_burn_test.exs` (novo)

**Step 1: testes RED:**
- (a) colisão: semear `IdRegistry` com um RtrId e entregar RETURN_CREATED trazendo esse `return_id`; a devolução sai com RtrId DIFERENTE (regenerado), queimado, e a linha de tracking usa o novo;
- (b) redelivery: entregar o MESMO evento duas vezes (mesmo return_id gerado na 1a); segunda entrega NÃO cria linha nova nem 2o job outbound (contar `monetarie_spi.messages` por original e2e + jobs Oban);
- (c) rollback: forçar `CumulativeCap` a recusar (amount > original_amount); NENHUMA linha e NENHUMA queima ficam (prova de mesma transação).
**Step 2:** rodar, Expected: FAIL.
**Step 3: implementação.** Estrutura do laço (até 3 tentativas):
```
candidato = ensure_return_id(message)           # mantém válido do evento, senão gera
xml = MessageBuilder.build("pacs.004", %{rtr_id: candidato, ...})
Repo.transaction(fn ->
  case ReturnIdBurn.burn(candidato, from_ispb, repo: Repo) do
    :ok -> cap + upsert_return_tracking + outbox (como hoje)
    {:error, :already_burned} ->
      case Repo.get_by(Transaction, return_id: candidato, direction: "OUTBOUND") do
        %{original_end_to_end_id: ^original_e2e} -> Repo.rollback(:redelivery_reuse)  # idempotente
        _ -> Repo.rollback(:rtr_collision)                                            # regenera
      end
  end
end)
```
`:redelivery_reuse` retorna `:ok` ao worker (ack); `:rtr_collision` regenera candidato NOVO via `MessageBuilder.generate_rtr_id/1` e repete (o XML é rebuildado com o novo RtrId). Esgotadas as tentativas: `{:error, :rtr_id_exhausted}` (NAK/retry).
**Step 4:** rodar testes novos + `mix test apps/spi_service/test/spi_service/workers/` Expected: PASS, zero regressão.
**Step 5:** commit `feat(pix): RtrId com queima estrutural no ReturnProcessor — colisao regenera, redelivery idempotente, burn na mesma tx do tracking` + push.

### Task 3: `handle_return_request` refeito (fim do trilho fantasma)

**Files:**
- Modify: `pix/backend/apps/settlement_service/lib/settlement_service/workers/core_event_processor.ex:839-926`
- Test: `pix/backend/apps/settlement_service/test/settlement_service/workers/core_event_processor_return_request_test.exs` (novo; seguir o padrão dos testes existentes do processor)

**Step 1: testes RED:**
- (a) feliz: semear pacs.008 INBOUND liquidada (STLD/ACSC) em `monetarie_spi.messages` (e2e `E...`); entregar `return_request` com `original_end_to_end_id`, `amount`, refs do edge (`transaction_id`, `hold_id`, `account_id`, `origin: "client"`); asserts: job outbox para `monetarie.spi.return.created` com `original_amount`/`original_date`/ISPBs derivados DA LINHA (não do payload), `origin=client`, refs preservadas; NENHUMA linha pacs.004 criada aqui; NENHUM publish de `transaction.returned`;
- (b) original inexistente: publica `monetarie.spi.transaction.error` com `transaction_id` do edge e `error_code: "RETURN_ORIGINAL_NOT_FOUND"`; nada mais acontece;
- (c) original não liquidada (PDNG): idem com `RETURN_ORIGINAL_NOT_SETTLED`;
- (d) original OUTBOUND (PIX enviado): idem com `RETURN_ORIGINAL_NOT_INBOUND`.
**Step 2:** rodar, FAIL.
**Step 3: implementação.** Buscar por `end_to_end_id == original_e2e AND message_code == "pacs.008" AND direction == "INBOUND"` com status em [STLD, ACSC] (usar `Shared.Spi.StatusCodes`); derivar `original_amount` da mesma fonte que o `CumulativeCap` lê (`json_input->>'amount'`), `original_date` = `movement_date`, contrapartes da linha. Publicar RETURN_CREATED via `Publisher.publish_async` DENTRO de `Repo.transaction` (ObanRouting resolve a instância). Remover a criação da linha, o `CumulativeCap` local (autoridade fica no ReturnProcessor) e o publish de `transaction.returned`. O payload de `transaction.error` segue o contrato do classificador do incidente 15/07 (ver produtor existente no processor).
**Step 4:** rodar + suite settlement_service inteira. PASS.
**Step 5:** commit `fix(pix)!: devolucao iniciada pelo cliente entra no trilho verdadeiro — handle_return_request deriva a original e publica RETURN_CREATED; fim da pacs.004 fantasma` + push.

### Task 4: desfecho de liquidação visível ao Core (`return.settled`)

**Files:**
- Modify: `pix/backend/apps/spi_service/lib/spi_service/workers/status_updater.ex` (~:225-247 e `return_rejected_event` :505+)
- Test: `pix/backend/apps/spi_service/test/spi_service/workers/status_updater_return_settled_test.exs` (novo)

**Step 1: testes RED:** liquidação (camt/pacs.002 settled) de linha `outbound_return?` publica `monetarie.spi.return.settled` UMA vez (gate CAS; re-entrega não repete) com payload: `return_id`, `original_end_to_end_id`, `amount` (mesmo formato do json_input, igual ao return.rejected), `origin`, `transaction_id`, `hold_id`, `account_id` (extraídos do `json_input` da linha), `settled_at`. `return_rejected_event` ganha os MESMOS campos de origem.
**Step 2:** FAIL.
**Step 3:** implementar: no ramo `outbound_return?` do `handle_settlement`, publicar via outbox dentro da `Repo.transaction` da transição (mesmo padrão do return.rejected do `handle_rejection`).
**Step 4:** PASS + suite do StatusUpdater.
**Step 5:** commit `feat(pix): liquidacao da pacs.004 de saida publica monetarie.spi.return.settled ao Core (antes: suprimida sem desfecho)` + push.

### Task 5: Core pula materialização para `origin=client`

**Files:**
- Modify: `core/backend/lib/monetarie/infra/nats/handlers/pix_handler.ex` (`handle_return`/`handle_return_legacy` :970-1008)
- Test: `core/backend/test/monetarie/infra/nats/handlers/pix_handler_return_origin_test.exs` (novo)

**Step 1: testes RED:** payload com `"origin" => "client"` NÃO cria journal nem transfer TB (asserts nas tabelas/TB) e retorna :ok com audit; payload sem origin mantém o comportamento atual (journal + TB) - teste de caracterização antes de mudar.
**Step 2:** FAIL. **Step 3:** implementar guard no topo de `handle_return_legacy` (e do ramo TbFirst por segurança). **Step 4:** PASS + suite do pix_handler. **Step 5:** commit `feat(core): handle_return pula materializacao para origin=client (debito ja esta no hold do edge)` + push.

### Task 6: Core consome `return.settled` e estende `return.rejected` (desfechos do hold)

**Files:**
- Modify: consumer/roteador que entrega `monetarie.spi.return.rejected` ao `handle_return_rejected` (localizar com `grep -rn "return.rejected" core/backend/lib/monetarie/infra/nats` - adicionar rota irmã para `return.settled`)
- Modify: `core/backend/lib/monetarie/infra/nats/handlers/pix_handler.ex` (`handle_return_rejected` :1040+; novo `handle_return_settled`)
- Test: `core/backend/test/monetarie/infra/nats/handlers/pix_handler_return_outcomes_test.exs` (novo; TB real)

**Step 1: testes RED:**
- settled origin=client: hold do edge é EFETIVADO (post via funil HoldRelease canônico do R1, idempotente), PaymentTransactions -> settled, webhook `pix.refund.completed` com `event_id` dedup por return_id; redelivery não duplica;
- settled origin ausente (cabine): no-op além de log (materialização já feita na criação);
- rejected origin=client: hold LIBERADO (void), PaymentTransactions -> failed com razão BACEN, webhook `pix.refund.failed`; redelivery idempotente;
- rejected origin ausente: estorno da materialização como hoje (caracterização).
**Step 2:** FAIL. **Step 3:** implementar usando o funil HoldRelease existente (NUNCA post/void direto no TB fora do funil). **Step 4:** PASS + suites de webhooks. **Step 5:** commit `feat(core): desfechos terminais da devolucao do cliente — settled efetiva hold + refund.completed; rejected libera hold + refund.failed` + push.

### Task 7: catálogo + `pix.refund.failed` + `pix.payout.returned` no caminho vivo

**Files:**
- Modify: `core/backend/lib/monetarie/schemas/webhooks/webhook.ex:33-49` (adicionar `pix.refund.failed`)
- Modify: `core/backend/lib/monetarie/infra/nats/handlers/pix_handler.ex` (`handle_transaction(payload, "returned")` :209+: dispatch `pix.payout.returned` após crédito, `event_id: "pix.payout.returned:<return_id>"`)
- Test: ampliar testes de webhook catalog + teste do dispatch no returned

**Steps:** RED (catálogo contém o evento novo; devolução recebida dispara payout.returned uma vez) -> FAIL -> implementar -> PASS -> commit `feat(core): pix.refund.failed no catalogo (produtor real) + pix.payout.returned ligado no caminho legado vivo` + push. Atualizar o comentário do `webhook.ex:26-32` (regra: todo evento tem produtor).

### Task 8: Partner refund com semântica validada (fim do refund cego)

**Files:**
- Modify: `core/backend/lib/monetarie_web/controllers/partner_v1/pix_controller.ex:1050-1134`
- Test: `core/backend/test/monetarie_web/controllers/partner_v1/pix_refund_test.exs` (novo ou ampliar existente)

**Step 0 (investigação, sem código):** como o PIX RECEBIDO fica em PaymentTransactions/transactions (grep `record_inbound_settled`, `PIXIN`, `handle_transaction_ledger`) para o lookup do edge; documentar no teste.
**Step 1: testes RED (contrato do design §7):** `:id` inexistente -> 404; `:id` de outra conta -> 404; `:id` de PIX ENVIADO -> 422 "apenas PIX recebido pode ser devolvido"; parcial acima do restante -> 422 com restante em centavos na mensagem; duas parciais fechando o total OK e terceira -> 422; saldo insuficiente -> 422 SEM hold preso; feliz -> 202 com `refundId` (transaction_id próprio), `status: "processing"`, `remainingRefundable`; payload publicado carrega `original_end_to_end_id` REAL + `origin: "client"`.
**Step 2:** FAIL. **Step 3:** implementar: carregar a original escopada; `remaining = original.amount - soma(refunds não-failed por metadata.original_transaction_id)`; hold como hoje; publish com o contrato novo. **Step 4:** PASS + suite partner. **Step 5:** commit `fix(partner)!: refund valida a transacao original (PIX recebido, teto de parciais, conta do titular) e publica origem real` + push.

### Task 9: trilha exposta - `GET /pix/payments/:id/refunds`

**Files:**
- Modify: `core/backend/lib/monetarie_web/router.ex` (scope partner, junto de :169) + `partner_v1/pix_controller.ex` (action `list_refunds`) + OpenAPI operation
- Test: ampliar `pix_refund_test.exs`

**Steps:** RED (lista devoluções da original: `refundId`, `rtrId` quando conhecido via metadata, `amount`, `status`, `reasonCode`, timestamps + `remainingRefundable` + resumo da original; escopo da conta; 404 fora do escopo) -> FAIL -> implementar (serializer no padrão `%{data: ...}` camelCase) -> PASS -> commit `feat(partner): trilha de devolucoes do pagamento (GET /pix/payments/:id/refunds)` + push. Nota: `rtrId` chega ao Core nos eventos return.settled/rejected (Task 6 grava `metadata.return_id`).

### Task 10: IB v2 `return_pix` no contrato novo

**Files:**
- Modify: `core/backend/lib/monetarie_web/controllers/v2/pix_controller.ex:547+`
- Test: teste do controller v2 correspondente

**Steps:** RED (payload publicado carrega `original_end_to_end_id` REAL resolvido da transação original + `origin: "client"`; validações espelhando a Task 8 no que o v2 já expõe) -> FAIL -> implementar -> PASS -> commit `fix(core): devolucao do IB publica a original real e origem cliente (mesmo trilho do partner)` + push.

### Task 11: regressão completa

**Step 1:** `cd pix/backend && mix test` (apps shared + spi_service + settlement_service + dict_service). Expected: 0 falhas novas (comparar com baseline; flakes provar com stash).
**Step 2:** `cd core/backend && mix test` Expected: idem.
**Step 3:** commit de eventuais ajustes + push.

### Task 12: revisão adversarial

Rodar revisão adversarial money-path (agentes independentes tentando REFUTAR: duplo débito origin misto, burn fora da tx, corrida de parciais no cap, redelivery nos desfechos, unidades nas 3 fronteiras, hold zumbi em todo caminho de erro). Consertar o que for confirmado, com teste para cada achado. Commit + push.

### Task 13: fechamento

1. Atualizar o design doc com qualquer desvio (ex.: `refundId` = transaction_id; `rtrId` na trilha).
2. Atualizar CLAUDE.md (estado canônico), MEMORY.md + memória de tópico, Serena e handoff da sessão.
3. Validação viva em HML SÓ após deploy autorizado pelo dono (pix-api + core-api; a migration `20260717150000` do key_type pega carona no deploy do pix-api). Roteiro vivo: receber PIX no recebedor de teste, devolver parcial 2x + total, provar RtrId único nas linhas, teto na 3a parcial, webhooks no receptor externo. Relatório de validação no padrão do cliente (chamada + retorno + erro; recusas em seção própria; responsabilidade do cliente).
