# Design: devolução de PIX iniciada pelo cliente (pacs.008 recebida -> pacs.004) no trilho verdadeiro

Data: 2026-07-17 (noite). Status: VALIDADO com o dono (decisão de débito: hold no edge + skip na materialização).
Escopo: item 5 da frente do fluxo do cliente (refund da Partner API + devolução do IB), base da revisão dos itens 1/3 (MED usa o mesmo trilho de pacs.004).

## 1. Mandato do dono

Observar na exatidão o fluxo de devolução de uma pacs.008 para gerar a pacs.004 sem nenhum problema, com:
- trilha de devolução completa;
- devoluções parciais calculadas concatenando com a pacs.008 original recebida;
- pacs.004 com e2e correto iniciado por D (RtrId);
- proibição estrutural de duas devoluções (parciais ou não) com o mesmo RtrId.

## 2. O fluxo observado (verdade do código em 17815a90/6592a859)

### 2.1 Trilho verdadeiro (cabine, maduro)

Entrada: evento `RETURN_CREATED` no subject `monetarie.spi.return.created`.
Publicadores hoje: auto-devolução inbound (`inbound_processor.ex:778`, códigos AC03/AC06/AC07/AC14/AG03), MED AGREED FR01 (`infraction_response_consumer.ex:162`), gateway admin (`POST /transactions/:transaction_id/return`, `spi_proxy_controller.ex` via `SpiService.Transactions.create_return` -> `EventPublisher.publish_return_created`, `transactions.ex:321`).

Consumidores:
- Core `PixHandler.handle_return` (`pix_handler.ex:970`): MATERIALIZA o débito do cliente (journal COSIF + transfer TB + fee não bloqueante). Desenhado para devoluções iniciadas pela cabine.
- Cabine `ReturnProcessor` (`return_processor.ex`, filter `monetarie.spi.return.>`):
  1. `validate_return_eligibility` (valor > 0, referência original obrigatória) :100-113;
  2. `SpiValidator.validate_outbound_return` (razão, janela por `original_date`, teto) :90-98 e `spi_validator.ex:216`;
  3. `ensure_return_id` :121-133 - RtrId conforme `^D[0-9A-Z]{8}[0-9]{12}[a-zA-Z0-9]{11}$` (:37), regenerado por `MessageBuilder.generate_rtr_id/1` (`message_builder.ex:1664`, fonte única, espelho do generate_e2e_id);
  4. `check_and_block_balance` :136-168 - bloqueio de saldo PI (debit-then-send), chave = RtrId;
  5. `queue_outbound_return` :170-248 - numa MESMA `Repo.transaction`: `CumulativeCap.guard` (advisory lock, soma pacs.004 OUTBOUND não rejeitadas do e2e original, fail-closed) + `upsert_return_tracking` (linha própria em `monetarie_spi.messages`: `unique_id = RtrId`, `end_to_end_id = e2e ORIGINAL` para a timeline concatenar, `return_id = RtrId`, `operation_time` determinístico f(RtrId) + índice único parcial, ON CONFLICT DO NOTHING) + outbox `monetarie.spi.outbound.return` com `xml_content` da pacs.004 (RtrId, OrgnlEndToEndId, valor parcial, razão, contrapartes invertidas);
  6. `OutboundSender` envia ao BACEN (marca ACSP + confirma bloqueio no ACK; falha -> `return_not_sent`, `outbound_sender.ex:415`).

Desfechos: pacs.002 da devolução no `StatusUpdater` - linhas de devolução de saída têm eventos de pagamento SUPRIMIDOS (`outbound_return?` :505); RJCT publica `monetarie.spi.return.rejected` (:456-470) e o Core estorna a materialização por RtrId com dedup (`handle_return_rejected`, `pix_handler.ex:1040`).

