# Plano de implementação: extrato com contraparte íntegra + aba Comprovante no coreadmin

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

**Goal:** Extrato do coreadmin com Pagador/Recebedor populados em todos os trilhos (PIX in/out, TED in/out, TEF, PIX interno), backfill do acervo, e aba Comprovante na tela Contas Correntes emitindo comprovantes de débitos (PIX/TED/TEF) no desenho do ib-front.

**Architecture:** O extrato lê exclusivamente `account_entries`; a contraparte vem do JSONB `metadata` (`recipient_*` para débito, `payer_*` para crédito, `counterparty_*` como fallback, resolvido em `bank_accounts_controller.ex:602-631`). Os fixes garantem que todo trilho grave essas chaves e que todo trilho GRAVE lançamento (TED-out, TEF crédito e PIX interno hoje não gravam). O comprovante do admin reusa o serializer do v2 extraído para módulo compartilhado. Design validado: `docs/plans/2026-07-15-coreadmin-extrato-contraparte-comprovante-design.md`.

**Tech Stack:** Elixir/Phoenix (core/backend), Ecto/Aurora PG, Vue 3 + PrimeVue (core/apps/admin), ExUnit + vitest + Playwright.

**Regras do projeto que valem para TODAS as tasks:**
- TDD estrito: escrever o teste, VER falhar, implementar, VER passar, commitar. Nunca pular o RED.
- Nunca tocar TigerBeetle/money-path. Extrato é perna PG de exibição, fail-soft.
- Commits locais são permitidos; PUSH somente com autorização explícita do dono.
- Rodar testes: `cd /Users/luizpenha/monetarie/core/backend && mix test <arquivo>` (o DB de teste nasce vazio, sem seed).
- Unidades: `account_entries.amount` e `transactions.amount` estão em SUBCENTAVOS (base_units). Endpoints admin existentes serializam como o `ae_entry_view` (`bank_accounts_controller.ex:560-577`): LEIA essa função antes de serializar valores novos e espelhe a unidade exatamente.
- Sessões paralelas trabalham PIX e SPB agora: commits pequenos por task, e antes de cada commit rode `git status` para não arrastar arquivo alheio.

---

## Fase 0: investigação do caso vivo (read-only em PROD)

### Task 1: Diagnosticar por que o PIX de R$ 2.500 da M2 ficou sem recebedor

**Files:**
- Create: `docs/reports/2026-07-15-extrato-contraparte-fase0-prod.md`

Contexto: `record_outbound_settled` (statement_entries.ex:67, 84-90) JÁ tem fallback `payload["creditor_name"]`/`creditor_document` desde 14/07. Se o lançamento de 15/07 saiu sem contraparte, então `tx.counterparty_name`, `tx.metadata["recipient_name"]` E `payload["creditor_name"]` estavam TODOS vazios, ou o lançamento nasceu por outro caminho (Máquina A com payload `%{}`: `atomic_payment_handler.ex:309` chama `record_outbound_settled(tx, %{})`, então o fallback do payload NUNCA atua nesse caminho e tudo depende do metadata da transação).

**Step 1: Localizar o lançamento e a transação em PROD (somente leitura)**

Acesso: ECS exec no serviço core-api de PROD (cluster `monetarie-greenfield-prod`), depois `bin/monetarie rpc`. Sempre com `AWS_PROFILE=vulcimonetarie AWS_REGION=sa-east-1`.

```bash
awsmon ecs list-tasks --cluster monetarie-greenfield-prod --service-name core-api
awsmon ecs execute-command --cluster monetarie-greenfield-prod --task <task-arn> \
  --container core-api --interactive --command "/bin/sh"
```

Dentro do container (queries READ-ONLY):

```elixir
bin/monetarie rpc '
alias Monetarie.Repo
# 1. O lançamento do extrato (conta da M2, débito de R$2.500 em 15/07)
Repo.query!("SELECT id, account_id, amount, description, reference, metadata FROM account_entries WHERE description LIKE :d AND entry_date = :dt AND amount = :a", %{})
' # adaptar: buscar por amount = -25000000 (subcentavos) e entry_date 2026-07-15
```

Na prática use `Repo.query!/2` com SQL literal e `IO.inspect`. Registrar: `reference` (E2E ou transaction_id), `metadata` completo do lançamento.

**Step 2: Rastrear a origem**

Com o `reference` do passo 1:
```elixir
# transactions: counterparty_name, metadata (tem recipient_name? recipient_document?)
# outbound_requests: recipient_name, recipient_document, metadata
# Origem do pagamento: transaction_id prefix + metadata["source"]/canal
```
Registrar qual elo perdeu o dado: (a) origem nunca teve (envio manual/partner sem recipientName), (b) OutboundRequest tinha e a materialização perdeu, (c) evento da cabine tinha creditor_name e o caminho usado ignora payload (Máquina A).

