# Migração da abertura de infração DICT para o fluxo vigente (API 2.12.1) - Plano de implementação

> **Para Claude:** SUB-SKILL OBRIGATÓRIA: usar superpowers:executing-plans (ou subagent-driven-development) para implementar tarefa a tarefa, TDD com RED primeiro.

**Objetivo:** fazer a abertura de infração do parceiro (`POST /api/partner/v1/pix/infractions`) voltar a funcionar contra o BACEN vivo, roteando-a pelo fluxo vigente de Recuperação de Valores (MED 2.0), e corrigir o shape 2.11 do nosso cliente de funds-recovery que viola o contrato 2.12.

**Arquitetura:** o contrato do parceiro NÃO muda (rotas, params, respostas). A mudança é no elo cabine: `DictService.Infractions.create_infraction_report_via_bacen` deixa de chamar `POST /infraction-reports/` (morto, 410) e passa a criar/reusar uma Recuperação de Valores (`POST /funds-recoveries/`), persistindo o espelho local de infração vinculado à recovery. O cancel de infração recovery-bound cancela a recovery. O builder de funds-recovery sobe para o shape 2.12 (ContactInformation obrigatória, TrackingGraphParameters dentro dos limites novos).

**Stack:** Elixir umbrella `pix/backend` (apps `dict_service` + `shared`), XML assinado XMLDSig, mTLS ICP-Brasil, testes ExUnit.

---

## Fatos provados (fonte: spec oficial API-DICT 2.12.1 + changelog, extraídos em 2026-07-18)

1. `POST /infraction-reports/` (createInfractionReport) e `PUT /infraction-reports/{id}` estão `deprecated: true` na spec 2.12.1; o changelog registra a marcação na 2.11.0_RC1 (2026-05-04). O homolog já responde `410 Gone "deprecated since 2026-04-13"`. NÃO existe path novo de criar infração: a notificação de infração passou a nascer DENTRO da Recuperação de Valores (`InfractionNotificationReason` = `REFUND_REQUEST` | `REFUND_CANCELLED`).
2. Seguem VIGENTES: `GET /infraction-reports/` (list), `GET /infraction-reports/{id}`, `POST .../acknowledge`, `POST .../cancel`, `POST .../close`, e `GET /funds-recoveries/{id}/infraction-reports`.
3. `POST /funds-recoveries/` (2.12): `Participant` + `FundsRecovery{RootTransactionId, SituationType, ReportDetails?, ContactInformation}` com **ContactInformation OBRIGATÓRIA** (`Email` + `Phone` ambos obrigatórios; Phone E.164 `^\+[0-9]\d{1,14}$`, Email minúsculo). `ReportDetails` obrigatório se `SituationType=OTHER`. `FlowType` está deprecado (só `AUTOMATED`), não enviar. `TrackingGraphParameters` é OPCIONAL com limites: `MaxTransactions` máx **5**, `MaxHops` máx **2**, `HopWindow` máx **PT2H**, `MinTransactionAmount` default **200.00**.
4. `POST /fraud-markers/`: `Participant` + `FraudMarker{TaxIdNumber, FraudType, Key?}` + `RequestId` (UUID v4). Nosso builder já está correto nesse shape.
5. `SituationType` enum: `SCAM | ACCOUNT_TAKEOVER | COERCION | FRAUDULENT_ACCESS | OTHER | UNKNOWN` (mesmo que já usamos).
6. Notificação de infração vinculada a Recuperação de Valores NÃO pode ser cancelada por `POST /infraction-reports/{id}/cancel` (erro documentado); cancela-se a recovery.
7. Erro relevante para reuso: `FundsRecoveryForTransactionAlreadyExists`, "ainda que a anterior tenha sido cancelada" (1 recovery por transação raiz, PARA SEMPRE).

## Defeitos nossos confirmados no código atual

- `render_create_funds_recovery` (`pix/backend/apps/shared/lib/shared/bacen/dict/request_builder.ex:482`) SEMPRE emite `TrackingGraphParameters` com defaults `0 / 500 / PT24H / 5`, todos fora dos limites 2.12 (`200.00 / 5 / PT2H / 2`). Provável causa raiz do `FundsRecoveryInvalid` do MED open de 18/07 (a interpretação "não há liquidação a recuperar" segue possível, mas o shape está errado ANTES disso).
- `ContactInformation` só é emitida se presente (`render_contact_information`), e NENHUM caller threada `contact_email/contact_phone` (`dict_api_responder.ex:191` não os monta): violação do required 2.12.
- `FundsRecovery.create_recovery` (`funds_recovery.ex:71`) injeta os mesmos defaults inválidos (`@default_max_transactions 500`, `@default_hop_window_hours 24`, `@default_max_hops 5`).
- `DictService.Infractions.create_infraction_report_via_bacen` (`infractions.ex:87`) chama o endpoint morto.

