# Handoff: fix do cadastro de chave PIX pela tela do IB (openingDate ausente)

Data: 2026-07-17 (noite)
Autor da investigação: sessão "cadastro EVP Gabriel Cardoso"
Severidade: CRÍTICA (cadastro de chave pelo IB está 100% quebrado para todos os clientes)
Escopo do fix: apenas `core-api` (nenhuma mudança em pix/cabine). Sem migration.

## Resumo em uma frase

O cadastro de chave PIX pela tela do Internet Banking rejeita 100% das
tentativas com `bacen_rejected` porque o Core (`V2.PixController.create_key`)
não envia o `openingDate` da conta ao registrar a chave no DICT do BACEN. A
Partner API já faz certo; o conserto é espelhar o que ela faz.

## Como isto foi provado (sem inferência)

Tudo abaixo é observação empírica, não dedução.

1. Prova viva em HML pela cadeia HTTP real do IB, autenticado como o próprio
   cliente Gabriel Cardoso (CPF `09918358912`, conta HML id `10023087` /
   `0982-4`), senha resetada para teste:
   - `POST /api/auth/login` (CPF + senha) devolveu 200 com o usuário e o JWT.
   - `GET /api/accounts` devolveu a conta `10023087` (número `000000982`).
   - `POST /api/accounts/10023087/pix/keys` com corpo `{"type":"evp"}` devolveu
     **HTTP 422** `{"error":{"reason":":bacen_rejected"}}`.
   - Log da cabine (pix-api HML) no mesmo instante: DICT do BACEN respondeu
     `400 Bad Request` com `detail = "Text '' could not be parsed at index 0"`.

2. Prova de controle em HML (isolando a variável `openingDate`), chamando
   `DictService.Keys.bacen_create_entry_payload/1` + `DictClient.create_entry/1`
   direto no nó, para a mesma conta do Gabriel:
   - `opening_date: nil` (compact_map remove o campo) => BACEN `400`,
     `"could not be parsed at index 0"`.
   - `opening_date: "2026-07-01"` (date-only) => BACEN `400`,
     `"could not be parsed at index 10"`.
   - `opening_date: "2026-07-01T00:00:00Z"` (dateTime ISO completo) => BACEN
     **200**, chave EVP criada e devolvida pelo BACEN. (Chave de teste excluída
     em seguida via `DictClient.delete_entry/2` = 200.)

Conclusão empírica: o BACEN exige `openingDate` como dateTime ISO completo. Sem
ele (ou como date-only) o CreateEntry falha.

## Causa-raiz exata (arquivo:linha)

- Trilho IB, payload SEM `opening_date`:
  `core/backend/lib/monetarie_web/controllers/v2/pix_controller.ex:970-988`
  (função `create_key/2`). O mapa passado a `Provider.create_key/2` tem
  `key_type`, `key_value`, `account`, `agency`, `account_type`, `owner_type`,
  `owner_document`, `owner_name`, `merchant_id`. Não há `opening_date`.

- Trilho Partner, payload COM `opening_date` (referência de como fazer certo):
  `core/backend/lib/monetarie_web/controllers/partner_v1/pix_controller.ex:119-132`
  inclui `"opening_date" => account_opening_date(account)`. O helper está em
  `partner_v1/pix_controller.ex:2119-2128`:
  ```elixir
  defp account_opening_date(%Account{inserted_at: %DateTime{} = dt}),
    do: dt |> DateTime.truncate(:second) |> DateTime.to_iso8601()

  defp account_opening_date(%Account{inserted_at: %NaiveDateTime{} = dt}),
    do: dt |> NaiveDateTime.truncate(:second) |> DateTime.from_naive!("Etc/UTC") |> DateTime.to_iso8601()

  defp account_opening_date(_account), do: nil
  ```

- A cabine já sabe consumir o campo (não precisa mexer): o responder lê
  `data["opening_date"]` em
  `pix/backend/apps/dict_service/lib/dict_service/nats/dict_api_responder.ex:345`
  e passa adiante; `DictService.Keys.bacen_create_entry_payload/1`
  (`pix/backend/apps/dict_service/lib/dict_service/keys.ex`) monta
  `"openingDate" => date_to_iso(attrs[:account_opening_date] || attrs[:opening_date])`
  e o `compact_map` descarta quando nil (por isso hoje o campo some do XML).

## Fix proposto

Em `V2.PixController.create_key/2`
(`core/backend/lib/monetarie_web/controllers/v2/pix_controller.ex`):

1. Resolver a conta (já é resolvida hoje via `find_user_account/2`, que retorna
   `{:ok, account_id}`). Ajustar para ter o `%Account{}` em mãos (ou carregar o
   `inserted_at` da conta), porque hoje só o `id` é usado.