**Step 3: Consultar a cabine pelo E2E (read-only)**

```elixir
bin/monetarie rpc 'Monetarie.Services.PixProviders.InHouse.CabinStatusLookup.by_end_to_end_id("<E2E>") |> IO.inspect(limit: :infinity)'
```
Registrar se a cabine conhece creditor name/document (isso valida a fonte do backfill).

**Step 4: Amostrar TED recebida no acervo**

```sql
-- amostra de 20 lançamentos de crédito TED: metadata tem payer_name/payer_document?
SELECT reference, metadata FROM account_entries
WHERE metadata->>'source' IN ('spb_inbound_credit') ORDER BY id DESC LIMIT 20;
-- e acervo antigo por outras sources de crédito sem payer_*:
SELECT metadata->>'source' AS src, count(*) FROM account_entries
WHERE amount > 0 AND metadata->>'payer_name' IS NULL AND metadata->>'counterparty_name' IS NULL
GROUP BY 1 ORDER BY 2 DESC;
```

**Step 5: Dimensionar o backfill**

```sql
-- débitos sem contraparte por source
SELECT metadata->>'source' AS src, count(*) FROM account_entries
WHERE amount < 0 AND metadata->>'counterparty_name' IS NULL AND metadata->>'recipient_name' IS NULL
GROUP BY 1 ORDER BY 2 DESC;
-- TED-out liquidadas sem lançamento (estimativa)
SELECT count(*) FROM transactions t WHERE t.type='ted' AND t.direction='outbound'
AND t.payment_status IN ('settled','completed')
AND NOT EXISTS (SELECT 1 FROM account_entries e WHERE e.reference IN (t.end_to_end_id, t.transaction_id));
-- TEF/PIX interno: legs _RCV sem lançamento
SELECT count(*) FROM transactions t WHERE t.transaction_id LIKE '%\_RCV'
AND NOT EXISTS (SELECT 1 FROM account_entries e WHERE e.reference IN (t.end_to_end_id, t.transaction_id));
```

**Step 6: Escrever o relatório e commitar**

`docs/reports/2026-07-15-extrato-contraparte-fase0-prod.md` com: causa raiz do caso M2 (com evidência), contagens por categoria de backfill, decisão de fix de origem para a Task 8.

```bash
git add docs/reports/2026-07-15-extrato-contraparte-fase0-prod.md && git commit -m "docs(reports): fase 0 extrato contraparte, evidencia PROD read-only"
```

---

## Fase 1: fixes de write-path (TDD)

### Task 2: PIX recebido persiste o documento do pagador

**Files:**
- Modify: `core/backend/lib/monetarie/use_cases/pix/statement_entries.ex:154-163` (metadata do inbound) e `:235-247` (backfill_inbound)
- Test: `core/backend/test/monetarie/use_cases/pix/statement_entries_counterparty_test.exs` (novo)

**Step 1: Escrever o teste que falha**

Modelar fixtures no padrão de `test/monetarie/use_cases/payments/machine_a_statement_entry_test.exs` (insert_account!/unique_e2e). Teste:

```elixir
defmodule Monetarie.UseCases.Pix.StatementEntriesCounterpartyTest do
  use Monetarie.DataCase, async: false
  alias Monetarie.UseCases.Pix.StatementEntries
  # ... insert_account! e unique_e2e copiados do machine_a_statement_entry_test

  test "PIX-in grava counterparty_document a partir de debtor_cpf_cnpj" do
    account = insert_account!()
    e2e = unique_e2e()

    {:ok, entry} =
      StatementEntries.record_inbound_settled(%{
        "direction" => "inbound",
        "account_id" => account.id,
        "amount" => 5_000,
        "end_to_end_id" => e2e,
        "transaction_id" => "PIXIN" <> e2e,
        "debtor_name" => "Fulano Pagador",
        "debtor_cpf_cnpj" => "12345678901",
        "debtor_ispb" => "00038166"
      })

    assert entry.metadata["counterparty_name"] == "Fulano Pagador"
    assert entry.metadata["counterparty_document"] == "12345678901"
  end
end
```

**Step 2: Rodar e ver falhar**

`mix test test/monetarie/use_cases/pix/statement_entries_counterparty_test.exs`
Esperado: FAIL (`counterparty_document` é nil).

**Step 3: Implementar**

Em `record_inbound_settled`, no `drop_nils` do metadata (linha ~155), acrescentar:

```elixir
"counterparty_document" =>
  payload["debtor_cpf_cnpj"] || payload["debtor_document"] || payload["sender_document"],
```

Em `backfill_inbound/1` (linha ~235), acrescentar ao payload:

```elixir
"debtor_cpf_cnpj" => tx.metadata["payer_document"] || tx.metadata["debtor_cpf_cnpj"],
```

