# W1-F1 — Partner API Fase A: infrações, claims, MED 2.0, defesas, devolução de TED e eventos de conta

> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

**Goal:** Fechar a Fase A do fluxo do cliente na Partner API (itens 1/2/3/4/6/17 e defeitos 2/3/4/8/9/10 da auditoria `docs/reports/2026-07-17-auditoria-fluxo-cliente-partner-api-telas.md`), com TDD sobre a forma real provada por sonda (G1), sem tocar em telas (F2) nem em docs/portal (F4).

**Architecture:** Toda rota nova da Partner API entra no scope `/api/partner/v1` do router do Core (pipeline `[:api, :partner_authenticated, :idempotent]`, que já herda PermissionEnforcer + rate limit), delega às pontes existentes (`Provider` -> `InHouse.Adapter` -> NATS req/reply `dict.api.request` para DICT/claims/MED; use cases do Core para TED) e serializa em camelCase com CENTAVOS na borda. Na cabine PIX, o `dict_api_responder` ganha o dispatch de ESCRITA de infrações e de cancel/refund do MED (hoje caem no catch-all "Unknown action") e o router do dict_service ganha o `POST /api/v2/infraction-reports` que o Core já chama (404 hoje). Na devolução de TED, o motor STR0010 do SPB E o consumer Core de `monetarie.spb.credits.return_status` JÁ EXISTEM (sonda corrige a auditoria); o trabalho é o trilho origin=client no Core (hold TigerBeetle -> devolution_request -> capture/release no desfecho) + rota partner + webhooks `ted.refund.*` + migration formal + conferência de documento no matching do crédito.

**Tech Stack:** Elixir/Phoenix (core/backend, pix/backend umbrella, spb/services/bacen_gateway), Ecto/Postgres, NATS JetStream, Oban, TigerBeetle (via `Wallet` / `Monetarie.UseCases.Transaction`), OpenApiSpex, ExUnit.

---

## Regras duras da frente (herdam do plano mestre)

- **G1**: nenhuma task começa sem o bloco CONTRATO PROVADO (todos preenchidos com evidência de 2026-07-18, HEAD `6be6fd5c`; se o arquivo divergir na execução, PARE e re-sonde). Onde restou lacuna pontual, a task traz "PROVAR NA EXECUÇÃO" como Step 0, com a sonda exata.
- **G2**: o teste RED nasce do payload/forma provada. Fixture inventada de caminho feliz reprova a task.
- **G3**: task fecha com teste focado verde + commit; a FRENTE fecha só com a Task 19 (validação viva HML como parceiro OAuth).
- **Unidades** (`core/backend/lib/monetarie/util/money_unit.ex` L16-24): Partner API HTTP = CENTAVOS (`to_cents/1` saída, `from_cents/1` entrada); webhooks recebem payload em base units e a camada converte no despacho; `transactions`/`account_entries` = SUBCENTAVOS; `spb_inbound_credits.amount_cents` = CENTAVOS; cabine = BRL.
- **Webhooks**: evento novo SÓ entra no catálogo `@valid_events` (`core/backend/lib/monetarie/schemas/webhooks/webhook.ex` L33-49) no MESMO commit do produtor real ("Only events with a real dispatcher may be announced"). Padrão = `Monetarie.UseCases.Webhooks.dispatch_event(evento, payload_com_account_id, event_id: "...")` com `rescue` fail-soft.
- **Migrations**: aditivas/idempotentes (`IF NOT EXISTS`), arquivo no MESMO commit do código que as usa.
- **Flags**: `SPB_AUTO_RETURN_ENABLED` já existe (Core `runtime.exs` L28, default `"false"`; task-def HML spb:68 sem env `*RETURN*` = OFF). NENHUMA task liga flag.
- **Ambientes**: tasks NÃO deployam nem escrevem em HML/PRD; sondas de execução são SELECT com LIMIT. Gotcha: túnel SSM em `localhost:15432` sombreia o Postgres de teste do Core (rodar `lsof -i :15432` antes de `mix test` no core).
- **Commits**: um por task, sem travessão em texto pt-br.

## Arquivos de conflito potencial com outras frentes (merge pelo orquestrador)

- `core/backend/lib/monetarie_web/router.ex`: F1 (scope partner) x F2 (rotas v2/merchant). Blocos distintos, mesmo arquivo.
- `core/backend/lib/monetarie_web/controllers/v2/merchant_portal_controller.ex`: F1 NÃO toca (a defesa do lojista sai pela Partner API; a rota de defesa no merchant portal fica para F2 reusar o use case da Task 12).
- `core/backend/lib/monetarie/schemas/webhooks/webhook.ex`: F1 adiciona 7 eventos; F4 documenta paridade.
- `docs/postman/*` e `docs/partner-portal/*`: F1 não toca; F4 documenta as rotas novas.

---

## CONTRATO PROVADO — síntese global (sondas de 2026-07-18, HEAD `6be6fd5c`)

1. **Partner router**: scope autenticado em `router.ex` L133-224; pipeline L134; `:partner_authenticated` = PartnerBearerAuth -> PermissionEnforcer -> RateLimiterPerKey (L81-85). Rotas literais antes de `/:id`.
2. **PermissionEnforcer** (`plugs/permission_enforcer.ex` L17-85): lista FLAT `{"MÉTODO", ~r"..."}, "escopo",`; regras `^/api/partner/v1/pix` por PREFIXO (L59-63) cobrem TODA sub-rota `/pix/*` nova (GET=pix:read, POST=pix:write). `/ted/*` e `/accounts/:id/events` PRECISAM de entrada nova (a regra de accounts L54 é ancorada com `$`). Primeira regra que casa vence; rota não mapeada = allow.
3. **Provider/Adapter (Core)**: `provider.ex` já declara infrações (L81-85: create/get/list/close/cancel), MED (L91-98: create/get/list_recoveries/get_recovery_graph/list_med_notifications/list_med_returns/request_med_return/cancel_recovery) e claims (L114-118: create/get/confirm/cancel/list; SEM `complete_claim`). Adapter publica por `nats_request("dict.api.request", %{action: ..., entity_id: ..., ...})` síncrono 10s (adapter.ex L479-496). Actions MED do adapter (provadas): `"cancel_recovery"` (id), `"request_med_return"` (data), `"get_recovery_graph"` (id), `"list_recoveries"` (data), `"list_med_returns"`, `"list_med_notifications"`.
4. **Responder da cabine** (`pix/backend/apps/dict_service/lib/dict_service/nats/dict_api_responder.ex`): dispatch existente = claims list/get/create/acknowledge/confirm/complete/cancel (L96-164), MED list_recoveries L166, create_recovery L191, get_recovery L227/L241, get_tracking_graph L264, infrações SÓ list L291 e get L314, keys L330+, catch-all "Unknown action" L485-487. Formato de reply: `%{"status" => "ok", "data" => ...}` | `%{"status" => "not_found"}` | `%{"status" => "error", "error" => msg}`, com `rescue`. `serialize_infraction/1` devolve `id, status, funds_recovery_id, end_to_end_id, debtor_ispb, creditor_ispb, transaction_amount (string decimal em reais), analysis_result, analysis_details, created_at, updated_at`. `serialize_claim/1` devolve `claimer_account`/`donor_account` (usados pelo escopo do Core).
5. **Contextos da cabine**: `infractions.ex` `create_infraction_report(attrs)` L69 (attrs: `funds_recovery_id, end_to_end_id, debtor_ispb, creditor_ispb, transaction_amount` em REAIS, exemplo do moduledoc `1000.00`; roteia BACEN-first por `BacenAdapter.external_mode()`), `acknowledge_report/2` L184, `analyse_report/2` L288, `close_report(report_id, reason)` L345, `cancel_report(report_id, reason)` L447, `list_reports(opts)` L547, `get_report/1` L786. `claims.ex` `complete_claim(claim_id, requester_ispb)` L224 (valida claimer + prazo + posse OTP), `list_claims(opts)` L341, `@claim_actions ~w(ACKNOWLEDGE CONFIRM CANCEL COMPLETE CONTEST)` L34 (CONTEST = único hit no app, ZERO lifecycle). `funds_recovery.ex` `cancel_recovery(recovery_id, reason, cancelled_by_ispb)` L462, `create_refund_request(recovery_id, attrs)` L229, `complete_refund(refund_id, attrs)` L266, `list_recoveries/1` L573.
6. **Router do dict_service** (`dict_service_web/router.ex`): infraction-reports = GET index/show + PUT acknowledge/analyse/close (L263-267), SEM POST. Refunds: `put "/refund-requests/:id/complete"` (bloco L268-273) EXISTE. `infraction_report_controller.ex` tem index/list_by_recovery/show/acknowledge/analyse/close (L14-131), SEM `create`.
7. **URLs do Core -> cabine dict** (`settlement_service/.../core_event_processor.ex`): `handle_infraction_create` L1507-1550, `POST #{dict_url}/api/v2/infraction-reports` L1525 (rota inexistente = 404, defeito 2), body `funds_recovery_id, end_to_end_id, debtor_ispb, creditor_ispb, transaction_amount, fraud_marker_message`; `handle_recovery_complete_refund` L1461-1503, `PUT #{dict_url}/api/v2/funds-recoveries/refunds/#{refund_id}/complete` L1478 (rota inexistente = 404, defeito 3; a rota real é `/api/v2/refund-requests/:id/complete`). HTTP = Finch `Shared.Finch`, retry 3x (L1695-1702), base = env `DICT_SERVICE_URL` default `http://localhost:4001` (L1716-1717).
8. **Máquina MED do SPI (corrige a auditoria)**: `spi_service/workers/supervisor.ex` L26/L31/L32 já supervisiona InfractionResponder + ResolutionConsumer + CancellationConsumer (PARTE B). `CautelarWorker` e `TimerEnforcer` são **Oban workers** (fila `:med`, `pix/backend/config/config.exs` L72) agendados no cron L79 (`*/5` TimerEnforcer) e L85 (`*/15` CautelarWorker), e PROVADOS RODANDO em HML: sonda `oban_jobs` via rpc em 2026-07-18 10:45 UTC = CautelarWorker `completed` 548x, TimerEnforcer `completed` 1881x, `attempted_at` do próprio minuto. O que falta é guarda de regressão (Task 11). `Trck002Handler` removido na PARTE B (zero referências no fonte).
9. **Webhooks (padrão)**: `Monetarie.UseCases.Webhooks.dispatch_event/3` (`use_cases/webhooks.ex` L157) exige `account_id` no payload (senão `{:error, :missing_tenant_scope}` L179-190) e converte base units -> centavos no despacho (L222-225). Produtores modelo: `dispatch_payout_webhook` (`pix_handler.ex` L552-588), `dispatch_lifecycle_webhook` (`partner_v1/pix_controller.ex` L980-1000, emite `pix.refund.requested` L1122), `dispatch_infraction_webhook` (`use_cases/med/processor.ex` L609-635, usa `block.account_id`), `dispatch_ted_received_webhook` (`use_cases/spb/inbound_credits.ex` L454: `"amount" => row.amount_cents * 100`, `event_id: "ted.received:#{row.num_ctrl_str}"`). Catálogo atual (webhook.ex L33-49): NÃO tem `pix.med.*`, `ted.refund.*` nem `pix.infraction.defense_submitted`.
10. **Devolução de TED (corrige a auditoria)**: consumer Core EXISTE: `infra/nats/consumers/spb_consumer.ex` L38 + L74-76 -> `SpbInboundCreditHandler.handle_return_status` (`handlers/spb_inbound_credit_handler.ex` L82-89) -> `InboundCredits.apply_return_outcome/3` (L1016 "returned", L1068 "return_failed", L1105 catch-all). `request_return/3` L880 só aceita `@returnable_statuses ~w(suspense_pending return_failed)` L856 (ZERO dinheiro no core); publica `devolution_request` no subject `monetarie.core.spb.devolution_request` L858 com payload flat L974-990 (`event, source, credit_id, num_ctrl_str, operation_id, message_type, amount_brl, debtor_ispb, dt_movto, reason_code, requested_by`). Motivos: `@return_reason_codes` numéricos, default "70" (L860-866, `validate_reason` L894-900).
11. **SPB (motor STR0010)**: `devolution_engine.ex` `build_and_send_return(message)` L409+ lê `message["num_ctrl_str"]`, `message["reason_code"]` (numérico), `message["message_type"]` opcional; resolve original por `control_number_clearing` em `spb_operations` (L616-645: `direction='inbound'`), valida `receiver_ispb == nosso`, valor, `sender_ispb` (L647-661; SEM conferência de CPF/nome, essa conferência é do lado Core), idempotência L667-693, valor INTEGRAL, e SEMPRE publica desfecho `publish_return_status/3` L558-583: payload `{"event": "return_status", "source": "spb", "num_ctrl_str", "outcome" ("returned"|"return_failed"), "published_at"}` + extras (`spb_num_ctrl` | `error_code`/`error_message`) via outbox no subject `monetarie.spb.credits.return_status` (L368-369). Consumer SPB: `core_event_consumer.ex` L23 (`monetarie.core.spb.>`) com `handle_event("devolution_request", message)` L217-233 (só usa `num_ctrl_str`/`reason_code`; chaves extras são ignoradas).
12. **Colunas runtime (defeito 9)**: `ensure_runtime_columns` L896-932 executa 6 `ALTER TABLE spb_operations ADD COLUMN IF NOT EXISTS` (`original_operation_id UUID, reason_code VARCHAR(20), notes TEXT, created_by TEXT, sit_lanc_str VARCHAR(10), num_ctrl_str VARCHAR(50)`) + `CREATE INDEX IF NOT EXISTS spb_operations_original_operation_id_idx` (L915). **Sonda viva HML** (rpc `BacenGateway.Repo`, 2026-07-18): as 6 colunas EXISTEM em `spb_operations` (`created_by text, notes text, num_ctrl_str varchar, original_operation_id uuid, reason_code varchar, sit_lanc_str varchar`). Última migration SPB: `priv/repo/migrations/20260714213000_*`.
13. **Matching do crédito (Core)**: `resolve_member_account(account_number, cpf_cnpj)` L150-158 = conta ativa por NÚMERO (3 formas, L167-186) com fallback por DOCUMENTO (L192-202, `users.tax_id`); quando casa por número NÃO confere o documento do favorecido (a lacuna "matching sem conferir CPF/nome").
14. **Money funnel do crédito**: `post_member_credit` L288-315 (TB-first `wallet_mod().deposit(tb_tx_id, account_id, MoneyUnit.from_cents(row.amount_cents))`) + Multi `AccountEntry` (subcentavos, positivo = crédito) + `PaymentTransactions.create` `"SPBCR"<>num_ctrl_str` + `create_member_cosif_je` fail-soft + `mark_processed -> credited_member` (L317-406). Hold de parceiro: `FundHold = Monetarie.UseCases.Transaction` com `hold_funds/3` L50, `release_funds/2` L82, `collect_funds/1` L149; pool = `AccountCode.settlement_pool()` (pix_controller L1069); seams de teste `:partner_pix_balance_fun`/`:partner_pix_hold_fun` (setup do `pix_controller_test.exs` L227-263).
15. **Defesa admin**: `POST /api/admin/infractions/:id/defense` (router L2525, scope `/api/admin` com EnsureAdmin+MFA L2315-2316); `admin/infractions_controller.ex` `def defense` L115-127 -> `submit_defense(%MedCautelarBlock{status: "active"}, defense_text, actor_id)` L291+ (grava `defense_text`/`defense_submitted_at` em `evidence_metadata`, `analysis_status: "defense_submitted"`).
16. **`GET /accounts/:id/events`**: rota inexistente; NÃO há tabela `account_events`. Fonte real filtrável por conta = `audit_logs` (`schemas/audit/audit_log.ex` L42-54: `action, resource_type, resource_id, actor_type, actor_id, changes, metadata, ip_address, inserted_at`). `webhook_deliveries` NÃO tem coluna de conta (account só dentro do payload JSONB). Padrão de escopo por path `:id`: `with_partner_account/3` do `accounts_controller.ex` L414-433 (PartnerScope.authorize_user).
17. **Testes**: partner em `core/backend/test/monetarie_web/controllers/partner_v1/` (auth real via `provision_partner_key/1` + `auth_conn/1` + `partner_bearer/2` de `test/support/conn_case.ex` L185-204; StubProvider por `Application.put_env(:monetarie, :partner_pix_provider, StubProvider)`). Cabine: `pix/backend/apps/dict_service/test/dict_service/nats/dict_api_responder_test.exs` e `dict_api_responder_med_write_test.exs` (padrão de entrypoint a copiar). SPB: `spb/services/bacen_gateway/test/bacen_gateway/devolution/{build_and_send_return_test.exs,devolution_engine_test.exs}`. Core SPB: `test/monetarie/use_cases/spb/{inbound_credits_test.exs,inbound_credit_returns_test.exs}` e `test/monetarie/infra/nats/handlers/spb_return_status_handler_test.exs`.

