# Bloco A: auth PJ, merchant subconta e API somente PJ. Plano de implementacao

> **Para o Claude:** SUB-SKILL OBRIGATORIA: use superpowers:executing-plans para executar tarefa a tarefa.

**Design aprovado:** `docs/plans/2026-07-22-auth-pj-merchant-api-design.md` (commit `2d0b4f13`).

**Objetivo:** fazer o merchant logar somente por e-mail como subconta de um CNPJ, e tornar a API
estruturalmente PJ-only, sem tocar em `accounts` nem em nenhum fluxo de dinheiro.

**Arquitetura:** tabelas novas `merchants` (uma por `users` PJ, ponte por `user_id`) e `merchant_users`
(operadores). A credencial do merchant vai para `users.login_email`, coluna nova com indice unico, porque
o `users.email` legado tem 121 valores repetidos. `api_keys` ganha `merchant_ref uuid` e a autorizacao
deixa de comparar id da URL: passa a derivar o merchant do token (claim do merchant, ou `member_pj` no IB).
Padrao expand and contract: nada e removido na mesma leva.

**Stack:** Elixir 1.17 / Phoenix 1.8, Ecto + Aurora PG 16, Guardian JWT, Vue 3 + PrimeVue.

**Regras da frente (do dono):** TDD com RED primeiro; review por task; nada de "pronto" sem prova viva;
push e deploy PRD so com OK explicito; nao deployar as jornadas de onboarding ate o QA visual do Bloco B.

---

## Convencoes deste repositorio que voce precisa saber

- Repo nos modulos: `Monetarie.Repo`. Em `rpc` dentro do container o modulo iniciado e
  `Monetarie.Infra.Repo.Base`.
- Migrations em `core/backend/priv/repo/migrations/`, nome `YYYYMMDDHHMMSS_descricao.exs`, com
  `@moduledoc` em pt-br explicando o porque, e `down` reversivel. Padrao do repo: `DROP ... IF EXISTS`
  antes do `ADD` para idempotencia.
- **CNPJ e ALFANUMERICO** (IN RFB 2.229/2024). O regex canonico do repo e `^[0-9A-Z]{12}[0-9]{2}$`, com
  constraint irma de maiusculas (`= UPPER(col)`). Ver `20260612400000_add_cnpj_alfa_checks.exs`.
  **Nao use `^[0-9]{14}$` nem `validate_length(is: 14)` do AVIV: rejeitaria CNPJ valido.**
- Testes de controller: `use MonetarieWeb.ConnCase, async: false`, token via
  `MonetarieWeb.Auth.Guardian.encode_and_sign/3`. Exemplo vivo:
  `test/monetarie_web/controllers/v2/api_key_controller_test.exs`.
- Rodar teste: `cd core/backend && mix test caminho/do_test.exs`. Na maquina do Eduardo os testes do
  Core rodam so dentro do container `monetarie-core-backend-test:latest`.
- Sem travessao em texto, docs em pt-br.

---

## Task 1: DDL aditivo

**Arquivos:**
- Criar: `core/backend/priv/repo/migrations/20260722100000_create_merchants_and_merchant_users.exs`

**Passo 1: escrever a migration**