**Step 4: Rodar e ver passar; regressão do arquivo vizinho**

```bash
mix test test/monetarie/use_cases/pix/statement_entries_counterparty_test.exs \
  test/monetarie/infra/nats/handlers/pix_handler_statement_entry_test.exs \
  test/monetarie/infra/nats/handlers/pix_handler_atomic_pg_test.exs
```

**Step 5: Commit**

```bash
git add core/backend/lib/monetarie/use_cases/pix/statement_entries.ex core/backend/test/monetarie/use_cases/pix/statement_entries_counterparty_test.exs
git commit -m "fix(core): extrato do PIX recebido persiste CPF/CNPJ do pagador (antes descartado)"
```

### Task 3: TED enviada passa a gravar lançamento no extrato

**Files:**
- Modify: `core/backend/lib/monetarie/infra/nats/handlers/spb_handler.ex` (ramo settled outbound, ~linha 141-160, bloco `Outbound confirmed`)
- Test: `core/backend/test/monetarie/infra/nats/handlers/spb_handler_outbound_statement_test.exs` (novo)

**Step 1: Teste que falha**

Padrão dos testes vizinhos (`spb_handler_accepted_no_ledger_test.exs` mostra como montar tx rastreada + payload de evento). Cenário: transação TED outbound rastreada (type "ted", direction "outbound", amount 10_000, account_id válido, metadata com `recipient_name`/`recipient_document`), chamar `SpbHandler.handle_transaction_updated(payload, "settled")`, e assertar:

```elixir
entry = Repo.one(from e in AccountEntry, where: e.reference == ^reference)
assert entry.amount == -10_000
assert entry.description =~ "TED enviada"
assert entry.metadata["counterparty_name"] == "Recebedor TED"
# idempotencia: segunda entrega nao duplica
assert {:ok, :already_exists} = ... # reprocessar o mesmo evento; count == 1
```

**Step 2: Ver falhar** (nenhum entry criado).

**Step 3: Implementar**

No else-branch outbound do `handle_tracked_status` (após o `Logger.info "Outbound tx ... confirmed"`), antes da tarifa:

```elixir
# Extrato: a TED enviada materializa o débito em account_entries (mesma
# função idempotente do PIX; rótulo por tx.type). Fail-soft por contrato.
Monetarie.UseCases.Pix.StatementEntries.record_outbound_settled(tx, payload)
```

Adicionar alias no topo do módulo. `record_outbound_settled` nunca levanta exceção e já pula tx sem `account_id` (guard interno), então não quebra o consumer.

**Step 4: Ver passar + regressão spb_handler**

```bash
mix test test/monetarie/infra/nats/handlers/spb_handler_outbound_statement_test.exs \
  test/monetarie/infra/nats/handlers/spb_handler_test.exs \
  test/monetarie/infra/nats/handlers/spb_handler_accepted_no_ledger_test.exs \
  test/monetarie/infra/nats/handlers/spb_handler_inbound_settled_mirror_test.exs
```

**Step 5: Commit** `fix(core): TED enviada gera lancamento no extrato (gap estrutural do spb_handler)`

### Task 4: TEF débito com contraparte (titular da conta destino)

**Files:**
- Modify: `core/backend/lib/monetarie/use_cases/payments/outbound/tef.ex:62-69` (prepare_request)
- Test: `core/backend/test/monetarie/use_cases/payments/outbound_tef_counterparty_test.exs` (novo)

Mecânica: `OutboundRequest` tem campos `recipient_name`/`recipient_document`, e `request_metadata/1` (`outbound_requests.ex:248-256`) já os copia para o `metadata` da transação; `record_outbound_settled` lê `tx.metadata["recipient_name"]`. Basta preencher os campos no `prepare_request`.

**Step 1: Teste que falha**

Criar duas contas (pagador e recebedor com user "Recebedor TEF", tax_id conhecido), montar `Normalized` de TEF e chamar `Tef.prepare_request(normalized)`; assertar `req.recipient_name == "Recebedor TEF"` e `req.recipient_document == <tax_id>`. Segundo teste de integração: fluxo `finish/1` completo (com TB de teste, padrão do machine_a test) termina com `account_entries` do débito contendo `counterparty_name`.

**Step 2: Ver falhar.**

**Step 3: Implementar**

```elixir
def prepare_request(%Normalized{tef: %Normalized.Tef{} = tef} = normalized) do
  req = OutboundRequest.new_pending(normalized)
  holder = account_holder(tef.to_account_id)

  %{req |
    type: "tef",
    recipient_key: to_string(tef.to_account_id),
    recipient_name: holder[:name],
    recipient_document: holder[:document],
    metadata: %{"to_account_id" => tef.to_account_id}
  }
end

# Titular (nome + documento) de uma conta, mesmo padrão do statement_holder
# do admin (bank_accounts_controller.ex:592).
defp account_holder(account_id) do
  Repo.one(
    from(a in Account,
      join: u in assoc(a, :user),
      where: a.id == ^account_id,
      select: %{name: u.name, document: u.tax_id}
    )
  ) || %{}
end
```