---

## Etapa A — Infrações DICT (item 1; defeitos 2 e 8)

### Task 1: dispatch de ESCRITA de infrações no dict_api_responder

**CONTRATO PROVADO:** síntese itens 4 e 5. O adapter do Core publica `%{action: "create_infraction", entity_id: ..., data: params}` (adapter.ex L265-267), `%{action: "close_infraction", id: ...}` (L288-290) e `%{action: "cancel_infraction", id: ...}` (L293-295); o responder NÃO tem essas clauses e responde `%{"status" => "error", "error" => "Unknown action: create_infraction"}` (L485-487). Assinaturas de destino: `Infractions.create_infraction_report(attrs)` L69, `close_report(report_id, reason)` L345, `cancel_report(report_id, reason)` L447.

**Files:**
- Modify: `pix/backend/apps/dict_service/lib/dict_service/nats/dict_api_responder.ex` (novas clauses ANTES do catch-all L485)
- Test: Create `pix/backend/apps/dict_service/test/dict_service/nats/dict_api_responder_infraction_write_test.exs`

**Step 0 (sonda local):** `sed -n '1,60p' pix/backend/apps/dict_service/test/dict_service/nats/dict_api_responder_med_write_test.exs` para capturar o entrypoint usado pelos testes (como o teste invoca o dispatch) e `sed -n '345,360p' pix/backend/apps/dict_service/lib/dict_service/infractions.ex` para confirmar a semântica do `reason` do `close_report` (AnalysisResult AGREED/DISAGREED). Copiar o MESMO entrypoint no teste novo.

**Step 1: Write the failing test** (forma real do fio, payload idêntico ao do adapter):

```elixir
defmodule DictService.Nats.DictApiResponderInfractionWriteTest do
  use DictService.DataCase, async: false
  # mesmo entrypoint do dict_api_responder_med_write_test.exs (Step 0)

  test "create_infraction cria o report e devolve status ok com data serializada" do
    params = %{
      "action" => "create_infraction",
      "entity_id" => "1",
      "data" => %{
        "end_to_end_id" => "E4602656220260718" <> String.duplicate("1", 15),
        "debtor_ispb" => "46026562",
        "creditor_ispb" => "00038166",
        "transaction_amount" => "100.00",
        "fraud_marker_message" => "relato do cliente"
      }
    }

    reply = invoke_responder(params)

    assert %{"status" => "ok", "data" => data} = reply
    assert data["status"] == "OPEN"
    assert data["end_to_end_id"] == params["data"]["end_to_end_id"]
  end

  test "cancel_infraction em report existente devolve ok; id desconhecido devolve error" do
    # cria via create_infraction, depois cancela por id
  end

  test "close_infraction repassa o reason (analysis_result)" do
    # cria e fecha com %{"action" => "close_infraction", "id" => id, "reason" => "AGREED"}
  end
end
```

**Step 2: Run test to verify it fails**

Run: `cd /Users/luizpenha/monetarie/pix/backend/apps/dict_service && mix test test/dict_service/nats/dict_api_responder_infraction_write_test.exs`
Expected: FAIL com reply `%{"status" => "error", "error" => "Unknown action: create_infraction"}`.

**Step 3: Write minimal implementation** (antes do catch-all; mesmo shape das clauses vizinhas, `rescue` incluso):

```elixir
  # ============================================================================
  # Infractions — ESCRITA (antes só list/get; o CREATE era órfão: defeito 8)
  # ============================================================================

  defp dispatch("create_infraction", params) do
    data = params["data"] || %{}

    attrs = %{
      funds_recovery_id: data["funds_recovery_id"],
      end_to_end_id: data["end_to_end_id"],
      debtor_ispb: data["debtor_ispb"],
      creditor_ispb: data["creditor_ispb"],
      transaction_amount: data["transaction_amount"],
      fraud_marker_message: data["fraud_marker_message"]
    }

    case DictService.Infractions.create_infraction_report(attrs) do
      {:ok, report} -> %{"status" => "ok", "data" => serialize_infraction(report)}
      {:error, %Ecto.Changeset{} = cs} -> %{"status" => "error", "error" => inspect(cs.errors)}
      {:error, reason} -> %{"status" => "error", "error" => to_string(reason)}
    end
  rescue
    e -> %{"status" => "error", "error" => Exception.message(e)}
  end

  defp dispatch("close_infraction", params) do
    reason = params["reason"] || (params["data"] || %{})["analysis_result"] || "AGREED"

    case DictService.Infractions.close_report(params["id"] || params["report_id"], reason) do
      {:ok, report} -> %{"status" => "ok", "data" => serialize_infraction(report)}
      {:error, :not_found} -> %{"status" => "not_found"}
      {:error, reason} -> %{"status" => "error", "error" => to_string(reason)}
    end
  rescue
    e -> %{"status" => "error", "error" => Exception.message(e)}
  end

  defp dispatch("cancel_infraction", params) do
    reason = params["reason"] || (params["data"] || %{})["reason"] || "cancelled_by_reporter"

    case DictService.Infractions.cancel_report(params["id"] || params["report_id"], reason) do
      {:ok, report} -> %{"status" => "ok", "data" => serialize_infraction(report)}
      {:error, :not_found} -> %{"status" => "not_found"}
      {:error, reason} -> %{"status" => "error", "error" => to_string(reason)}
    end
  rescue
    e -> %{"status" => "error", "error" => Exception.message(e)}
  end
```