2. Acrescentar ao mapa passado a `Provider.create_key/2` a chave
   `"opening_date"` com o `inserted_at` da conta formatado como dateTime ISO
   completo (`DateTime.to_iso8601/1`), exatamente como o helper
   `account_opening_date/1` do trilho Partner.
3. Não mudar mais nada do payload nem da cabine.

Sugestão de implementação enxuta (mesma disciplina do Partner): extrair
`account_opening_date/1` para um módulo compartilhável (ou duplicar o helper no
controller do IB), para os dois trilhos derivarem a data da mesma fonte
(`account.inserted_at`).

## SOLUÇÃO IMPLEMENTADA (2026-07-17 noite, autorizada pelo dono)

Implementada com TDD (RED 6/6 provado antes do codigo, GREEN depois) na sessao
da frente de qualidade. Autorizacao do dono: commit, push, build e deploy em
HML; PRD SEGURADO ate comando dele.

1. Fonte unica nova: `core/backend/lib/monetarie/util/dict_opening_date.ex`
   (`Monetarie.Util.DictOpeningDate.from_account/1`). Recebe a conta e devolve
   o `inserted_at` truncado a segundos como dateTime ISO 8601 completo
   (`2026-07-09T00:19:53Z`); `NaiveDateTime` assume UTC; sem `inserted_at`
   utilizavel devolve `nil` (o compact_map da cabine descarta). Testes:
   `test/monetarie/util/dict_opening_date_test.exs` (4 casos, incluindo a
   garantia de que NUNCA sai date-only).

2. Trilho IB corrigido: `V2.PixController.create_key`
   (`core/backend/lib/monetarie_web/controllers/v2/pix_controller.ex`) agora
   carrega a conta resolvida (`Repo.get(Account, account_id)`) e inclui
   `"opening_date" => Monetarie.Util.DictOpeningDate.from_account(account)`
   no payload do `Provider.create_key`. Nada mais do payload mudou.

3. Trilho Partner dedupado: o helper local `account_opening_date/1` de
   `partner_v1/pix_controller.ex` virou delegacao para a mesma fonte unica
   (comportamento identico, teste comportamental existente da Partner que
   inspeciona o payload do provider continua verde).

4. Guarda de regressao: `test/monetarie_web/controllers/v2/pix_controller_opening_date_test.exs`
   (estilo AST da casa) trava que o create_key do IB envia `opening_date`
   derivado da fonte unica e que o Partner usa a mesma fonte.

Suites: `test/monetarie_web/controllers/partner_v1/` +
`test/monetarie_web/controllers/v2/` = 203 testes, 0 falhas.

Deploy HML e validacao viva: registrados na secao "Execucao do deploy" no fim
deste arquivo.

## Validação do fix (obrigatória antes de declarar resolvido)

1. TDD no controller: teste que `create_key` inclui `opening_date` no payload
   enviado ao `Provider` (com um `%Account{inserted_at: ...}` conhecido),
   formatado como dateTime ISO completo. RED antes do fix, GREEN depois.
2. Prova viva em HML pela MESMA cadeia HTTP do IB (login do cliente ->
   `POST /accounts/:id/pix/keys {type:"evp"}`): deve devolver 202/aceito e a
   chave deve aparecer em `GET /accounts/:id/pix/keys`. Excluir a chave de teste
   ao final (`DELETE`), para não deixar lixo no DICT homolog.
3. Só então deploy de `core-api` (regra do dono: em produção não existe teste; a
   prova definitiva é o cliente cadastrando pela tela com acompanhamento).

## Estado atual da chave do Gabriel em PRODUÇÃO (contexto, não é parte do fix)

A EVP do Gabriel JÁ foi registrada em produção nesta sessão, por CONTORNO do bug
(injetei o `opening_date` real da conta no `Provider.create_key` via rpc). Ela
está viva e visível para ele no IB:

- Chave EVP (gerada pelo BACEN): `31eaebe4-ecab-4e14-a25e-2458f951a705`
- Evento no DICT do BACEN: `ADDED` (registro autoritativo confirmado).
- Espelho na cabine `monetarie_dict.keys`: id 1222, status `ACTIVE`, conta 2031.
- `Provider.list_keys` filtrando pelo CPF do Gabriel retorna a chave (é a mesma
  fonte que a tela do IB consome). Ou seja: o objetivo do dono (chave disponível
  no IB do Gabriel) está cumprido, independentemente do fix.

Não reprocessar nem recriar esta chave. O fix é para destravar o cadastro pela
tela para todos os clientes daqui em diante.

## Pontos de atenção correlatos (não bloqueiam o fix principal; avaliar depois)