**Step 4: Ver passar + regressão** `mix test test/monetarie/use_cases/payments/` (diretório inteiro).

**Step 5: Commit** `fix(core): TEF debita com contraparte (titular da conta destino) no extrato`

### Task 5: TEF crédito ganha lançamento no extrato do recebedor

**Files:**
- Modify: `core/backend/lib/monetarie/use_cases/pix/statement_entries.ex` (nova função pública `record_internal_credit/1`)
- Modify: `core/backend/lib/monetarie/use_cases/payments/outbound/tef.ex:173-205` (create_receiver_transaction)
- Test: ampliar `outbound_tef_counterparty_test.exs`

**Step 1: Teste que falha**

Após `finish/1` de uma TEF: existe `account_entries` com `reference == "#{transaction_id}_RCV"`, `amount == +valor`, `description =~ "Transferência recebida"`, `metadata["payer_name"] == <nome do pagador>`, `metadata["payer_document"] == <doc>`. Idempotência: reprocessar não duplica.

**Step 2: Ver falhar.**

**Step 3: Implementar**

Em `statement_entries.ex`, nova função (usa o mesmo `insert_entry` idempotente):

```elixir
@doc """
Crédito de transferência interna (TEF recebida / PIX interno recebido) no
extrato do recebedor. Idempotente por reference+source. Nunca levanta exceção.
"""
@spec record_internal_credit(map()) ::
        {:ok, AccountEntry.t() | :already_exists} | {:error, term()}
def record_internal_credit(%{account_id: account_id, amount: amount} = attrs)
    when is_integer(account_id) and is_integer(amount) and amount > 0 do
  settled_date = (attrs[:settled_at] || DateTime.utc_now()) |> DateTime.to_date()

  insert_entry(%{
    account_id: account_id,
    entry_date: settled_date,
    value_date: settled_date,
    amount: amount,
    description: describe(attrs[:label] || "Transferência recebida", attrs[:payer_name]),
    category: "transfer",
    reference: attrs[:reference],
    status: "confirmed",
    metadata:
      drop_nils(%{
        "source" => attrs[:source],
        "transaction_id" => attrs[:transaction_id],
        "end_to_end_id" => attrs[:end_to_end_id],
        "payer_name" => attrs[:payer_name],
        "payer_document" => attrs[:payer_document],
        "counterparty_name" => attrs[:payer_name]
      })
  })
rescue
  e ->
    Logger.error("[StatementEntries] Exceção no crédito interno ref=#{inspect(attrs[:reference])}: #{Exception.message(e)}")
    {:error, e}
end
```

Em `tef.ex` `create_receiver_transaction/1`: resolver o titular PAGADOR (`account_holder(req.account_id)`), acrescentar `"payer_name"`/`"payer_document"` ao metadata da tx `_RCV`, e no ramo `{:ok, _}` do insert chamar:

```elixir
StatementEntries.record_internal_credit(%{
  account_id: to_account_id,
  amount: req.amount,
  reference: "#{req.transaction_id}_RCV",
  transaction_id: "#{req.transaction_id}_RCV",
  source: "tef_receiver",
  label: "Transferência recebida",
  payer_name: sender[:name],
  payer_document: sender[:document]
})
```

(alias `Monetarie.UseCases.Pix.StatementEntries` no topo.)

**Step 4: Ver passar + regressão payments.**

**Step 5: Commit** `fix(core): recebedor de TEF passa a ver o credito no extrato (com pagador)`

### Task 6: PIX interno grava as duas pernas no extrato

**Files:**
- Modify: `core/backend/lib/monetarie/use_cases/pix/internal_transfer.ex` (settle, após persist_records)
- Test: ampliar `core/backend/test/monetarie/use_cases/pix/internal_transfer_test.exs`

**Step 1: Teste que falha**

No teste existente de `settle/1` bem-sucedido, assertar que nascem 2 `account_entries`: débito do sender (reference = e2e, source `pix_outbound_settled`, description "PIX enviado - <recebedor>", amount negativo) e crédito do receiver (reference = `"#{transaction_id}_RCV"`, source `pix_internal`, description "PIX recebido - <pagador>", amount positivo, `payer_name`/`payer_document` no metadata). Idempotência: chamar `settle` de novo (mesmo transaction_id) não duplica.

Atenção: o teste precisa que o ctx traga `metadata` com `recipient_name`/`recipient_document` (é o `outbound_pix_metadata` do controller, `pix_controller.ex:1113`) e `sender_name`/`sender_document`.

**Step 2: Ver falhar.**

**Step 3: Implementar**