Ajustar os retornos de erro ao que `create_infraction_report/close_report/cancel_report` REALMENTE devolvem (o teste RED confirma).

**Step 4: Run test to verify it passes**

Run: mesmo comando do Step 2. Expected: PASS. Regressão: `mix test test/dict_service/nats/`.

**Step 5: Commit**

```bash
git add -A && git commit -m "feat(pix): dispatch de escrita de infracoes no dict_api_responder (create/close/cancel) - defeito 8 da auditoria"
```

### Task 2: `POST /api/v2/infraction-reports` no dict_service (fix do 404 do Core)

**CONTRATO PROVADO:** síntese itens 6 e 7. O Core chama `POST #{DICT_SERVICE_URL}/api/v2/infraction-reports` (core_event_processor.ex L1525) com body JSON `funds_recovery_id, end_to_end_id, debtor_ispb, creditor_ispb, transaction_amount, fraud_marker_message` e espera status 200/201 (L1531). O router do dict_service (L263-267) não tem POST; o controller não tem `def create`.

**Files:**
- Modify: `pix/backend/apps/dict_service/lib/dict_service_web/router.ex` (1 linha no bloco de infraction-reports)
- Modify: `pix/backend/apps/dict_service/lib/dict_service_web/controllers/infraction_report_controller.ex` (action `create`)
- Test: Create `pix/backend/apps/dict_service/test/dict_service_web/controllers/infraction_report_controller_test.exs`

**Step 1: Write the failing test** (body EXATO do caller provado):

```elixir
defmodule DictServiceWeb.InfractionReportControllerTest do
  use DictServiceWeb.ConnCase, async: false

  test "POST /api/v2/infraction-reports cria o report (contrato do CoreEventProcessor)", %{conn: conn} do
    body = %{
      "funds_recovery_id" => nil,
      "end_to_end_id" => "E4602656220260718" <> String.duplicate("2", 15),
      "debtor_ispb" => "46026562",
      "creditor_ispb" => "00038166",
      "transaction_amount" => "52.74",
      "fraud_marker_message" => "cliente reporta fraude"
    }

    conn = post(conn, "/api/v2/infraction-reports", body)

    assert %{"id" => _, "status" => "OPEN"} = json_response(conn, 201)
  end

  test "POST sem end_to_end_id devolve 422", %{conn: conn} do
    conn = post(conn, "/api/v2/infraction-reports", %{"debtor_ispb" => "46026562"})
    assert json_response(conn, 422)
  end
end
```

**Step 2:** Run: `cd /Users/luizpenha/monetarie/pix/backend/apps/dict_service && mix test test/dict_service_web/controllers/infraction_report_controller_test.exs`
Expected: FAIL 404 (Phoenix.Router.NoRouteError).

**Step 3: Write minimal implementation.** Router (linha antes do `get "/infraction-reports"` L263):

```elixir
    post "/infraction-reports", InfractionReportController, :create
```

Controller (reusar o serializer que `show` já usa; não inventar serializer novo):

```elixir
  def create(conn, params) do
    attrs = %{
      funds_recovery_id: params["funds_recovery_id"],
      end_to_end_id: params["end_to_end_id"],
      debtor_ispb: params["debtor_ispb"],
      creditor_ispb: params["creditor_ispb"],
      transaction_amount: params["transaction_amount"],
      fraud_marker_message: params["fraud_marker_message"]
    }

    case Infractions.create_infraction_report(attrs) do
      {:ok, report} ->
        conn |> put_status(:created) |> json(serialize_report(report))

      {:error, reason} ->
        conn |> put_status(:unprocessable_entity) |> json(%{error: format_error(reason)})
    end
  end
```

**Step 4:** Run: mesmo comando. Expected: PASS. Regressão: `mix test test/dict_service_web/controllers/`.

**Step 5: Commit**

```bash
git add -A && git commit -m "fix(pix): POST /api/v2/infraction-reports no dict_service - mata o 404 do handle_infraction_create do Core (defeito 2)"
```

### Task 3: rotas de infração na Partner API

**CONTRATO PROVADO:** síntese itens 1, 2, 3 e 4. Provider pronto (L81-85); regras `/pix` do PermissionEnforcer cobrem as rotas novas SEM entrada nova; padrão de controller = `create_med`/`show_med` (`partner_v1/pix_controller.ex` L408-465) com `with_partner_account` + `ensure_account_transaction(account, e2e)` (L468-486) + `cabin_error/cabin_app_error` (L2226-2254). Serializer da cabine devolve os campos do item 4 da síntese. Webhooks `pix.infraction.created|resolved` JÁ existem no catálogo e são despachados pelo `Med.Processor`.

**Files:**
- Modify: `core/backend/lib/monetarie_web/router.ex` (4 rotas no scope partner, junto do bloco `/pix/med` L188)
- Modify: `core/backend/lib/monetarie_web/controllers/partner_v1/pix_controller.ex` (4 actions + serializer + operations OpenAPI)
- Test: Create `core/backend/test/monetarie_web/controllers/partner_v1/infractions_partner_test.exs`

**Step 0 (PROVAR NA EXECUÇÃO, HML read-only):** forma real do metadata de contraparte de um PIX da conta (para `debtor_ispb`/`creditor_ispb` do create). Sonda:

```
awsmon ecs execute-command --cluster monetarie-greenfield-homolog \
  --task <arn de list-tasks core-api> --container core-api --interactive \
  --command "bin/monetarie rpc \"Monetarie.Repo.query!(~s[SELECT metadata FROM transactions WHERE type='pix' AND direction='inbound' ORDER BY started_at DESC LIMIT 1]).rows |> IO.inspect()\""
```

Anotar a chave real do ISPB da contraparte (esperado: `payer_ispb` ou equivalente; para outbound conferir a chave do recebedor). Usar EXATAMENTE essas chaves; se ausentes, enviar o nosso lado com `Config.institution_ispb()` e OMITIR a contraparte (validar no teste da Task 1 que attrs parciais não explodem).

**Step 1: Write the failing test** (StubProvider do setup padrão; respostas no formato REAL da cabine):

```elixir
defmodule MonetarieWeb.PartnerV1.InfractionsPartnerTest do
  use MonetarieWeb.ConnCase, async: false
  # setup identico ao pix_controller_test.exs (StubProvider + provision_partner_key + open_customer_account)

  test "POST /pix/infractions abre infracao para e2e da conta e devolve camelCase" do
    # inserir transacao inbound da conta com end_to_end_id e2e
    # StubProvider.create_infraction -> {:ok, %{"status" => "ok", "data" => %{"id" => "...",
    #   "status" => "OPEN", "end_to_end_id" => e2e, "transaction_amount" => "100.00", ...}}}
    conn = auth_conn(key) |> post("/api/partner/v1/pix/infractions", %{
      "accountId" => account_id, "endToEndId" => e2e, "details" => "fraude relatada"
    })
    assert %{"data" => %{"infractionId" => _, "status" => "OPEN", "endToEndId" => ^e2e}} =
             json_response(conn, 202)
  end

  test "POST /pix/infractions com e2e de OUTRA conta devolve 422 (transacao nao encontrada)"
  test "GET /pix/infractions lista filtrando por transacoes da conta"
  test "GET /pix/infractions/:id de e2e alheio devolve 404"
  test "POST /pix/infractions/:id/cancel devolve 200"
end
```

**Step 2:** Run: `cd /Users/luizpenha/monetarie/core/backend && mix test test/monetarie_web/controllers/partner_v1/infractions_partner_test.exs`
Expected: FAIL 404 (rota inexistente).

**Step 3: Write minimal implementation.** Router:

```elixir
    # Infracoes DICT relatadas pelo parceiro (item 1 Fase A)
    post "/pix/infractions", PixController, :create_infraction
    get "/pix/infractions", PixController, :list_infractions
    get "/pix/infractions/:id", PixController, :show_infraction
    post "/pix/infractions/:id/cancel", PixController, :cancel_infraction
```

Controller (padrão create_med; `transaction_amount` da cabine em REAIS, converter do subcentavo com `MoneyUnit.to_brl/1`):

```elixir
  def create_infraction(conn, params) do
    with_partner_account(conn, account_id_param(params), fn account ->
      with {:ok, e2e} <- require_param(params, ["end_to_end_id", "endToEndId"]),
           {:ok, tx} <- fetch_account_transaction(account, e2e) do
        {debtor_ispb, creditor_ispb} = infraction_ispbs(tx)

        case provider().create_infraction(account.entity_id, %{
               "end_to_end_id" => e2e,
               "debtor_ispb" => debtor_ispb,
               "creditor_ispb" => creditor_ispb,
               "transaction_amount" => MoneyUnit.to_brl(tx.amount),
               "fraud_marker_message" => to_string(params["details"] || "")
             }) do
          {:ok, %{"status" => "error"} = result} -> cabin_app_error(conn, result, "abrir infração DICT")
          {:ok, result} -> conn |> put_status(:accepted) |> json(%{data: infraction_payload(result)})
          {:error, reason} -> cabin_error(conn, reason, "abrir infração DICT")
        end
      else
        {:error, :missing_param} -> bad_request(conn, "Parâmetros obrigatórios: accountId, endToEndId")
        {:error, :transaction_not_found} -> unprocessable(conn, "Transação não encontrada para esta conta")
      end
    end)
  end
```

`fetch_account_transaction/2` = variação do `ensure_account_transaction` devolvendo a transação (mesma query L468-486, `Repo.one`). `infraction_ispbs/1` decide por `tx.direction` com a chave do Step 0. `infraction_payload/1`:

```elixir
  defp infraction_payload(%{"data" => data}), do: infraction_payload(data)

  defp infraction_payload(data) when is_map(data) do
    %{
      infractionId: data["id"],
      status: data["status"],
      endToEndId: data["end_to_end_id"],
      debtorIspb: data["debtor_ispb"],
      creditorIspb: data["creditor_ispb"],
      analysisResult: data["analysis_result"],
      analysisDetails: data["analysis_details"],
      createdAt: data["created_at"],
      updatedAt: data["updated_at"]
    }
  end
```

`list_infractions`: `provider().list_infractions(account.entity_id, %{"limit" => ..., "offset" => ...})` + pós-filtro `ensure_account_transaction(account, item["end_to_end_id"]) == :ok`. `show_infraction`: `provider().get_infraction` + mesmo gate (alheio = `not_found`, NUNCA 403 que vaza existência). `cancel_infraction`: gate + `provider().cancel_infraction`. Declarar `operation(:create_infraction, ...)` etc. no padrão OpenApiSpex das actions vizinhas (L92, L572).

**Step 4:** Run: mesmo comando; Expected: PASS. Regressão: `mix test test/monetarie_web/controllers/partner_v1/`.

**Step 5: Commit**

```bash
git add -A && git commit -m "feat(partner): rotas de infracao DICT (create/list/show/cancel) escopadas por transacao da conta (item 1 Fase A)"
```

---

## Etapa B — Claims (item 2)

### Task 4: `GET /pix/claims` (listagem)