Devolução RECEBIDA (contraparte devolve PIX que NÓS enviamos): `inbound_processor.ex:1167+` (aceite pacs.002; crédito SÓ na liquidação camt.054 BOOK :1521-1564) -> `monetarie.spi.transaction.returned` -> Core `handle_transaction(payload, "returned")` (`pix_handler.ex:209`): credita o pagador original com teto (`validate_return_ceiling`) e dedup por RtrId. Webhook `pix.payout.returned` HOJE só dispara no caminho TbFirst (flag proibida) - gap.

### 2.2 Trilho fantasma (cliente-iniciado) - DEFEITO GRAVE

IB `v2/pix_controller.ex:547` (`return_pix`) e Partner `partner_v1/pix_controller.ex:1050` (`refund`): seguram hold no Core, criam `PaymentTransactions`, publicam `monetarie.core.pix.return` com `event=return_request` -> `CoreEventProcessor.handle_return_request` (`core_event_processor.ex:841-926`): cria linha pacs.004 PDNG SEM RtrId e SEM XML (e2e = original), aplica CumulativeCap, publica `monetarie.spi.transaction.returned` (`TRANSACTION_RETURNED`).

Esse subject não aciona envio nenhum: `StatusUpdater` não tem cláusula para `TRANSACTION_RETURNED` (catch-all :122 ignora) e o Core trata como audit-only (payload não INBOUND, `pix_handler.ex:230`). PROVADO: devolução iniciada pelo cliente NUNCA chega ao BACEN; o hold fica preso e a linha PDNG eterna. Agravante Partner: o refund é cego (não carrega a original; `end_to_end_id = :id` da URL ecoado).

### 2.3 Peças mortas/aproveitáveis

- `monetarie_spi.return_id_registry` existe desde o baseline (squash `20260101000000`; schema `Shared.Schemas.Spi.IdRegistry`, PK `return_id`, max 35, campo `ispb`) e tem ZERO chamadores. Vira a queima estrutural do RtrId.
- `Shared.E2eBurn` (`e2e_burn.ex`) é o padrão a espelhar: ON CONFLICT DO NOTHING + checagem de num_rows, opção `:repo` para participar do rollback da transação envolvente (lição do item B: outbox cross-repo).

## 3. Decisões

1. UM único emissor de pacs.004: o `ReturnProcessor`. O trilho fantasma morre.
2. Débito do cliente na devolução iniciada por ele: HOLD NO EDGE + SKIP na materialização do Core (decisão do dono). O `handle_return` pula journal/TB quando `origin=client` (o débito já está no hold do edge); rejeição libera o hold; liquidação efetiva o débito. Devoluções iniciadas pela cabine (auto/MED/admin) seguem materializando como hoje.
3. A verdade da ORIGINAL é da cabine: `handle_return_request` refeito busca a pacs.008 INBOUND liquidada em `monetarie_spi.messages` e deriva `original_amount`/`original_date`/contrapartes DELA (nunca do payload do chamador). Fail-closed se ausente/não liquidada.
4. Validação em DUAS camadas: edge (Partner/v2) valida contra as transações do Core (existência, conta do chamador, direção inbound, restante devolvível) para 422 imediato e legível; cabine revalida autoritativamente (original + janela + CumulativeCap). Nenhuma camada confia na outra.
5. RtrId: nasce no `ReturnProcessor` (`ensure_return_id`), com QUEIMA ESTRUTURAL no `return_id_registry` dentro da MESMA transação da linha de tracking; colisão regenera e re-tenta (loop curto). Nenhum RtrId repetido, por decisão do banco.
6. Webhooks: `pix.refund.requested` (existente) no aceite do pedido; `pix.refund.completed` (produtor existente em `pix_handler.ex:407-410`, cobrir o caminho novo); `pix.refund.failed` NOVO no catálogo com produtor real (return.rejected de origem cliente); `pix.payout.returned` ligado no caminho legado vivo do `handle_transaction("returned")`.

## 4. Arquitetura alvo (fluxo cliente-iniciado)