Em `settle/1`, no ramo `{:ok, _tid}` após `persist_records(ctx, now)` (fail-soft, fora da transação de display rows):

```elixir
record_statement_legs(ctx, now)
```

```elixir
# Extrato das duas pernas (fail-soft; dinheiro já se moveu no TB).
defp record_statement_legs(ctx, now) do
  meta = ctx[:metadata] || %{}

  StatementEntries.record_outbound_settled(
    %{
      account_id: ctx.sender_account_id,
      amount: ctx.amount,
      transaction_id: ctx.transaction_id,
      end_to_end_id: ctx[:end_to_end_id],
      counterparty_name: meta["recipient_name"],
      completed_at: now,
      type: "pix",
      metadata: meta
    },
    %{"settled_at" => DateTime.to_iso8601(now)}
  )

  StatementEntries.record_internal_credit(%{
    account_id: ctx.receiver_account_id,
    amount: ctx.amount,
    reference: "#{ctx.transaction_id}_RCV",
    transaction_id: "#{ctx.transaction_id}_RCV",
    end_to_end_id: ctx[:end_to_end_id],
    source: "pix_internal",
    label: "PIX recebido",
    payer_name: ctx[:sender_name],
    payer_document: ctx[:sender_document],
    settled_at: now
  })

  :ok
end
```

Nota: `record_outbound_settled` recebe um struct/map com `account_id`, `amount`, `transaction_id`, `end_to_end_id`, `counterparty_name`, `metadata`, `type`, `completed_at` (confira o uso na função: `tx.account_id`, `tx.amount`, `tx.end_to_end_id`, `tx.transaction_id`, `tx.counterparty_name`, `tx.metadata`, `tx.type`). Um map plain com essas chaves atômicas funciona (acesso via `.`); se o compilador reclamar, montar um `%Transaction{}` em memória. Alias `Monetarie.UseCases.Pix.StatementEntries` já é vizinho do módulo.

**Step 4: Ver passar + regressão** `mix test test/monetarie/use_cases/pix/internal_transfer_test.exs test/monetarie/use_cases/wallet_internal_transfer_test.exs`

**Step 5: Commit** `fix(core): PIX interno (book-transfer) grava as duas pernas no extrato`

### Task 7: serializer do comprovante: TEF rotulada + interno liquidado

**Files:**
- Modify: `core/backend/lib/monetarie_web/controllers/v2/transaction_controller.ex` (`transaction_description/1` ~linha 566; `pix_settlement_metadata_proven?/1` ~linha 381)
- Test: `core/backend/test/monetarie_web/controllers/v2/transaction_receipt_internal_test.exs` (novo; achar teste de receipt existente com `grep -rl "receipt" core/backend/test/monetarie_web/controllers/v2/` e seguir o padrão de setup/auth dele)

**Step 1: Testes que falham**

(a) receipt de tx `type: "tef"` traz `description == "Transferencia interna"` (hoje "Transacao"); (b) receipt do PAGADOR de PIX interno (metadata `settlement_source: "internal"`, `spi_status_id: 7`, direction débito) traz `status == "settled"` (hoje "processing").

**Step 2: Ver falhar.**

**Step 3: Implementar**

```elixir
{"tef", _} -> "Transferencia interna"
```
(antes do catch-all `_ -> "Transacao"`), e em `pix_settlement_metadata_proven?/1`:

```elixir
source_ok = source in ["spi", "spi_pacs002", "pacs002", "bacen_pacs002", "spi_cabin", "cabin", "internal"]
```

Justificativa no comentário: "internal" só é gravado por `InternalTransfer.settle` DEPOIS do transfer TB confirmado, com `spi_status_id: 7` (o `status_ok` continua sendo o gate).

**Step 4: Ver passar + regressão dos testes v2 de transaction/receipt.**

**Step 5: Commit** `fix(core): comprovante rotula TEF e reconhece liquidacao interna como provada`

### Task 8: fix de origem do PIX enviado (contingente na Fase 0)

**Files:** definidos pela Fase 0.

Implementar o fix apontado pelo relatório da Task 1, com TDD:
- Se (a) origem sem dado (envio manual/partner sem recipientName): no caminho de criação, resolver o recebedor da consulta DICT já feita (o IB sempre consulta DICT antes de enviar; partner idem) e persistir `recipient_name/document` no `OutboundRequest`. Se o canal realmente não tem o dado (ex.: envio manual por chave sem consulta), garantir que o evento settled da cabine (que carrega as partes desde 14/07) alimente o caminho: no `atomic_payment_handler.ex:309`, trocar `record_outbound_settled(tx, %{})` por passar o payload do evento quando existir.
- Se (b) perda na materialização: corrigir `request_metadata`/`move_to_transactions`.
- Se (c) payload ignorado: já coberto pelo item acima do payload na Máquina A.