```elixir
defmodule Monetarie.Repo.Migrations.CreateMerchantsAndMerchantUsers do
  use Ecto.Migration

  @moduledoc """
  Bloco A (design 2026-07-22): merchant vira entidade propria, subconta de um CNPJ,
  e a API passa a ser PJ-only por construcao.

  DDL 100% ADITIVO. Nada e removido aqui: `api_keys.merchant_id` (integer, hoje
  igual a `users.id`) segue intacto para rollback de codigo livre. A contracao
  acontece em migration separada, so depois do codigo novo provado nos 2 ambientes.

  `merchants.user_id` e a ponte com o legado: as contas continuam penduradas em
  `users` (`accounts.user_id`), que e money-path vivo, e NAO sao tocadas.

  `users.login_email` e coluna NOVA de proposito. O `users.email` legado tem 121
  valores repetidos e 277 PJ colidindo (medido em HML e PRD em 2026-07-22), entao
  nao pode virar credencial sem sanear 319 usuarios antes.

  CNPJ alfanumerico (IN RFB 2.229/2024): regex canonico do repo.
  """
  @cnpj_re "^[0-9A-Z]{12}[0-9]{2}$"

  def up do
    create table(:merchants, primary_key: false) do
      add :id, :binary_id, primary_key: true
      add :entity_id, :binary_id
      add :user_id, :integer, null: false
      add :document, :string, size: 14, null: false
      add :name, :string
      add :trading_name, :string
      add :status, :string, null: false, default: "active"
      add :access_mode, :string, null: false, default: "full"
      add :capability_ceiling, {:array, :string}, null: false, default: []
      timestamps(type: :utc_datetime)
    end

    create unique_index(:merchants, [:user_id])
    create unique_index(:merchants, [:document])
    create index(:merchants, [:entity_id])

    execute(
      "ALTER TABLE merchants ADD CONSTRAINT merchants_document_alfa_chk CHECK (document ~ '#{@cnpj_re}')",
      "ALTER TABLE merchants DROP CONSTRAINT IF EXISTS merchants_document_alfa_chk"
    )

    execute(
      "ALTER TABLE merchants ADD CONSTRAINT merchants_document_upper_chk CHECK (document = UPPER(document))",
      "ALTER TABLE merchants DROP CONSTRAINT IF EXISTS merchants_document_upper_chk"
    )

    create table(:merchant_users, primary_key: false) do
      add :id, :binary_id, primary_key: true
      add :merchant_id, references(:merchants, type: :binary_id, on_delete: :delete_all), null: false
      add :user_id, :integer, null: false
      add :role, :string, null: false, default: "owner"
      add :status, :string, null: false, default: "invited"
      add :capabilities, {:array, :string}, null: false, default: []
      timestamps(type: :utc_datetime)
    end

    create unique_index(:merchant_users, [:merchant_id, :user_id])
    create index(:merchant_users, [:user_id])

    alter table(:users) do
      add :login_email, :string
    end

    # Unicidade case-insensitive so onde a coluna existe. E a garantia que o
    # `users.email` legado nao tem.
    execute(
      "CREATE UNIQUE INDEX users_login_email_uidx ON users (lower(login_email)) WHERE login_email IS NOT NULL",
      "DROP INDEX IF EXISTS users_login_email_uidx"
    )

    alter table(:api_keys) do
      add :merchant_ref, references(:merchants, type: :binary_id, on_delete: :restrict)
    end

    create index(:api_keys, [:merchant_ref])

    alter table(:collaborator_invites) do
      add :merchant_id, references(:merchants, type: :binary_id, on_delete: :delete_all)
    end
  end

  def down do
    alter table(:collaborator_invites), do: remove(:merchant_id)
    alter table(:api_keys), do: remove(:merchant_ref)
    execute("DROP INDEX IF EXISTS users_login_email_uidx")
    alter table(:users), do: remove(:login_email)
    drop table(:merchant_users)
    drop table(:merchants)
  end
end
```

**Passo 2: rodar e conferir**

```
cd core/backend && mix ecto.migrate
```
Esperado: `Migrated 20260722100000` sem erro.

**Passo 3: provar as duas guardas do banco**

```
mix run -e '
  alias Monetarie.Repo
  # CNPJ alfanumerico VALIDO tem que passar
  Repo.query!("INSERT INTO merchants (id,user_id,document,status,access_mode,capability_ceiling,inserted_at,updated_at) VALUES (gen_random_uuid(), -1, ${:ok}, ...)")
'
```
Na pratica isso vira teste no Task 2. Aqui basta conferir que as constraints existem:
```
mix run -e 'IO.inspect Monetarie.Repo.query!("SELECT conname FROM pg_constraint WHERE conrelid = ${:merchants}::regclass").rows'
```