**CONTRATO PROVADO:** síntese itens 3 e 4. `Provider.list_claims` (provider.ex L118) e adapter (`%{action: "list_claims", entity_id: ..., data: filters}` L423) EXISTEM; responder despacha `list_claims` (L96-120, filtros `claimer_ispb, donor_ispb, status, claim_type, role, limit, offset`; reply `%{"claims" => [...], "total" => n}`); `serialize_claim` carrega `claimer_account`/`donor_account`; o escopo local `claim_belongs_to_account?/2` (pix_controller L562-565) compara com `account.account_number`. Só falta rota + action.

**Files:**
- Modify: `core/backend/lib/monetarie_web/router.ex` (rota literal `get "/pix/claims"` ANTES de `post "/pix/claims"` L181)
- Modify: `core/backend/lib/monetarie_web/controllers/partner_v1/pix_controller.ex`
- Test: Modify `core/backend/test/monetarie_web/controllers/partner_v1/pix_controller_test.exs` (describe novo)

**Step 1: Write the failing test**

```elixir
  describe "GET /pix/claims" do
    test "lista apenas claims em que a conta e claimer ou donor" do
      # StubProvider.list_claims -> {:ok, %{"status" => "ok", "data" => %{"claims" => [
      #   %{"id" => "c1", "claimer_account" => account_number, "status" => "OPEN"},
      #   %{"id" => "c2", "claimer_account" => "999999", "donor_account" => "888888"}
      # ], "total" => 2}}}
      conn = auth_conn(key) |> get("/api/partner/v1/pix/claims?accountId=#{account_id}")
      assert %{"data" => %{"claims" => [%{"id" => "c1"}], "total" => 1}} = json_response(conn, 200)
    end
  end
```

**Step 2:** Run: `mix test test/monetarie_web/controllers/partner_v1/pix_controller_test.exs:<linha do teste novo>`. Expected: FAIL 404.

**Step 3: Write minimal implementation.** Router: `get "/pix/claims", PixController, :list_claims_partner`. Action:

```elixir
  def list_claims_partner(conn, params) do
    with_partner_account(conn, account_id_param(params), fn account ->
      filters = %{
        "status" => params["status"],
        "limit" => params["limit"] || 50,
        "offset" => params["offset"] || 0
      }

      case provider().list_claims(account.entity_id, filters) do
        {:ok, %{"status" => "error"} = r} -> cabin_app_error(conn, r, "listar reivindicações")
        {:ok, result} ->
          claims =
            result
            |> extract_claims_list()
            |> Enum.filter(&claim_belongs_to_account?(&1, account))

          json(conn, %{data: %{claims: Enum.map(claims, &claim_payload/1), total: length(claims)}})
        {:error, reason} -> cabin_error(conn, reason, "listar reivindicações")
      end
    end)
  end
```

`extract_claims_list/1` desembrulha `%{"data" => %{"claims" => list}}` e variantes. Declarar `operation(:list_claims_partner, ...)`.

**Step 4:** Run: PASS + regressão do arquivo.

**Step 5: Commit**

```bash
git add -A && git commit -m "feat(partner): GET /pix/claims escopado por claimer/donor da conta (item 2 Fase A)"
```

### Task 5: `POST /pix/claims/:id/complete`

**CONTRATO PROVADO:** síntese itens 3, 4 e 5. A cabine JÁ despacha `complete_claim` (responder L151 -> `Claims.complete_claim(claim_id, requester_ispb)` L224, validações claimer/prazo/OTP). O Provider do Core NÃO tem `complete_claim` (L114-118) nem o adapter/behaviour. Padrão de action = `confirm_claim` (pix_controller L510-520) com `scoped_claim/4` (L539-560).

**Files:**
- Modify: `core/backend/lib/monetarie/services/pix_providers/behaviour.ex` (callback novo, mesmo shape de `confirm_claim`)
- Modify: `core/backend/lib/monetarie/services/pix_providers/provider.ex` (`def complete_claim(entity_id, id), do: dispatch(entity_id, :complete_claim, [id])`)
- Modify: `core/backend/lib/monetarie/services/pix_providers/in_house/adapter.ex` (`nats_request("dict.api.request", %{action: "complete_claim", id: id, entity_id: entity_id})`)
- Modify: `core/backend/lib/monetarie_web/router.ex` (`post "/pix/claims/:id/complete", PixController, :complete_claim`)
- Modify: `core/backend/lib/monetarie_web/controllers/partner_v1/pix_controller.ex` (action clone de `confirm_claim` trocando o verbo)
- Test: Modify `pix_controller_test.exs` (describe novo, StubProvider gravando `{:complete_claim, entity_id, id}`)

**Step 1:** teste RED (404 na rota). **Step 2:** Run: FAIL. **Step 3:** os 5 arquivos acima (diffs de 3-8 linhas; copiar `confirm_claim` em cada camada e renomear). **Step 4:** PASS + `mix test test/monetarie_web/controllers/partner_v1/pix_controller_test.exs`.

**Step 5: Commit**

```bash
git add -A && git commit -m "feat(partner): POST /pix/claims/:id/complete pela ponte dict.api.request (cabine ja validava claimer/prazo/posse)"
```

### Task 6: CONTEST: remover a ficção do catálogo de ações de claim

**CONTRATO PROVADO:** síntese item 5. `@claim_actions ~w(ACKNOWLEDGE CONFIRM CANCEL COMPLETE CONTEST)` (claims.ex L34) é o ÚNICO hit de CONTEST no app dict_service; não há lifecycle, dispatch nem função no `dict_client.ex`. Mesma classe da ficção do trck002_handler removida na PARTE B. As operações reais de claim vivas são acknowledge/confirm/cancel/complete.

**Files:**
- Modify: `pix/backend/apps/dict_service/lib/dict_service/claims.ex` (L34)
- Test: localizar cobertura com `grep -rn "claim_actions" pix/backend/apps/dict_service/test`; se não houver, criar assert no teste de claims

**Step 0 (guarda):** `grep -rn "contest" /Users/luizpenha/monetarie/pix/backend/apps/shared/lib/shared/bacen/dict_client.ex` (esperado: vazio) e conferir o material DICT v2.11 de referência em `/Users/luizpenha/cecresa/md` se disponível. Se existir operação CONTEST REAL no manual, PARAR e re-escopar a task para implementar em vez de remover.

**Step 1:** teste RED: `assert DictService.Claims.claim_actions() == ~w(ACKNOWLEDGE CONFIRM CANCEL COMPLETE)`. **Step 2:** FAIL (lista atual tem CONTEST). **Step 3:** remover `CONTEST` de `@claim_actions`. **Step 4:** PASS + `mix test test/dict_service/` do módulo de claims sem regressão.

**Step 5: Commit**

```bash
git add -A && git commit -m "fix(pix): remove CONTEST fantasma do catalogo de acoes de claim (zero lifecycle, mesma classe do trck002)"
```

---

## Etapa C — MED 2.0 (item 3; defeitos 3 e 4)

### Task 7: fix da URL do `handle_recovery_complete_refund` (404 garantido)

**CONTRATO PROVADO:** síntese item 7. Caller monta `PUT #{dict_url}/api/v2/funds-recoveries/refunds/#{refund_id}/complete` (core_event_processor.ex L1478, comentário L1473-1474 admite "no dedicated endpoint"); a rota REAL do dict_service é `put "/refund-requests/:id/complete", RefundController, :complete` (router do dict_service, bloco L268-273). Fix no CALLER.

**Files:**
- Modify: `pix/backend/apps/settlement_service/lib/settlement_service/workers/core_event_processor.ex` (L1478 + helper público puro)
- Test: Create `pix/backend/apps/settlement_service/test/settlement_service/workers/core_event_processor_refund_url_test.exs`

**Step 1: Write the failing test**

```elixir
defmodule SettlementService.Workers.CoreEventProcessorRefundUrlTest do
  use ExUnit.Case, async: true

  test "refund complete aponta para a rota REAL do dict_service" do
    assert SettlementService.Workers.CoreEventProcessor.refund_complete_path("abc-123") ==
             "/api/v2/refund-requests/abc-123/complete"
  end
end
```

**Step 2:** Run: `cd /Users/luizpenha/monetarie/pix/backend/apps/settlement_service && mix test test/settlement_service/workers/core_event_processor_refund_url_test.exs`
Expected: FAIL (undefined function).

**Step 3: Write minimal implementation**

```elixir
  @doc "Path REAL do complete de refund no dict_service (a rota antiga /funds-recoveries/refunds/... nunca existiu: 404)."
  def refund_complete_path(refund_id), do: "/api/v2/refund-requests/#{refund_id}/complete"
```

e em `handle_recovery_complete_refund` (L1475-1481): `Finch.build(:put, dict_url <> refund_complete_path(refund_id), ...)`.

**Step 4:** PASS + `mix test test/settlement_service/workers/` (regressão).

**Step 5: Commit**

```bash
git add -A && git commit -m "fix(pix): recovery_complete_refund usa a rota real /api/v2/refund-requests/:id/complete (defeito 3, 404 garantido antes)"
```

### Task 8: dispatch MED faltante no responder (cancel_recovery, request_med_return, get_recovery_graph)

**CONTRATO PROVADO:** síntese itens 3, 4 e 5. Adapter publica `"cancel_recovery"` (id), `"request_med_return"` (data), `"get_recovery_graph"` (id); responder só tem `get_tracking_graph` L264 (nome diferente = catch-all para o Core), e nada de cancel/refund. Destinos: `FundsRecovery.cancel_recovery(recovery_id, reason, cancelled_by_ispb)` L462 e `create_refund_request(recovery_id, attrs)` L229.

**Files:**
- Modify: `pix/backend/apps/dict_service/lib/dict_service/nats/dict_api_responder.ex`
- Test: Modify `pix/backend/apps/dict_service/test/dict_service/nats/dict_api_responder_med_write_test.exs` (describes novos)

**Step 0:** `sed -n '229,266p' pix/backend/apps/dict_service/lib/dict_service/funds_recovery.ex` para confirmar as chaves de attrs do `create_refund_request` (o RED nasce delas) e o retorno de `cancel_recovery`.

**Step 1: Write the failing test** (3 casos: `%{"action" => "cancel_recovery", "id" => id, "reason" => "..."}` devolve ok; `%{"action" => "get_recovery_graph", "id" => id}` devolve o MESMO shape do `get_tracking_graph`; `%{"action" => "request_med_return", "data" => %{"recovery_id" => id, ...attrs do Step 0...}}` devolve ok).

**Step 2:** Run: FAIL com "Unknown action".

**Step 3: Write minimal implementation** (antes do catch-all):

```elixir
  defp dispatch("get_recovery_graph", %{"id" => recovery_id}),
    do: dispatch("get_tracking_graph", %{"recovery_id" => recovery_id})

  defp dispatch("cancel_recovery", params) do
    reason = params["reason"] || (params["data"] || %{})["reason"] || "cancelled_by_creator"

    case DictService.FundsRecovery.cancel_recovery(params["id"], reason, our_ispb()) do
      {:ok, recovery} -> %{"status" => "ok", "data" => serialize_recovery(recovery)}
      {:error, :not_found} -> %{"status" => "not_found"}
      {:error, reason} -> %{"status" => "error", "error" => to_string(reason)}
    end
  rescue
    e -> %{"status" => "error", "error" => Exception.message(e)}
  end

  defp dispatch("request_med_return", params) do
    data = params["data"] || %{}

    case DictService.FundsRecovery.create_refund_request(data["recovery_id"], data) do
      {:ok, refund} -> %{"status" => "ok", "data" => serialize_refund_request(refund)}
      {:error, reason} -> %{"status" => "error", "error" => to_string(reason)}
    end
  rescue
    e -> %{"status" => "error", "error" => Exception.message(e)}
  end
```