Commit: `fix(core): origem do PIX enviado persiste recebedor (causa raiz fase 0: <resumo>)`

### Task 9: regressão da Fase 1

**Step 1:** `cd core/backend && mix test test/monetarie/use_cases/pix test/monetarie/use_cases/payments test/monetarie/infra/nats/handlers test/monetarie_web/controllers/admin/bank_accounts_statement_test.exs test/monetarie_web/controllers/v2 --max-failures 10`

**Step 2:** Comparar falhas com a baseline da main limpa (se houver falha, provar com `git stash` que é pré-existente antes de seguir).

**Step 3:** Commit de eventuais ajustes.

---

## Fase 2: backfill do acervo

### Task 10: módulo `Monetarie.Release.StatementBackfill` com dry-run (TDD)

**Files:**
- Create: `core/backend/lib/monetarie/release/statement_backfill.ex`
- Test: `core/backend/test/monetarie/release/statement_backfill_test.exs`

IMPORTANTE: release de produção NÃO tem mix tasks; o módulo é chamado via `bin/monetarie rpc 'Monetarie.Release.StatementBackfill.dry_run()'`.

API:

```elixir
defmodule Monetarie.Release.StatementBackfill do
  @moduledoc """
  Backfill do extrato (account_entries): completa contraparte ausente e cria
  lançamentos que os write-paths antigos não gravavam. Idempotente: só preenche
  chaves AUSENTES no metadata e usa as mesmas references/sources dos caminhos
  vivos. NUNCA altera amount, account_id, datas ou TigerBeetle.

  Uso (via rpc):
    Monetarie.Release.StatementBackfill.dry_run()            # só conta, zero writes
    Monetarie.Release.StatementBackfill.apply(limit: 500)    # aplica em lotes
    Monetarie.Release.StatementBackfill.reconcile()          # batimento extrato x TB
  """
  # Categorias:
  # :pix_out_counterparty  — débitos source pix_outbound_settled sem counterparty_name;
  #                          fonte: tx.metadata, senão CabinStatusLookup.by_end_to_end_id
  # :pix_in_document       — créditos pix_inbound_settled/pix_return_received sem
  #                          counterparty_document; fonte: tx.metadata, senão cabine
  # :ted_out_missing_entry — transactions ted/outbound settled sem account_entry;
  #                          ação: StatementEntries.record_outbound_settled(tx, meta_payload)
  # :ted_in_document       — créditos spb_inbound_credit sem payer_document; fonte: tx metadata
  # :internal_missing_legs — transactions *_RCV (tef_receiver/pix_internal) sem entry;
  #                          ação: record_internal_credit; e sender legs de pix interno
  #                          (metadata internal_transfer=true) sem entry: record_outbound_settled
  # :tef_out_counterparty  — débitos de TEF sem contraparte; fonte: titular da conta destino
end
```

**Step 1: Testes que falham** (uma categoria local por teste; sem cabine):

- fixture: entry PIX-out sem counterparty + tx com `recipient_name` no metadata; `dry_run()` conta 1 em `:pix_out_counterparty`; `apply()` preenche `counterparty_name`/`counterparty_document` E atualiza `description` ("PIX enviado - Nome"); segundo `apply()` retorna 0.
- fixture: tx TED-out settled sem entry; `apply()` cria o entry via `record_outbound_settled`; re-run 0.
- fixture: tx `_RCV` de TEF sem entry; `apply()` cria via `record_internal_credit`; re-run 0.
- fixture: entry com counterparty JÁ preenchida não é tocada (assert metadata idêntico).
- `apply()` nunca altera `amount` (assert).

**Step 2: Ver falhar. Step 3: Implementar** (queries por categoria, lotes de `limit`, cabine best-effort com `try/rescue` e contador `skipped`, relatório `%{category => %{found, updated, created, skipped}}`; `Logger.info` por lote). A consulta à cabine NÃO entra nos testes unitários (validação em HML).

**Step 4: `reconcile/0`:** para cada conta tocada, comparar `SUM(account_entries.amount)` com o saldo TB. Antes de escrever, ler `BankAccountsController.balance` (action `balance`, `bank_accounts_controller.ex`) e usar A MESMA fonte de saldo TB. Retorna lista de divergências (não corrige nada).

**Step 5: Ver passar. Step 6: Commit** `feat(core): Release.StatementBackfill dry-run/apply/reconcile para o acervo do extrato`

### Task 11: rodar o backfill local e validar

**Step 1:** No ambiente local (containers do dev), `bin/... rpc` ou `iex -S mix`: `dry_run()` e revisar contagens; `apply()`; `reconcile()`.
**Step 2:** Conferir na tela local (coreadmin) um extrato antes/depois.
**Step 3:** Registrar o resultado no relatório da Fase 0 (seção "execução local").
**Step 4:** HML e PROD NÃO fazem parte desta task: exigem deploy da imagem nova + autorização do dono (dry-run primeiro, revisão das contagens pelo dono, apply fora de janela de operação).