```
Partner POST /pix/payments/:id/refund   IB POST .../pix/return
        |  valida original no Core (conta, inbound, restante) + hold
        v
monetarie.core.pix.return (event=return_request, origin=client,
                           original_end_to_end_id REAL, refs do edge)
        v
CoreEventProcessor.handle_return_request (REFEITO)
  - busca pacs.008 INBOUND STLD em monetarie_spi.messages (fail-closed)
  - deriva original_amount/original_date/ISPBs da original
  - outbox na mesma tx: RETURN_CREATED -> monetarie.spi.return.created
    (sem linha fantasma, sem transaction.returned)
        v
ReturnProcessor (trilho existente)
  - SpiValidator + ensure_return_id + BURN return_id_registry (mesma tx)
  - bloqueio PI + CumulativeCap + linha própria + pacs.004 XML
        v
OutboundSender -> BACEN
        v
pacs.002:  ACSP/liquidada -> débito efetivado no Core + pix.refund.completed
           RJCT -> return.rejected -> Core: origin=client libera hold +
                  pix.refund.failed; origin=cabine estorna materialização
Core PixHandler.handle_return: origin=client -> SKIP materialização
```

Trilha exposta: `GET /pix/payments/:id/refunds` (Partner) lista as devoluções da original (RtrId, valor, status, timestamps, restante devolvível); a resposta do POST devolve `refundId` (RtrId) + `remainingRefundable`. Telas leem a mesma trilha (timeline por e2e original já concatena).

## 5. Regras de dinheiro (fail-safe)

- Parciais: teto autoritativo = `CumulativeCap` na cabine (soma não rejeitadas, advisory lock, fail-closed). Edge calcula restante para UX; a cabine decide.
- Idempotência: redelivery de `RETURN_CREATED` reusa a linha por RtrId (upsert existente); burn no mesmo tx impede RtrId duplicado entre devoluções DISTINTAS; dedup do Core por RtrId nos desfechos (já existe em rejected).
- Nada de estado intermediário eterno: todo pedido tem desfecho terminal (enviado+liquidado, rejeitado com hold liberado, ou recusado sincronamente no edge). Sem hold zumbi (classe do incidente 15/07).
- Unidades: Partner fala CENTAVOS, Core opera base_units (`MoneyUnit.from_cents`, mesmo contrato do send_pix); cabine publica reais no fio como hoje. Conferir a tabela do MoneyUnit em cada fronteira.

## 6. Tratamento de erros

- Edge: 404 (:id inexistente/de outra conta), 422 (não é PIX recebido; acima do restante; janela vencida; saldo insuficiente), erros de cabine via `cabin_error/cabin_app_error` como hoje.
- Cabine: original ausente/não liquidada -> rejeita ao Core com razão legível (evento de erro no subject de resposta do fluxo, não DLQ silenciosa); falha de infra -> NAK/retry padrão do worker.
- `pix.refund.failed` carrega a razão BACEN real (reason_code + descrição).

## 7. Testes (TDD, RED primeiro; dinheiro com TigerBeetle real)

Edge (core): :id inexistente 404; :id de PIX ENVIADO 422; :id de outra conta 404; parcial acima do restante 422 com restante na mensagem; duas parciais fechando o total OK e terceira 422; saldo insuficiente 422 sem hold preso.
Processor (cabine): original inexistente fail-closed; RETURN_CREATED origin=client com derivação da original; redelivery idempotente (1 linha, 1 burn); colisão de RtrId regenera (forçada por seed no registry); CumulativeCap contra corrida (duas parciais concorrentes somando acima do teto: uma aborta).
Core desfechos: handle_return skip para origin=client (zero journal/TB) e materialização intacta para origin=cabine; return.rejected origin=client libera hold + webhook failed; liquidação efetiva débito + webhook completed; pix.payout.returned no caminho legado vivo.
Regressão: suites spi_service/settlement_service/shared + partner controller tests.