Reusar `serialize_recovery` existente; criar `serialize_refund_request` mínimo se não existir. `our_ispb()`: reusar o helper de ISPB que o responder/contexto já usa (grep no arquivo).

**Step 4:** PASS + `mix test test/dict_service/nats/`.

**Step 5: Commit**

```bash
git add -A && git commit -m "feat(pix): dispatch MED cancel_recovery/request_med_return/get_recovery_graph no dict_api_responder (antes: Unknown action)"
```

### Task 9: rotas MED na Partner API (list/cancel/graph/refund)

**CONTRATO PROVADO:** síntese itens 1, 2, 3. Provider pronto (L91-98); regras `/pix` do enforcer cobrem; padrão de escopo = `show_med` (L442-465: `med_payload` + `ensure_account_transaction(account, payload[:rootTransactionId])`, alheio = 404).

**Files:**
- Modify: `core/backend/lib/monetarie_web/router.ex`:

```elixir
    get "/pix/med", PixController, :list_med
    post "/pix/med/:id/cancel", PixController, :cancel_med
    get "/pix/med/:id/graph", PixController, :med_graph
    post "/pix/med/:id/refund", PixController, :request_med_refund
```

(`get "/pix/med"` literal ANTES de `get "/pix/med/:id"` L189.)
- Modify: `core/backend/lib/monetarie_web/controllers/partner_v1/pix_controller.ex` (4 actions + operations)
- Test: Modify `core/backend/test/monetarie_web/controllers/partner_v1/med_partner_test.exs`

**Step 1: Write the failing test** (4 describes; StubProvider devolve recoveries com `root_transaction_id` da conta e de conta alheia; asserts: list filtra, cancel/graph/refund de recovery alheia = 404).

**Step 2:** Run: `mix test test/monetarie_web/controllers/partner_v1/med_partner_test.exs`. Expected: FAIL 404 de rota.

**Step 3: Write minimal implementation**

```elixir
  def list_med(conn, params) do
    with_partner_account(conn, account_id_param(params), fn account ->
      case provider().list_recoveries(account.entity_id, %{"limit" => params["limit"] || 50}) do
        {:ok, %{"status" => "error"} = r} -> cabin_app_error(conn, r, "listar intervenções MED")
        {:ok, result} ->
          meds =
            result
            |> extract_recoveries_list()
            |> Enum.map(&med_payload/1)
            |> Enum.filter(&(ensure_account_transaction(account, &1[:rootTransactionId]) == :ok))

          json(conn, %{data: %{recoveries: meds, total: length(meds)}})
        {:error, reason} -> cabin_error(conn, reason, "listar intervenções MED")
      end
    end)
  end
```

`cancel_med`/`med_graph`/`request_med_refund`: mesmo gate do `show_med` (buscar via `provider().get_recovery`, `ensure_account_transaction`; alheio = `not_found`), depois `provider().cancel_recovery(entity_id, id)` / `provider().get_recovery_graph(entity_id, id)` (graph = passthrough em `data`) / `provider().request_med_return(entity_id, %{"recovery_id" => id, ...attrs do Step 0 da Task 8...})`.

**Step 4:** PASS + `mix test test/monetarie_web/controllers/partner_v1/med_partner_test.exs`.

**Step 5: Commit**

```bash
git add -A && git commit -m "feat(partner): rotas MED list/cancel/graph/refund escopadas pela transacao raiz da conta (item 3 Fase A)"
```

### Task 10: webhooks `pix.med.*` (catálogo + produtores reais no mesmo commit)

**CONTRATO PROVADO:** síntese item 9. Catálogo em webhook.ex L33-49 SEM `pix.med.*`. Produtores com `account_id` disponível: (a) partner `create_med`/`cancel_med` (conta validada no request); (b) `Med.Processor.handle_recovery_update` (`use_cases/med/processor.ex` L179-203: branch `REFUND_COMPLETED`/`COMPLETED` chama `Med.mark_transferred(block)`; `block.account_id` existe e já é usado pelo `dispatch_infraction_webhook` L609-635, o padrão fail-soft a copiar).

**Files:**
- Modify: `core/backend/lib/monetarie/schemas/webhooks/webhook.ex` (adicionar `pix.med.created pix.med.completed pix.med.cancelled` ao `@valid_events`)
- Modify: `core/backend/lib/monetarie_web/controllers/partner_v1/pix_controller.ex` (dispatch nos aceites de create_med e cancel_med)
- Modify: `core/backend/lib/monetarie/use_cases/med/processor.ex` (dispatch em handle_recovery_update: completed; branch novo para "CANCELLED")
- Test: Create `core/backend/test/monetarie/use_cases/med/processor_med_webhooks_test.exs` + asserts de Delivery nos testes partner da Task 9

**Step 1: Write the failing test** (padrão do `account_events_test.exs`: `Webhooks.create_webhook_for_api_key/2` inscrevendo `pix.med.completed`, chamar `Med.Processor.handle_recovery_update(%{"recovery_id" => ..., "status" => "COMPLETED"})` com block semeado com `account_id`, assert `Repo.one(from d in Delivery, where: d.event_type == "pix.med.completed")` com payload `accountId`/`medId`).

**Step 2:** Run: FAIL (evento não whitelisted / Delivery ausente).

**Step 3: Write minimal implementation** (catálogo + produtores):

```elixir
  # processor.ex, no branch REFUND_COMPLETED/COMPLETED apos Med.mark_transferred(block):
  dispatch_med_webhook("pix.med.completed", block, status)

  defp dispatch_med_webhook(event, block, status) do
    Webhooks.dispatch_event(
      event,
      %{
        "event" => event,
        "account_id" => block.account_id,
        "medId" => block.recovery_id,
        "endToEndId" => block.e2e_id,
        "amount" => block.blocked_amount,
        "status" => status
      },
      event_id: "#{event}:#{block.recovery_id}"
    )

    :ok
  rescue
    error ->
      Logger.warning("[MED Processor] med webhook (#{event}) failed: " <> Exception.message(error))
      :ok
  end
```

No partner controller: dispatch após o 202 do create (`pix.med.created`, event_id `pix.med.created:<medId>`) e do cancel (`pix.med.cancelled`), padrão `dispatch_lifecycle_webhook`.

**Step 4:** PASS + `mix test test/monetarie/use_cases/med/ test/monetarie_web/controllers/partner_v1/med_partner_test.exs`.

**Step 5: Commit**

```bash
git add -A && git commit -m "feat(core): webhooks pix.med.created/completed/cancelled com produtores reais (catalogo + dispatch no mesmo commit)"
```

### Task 11: guarda de regressão do cron MED (TimerEnforcer + CautelarWorker)

**CONTRATO PROVADO:** síntese item 8. Os dois workers RODAM em produção via Oban cron (config.exs L79/L85, fila `:med` L72; prova viva HML `oban_jobs`: 548/1881 completed em 2026-07-18 10:45 UTC). O defeito 4 da auditoria estava DESATUALIZADO para estes dois; o risco real remanescente é a classe "cron/fila removido do config e ninguém percebe" (incidente `:pix_out_retry` de 16/07). Não existe teste que trave isso no spi_service.

**Files:**
- Test: Create `pix/backend/apps/spi_service/test/spi_service/med/med_cron_coverage_test.exs`

**Step 1: Write the test** (nasce verde; o RED é provado por mutação):

```elixir
defmodule SpiService.Med.MedCronCoverageTest do
  use ExUnit.Case, async: true

  # Guarda estrutural (classe :pix_out_retry, 2026-07-16): a maquina de prazos
  # MED so existe em producao porque estas entradas de cron + fila existem.
  test "TimerEnforcer e CautelarWorker estao no crontab do Oban com a fila :med viva" do
    oban = Application.fetch_env!(:spi_service, Oban)

    queues = Keyword.fetch!(oban, :queues)
    assert Keyword.get(queues, :med, 0) >= 1

    {Oban.Plugins.Cron, cron_opts} =
      oban |> Keyword.fetch!(:plugins) |> List.keyfind(Oban.Plugins.Cron, 0)

    workers = cron_opts |> Keyword.fetch!(:crontab) |> Enum.map(fn {_expr, mod} -> mod end)

    assert SpiService.Med.TimerEnforcer in workers
    assert SpiService.Med.CautelarWorker in workers
  end
end
```

**Step 2:** Run: `cd /Users/luizpenha/monetarie/pix/backend/apps/spi_service && mix test test/spi_service/med/med_cron_coverage_test.exs` Expected: PASS. Provar o RED comentando a linha 85 do `pix/backend/config/config.exs` e rodando de novo (FAIL), depois restaurar.

**Step 3:** nada além do teste (o contrato provou que o comportamento já existe; a task registra a prova e trava a regressão).

**Step 4:** PASS final com o config restaurado.

**Step 5: Commit**

```bash
git add -A && git commit -m "test(pix): guarda de regressao do cron MED (TimerEnforcer/CautelarWorker + fila :med) - fecha o defeito 4 remanescente da auditoria"
```

---

## Etapa D — Defesa de infração pelo cliente (item 4)

### Task 12: defesa via Partner API (fim do 403) + webhook `pix.infraction.defense_submitted`

**CONTRATO PROVADO:** síntese itens 9 e 15. Hoje a defesa só existe em `POST /api/admin/infractions/:id/defense` (EnsureAdmin = cliente recebe 403); a lógica real é `submit_defense(%MedCautelarBlock{status: "active"}, defense_text, actor_id)` (admin/infractions_controller.ex L291+: `evidence_metadata` ganha `defense_text`/`defense_submitted_at`, `analysis_status: "defense_submitted"`). `MedCautelarBlock` tem `account_id` (usado em processor.ex L614). O catálogo removeu `infraction.defense_submitted` por falta de produtor (comentário webhook.ex L27-31); esta task cria o produtor legítimo.

**Files:**
- Create: `core/backend/lib/monetarie/use_cases/med/defense.ex` (use case extraído; fonte única admin+partner)
- Modify: `core/backend/lib/monetarie_web/controllers/admin/infractions_controller.ex` (delega ao use case, comportamento intacto)
- Modify: `core/backend/lib/monetarie_web/router.ex` (`post "/pix/infractions/:id/defense", PixController, :submit_infraction_defense` no scope partner; coberta por `pix:write` via prefixo)
- Modify: `core/backend/lib/monetarie_web/controllers/partner_v1/pix_controller.ex` (action)
- Modify: `core/backend/lib/monetarie/schemas/webhooks/webhook.ex` (+ `pix.infraction.defense_submitted`)
- Test: Create `core/backend/test/monetarie_web/controllers/partner_v1/infraction_defense_test.exs`