---

## Fase 3: aba Comprovante no coreadmin

### Task 12: extrair o serializer de comprovante para módulo compartilhado

**Files:**
- Create: `core/backend/lib/monetarie/use_cases/receipts/payload.ex`
- Modify: `core/backend/lib/monetarie_web/controllers/v2/transaction_controller.ex`
- Test: os testes v2 existentes de receipt são o harness da refatoração (devem passar inalterados)

**Step 1:** Rodar os testes v2 de receipt ANTES (baseline verde): `grep -rl "receipt" core/backend/test/monetarie_web/controllers/v2/ | xargs mix test`

**Step 2:** Criar `Monetarie.UseCases.Receipts.Payload` com função pública:

```elixir
@spec build(map(), Account.t() | nil, Account.t() | nil, String.t(), term()) :: map()
def build(tx_data, payer_account, receiver_account, direction, merchant_id)
```

Mover VERBATIM do controller (sem mudar comportamento): `receipt_direction/1`, `receipt_status/1`, `receipt_pix_status/1`, `pix_settlement_proven?/1`, `pix_settlement_metadata_proven?/1`, `normalize_receipt_status/1`, `int_status_id/1`, `receipt_auth_code/2`, `generate_auth_code/1`, `build_receipt_payer/4`, `build_receipt_receiver/4`, `metadata_party/2`, `build_party/3`, `build_pix_data/1`, `present?/1` e helpers que eles puxarem. O `receipt/2` do controller passa a montar `receipt_data` via `Payload.build/5` (o lookup `find_transaction_or_entry` FICA no controller).

**Step 3:** Rodar os mesmos testes: verdes, zero diff de contrato.

**Step 4: Commit** `refactor(core): serializer de comprovante extraido para UseCases.Receipts.Payload`

### Task 13: endpoints admin de comprovante (TDD)

**Files:**
- Create: `core/backend/lib/monetarie_web/controllers/admin/receipts_controller.ex`
- Modify: `core/backend/lib/monetarie_web/router.ex` (scope admin, junto das rotas bank-accounts, ~linha 2474)
- Test: `core/backend/test/monetarie_web/controllers/admin/receipts_controller_test.exs`

Rotas:

```elixir
get "/bank-accounts/:id/receipts", ReceiptsController, :index
get "/bank-accounts/:id/receipts/:transaction_id", ReceiptsController, :show
```

(Deviação registrada do design: o show é aninhado na conta em vez de `/admin/transactions/:id/receipt`, para escopo e fallback de partes; o payload é o mesmo.)

**Step 1: Testes que falham** (padrão de auth/gate: copiar setup de `admin/bank_accounts_statement_test.exs`, incluindo o teste de 403 sem admin):

- `index` lista SÓ débitos liquidados outbound de tipos pix/ted/tef da conta (fixture com 1 pix settled, 1 pix pending, 1 inbound, 1 ted settled, 1 tef settled: retorna 3), ordenado por `completed_at` desc, com `meta.total`, filtros `date_from/date_to`, paginação `page/per_page`.
- Cada item: `transaction_id`, `type`, `completed_at`, `amount` (MESMA unidade do statement: ler `ae_entry_view` antes), `fee_amount`, `description`, `recipient_name`, `recipient_document`, `status`.
- `show` devolve payload de comprovante com `payer`, `receiver`, `pix`, `auth_code`, `status` (mesmo shape do v2: comparar num teste que monta a mesma tx e chama os dois endpoints).
- `show` de tx de OUTRA conta retorna 404; `show` de tx inbound retorna 404 (comprovante de pagamento é débito).
- Gate: sem admin, 403 (usar o plug/pipeline que as rotas bank-accounts já usam; verificar `EnsureAdmin` no scope).

**Step 2: Ver falhar. Step 3: Implementar**

`index`: query em `transactions` (`account_id == ^id`, `direction == "outbound"`, `type in ["pix","ted","tef"]`, `payment_status in ["settled","completed"]`), filtros de data sobre `completed_at`, paginação com `meta.total`.
`show`: buscar tx por `transaction_id` + `account_id` + outbound; carregar contas from/to; responder `%{data: Monetarie.UseCases.Receipts.Payload.build(tx_map, from_account, to_account, "debit", tx.merchant_id)}` (montar `tx_map` no mesmo shape que o v2 monta a partir da Transaction; espelhar o adaptador do v2).

**Step 4: Ver passar + regressão admin.** **Step 5: Commit** `feat(core-admin): endpoints de comprovante por conta (lista de debitos elegiveis + payload)`

### Task 14: aba Comprovante no AccountDetail + tabela de operações