**Passo 4: commit**

```
git add core/backend/priv/repo/migrations/20260722100000_create_merchants_and_merchant_users.exs
git commit -m "feat(auth): DDL aditivo de merchants, merchant_users, login_email e api_keys.merchant_ref"
```

---

## Task 2: schemas Ecto de Merchant e MerchantUser

**Arquivos:**
- Criar: `core/backend/lib/monetarie/schemas/merchants/merchant.ex`
- Criar: `core/backend/lib/monetarie/schemas/merchants/merchant_user.ex`
- Teste: `core/backend/test/monetarie/schemas/merchants/merchant_schema_test.exs`

**Passo 1: escrever os testes que falham (RED)**

Cobrir, no minimo:
1. `Merchant.changeset` aceita CNPJ numerico de 14.
2. **`Merchant.changeset` aceita CNPJ ALFANUMERICO** (ex.: `"12ABC34501DE35"`), porque a IN RFB 2.229/2024
   vale e o AVIV nao trata isso.
3. `Merchant.changeset` recusa CPF de 11 digitos.
4. `Merchant.changeset` recusa CNPJ minusculo (constraint de maiusculas).
5. `document` duplicado viola o unique.
6. `user_id` duplicado viola o unique (um merchant por usuario PJ).
7. `MerchantUser.changeset` recusa role fora de `owner admin financial operator readonly`.
8. `MerchantUser.changeset` recusa capability fora do catalogo.
9. `[merchant_id, user_id]` duplicado viola o unique.

**Passo 2: rodar e ver falhar**

```
cd core/backend && mix test test/monetarie/schemas/merchants/merchant_schema_test.exs
```
Esperado: falha por modulo inexistente.

**Passo 3: implementar os schemas**

Espelhar o AVIV (`coreproviders/backend/lib/fluxiq/schemas/merchants/*.ex`) com duas divergencias
conscientes, que devem aparecer no `@moduledoc`:
- `document` valida pelo regex alfanumerico do repo, nao por `validate_length(is: 14)`;
- existe `user_id` (a ponte com o `users` PJ legado), que o AVIV nao tem.

Status de `merchant_users` nasce `invited` e so vira `active` no primeiro acesso (Task 9).

**Passo 4: rodar ate passar. Passo 5: commit.**

---

## Task 3: catalogo de capabilities, fail-closed

**Arquivos:**
- Criar: `core/backend/lib/monetarie/util/capabilities.ex`
- Teste: `core/backend/test/monetarie/util/capabilities_test.exs`

**Passo 1: testes RED**
1. `valid?/1` recusa capability desconhecida.
2. `ceiling_for/2` com `access_mode: "api_only"` devolve exatamente
   `["dashboard.view", "apikeys.manage", "webhooks.manage"]`.
3. `ceiling_for/2` com `access_mode: "custom"` devolve o `capability_ceiling` do merchant.
4. `effective_for/2` e a interseccao das capabilities do operador com o teto do merchant.
5. **Fail-closed: operador sem capabilities devolve lista VAZIA, nunca tudo.** Esse e o ponto onde nao
   copiamos o AVIV, que roda `require_capability_enforce=false` em log-only e tem frontend fail-open.

**Passos 2 a 5:** implementar, rodar, commitar.

---

## Task 4: CANCELADA por decisao do dono (2026-07-22)

**Nao existe backfill de merchants.** Nenhum cliente existente e ativado. Os 856 usuarios PJ de HML e
PRD seguem sem merchant, e o merchant nasce deliberadamente pelo cadastro do admin (Task 9) quando um
PJ real for habilitado para o portal merchant ou para a API.