**Step 0:** ler o corpo completo de `submit_defense` e `fetch_scoped_infraction_row` no admin controller (linhas 280-340) para extrair SEM mudar semântica; anotar o alias real de `MedCautelarBlock` no topo do arquivo.

**Step 1: Write the failing test**

```elixir
  test "parceiro submete defesa de infracao da PROPRIA conta" do
    # semear MedCautelarBlock status "active" com account_id da conta do parceiro
    conn = auth_conn(key) |> post("/api/partner/v1/pix/infractions/#{block.infraction_report_id}/defense",
      %{"accountId" => account_id, "defenseText" => "contestamos o relato; anexos via canal"})
    assert %{"data" => %{"status" => "defense_submitted"}} = json_response(conn, 200)
    # assert Delivery de pix.infraction.defense_submitted quando ha webhook inscrito
  end

  test "bloco de OUTRA conta devolve 404"
  test "bloco nao-active devolve 422"
```

**Step 2:** Run: FAIL 404 de rota.

**Step 3: Write minimal implementation.** Use case:

```elixir
defmodule Monetarie.UseCases.Med.Defense do
  @moduledoc "Fonte unica da defesa de infracao MED (admin e partner)."

  # corpo movido de Admin.InfractionsController.submit_defense/3, intacto
  def submit(%MedCautelarBlock{status: "active"} = block, defense_text, actor_id) do ... end
  def submit(%MedCautelarBlock{}, _text, _actor), do: {:error, :not_defensible}
end
```

Partner action: `with_partner_account` -> localizar block por `infraction_report_id == id or block.id == id` COM `account_id == account.id` (alheio/inexistente = `not_found`) -> `require_param defenseText` -> `Defense.submit(block, text, "partner:" <> to_string(partner_id(conn)))` -> despachar `pix.infraction.defense_submitted` (payload `account_id`, `infractionId`, `blockId`, `status`) -> 200 camelCase. Admin controller passa a chamar `Defense.submit/3`.

**Step 4:** PASS + `mix test test/monetarie_web/controllers/partner_v1/infraction_defense_test.exs` + suite do diretório admin (`mix test test/monetarie_web/controllers/admin/`).

**Step 5: Commit**

```bash
git add -A && git commit -m "feat(partner): defesa de infracao pelo cliente (use case unico admin+partner) + webhook pix.infraction.defense_submitted (item 4 Fase A)"
```

---

## Etapa E — Devolução de TED (item 6; defeito 9)

### Task 13: migration formal das colunas runtime do DevolutionEngine

**CONTRATO PROVADO:** síntese item 12 (código L896-932 + sonda viva HML: as 6 colunas JÁ existem em `spb_operations`; a migration é regularização idempotente, no-op nos ambientes vivos).

**Files:**
- Create: `spb/services/bacen_gateway/priv/repo/migrations/20260718150000_formalize_devolution_runtime_columns.exs`

**Step 1/2: baseline.** `cd /Users/luizpenha/monetarie/spb/services/bacen_gateway && MIX_ENV=test mix ecto.migrate` antes de criar o arquivo (prova o baseline verde).

**Step 3: Write the migration** (espelho EXATO dos statements L906-915):

```elixir
defmodule BacenGateway.Repo.Migrations.FormalizeDevolutionRuntimeColumns do
  use Ecto.Migration

  @moduledoc """
  Formaliza as colunas que o DevolutionEngine criava em runtime via
  ensure_runtime_columns/0 (defeito 9 da auditoria 2026-07-17). Idempotente:
  em HML/PRD as colunas ja existem (criadas pelo runtime) e vira no-op.
  """

  def up do
    execute "ALTER TABLE spb_operations ADD COLUMN IF NOT EXISTS original_operation_id UUID"
    execute "ALTER TABLE spb_operations ADD COLUMN IF NOT EXISTS reason_code VARCHAR(20)"
    execute "ALTER TABLE spb_operations ADD COLUMN IF NOT EXISTS notes TEXT"
    execute "ALTER TABLE spb_operations ADD COLUMN IF NOT EXISTS created_by TEXT"
    execute "ALTER TABLE spb_operations ADD COLUMN IF NOT EXISTS sit_lanc_str VARCHAR(10)"
    execute "ALTER TABLE spb_operations ADD COLUMN IF NOT EXISTS num_ctrl_str VARCHAR(50)"

    execute "CREATE INDEX IF NOT EXISTS spb_operations_original_operation_id_idx ON spb_operations (original_operation_id)"
  end

  def down do
    :ok
  end
end
```

NÃO remover `ensure_runtime_columns/0` (cinto e suspensório fail-soft; barato via `:persistent_term`).

**Step 4:** `MIX_ENV=test mix ecto.migrate` 2x sem erro + `mix test test/bacen_gateway/devolution/` (regressão do engine).

**Step 5: Commit**

```bash
git add -A && git commit -m "chore(spb): migration formal das colunas runtime do DevolutionEngine (defeito 9; idempotente, no-op em HML/PRD)"
```

### Task 14: conferência de documento no matching do crédito TED (Core)

**CONTRATO PROVADO:** síntese item 13. `resolve_member_account/2` casa por NÚMERO sem conferir `creditor_cpf_cnpj` contra `users.tax_id` do dono da conta (o fallback por documento L192-202 só roda quando o número NÃO casa). Risco real: STR0008 com número certo e titular errado credita a conta errada. Regra: número casou E documento veio E documento diverge (só dígitos) = NÃO credita, `{:error, :creditor_document_mismatch}` -> suspense (fluxo D1 existente). Documento ausente em qualquer lado = comportamento atual preservado (zero regressão de acervo). Nome NÃO bloqueia (grafia livre); divergência de nome = log warning.

**Files:**
- Modify: `core/backend/lib/monetarie/use_cases/spb/inbound_credits.ex` (`resolve_member_account/2` + helpers)
- Test: Modify `core/backend/test/monetarie/use_cases/spb/inbound_credits_test.exs`

**Step 1: Write the failing test**

```elixir
  test "credito com numero de conta valido mas CPF divergente NAO credita (vai a suspense)" do
    # conta ativa numero "1742" cujo user.tax_id = "11111111111"
    assert {:error, :creditor_document_mismatch} =
             InboundCredits.resolve_member_account("1742", "22222222222")
  end

  test "documento ausente preserva o matching atual por numero" do
    assert {:ok, _id} = InboundCredits.resolve_member_account("1742", nil)
  end

  test "numero nao casa e documento casa: fallback por documento continua valendo"
```

**Step 2:** Run: `cd /Users/luizpenha/monetarie/core/backend && mix test test/monetarie/use_cases/spb/inbound_credits_test.exs`
Expected: FAIL (primeiro teste devolve `{:ok, id}` hoje).

**Step 3: Write minimal implementation**

```elixir
  def resolve_member_account(account_number, cpf_cnpj) do
    case find_active_account_by_number_with_doc(account_number) do
      {account_id, owner_tax_id} ->
        if document_compatible?(owner_tax_id, cpf_cnpj) do
          {:ok, account_id}
        else
          {:error, :creditor_document_mismatch}
        end

      nil ->
        case find_active_account_by_document(cpf_cnpj) do
          nil -> {:error, :account_not_found}
          id -> {:ok, id}
        end
    end
  end

  # nil/vazio em qualquer lado = compativel (comportamento historico preservado)
  defp document_compatible?(tax_id, doc) when is_binary(tax_id) and is_binary(doc) do
    digits = &String.replace(&1, ~r/\D/, "")
    digits.(tax_id) == "" or digits.(doc) == "" or digits.(tax_id) == digits.(doc)
  end

  defp document_compatible?(_, _), do: true
```

`find_active_account_by_number_with_doc/1` = a query atual L167-186 com `join: u in assoc(a, :user)` e `select: {a.id, u.tax_id}`. Conferir que `mark_suspense` formata `:creditor_document_mismatch` legivelmente (teste do fluxo `process_row`).

**Step 4:** PASS + `mix test test/monetarie/use_cases/spb/inbound_credits_test.exs`.

**Step 5: Commit**

```bash
git add -A && git commit -m "fix(core): matching do credito TED confere CPF/CNPJ do favorecido quando presente; divergencia vira suspense (nunca credita conta de titular errado)"
```

### Task 15: use case `request_client_return` (devolução de TED CREDITADA, origin=client)

**CONTRATO PROVADO:** síntese itens 10, 11 e 14. `request_return/3` atual NÃO cobre `credited_member` (dinheiro já na conta do cliente); devolver exige hold TB ANTES de comandar o STR0010 e capturar/soltar no desfecho. O SPB devolve o valor INTEGRAL (o motor resolve o valor na `spb_operations` dele; chaves extras do payload são ignoradas: consumer SPB L217-233). Hold: `Monetarie.UseCases.Transaction.hold_funds(account_id, AccountCode.settlement_pool(), amount_subcents)`; publicação: MESMO subject/payload do `request_return` (outbox Oban na mesma transação, padrão L929-953).

**Files:**
- Modify: `core/backend/lib/monetarie/use_cases/spb/inbound_credits.ex` (função nova + seam `fund_hold_mod()`)
- Test: Create `core/backend/test/monetarie/use_cases/spb/inbound_credit_client_return_test.exs`

**Step 0:** `sed -n '136,176p' core/backend/lib/monetarie/use_cases/transaction.ex` (corpo de `collect_funds/1` e átomos de erro de replay: hold já postado/já liberado); anotar quais erros contam como idempotentes.

**Step 1: Write the failing test** (seam: `Application.put_env(:monetarie, :spb_fund_hold_mod, StubHold)`; publisher stub = o mesmo dos testes existentes de `inbound_credits_test.exs`, grep `publisher_mod`):

```elixir
  test "credited_member da conta do parceiro vira returning com hold e publica devolution_request origin=client" do
    row = insert_credit(status: "credited_member", account_id: account.id, amount_cents: 5274)

    assert :ok = InboundCredits.request_client_return(row.id, "70", account, "partner-1")

    updated = Repo.get!(InboundCredit, row.id)
    assert updated.status == "returning"
    assert updated.resolution["return_origin"] == "client"
    assert updated.resolution["return_hold_id"]
    assert_publish("monetarie.core.spb.devolution_request",
      %{"origin" => "client", "num_ctrl_str" => row.num_ctrl_str})
  end

  test "linha de OUTRA conta = :not_found; suspense_pending = :not_returnable_by_client"
  test "reason fora do dominio numerico = :invalid_reason_code"
  test "falha ao publicar desfaz a transicao e SOLTA o hold"
```

**Step 2:** Run: `mix test test/monetarie/use_cases/spb/inbound_credit_client_return_test.exs`. Expected: FAIL (função inexistente).

**Step 3: Write minimal implementation**