## 8. Revisão adversarial (2026-07-17, 3 lentes independentes) — achados CONFIRMADOS e correções aplicadas

Lentes: dinheiro, concorrência/idempotência, contratos/skew de deploy. O mecanismo central resistiu
(corrida do claim fechada por construção no Postgres, cap sob advisory serializado, unidades das
fronteiras conferidas produtor a produtor, dedup do edge). Corrigido na mesma rodada, com teste para cada:

1. CRITICA (C1): StaleHoldChecker resolvia DEVOLUÇÃO com timeout cego aos 30min (hold devolvido com a
   pacs.004 ainda passível de liquidar via replay de DLQ/reenvio; perda silenciosa da instituição).
   Fix: devolução NUNCA sofre timeout cego; quarentena barulhenta (log ERROR + telemetria
   monetarie.refund.hold_stale) e o desfecho vem sempre da cabine.
2. ALTA (A2/A1): falha terminal no ReturnProcessor (razão inválida, janela, teto, esgotamento) não
   respondia ao Core. Fix: origin=client publica return.rejected com referências do edge (o Core libera
   o hold + pix.refund.failed) e ACKa; infra transitória segue em retry; esgotamento também responde.
3. ALTA (A3): tracking best-effort quebrava o exatamente-uma (linha ausente => redelivery virava
   SEGUNDA pacs.004). Fix: tracking fail-loud (rollback) + conflict_target explícito no unique do
   return_id (colisão de (e2e, operation_time) vira erro barulhento, nunca engolida — A6).
4. ALTA (A4/A2-money): identidade fraca engolia devolução legítima como replay (linha REJEITADA contava
   como redelivery; gerador do gateway emitia RtrId de 29 chars; auto-return/MED/admin sem
   transaction_id). Fix: replay exige linha NÃO-rejeitada + transaction_id casando; os 3 produtores
   carimbam identidade forte; gerador do gateway delega ao MessageBuilder (32 chars).
5. MEDIA (A5): settled/rejected das linhas de devolução por guard em memória (corrida com
   max_concurrency 50). Fix: transição CAS dedicada (settle_outbound_return_line /
   reject_outbound_return_line) — o perdedor morre no WHERE do UPDATE.
6. MEDIA (M2): minuto do RtrId vinha do e2e ORIGINAL (até 89 dias; partição errada). Fix: initiated_at
   propagado do pedido.
7. MEDIA (M3): CumulativeCap tratava acervo de unidade mista como centavos. Fix: a FORMA decide a
   unidade (decimal = reais x100; inteiro = centavos).
8. MEDIA (M1): refund do partner não liberava o hold no rollback (órfão invisível). Fix: release no
   erro (idioma do v2), injetável para teste.
9. MEDIA: edge sem validação de razão/janela (202 que morria determinístico na cabine). Fix: whitelist
   BACEN de 13 códigos + janela de 90 dias no fetch_refundable_original (fonte única partner+IB).
10. ALTA (skew-5): parcial do IB enviava base_units onde o backend lê CENTAVOS (100x, e o trilho agora
    é real). Fix: PixRefundView em centavos ponta a ponta.

MANDATOS DE DEPLOY (obrigatórios): deployar core-api ANTES de pix-api (o core velho manda
return.settled para a MATERIALIZAÇÃO via catch-all — COSIF fantasma 100x; com origin=client, débito
real); ROLLBACK do core proibido com devoluções origin=client em voo. Follow-ups registrados na task
list (sweeper de blocks PI órfãos; materialização cabine-origem sem conta; COSIF misto no swap; docs).

## 9. Fora do escopo deste design

MED/infrações (itens 1/3 reutilizam o trilho e o burn; design próprio), devolução de TED (STR0010, item 6), telas de devolução além do necessário para não quebrar o IB (o `return_pix` v2 muda só o payload publicado), TbFirst/two-phase (flag segue proibida).