Consequencias assumidas, que precisam aparecer na prova viva e no relatorio:
- PJ sem merchant recebe **403** na gestao de chaves pelo IB. Correto sob a regra nova.
- Ninguem entra no portal merchant ate o admin cadastrar o operador.
- Em HML existe **1 chave de API ativa de dono PJ** (`extapi-validador-funded`, usada na validacao da
  Fase 5) que ficara sem `merchant_ref`. O fallback de leitura da Task 8 mantem essa chave
  funcionando. Se quisermos migra-la para o modelo novo, e a criacao deliberada de UM merchant, nao um
  backfill, e depende de OK do dono.

---

## Task 5: login do merchant, e-mail e senha apenas

**Arquivos:**
- Criar: `core/backend/lib/monetarie_web/controllers/merchant/auth_controller.ex`
- Modificar: `core/backend/lib/monetarie_web/router.ex` (scope novo)
- Modificar: `core/backend/lib/monetarie_web/auth/guardian.ex` (claims `portal` e `merchant_id`)
- Teste: `core/backend/test/monetarie_web/controllers/merchant/auth_controller_test.exs`

**Passo 1: testes RED (estes sao os testes do mandato 4)**
1. E-mail de operador `active` de merchant `active` devolve 200, com `token`, `merchant` e `capabilities`.
2. **CPF valido devolve 401.**
3. **CNPJ valido devolve 401.**
4. E-mail com senha errada devolve 401 e a mensagem e a mesma do caso 2 e 3 (sem enumeracao).
5. Operador com `status: "suspended"` devolve 401.
6. Merchant com `status: "suspended"` devolve 401.
7. E-mail inexistente devolve 401.
8. Token emitido carrega `portal == "merchant"` e o `merchant_id` correto.
9. Usuario com `totp_enabled` recebe `mfa_required`, igual ao fluxo do IB.

**Passo 3: implementar**

Rota nova, fora do controller do IB:
```elixir
scope "/api/merchant", MonetarieWeb.Merchant do
  pipe_through [:api, :rate_limited_auth]
  post "/auth/login", AuthController, :login
end
```

O controller **nunca** chama `String.replace(x, ~r/\D/, "")` e **nunca** consulta `users.tax_id`.
Ele exige `%{"email" => email, "password" => password}`, e se o valor nao contiver `@` devolve 401 sem
consultar o banco (com `Bcrypt.no_user_verify()` para nao vazar tempo).

Resolucao: `login_email` (lower) para `users` para `merchant_users` com `status: "active"` para
`merchants` com `status: "active"`. Falta de qualquer elo devolve o mesmo 401 generico.

Em `Guardian.build_claims/3` adicionar `maybe_add_claim("portal", ...)` e `maybe_add_claim("merchant_id", ...)`,
e em `merge_entity_claims/2` propagar os dois para o resource, no mesmo estilo dos claims de entidade.

**Divergencia consciente do AVIV a registrar no `@moduledoc`:** o `classify_credential` do AVIV
(`merchant/auth/login_controller.ex:48`) aceita CPF de 11 digitos e cai em `do_legacy_login`. Aqui esse
ramo nao existe, por ordem do dono.

---

## Task 6: o IB passa a recusar e-mail

**Arquivos:**
- Modificar: `core/backend/lib/monetarie_web/controllers/v2/auth_controller.ex:42`
- Teste: `core/backend/test/monetarie_web/controllers/v2/auth_email_rejection_test.exs`

**Passo 1: testes RED**
1. Login do IB com CPF continua 200 (nao regredir).
2. Login do IB com CNPJ continua 200 (nao regredir).
3. **Login do IB com string contendo `@` devolve 401**, sem consultar o banco.

**Passo 3: implementar.** Guarda no inicio do `login/2`: se `String.contains?(document, "@")`, devolve o
401 padrao. Hoje o strip transforma e-mail em string vazia e o efeito ja e 401, mas por acidente. O teste
tranca o comportamento e a intencao.

---