```elixir
  @doc """
  Devolução comandada pelo CLIENTE (Partner API) de credito TED JA CREDITADO.
  Diferente de request_return/3 (suspense, zero dinheiro): aqui o valor esta na
  conta do cliente, entao TB-hold PRIMEIRO; captura/solta no desfecho
  (apply_return_outcome, Task 16). Devolucao INTEGRAL (STR0010).
  """
  @spec request_client_return(String.t(), String.t() | nil, Account.t(), String.t()) ::
          :ok | {:error, term()}
  def request_client_return(id, reason_code, %Account{} = account, partner_ref) do
    reason = normalize_reason(reason_code)

    with {:ok, reason} <- validate_reason(reason),
         {:ok, row} <- fetch_client_returnable(id, account.id) do
      amount_subcents = MoneyUnit.from_cents(row.amount_cents)

      case fund_hold_mod().hold_funds(account.id, AccountCode.settlement_pool(), amount_subcents) do
        {:ok, hold_id} -> do_request_client_return(row, reason, partner_ref, hold_id, amount_subcents)
        {:error, reason} -> {:error, {:hold_failed, reason}}
      end
    end
  end

  defp fetch_client_returnable(id, account_id) do
    with {:ok, uuid} <- Ecto.UUID.cast(id),
         %InboundCredit{} = row <- Repo.get(InboundCredit, uuid) do
      cond do
        row.account_id != account_id -> {:error, :not_found}
        row.status == "credited_member" -> {:ok, row}
        row.status == "returning" -> {:error, :return_in_flight}
        row.status == "returned" -> {:error, :already_returned}
        true -> {:error, :not_returnable_by_client}
      end
    else
      _ -> {:error, :not_found}
    end
  end

  defp do_request_client_return(row, reason, partner_ref, hold_id, amount_subcents) do
    # Multi identico ao do_request_return (L916-967): CAS credited_member->returning
    # + publish_async no MESMO subject; resolution ganha:
    #   "return_origin" => "client", "return_hold_id" => to_string(hold_id),
    #   "return_reason_code" => reason, "return_requested_by" => partner_ref,
    #   "return_requested_at" => iso8601
    # payload = devolution_request_payload(row, reason, partner_ref)
    #           |> Map.put("origin", "client")
    # Falha do Multi => fund_hold_mod().release_funds(hold_id, amount_subcents) best-effort + {:error, _}
  end

  defp fund_hold_mod,
    do: Application.get_env(:monetarie, :spb_fund_hold_mod, Monetarie.UseCases.Transaction)
```

**Step 4:** PASS + `mix test test/monetarie/use_cases/spb/`.

**Step 5: Commit**

```bash
git add -A && git commit -m "feat(core): request_client_return - devolucao de TED creditada com hold TB primeiro e devolution_request origin=client (item 6 Fase A)"
```

### Task 16: desfecho origin=client no `apply_return_outcome` + webhooks `ted.refund.completed|failed`

**CONTRATO PROVADO:** síntese itens 10, 11 e 14. O consumer vivo entrega `payload` com `num_ctrl_str`/`outcome` (`spb_inbound_credit_handler.ex` L82-89); `apply_return_outcome` hoje só troca status (L1016-1103) e a clause "returned" recusa estados `credited_*` ("estado de dinheiro tem precedência", L1056-1064). Para origin=client a linha está em `returning` COM hold; o desfecho tem que mover o dinheiro: `collect_funds(hold_id)` (capture) no aceito, `release_funds(hold_id, amount)` no falho; espelho PG segue o funil do crédito (AccountEntry + transactions + COSIF fail-soft, L317-406, com sinais invertidos e `"SPBRT"<>num_ctrl_str`).

**Files:**
- Modify: `core/backend/lib/monetarie/use_cases/spb/inbound_credits.ex` (branches client em `apply_return_outcome` + `finalize_client_return/2` + `fail_client_return/2` + `dispatch_ted_refund_webhook/3`)
- Modify: `core/backend/lib/monetarie/schemas/webhooks/webhook.ex` (+ `ted.refund.completed ted.refund.failed`)
- Test: Modify `core/backend/test/monetarie/use_cases/spb/inbound_credit_returns_test.exs` (describes client) e `core/backend/test/monetarie/infra/nats/handlers/spb_return_status_handler_test.exs` (fio completo payload -> desfecho)

**Step 0:** achar o lançamento de EXTRATO da TED de SAÍDA no spb_handler (`grep -n "AccountEntry" core/backend/lib/monetarie/infra/nats/handlers/spb_handler.ex | head`) e espelhar `category`/sinal/descrição do débito; reconferir os átomos idempotentes de `collect_funds`/`release_funds` (Step 0 da Task 15).

**Step 1: Write the failing test** (payload REAL do SPB, provado na síntese item 11):

```elixir
  test "returned de origin=client captura o hold, lanca debito no extrato e dispara ted.refund.completed" do
    row = insert_credit(status: "returning", account_id: account.id, amount_cents: 5274,
            resolution: %{"return_origin" => "client", "return_hold_id" => "hold-1",
                          "return_reason_code" => "70"})

    payload = %{"event" => "return_status", "source" => "spb",
                "num_ctrl_str" => row.num_ctrl_str, "outcome" => "returned",
                "spb_num_ctrl" => "STR20260718...", "published_at" => "..."}

    assert :ok = SpbInboundCreditHandler.handle_return_status(payload)

    assert Repo.get!(InboundCredit, row.id).status == "returned"
    assert StubHold.collected?("hold-1")
    entry = Repo.get_by!(AccountEntry, reference: "SPBRT" <> row.num_ctrl_str)
    assert entry.amount == -527_400   # SUBCENTAVOS, negativo = debito
    assert Repo.get_by!(Transaction, transaction_id: "SPBRT" <> row.num_ctrl_str).direction == "outbound"
    # + Delivery ted.refund.completed com accountId e amount 5274 (CENTAVOS na entrega)
  end

  test "redelivery de returned e idempotente (nao duplica debito nem webhook)"
  test "return_failed de origin=client SOLTA o hold e dispara ted.refund.failed com error_code"
  test "returned SEM origin=client segue o caminho antigo (so status, zero dinheiro)"
```

**Step 2:** Run: FAIL (branch inexistente; débito não lançado).

**Step 3: Write minimal implementation.** Em `apply_return_outcome("returned", ...)`, ANTES da clause genérica de `returning`:

```elixir
      %InboundCredit{status: "returning", resolution: %{"return_origin" => "client"}} = row ->
        finalize_client_return(row, details)
```

`finalize_client_return/2` (ordem dinheiro-primeiro, espelho do funil do crédito):

1. `fund_hold_mod().collect_funds(row.resolution["return_hold_id"])`; erro idempotente (átomo do Step 0) = segue; erro real = log ERRO + `{:error, ...}` (o handler propaga para NAK/redelivery; conferir L82-89) e NÃO transiciona status.
2. `Ecto.Multi`: `AccountEntry` débito (`amount: -MoneyUnit.from_cents(row.amount_cents)`, `reference: "SPBRT" <> row.num_ctrl_str`, category/descrição espelhadas do Step 0, metadata com `num_ctrl_str`, `return_reason_code` e contraparte `debtor_*`) + `PaymentTransactions.create` (`"SPBRT" <> num_ctrl_str`, type "ted", direction "outbound", status "completed", amount subcentavos) + COSIF JE fail-soft (mesmo padrão `create_member_cosif_je` sobre a entry de débito; sem mapping = log + skip, NUNCA bloquear) + CAS `returning -> returned` (`update_all` retornando 1; 0 linhas = redelivery, rollback + `:ok`).
3. Pós-commit: `dispatch_ted_refund_webhook("ted.refund.completed", row, %{"spbNumCtrl" => details["spb_num_ctrl"]})` (payload `account_id: row.account_id`, `"amount" => row.amount_cents * 100` [base units, padrão do ted.received], `event_id: "ted.refund.completed:" <> row.num_ctrl_str`, rescue fail-soft).

`apply_return_outcome("return_failed", ...)`: branch client ANTES da genérica: `release_funds(hold_id, amount_subcents)` (erro idempotente = segue) + CAS `returning -> return_failed` (mantendo `error_code`/`error_message` como hoje) + webhook `ted.refund.failed` (payload com `errorCode`/`errorMessage` dos details). Catálogo: adicionar os 2 eventos NESTE commit.

**Step 4:** PASS + `mix test test/monetarie/use_cases/spb/ test/monetarie/infra/nats/handlers/spb_return_status_handler_test.exs test/monetarie/infra/nats/handlers/spb_inbound_credit_handler_test.exs`.

**Step 5: Commit**

```bash
git add -A && git commit -m "feat(core): desfecho origin=client da devolucao de TED - captura/solta hold, espelho extrato/transactions/COSIF e webhooks ted.refund.completed|failed"
```

### Task 17: rotas partner de TED recebida + `ted.refund.requested`

**CONTRATO PROVADO:** síntese itens 1, 2, 9, 10. Não há NENHUMA rota `/ted` no scope partner nem regra no enforcer (rota nova sem regra = allow; PROIBIDO: entrada explícita obrigatória). `spb_inbound_credits` tem tudo que a listagem precisa (schema L51-77) com `amount_cents` já em CENTAVOS (unidade da partner, sem conversão). Motivos válidos: `InboundCredits.return_reason_codes/0` (L860-866).

**Files:**
- Create: `core/backend/lib/monetarie_web/controllers/partner_v1/ted_controller.ex`
- Modify: `core/backend/lib/monetarie_web/router.ex`:

```elixir
    # TED recebida (creditos SPB) — listagem + devolucao pelo cliente (item 6 Fase A)
    get "/ted/credits", TedController, :list_credits
    post "/ted/credits/:id/refund", TedController, :refund_credit
```

- Modify: `core/backend/lib/monetarie_web/plugs/permission_enforcer.ex` (2 entradas novas, logo após o bloco transfers L68-70):

```elixir
    # Partner API v1 — TED credits (leitura + devolucao de TED recebida)
    {"GET", ~r"^/api/partner/v1/ted"}, "transfer:read",
    {"POST", ~r"^/api/partner/v1/ted"}, "transfer:write",
```

- Modify: `core/backend/lib/monetarie/schemas/webhooks/webhook.ex` (+ `ted.refund.requested`)
- Test: Create `core/backend/test/monetarie_web/controllers/partner_v1/ted_controller_test.exs`

**Step 1: Write the failing test**

```elixir
  test "GET /ted/credits lista creditos TED da conta em CENTAVOS" do
    insert_credit(status: "credited_member", account_id: account.id, amount_cents: 5274,
      num_ctrl_str: "STR20260718X", debtor_name: "WISE", debtor_cpf_cnpj: "123...")
    insert_credit(status: "credited_member", account_id: outra_conta.id, amount_cents: 100)

    conn = auth_conn(key) |> get("/api/partner/v1/ted/credits?accountId=#{account_id}")

    assert %{"data" => %{"credits" => [c]}} = json_response(conn, 200)
    assert c["amount"] == 5274
    assert c["numCtrlStr"] == "STR20260718X"
    assert c["refundable"] == true
  end

  test "POST /ted/credits/:id/refund devolve 202 e dispara ted.refund.requested" do
    # stub :spb_fund_hold_mod; assert row returning + Delivery ted.refund.requested
  end

  test "refund de credito de outra conta = 404; reason invalido = 422; token sem transfer:write = 403"
```

**Step 2:** Run: `mix test test/monetarie_web/controllers/partner_v1/ted_controller_test.exs`. Expected: FAIL 404.