**Files:**
- Modify: `core/apps/admin/src/views/accounts/components/AccountDetail.vue` (tabs, linhas 44-48 e 122-131)
- Create: `core/apps/admin/src/views/accounts/tabs/AccountComprovantesTab.vue`

**Step 1:** Em `AccountDetail.vue`: importar a nova tab, inserir na lista entre extrato e dados:

```ts
const tabs: TabDef[] = [
  { key: 'resumo', label: 'Resumo', icon: 'pi pi-chart-bar' },
  { key: 'extrato', label: 'Extrato', icon: 'pi pi-list' },
  { key: 'comprovante', label: 'Comprovante', icon: 'pi pi-file-check' },
  { key: 'dados', label: 'Dados da Conta', icon: 'pi pi-id-card' },
]
```
e no template: `<AccountComprovantesTab v-if="activeTab === 'comprovante'" :account-id="accountId" />`.

**Step 2:** `AccountComprovantesTab.vue`, seguindo o padrão do `AccountExtratoTab.vue` (mesmos componentes PrimeVue, `api.get`, filtro de período com Calendar, DataTable com paginação server-side ligada a `meta.total`): colunas Data, Tipo (Tag PIX/TED/TEF), Recebedor (nome + documento formatado com `statementParties.ts`), Valor (formatador do extrato, débito em vermelho), e coluna de ação com Button "Emitir comprovante" que abre `Dialog` com o `ReceiptDocument` (Task 15) carregando `GET /admin/bank-accounts/:id/receipts/:transaction_id`.

**Step 3:** `pnpm --filter @monetarie/admin build` compila sem erro.

**Step 4: Commit** `feat(core-admin): aba Comprovante na tela Contas Correntes (lista de debitos elegiveis)`

### Task 15: componente ReceiptDocument (desenho do ib-front) + impressão

**Files:**
- Create: `core/apps/admin/src/views/accounts/components/ReceiptDocument.vue`
- Reference: `core/apps/banking/src/views/receipts/ReceiptView.vue` (fonte da adaptação)

**Step 1:** Copiar de `ReceiptView.vue` do banking e adaptar:
- Vira componente de apresentação: `props: { receipt: any }` (payload já buscado pela tab); REMOVER router, fetch próprio, auth e merchant store.
- MANTER: `normalizeReceipt` (normaliza os dois formatos), constantes `MONETARIE_CNPJ`/`MONETARIE_ISPB`, blocos do template (header com marca, valor/status/tarifa, dados do recebedor, informações do pagamento, dados do pagador, identificação e autenticação com copy-to-clipboard, footer legal), `handlePrint()` com iframe oculto e CSS `@media print`/`@page A4`.
- Botão "Imprimir comprovante" visível no Dialog.

**Step 2:** Build do admin verde.

**Step 3:** Testes vitest: `core/apps/admin/src/views/accounts/__tests__/` (seguir onde os specs do app vivem: `ls core/apps/admin/src/**/__tests__` / `pnpm --filter @monetarie/admin test`): (a) tab renderiza linhas do mock e o botão; (b) ReceiptDocument renderiza nome do recebedor, valor formatado, E2E e footer legal a partir de um payload fixture (copiar um payload real do teste de controller).

**Step 4:** Rodar vitest do admin: baseline + novos verdes.

**Step 5: Commit** `feat(core-admin): comprovante no desenho do ib-front com impressao (Lei 14.063)`

### Task 16: validação viva local (Playwright) + fechamento

**Step 1:** Subir o ambiente local (containers core-api + admin dev server; receita nas memórias Serena `dev_commands`/`cabines_pix_spb_local`). Criar/usar conta com um PIX enviado, uma TEF e (se o simulador local permitir) uma TED.

**Step 2:** Playwright: navegar Contas Correntes, abrir a conta, validar na aba Extrato que Pagador E Recebedor aparecem nas linhas novas; abrir aba Comprovante, emitir comprovante de um débito; screenshots de: extrato populado, lista de comprovantes, comprovante aberto (regra #11: sem screenshot não está validado).

**Step 3:** Regressão final completa das áreas tocadas (backend dirs da Fase 1 + admin vitest + build dos 3 apps frontend que compartilham shared: `pnpm --filter @monetarie/admin build`).

**Step 4:** Atualizar `docs/plans/2026-07-15-coreadmin-extrato-contraparte-comprovante-design.md` (status: implementado) e escrever handoff curto em `docs/handoff/` com o que falta (backfill HML/PROD com autorização; follow-ups: unificar ReceiptView nos 3 apps).

**Step 5: Commit** `docs(handoff): extrato contraparte + comprovante coreadmin, validacao viva local`

---

## Fora do escopo deste plano (dependem do dono)

- Deploy HML/PROD (regra dura: nunca durante operação de money-path; combinar janela).
- Backfill em HML/PROD (dry-run primeiro, contagens revisadas pelo dono).
- Push para origin (autorização explícita por push).