## Task 7: gate PJ na gestao de chaves (coracao do mandato 3)

**Arquivos:**
- Criar: `core/backend/lib/monetarie_web/plugs/merchant_scope.ex`
- Modificar: `core/backend/lib/monetarie_web/controllers/v2/api_key_controller.ex` (remover `authorized?/2`)
- Teste: `core/backend/test/monetarie_web/controllers/v2/api_key_pj_gate_test.exs`

**Passo 1: testes RED**
1. **Token de usuario PF logado no IB, `POST /api/merchants/:id/api-keys`: 403.**
2. Idem para `PATCH .../:id`, `PATCH .../:id/status`, `PATCH .../:id/ip-whitelist`, `DELETE .../:id` e
   `GET` de listagem: 403.
3. **Token de usuario PJ logado no IB: 201**, e a chave nasce com `merchant_ref` do merchant daquele CNPJ.
4. Token de merchant com capability `apikeys.manage`: 201.
5. Token de merchant sem a capability: 403.
6. **O `merchant_id` da URL e ignorado:** token do merchant A tentando criar chave para o merchant B cria
   no A ou devolve 403, nunca escreve no B. (Trancar como 403.)
7. Usuario PJ sem merchant (backfill nao rodou) devolve 403, nunca 500.

**Passo 3: implementar**

`MerchantScope` resolve e atribui `conn.assigns[:merchant]`:
- claims com `portal == "merchant"`: carrega o merchant do claim e exige capability efetiva;
- token de IB: carrega `users`, exige `user_type == "member_pj"`, resolve `merchants` por `user_id`;
- qualquer outro caso: 403 com `%{error: "Acesso nao autorizado"}` (mesma forma de hoje, para nao
  quebrar contrato de tela).

O `ApiKeyController` passa a usar `conn.assigns.merchant.id` e a gravar `merchant_ref`. **Apagar
`authorized?/2`**, que e a origem da falha.

**Cinto e suspensorio:** `ApiKeys.create_api_key/2` recusa quando o `users` alvo nao e `member_pj`.

---

## Task 8: resolucao de conta pelo caminho novo, sem quebrar a Partner API

**Arquivos:**
- Modificar: `core/backend/lib/monetarie_web/plugs/api_key_auth.ex:187`
- Modificar: `core/backend/lib/monetarie_web/controllers/external/helpers.ex:35-49`
- Teste: `core/backend/test/monetarie_web/controllers/external/merchant_ref_resolution_test.exs`

**Passo 1: testes RED**
1. Chave com `merchant_ref` resolve a conta por `merchants.user_id` para `accounts` e responde igual ao
   comportamento de hoje (regressao da Fase 5).
2. **Chave de parceiro (`partner_id` preenchido, `merchant_ref` nulo) continua funcionando**, provando
   que a Partner API ficou fora do escopo.
3. Chave sem `merchant_ref` e sem `partner_id` e recusada.
4. Chave cujo merchant esta `suspended` e recusada.

**Passo 3: implementar** com fallback de leitura no `merchant_id` antigo enquanto a coluna existir,
para o deploy nao ter janela quebrada. Marcar o fallback com `# TODO contracao Task 14`.

---

## Task 9: cadastro do operador e primeiro acesso

**Arquivos:**
- Criar: `core/backend/lib/monetarie/use_cases/merchants/operators.ex`
- Modificar: `core/backend/lib/monetarie/use_cases/collaborators.ex` (aceitar `merchant_id`)
- Criar: rotas de admin em `router.ex` sob o escopo admin ja existente
- Teste: `core/backend/test/monetarie/use_cases/merchants/operators_test.exs`

**Passo 1: testes RED**
1. Admin cria operador com e-mail e role: nasce `merchant_users` com `status: "invited"` e convite com token.
2. `login_email` duplicado e recusado com erro legivel, nao com 500 de constraint.
3. Primeiro acesso com token valido define senha e vira `status: "active"`.
4. Token expirado devolve erro e nao ativa.
5. Operador de um merchant nao consegue aceitar convite de outro.
6. **Nenhuma senha e herdada:** o fluxo nunca copia `users.pass` existente.