**Step 3: Write minimal implementation.** Controller com `with_partner_account` por `accountId` de params (copiar o helper local do pix_controller); `list_credits` = `Repo.all(from c in InboundCredit, where: c.account_id == ^account.id, order_by: [desc: c.inserted_at], limit: 50)` com filtro opcional `status`; serialização:

```elixir
  defp credit_payload(c) do
    %{
      id: c.id,
      numCtrlStr: c.num_ctrl_str,
      amount: c.amount_cents,                       # CENTAVOS (coluna ja e centavos)
      senderName: c.debtor_name,
      senderDocument: c.debtor_cpf_cnpj,
      senderIspb: c.debtor_ispb,
      status: c.status,
      refundable: c.status == "credited_member",
      settlementDate: c.dt_movto,
      receivedAt: c.inserted_at
    }
  end
```

`refund_credit`: `reason = params["reason"] || params["reasonCode"]` -> `InboundCredits.request_client_return(id, reason, account, "partner:" <> to_string(partner_id(conn)))`; `:ok` -> webhook `ted.refund.requested` (event_id `"ted.refund.requested:" <> num_ctrl_str`) + 202 `%{data: %{refundId: id, numCtrlStr: ..., amount: c.amount_cents, status: "processing"}}`; erros mapeados (`:not_found` 404; `:invalid_reason_code` 422 listando `InboundCredits.return_reason_codes() |> Map.keys()`; `:return_in_flight`/`:already_returned`/`:not_returnable_by_client` 422; `{:hold_failed, _}` 422 "Saldo insuficiente para a devolução"). Declarar `operation(...)` nas 2 actions (documentar devolução INTEGRAL, sem campo amount).

**Step 4:** PASS + `mix test test/monetarie_web/controllers/partner_v1/`.

**Step 5: Commit**

```bash
git add -A && git commit -m "feat(partner): GET /ted/credits + POST /ted/credits/:id/refund com escopo por conta, enforcer transfer:* e webhook ted.refund.requested"
```

---

## Etapa F — `GET /accounts/:id/events` (item 17)

### Task 18: eventos da conta a partir da trilha de auditoria

**CONTRATO PROVADO:** síntese itens 2 e 16. Fonte DECIDIDA: `audit_logs` (única tabela com `resource_type`/`resource_id` filtráveis por conta; `webhook_deliveries` não tem coluna de conta; `account_events` não existe). Semântica honesta documentada na resposta: trilha de auditoria da conta, NÃO um event-store completo. Enforcer: a regra de accounts L54 é `$`-ancorada e NÃO cobre `/events`; entrada nova obrigatória ANTES do bloco genérico (L53).

**Files:**
- Modify: `core/backend/lib/monetarie_web/router.ex` (`get "/accounts/:id/events", AccountsController, :events` junto das rotas de accounts L145-156)
- Modify: `core/backend/lib/monetarie_web/plugs/permission_enforcer.ex` (antes da L53):

```elixir
    {"GET", ~r"^/api/partner/v1/accounts/[^/]+/events$"}, "account:read",
```

- Modify: `core/backend/lib/monetarie_web/controllers/partner_v1/accounts_controller.ex` (action `events` + operation)
- Test: Create `core/backend/test/monetarie_web/controllers/partner_v1/accounts_events_endpoint_test.exs`

**Step 1: Write the failing test** (AuditLog real semeado):

```elixir
  test "GET /accounts/:id/events devolve a trilha da conta em ordem decrescente" do
    Repo.insert!(%AuditLog{action: "UPDATE", resource_type: "account",
      resource_id: to_string(account_id), actor_type: "admin", actor_id: "7",
      changes: %{"status" => ["active", "blocked"]}, inserted_at: DateTime.utc_now()})

    conn = auth_conn(key) |> get("/api/partner/v1/accounts/#{account_id}/events")

    assert %{"data" => %{"events" => [ev]}} = json_response(conn, 200)
    assert ev["action"] == "UPDATE"
    assert ev["resourceType"] == "account"
  end

  test "conta de outro parceiro = 403/404 (escopo with_partner_account)"
  test "filtros from/to e limit funcionam"
```

**Step 2:** Run: `mix test test/monetarie_web/controllers/partner_v1/accounts_events_endpoint_test.exs`. Expected: FAIL 404.

**Step 3: Write minimal implementation** (padrão `with_partner_account/3` do próprio controller, L414-433):

```elixir
  def events(conn, %{"id" => id} = params) do
    with_partner_account(conn, id, fn account ->
      limit = parse_limit(params["limit"], 50, 100)

      query =
        from(l in AuditLog,
          where: l.resource_type == "account" and l.resource_id == ^to_string(account.id),
          order_by: [desc: l.inserted_at, desc: l.id],
          limit: ^limit
        )
        |> maybe_filter_period(params["from"], params["to"])

      events =
        query
        |> Repo.all()
        |> Enum.map(fn l ->
          %{
            id: l.id,
            action: l.action,
            resourceType: l.resource_type,
            actorType: l.actor_type,
            changes: l.changes,
            metadata: l.metadata,
            occurredAt: l.inserted_at
          }
        end)

      json(conn, %{data: %{events: events, source: "audit_trail"}})
    end)
  end
```

Operation OpenAPI documenta: "Trilha de auditoria da conta (fonte: audit logs). Eventos de webhook seguem pelo catálogo GET /webhooks/events."

**Step 4:** PASS + `mix test test/monetarie_web/controllers/partner_v1/accounts_events_endpoint_test.exs test/monetarie_web/controllers/partner_v1/accounts_controller_test.exs`.

**Step 5: Commit**

```bash
git add -A && git commit -m "feat(partner): GET /accounts/:id/events servindo a trilha de auditoria da conta (item 17 Fase A; enforcer account:read explicito)"
```

---

## Etapa G — Fecho da frente

### Task 19: roteiro de VALIDAÇÃO VIVA em HML como parceiro OAuth (executa o ORQUESTRADOR, pós-deploy HML)

**CONTRATO PROVADO:** receita viva de 16-17/07 (túnel SSM 18080 roteando os hosts por Host header; secret `monetarie/homolog/partner/herbeth-santana/api_credentials`; validação item a item com evidência chamada+retorno). Tasks HML vivas na sonda de 2026-07-18: core-api `ebe8e8d4...`, pix-api `fffadeb4...`, spb-api `064c5ca9...` (re-listar na hora: `awsmon ecs list-tasks --cluster monetarie-greenfield-homolog --service-name <svc>`).

**Pré-condições:** frente mergeada na main, suites completas verdes (core full + dict_service + spi_service + settlement_service + spb), deploy HML core-api ANTES de pix-api. Migration SPB via rpc no deploy do spb (no-op provado). ICOM conferido pós-swap do pix.

**Setup:**

```bash
CREDS=$(awsmon secretsmanager get-secret-value --secret-id monetarie/homolog/partner/herbeth-santana/api_credentials --query SecretString --output text)
# tunel SSM local 18080 -> ALB interno (mesma receita de 17/07, via EC2 NATS i-02ce3a3b6b3ad0d37)
TOKEN=$(curl -s -X POST http://localhost:18080/api/partner/v1/oauth/token -H "Host: coreapi-h.monetarie.internal" \
  -H "content-type: application/json" -d "{\"grant_type\":\"client_credentials\",\"client_id\":\"...\",\"client_secret\":\"...\"}" | jq -r .access_token)
AUTH="Authorization: Bearer $TOKEN"
HOST="Host: coreapi-h.monetarie.internal"
```

**Roteiro (cada item: comando + resultado esperado + evidência colada no fecho):**

1. `curl -s -H "$HOST" -H "$AUTH" http://localhost:18080/api/partner/v1/webhooks/events` -> 200; a lista CONTÉM `pix.med.created pix.med.completed pix.med.cancelled ted.refund.requested ted.refund.completed ted.refund.failed pix.infraction.defense_submitted`.
2. `GET /pix/claims?accountId=<conta herbeth>` -> 200 `{claims: [...], total: n}` (vazio é resultado válido, NÃO é falha).
3. `POST /pix/claims/00000000-0000-0000-0000-000000000000/complete?accountId=...` -> 404 legível (sonda negativa segura).
4. `GET /pix/med?accountId=...` -> 200 lista (vazia ok). `GET /pix/med/<uuid inexistente>` -> 404.
5. `GET /pix/infractions?accountId=...` -> 200. `POST /pix/infractions` SEM endToEndId -> 400 listando os obrigatórios. ATENÇÃO: criar infração REAL em HML fala com o DICT homolog do BACEN; executar o caminho feliz (create com e2e de transação entre contas NOSSAS + cancel imediato) SOMENTE com o orquestrador acompanhando; senão registrar "validado por sonda negativa + testes".
6. `POST /pix/infractions/<id inexistente>/defense` -> 404. Caminho feliz da defesa exige bloco cautelar vivo: se não houver, evidência = teste de integração + pendência de infração orgânica (registrar).
7. `GET /ted/credits?accountId=...` -> 200 `{credits: [...]}` (conferir unidade CENTAVOS contra o extrato da conta). `POST /ted/credits/<uuid inexistente>/refund` -> 404. Caminho feliz do refund de TED = SÓ com TED real recebida na conta de teste (roteiro: TED homolog enviada, valida `ted.received`, dispara refund, acompanha `monetarie.spb.credits.return_status` e o webhook `ted.refund.completed`; STR0010 real contra o BACEN homolog: janela combinada com o dono).
8. `GET /accounts/<id>/events` -> 200 com trilha (a conta herbeth tem auditoria de sobra).
9. Token com escopo insuficiente (`pix:read` apenas): `POST /pix/infractions` -> 403 `required_permission: "pix:write"`; `GET /ted/credits` -> 403 `transfer:read`.
10. Cabine: conferir por rpc que o create de infração não dá mais 404 no caminho interno: sonda read-only antes/depois do item 5: `bin/monetarie_pix rpc "Shared.Repo.query!(~s[SELECT id, status, end_to_end_id FROM monetarie_dict.infraction_reports ORDER BY created_at DESC LIMIT 3]).rows |> IO.inspect()"`.
11. Webhook de verdade: inscrever um receptor externo nos eventos novos (receita do PDF de 17/07) e provar a entrega de pelo menos `ted.refund.requested` OU `pix.med.created` (o que o roteiro do dia permitir).

**Fecho:** colar evidências no handoff da frente + relatório do cliente via F4 (regra do dono: 4xx só como recusa de proteção, nunca defeito interno).

---

## Ordem de execução e dependências

- Tasks 1-2 (cabine) antes da 3 (a validação viva da 3 usa o dispatch novo; os TESTES da 3 usam StubProvider e podem rodar em paralelo se necessário).
- Task 5 depende só do Core (cabine já pronta). Task 8 antes da 9 (mesma lógica 1->3).
- Tasks 13/14 independentes. 15 -> 16 -> 17 em sequência; 14/15/16 tocam o MESMO arquivo `inbound_credits.ex`: NUNCA paralelizar entre implementadores.
- Task 19 por último, executada pelo orquestrador.
- Suites completas no gate de merge: `core/backend` full, `pix/backend/apps/{dict_service,spi_service,settlement_service}`, `spb/services/bacen_gateway`.