## Decisões de design

- **D1:** o partner `POST /pix/infractions` passa a criar/reusar Recuperação de Valores. Convergência: 1 recovery por `root_transaction_id`; se já existe recovery local para o E2E (criada por `/pix/med` ou por infração anterior), a infração nova se VINCULA a ela em vez de criar outra (o BACEN recusa duplicata até de cancelada). `/pix/med` continua como está (mesmo fluxo por baixo).
- **D2:** builder 2.12: `contact_email`/`contact_phone` viram obrigatórios; `TrackingGraphParameters` NÃO é mais emitido por default (o DICT aplica os defaults dele); quando o caller informar parâmetros, validar contra os máximos 2.12 fail-closed com erro legível.
- **D3:** contato institucional vem de env novo da cabine pix: `DICT_MED_CONTACT_EMAIL` e `DICT_MED_CONTACT_PHONE` (runtime.exs), fail-closed em modo `:bacen` se ausentes. Valores reais de HML/PRD = decisão do dono na hora do deploy (NÃO inventar).
- **D4:** `Participant` (criador da recovery) = ISPB da instituição (fonte única de config da cabine), nunca o `debtor_ispb` cru do payload.
- **D5:** cancel de infração: se o espelho local tem `funds_recovery_id`, cancela a recovery (BACEN) e marca o espelho `CANCELLED`; sem recovery (acervo antigo), mantém o cancel de infração direto (endpoint segue vigente).
- **D6:** remover o caminho morto: `DictClient.create_infraction_report`, `RequestBuilder.build_create_infraction_report_xml`, parser e callbacks dos adapters. `DictService.Infractions.create_infraction_report/1` (API pública da cabine) PERMANECE, com a nova implementação. `acknowledge/close/cancel/list/get` de infração permanecem (vigentes).
- **D7:** o evento `monetarie.dict.infractions.reported` e o espelho local `monetarie_dict.infraction_reports` continuam nascendo no create (preserva elo cautelar do core e webhooks). O poller inbound existente segue sincronizando pelo `GET /infraction-reports/` (vigente).

---

### Task 1: Builder funds-recovery no shape 2.12

**Arquivos:**
- Modificar: `pix/backend/apps/shared/lib/shared/bacen/dict/request_builder.ex` (`@funds_recovery_required_fields`:453, `render_create_funds_recovery`:482, `render_contact_information`:869)
- Teste: localizar o teste existente do builder de funds recovery (`grep -rl "build_create_funds_recovery_xml" pix/backend/apps/shared/test`) e evoluir; criar casos novos no mesmo arquivo.

**Passo 1 (RED):** escrever testes que hoje FALHAM:
```elixir
test "exige contact_email e contact_phone" do
  attrs = valid_attrs() |> Map.delete(:contact_email)
  assert {:error, {:missing_field, :contact_email}} =
    RequestBuilder.build_create_funds_recovery_xml(attrs, sign: false)
end

test "nao emite TrackingGraphParameters quando nenhum parametro foi informado" do
  {:ok, xml} = RequestBuilder.build_create_funds_recovery_xml(valid_attrs(), sign: false)
  refute xml =~ "<TrackingGraphParameters>"
  assert xml =~ "<ContactInformation>"
  assert xml =~ "<Email>"
  assert xml =~ "<Phone>"
end

test "valida limites 2.12 dos parametros do grafo" do
  attrs = Map.put(valid_attrs(), :max_transactions, 500)
  assert {:error, {:tracking_graph_param_invalid, :max_transactions}} =
    RequestBuilder.build_create_funds_recovery_xml(attrs, sign: false)
end
```
Cobrir também: `max_hops > 2` inválido, `hop_window` fora de `PT1H/PT2H` inválido, parâmetros válidos emitem o bloco com os valores informados.

**Passo 2:** rodar e ver RED: `cd pix/backend && mix test apps/shared/test/<arquivo> --seed 0`

**Passo 3 (GREEN):** implementar: required += `:contact_email, :contact_phone`; `render_tracking_graph_parameters/1` só emite o bloco se algum dos 4 params presente, com validação prévia (`max_transactions in 1..5`, `max_hops in 1..2`, `hop_window in ["PT1H","PT2H"]`, `min_transaction_amount >= 0`); `ContactInformation` sempre com Email+Phone. Atualizar docstring para 2.12.1.