---

## Task 10: frontends

**Arquivos:**
- Modificar: `core/apps/merchant/src/views/auth/LoginView.vue` (ja alterado na working tree, e-mail only)
- Modificar: `core/apps/merchant/src/http/api.ts` (apontar para `/api/merchant/auth/login`)
- Modificar: `core/apps/merchant/src/stores/auth.ts` (guardar `capabilities` e `merchant`)
- Modificar: menu e rotas do merchant para esconder API sem `apikeys.manage`
- Modificar: `core/apps/admin/...` tela de operadores do merchant

**Regra:** o esconder do frontend e UX. A decisao real e sempre do backend (Task 7).

**Prova:** `pnpm --filter @monetarie/merchant-portal build` verde e vitest verde.

---

## Task 11: remocao das chaves de API em conta PF

**Arquivos:**
- Criar: `core/backend/lib/monetarie/release/pf_api_key_purge.ex`
- Teste: `core/backend/test/monetarie/release/pf_api_key_purge_test.exs`

**Alvo medido em 2026-07-22** (secao 1.2 do design): HML tem 3 chaves de dono `member_pf`
(`extapi-validador-hml` ativa, `dev vulci` ativa, `dev vulci` revogada), nenhuma com `last_used_at` e
nenhuma com webhook ligado. PRD tem zero chaves.

**Passo 1: testes RED**
1. Remove chave cujo dono e `member_pf`.
2. **Nao toca em chave de parceiro** (`partner_id` preenchido). Ordem explicita do dono.
3. Nao toca em chave de dono `member_pj`.
4. Idempotente.
5. Registra auditoria com id, nome e dono de cada chave removida.

---

## Task 12: deploy HML e prova viva

Ordem: migration via `run-task` com a imagem nova ANTES do swap, depois backfill (Task 4), depois purge
(Task 11), depois swap do `core-api`, depois as UIs.

**Provas obrigatorias, todas com saida colada no relatorio:**
1. Login do IB com CPF: 200. Login do IB com CNPJ: 200.
2. Login do IB com e-mail: 401.
3. Login do merchant com e-mail de operador real: 200 com claim `portal: merchant`.
4. **`curl` direto no backend, sem tela, com CPF no endpoint do merchant: 401.**
5. **`curl` direto com CNPJ no endpoint do merchant: 401.**
6. **`curl` de criacao de api_key com token de usuario PF: 403.**
7. `curl` de criacao de api_key com token de usuario PJ pelo IB: 201.
8. Uma rota da API externa da Fase 5 respondendo igual ao de hoje.
9. Chave de parceiro ainda funcionando.
10. Screenshot das telas (regra 11).

---

## Task 13: deploy PRD

**Pre-condicao que nao pode ser pulada:** levantar, read-only, quem usa o portal merchant em PRD hoje.
Se houver alguem, os operadores tem que estar cadastrados e com primeiro acesso feito ANTES do corte,
senao a pessoa perde o acesso. PRD tem zero `api_keys`, entao o lado da API e no-op.

Deploy so com OK explicito do dono. UIs do banking e do merchant precisam de BUILD separado com
`--build-arg VITE_APP_ENV=production` (retag por digest deixaria o selo "Homologacao" em producao).

---

## Task 14: contracao (migration separada, depois de tudo provado)

Derrubar `api_keys.merchant_id` inteiro e o fallback de leitura do Task 8. So depois do codigo novo
provado nos dois ambientes.

---

## O que este plano NAO faz

Nao toca pix, spb, ledger, TigerBeetle nem qualquer fluxo de dinheiro. Nao migra `accounts`. Nao mexe na
Partner API. Nao trata o Bloco B (QA visual do onboarding).