1. accountNumber = ID interno no DICT. Tanto o IB quanto o Partner gravam
   `"account" => to_string(account.id)` (o PK), não o número textual da conta
   (`0982-4`). A EVP do Gabriel foi para o BACEN com `accountNumber = "2031"`.
   No recebimento, `Monetarie.UseCases.Banking.AccountResolver.resolve_account_id/1`
   (`core/backend/lib/monetarie/use_cases/banking/account_resolver.ex:85-95`)
   tenta `find_account_by_number(creditor_account)` PRIMEIRO. Query read-only em
   PRD confirmou que NÃO existe conta com número `2031`, então não há risco de
   crédito na conta errada; o recebimento cai no fallback por documento
   (`find_account_by_document` pelo CPF). É funcional porém frágil. Ideal:
   registrar o número textual real da conta no DICT. Vale um estudo próprio
   (afeta o Partner também).

2. Listagem do IB com teto de 50. `handle_list_keys`
   (`pix/backend/apps/dict_service/lib/dict_service/nats/dict_lookup_responder.ex:104`)
   busca só as 50 chaves mais recentes do ISPB inteiro e SÓ DEPOIS filtra pelo
   CPF do dono. Com o crescimento do diretório, chaves antigas de um cliente
   podem sumir da tela dele. Corrigir para filtrar por `owner_cpf_cnpj` no banco
   (não em memória sobre um top-50 global).

3. Casing no create do pix-admin. `EntryController.create`
   (`pix/backend/apps/dict_service/lib/dict_service_web/controllers/entry_controller.ex:38`)
   faz `String.to_existing_atom(params["key_type"])` sem baixar caixa, enquanto o
   frontend pix-admin envia `"EVP"` maiúsculo. Isso tende a levantar
   `ArgumentError`. A migration `20260717150000` (CHECK key_type = UPPER) protege
   a COLUNA, não este `to_existing_atom`. Alinhar com o trilho NATS, que faz
   `String.downcase` antes (`dict_api_responder.ex:335`).

## Execução do deploy (2026-07-17 noite)

- Commit do fix: `b0cd09ad` (pushado na main, `23f4ab3f..b0cd09ad`).
- Build: `scripts/deploy_hml_arm64.sh homolog-b0cd09ad-openingdate-20260717 core-api`.
- HML: task-def `monetarie-core-api-homolog:162` (rollback = `:161`,
  imagem `homolog-23f4ab3f-tedarc-20260717`), rollout COMPLETED, health 200.
  Sem migration; so core-api (pix/spb intocados).
- PRD: SEGURADO por ordem do dono. Quando ele liberar: retag do MESMO digest
  de homolog (imagetools, guard MATCH) e registrar task-def nova em cima da
  revisao vigente de `monetarie-core-api-prod` na hora (a familia avanca pela
  sessao paralela; hoje esta em `:64`).

## Validacao viva pos-deploy (HML, mesma cadeia HTTP do IB, como Gabriel)

1. ANTES (rev `:161`, minutos antes do swap): `POST /api/accounts/10023087/pix/keys
   {"type":"evp"}` com o JWT do proprio Gabriel devolveu **422 :bacen_rejected**
   (o defeito reproduzido pela ultima vez).
2. DEPOIS (rev `:162`): a MESMA chamada devolveu **202** com a chave EVP
   `707ecb57-85d8-44b0-a671-19c8c18f6000` GERADA PELO BACEN.
3. `GET /api/accounts/10023087/pix/keys` = 200 com a chave `ACTIVE` na
   listagem que a tela do IB consome.
4. Limpeza: `DELETE .../pix/keys/707ecb57-...` = 202 e a listagem voltou a 0
   chaves (nada de lixo no DICT homolog; a chave de teste foi excluida).

Cadastro de chave pela tela do IB esta FUNCIONANDO em HML. A prova definitiva
de PRD, pela regra do dono, e o cliente cadastrando pela tela com
acompanhamento, depois do deploy autorizado.

## Referências de código

- IB create (defeito): `core/backend/lib/monetarie_web/controllers/v2/pix_controller.ex:947-1023`
- Partner create (referência correta): `core/backend/lib/monetarie_web/controllers/partner_v1/pix_controller.ex:113-156` + `:2119-2128`
- Cabine responder: `pix/backend/apps/dict_service/lib/dict_service/nats/dict_api_responder.ex:330-390`
- Cabine payload BACEN: `pix/backend/apps/dict_service/lib/dict_service/keys.ex` (`bacen_create_entry_payload/1`, `date_to_iso/1`, `compact_map/1`)
- Resolver de conta no recebimento: `core/backend/lib/monetarie/use_cases/banking/account_resolver.ex:84-110`