**Passo 4:** testes do arquivo verdes + suíte do app `shared` sem regressão nos arquivos de dict.

**Passo 5:** commit `fix(dict): builder funds-recovery no shape 2.12 (ContactInformation obrigatoria, TrackingGraphParameters nos limites)`.

### Task 2: `FundsRecovery.create_recovery` sem defaults inválidos + contato institucional

**Arquivos:**
- Modificar: `pix/backend/apps/dict_service/lib/dict_service/funds_recovery.ex` (defaults :34-37 e :82-85, `bacen_create_recovery_payload`:1540, `recovery_contact_information`:1557)
- Modificar: `pix/backend/config/runtime.exs` (+ config test em `config/config.exs` ou `test.exs`): `DICT_MED_CONTACT_EMAIL`/`DICT_MED_CONTACT_PHONE`
- Teste: testes do contexto funds_recovery (localizar por `grep -rl "create_recovery" pix/backend/apps/dict_service/test`)

**Passos (TDD igual à Task 1):**
- RED: `create_recovery` em modo bacen SEM contato configurado retorna erro legível `{:error, :med_contact_not_configured}`; COM contato configurado, o payload enviado ao adapter contém `contactInformation` com email/phone do config; attrs SEM parâmetros de grafo não enviam `trackingGraphParameters`; parâmetro acima do limite retorna erro legível ANTES de chamar o BACEN.
- GREEN: remover a injeção de defaults (:82-85); mesclar contato do config quando o caller não informar; validar limites 2.12 no create (espelho da validação do builder, para o erro chegar legível ao parceiro como 400).
- Atualizar comentários 2.11.0 para 2.12.1. Commit.

### Task 3: Re-rotear `create_infraction_report_via_bacen` para Recuperação de Valores

**Arquivos:**
- Modificar: `pix/backend/apps/dict_service/lib/dict_service/infractions.ex` (:87-108, payload :841+)
- Modificar (se preciso): `pix/backend/apps/dict_service/lib/dict_service/funds_recovery.ex` (+ `get_recovery_by_root_transaction_id/1` se não existir)
- Teste: `pix/backend/apps/dict_service/test/dict_service/nats/dict_api_responder_infraction_write_test.exs`, `.../infractions_bacen_payload_test.exs` (será reformado), novo teste do re-roteio.

**Passo 1 (RED):** testes novos:
```elixir
test "create via bacen cria recovery e vincula o espelho local" do
  # adapter mockado: create_funds_recovery -> {:ok, %{"FundsRecovery" => %{"Id" => rec_id, "Status" => "CREATED"}}}
  # NAO deve haver chamada a create_infraction_report no adapter
  {:ok, report} = Infractions.create_infraction_report(attrs_com_situation_type)
  assert report.funds_recovery_id
  assert report.status == "OPEN"
end

test "reusa recovery local existente para o mesmo root_transaction_id" do
  # recovery pre-existente para o E2E: adapter NAO recebe create_funds_recovery de novo
end

test "sem situation_type continua fail-closed" do
  assert {:error, {:missing_field, :situation_type}} = ...
end
```

**Passo 3 (GREEN):**
- `create_infraction_report_via_bacen/1`: resolver recovery (attrs.funds_recovery_id || lookup local por `root_transaction_id == end_to_end_id` || `FundsRecovery.create_recovery(%{creator_ispb: <ispb institucional via config da cabine>, root_transaction_id: e2e, fraud_category: situation_type, report_details: report_details || fraud_marker_message})`), depois `insert_infraction_report_and_publish` com `funds_recovery_id` preenchido. Erros do BACEN propagam legíveis (reusar `bacen_error_reason`). O ledger de operação externa passa a ser o da recovery (o fluxo `create_recovery_via_bacen` já registra o seu); remover o `DictExternalOperations.start(:create_infraction_report, ...)`.
- Preservar o comportamento local-mode (`insert_local_infraction_report`) intacto.

**Passo 5:** suíte `dict_service` dos arquivos de infração + responder verdes. Commit.

### Task 4: Cancel de infração recovery-bound cancela a recovery

**Arquivos:**
- Modificar: `pix/backend/apps/dict_service/lib/dict_service/infractions.ex` (`cancel_report`)
- Teste: arquivo de testes do cancel (localizar por `grep -rn "cancel_report" pix/backend/apps/dict_service/test`)

TDD: RED com espelho `funds_recovery_id` preenchido, cancel deve chamar o cancel da RECOVERY no adapter (e não o cancel de infração) e marcar o espelho `CANCELLED`; espelho sem recovery mantém o caminho antigo. GREEN, suíte, commit.

### Task 5: Remover o caminho morto do create/update de infração

**Arquivos:**
- Modificar: `pix/backend/apps/shared/lib/shared/bacen/dict_client.ex` (remover `create_infraction_report/2`:693, `infraction_attrs_from_params/1`:728; atualizar moduledoc :22)
- Modificar: `pix/backend/apps/shared/lib/shared/bacen/dict/request_builder.ex` (remover `build_create_infraction_report_xml`:281 + render :844)
- Modificar: `pix/backend/apps/shared/lib/shared/bacen/dict/response_parser.ex` (remover parser do create)
- Modificar: `pix/backend/apps/dict_service/lib/dict_service/bacen_adapter.ex` (callback :26/:124-126) + `bacen_adapter/real.ex:47` + adapter simulador
- Modificar: `pix/backend/apps/dict_service/lib/dict_service/infractions.ex` (remover `bacen_create_infraction_payload` se ficar órfão)
- Testes: remover/reformar `request_builder_infractions_test.exs` (só o create), `dict_client_infractions_test.exs` (só o create), `response_parser_infractions_test.exs` (só o create), `infractions_bacen_payload_test.exs`

Antes de remover: `grep -rn "create_infraction_report\|build_create_infraction_report" pix/backend/apps --include="*.ex"` e confirmar que só os pontos acima referenciam. `DictService.Infractions.create_infraction_report/1` PERMANECE. Compilar sem warning, suítes verdes, commit.

### Task 6: Regressão completa

- `cd pix/backend && mix test apps/dict_service` e `mix test apps/shared`: ZERO falhas novas (comparar com baseline da main se houver flake conhecido).
- Core (contrato preservado): `cd core/backend && mix test test/monetarie_web/controllers/partner_v1/infractions_partner_test.exs test/monetarie_web/controllers/partner_v1/infraction_situation_type_test.exs test/monetarie_web/controllers/partner_v1/infraction_defense_test.exs`: verdes SEM mudança de código no core (se precisar mudar core, PARAR e reavaliar o design).
- Commit final de código + atualizar docstrings restantes.

### Task 7: Validação viva em HML (gated: deploy só com OK do dono)

Pré-condições: OK do dono para deploy HML do pix-api (DADO em 18/07 ~21h BRT); valores reais decididos pelo dono em 18/07: `DICT_MED_CONTACT_EMAIL=compliance@monetarie.com` e `DICT_MED_CONTACT_PHONE=+5548991411426` (entram na task-def do pix-api HML no deploy); túnel SSM; partner key de campanha `cli_cea592a4f005d96561af1a3e`.

Escada de prova honesta (BACEN é a verdade):
1. `POST /pix/infractions` do parceiro sobre transação conhecida: provar que o 410 SUMIU (a requisição agora vai a `POST /funds-recoveries/`).
2. Se o BACEN responder erro de negócio (ex.: `FundsRecoveryTransactionNotEligible` para raiz rejeitada), isso PROVA shape aceito + endpoint vigente: registrar como recusa de proteção, não defeito.
3. Melhor esforço para raiz LIQUIDADA: verificar se existe PIX-out ACSP em HML; se não houver, tentar lookup-to-pay numa chave DICT de participante homolog que liquide; documentar honestamente o degrau alcançado.
4. Reprovar também o MED open (`POST /pix/med`): com o shape 2.12 o `FundsRecoveryInvalid` deve mudar de figura (sumir ou virar erro de elegibilidade legível).
5. Conferir webhook/espelho: infração aparece na listagem do parceiro vinculada; inbox do dono se houver evento.

REGRAS: nunca deployar durante operação de money-path do dono; PRD só com OK explícito.

MIGRATION NOVA DA FRENTE (aplicar via rpc ANTES do swap, HML e depois PRD): `pix/backend/apps/shared/priv/repo/migrations/20260718200000_add_uq_infraction_report_tx.exs` (índice único parcial `uq_infraction_report_tx` em `monetarie_dict.infraction_reports(funds_recovery_id, end_to_end_id) WHERE funds_recovery_id IS NOT NULL`; aditiva, IF NOT EXISTS, segura para acervo).

### Task 8: Fecho documental

- Relatório interno curto em `docs/reports/` com a prova (chamadas + respostas BACEN).
- Atualizar `CLAUDE.md` (estado canônico) + memória persistente + Serena live-state.
- Follow-up registrado: expor marcação de fraude (`/fraud-markers/`) na Partner API é OUTRA frente (hoje só admin da cabine), decisão de produto do dono.
