# Plano de Implementação — Trava de contas obrigatórias na liberação do contrato

- **Spec:** `docs/superpowers/specs/2026-07-06-loan-disbursement-required-accounts-design.md`
- **Branch:** `feat/loan-product-required-cosif-accounts`
- **Data:** 2026-07-06

## Fase 0 — Aterrissagem (mapeada)

- `PUT /api/v1/credit/loans/:id/disburse` → `LoanController.disburse/2` (`loan_controller.ex:210`)
  → `Loans.disburse_loan/2` (`loans.ex:278`), `Ecto.Multi`.
- Passo `:loan` (`loans.ex:283`): `repo.preload(loan, [:loan_product, :member])` — **sem** `:historic`.
- Passo `:validate` (`loans.ex:289`): checa `status == "approved"`.
- `FallbackController` mapeia átomos de erro → HTTP; shape `{"error": {"status","message","details"}}`.
- Front: `LoanDetailView.vue` (handler `:187`) e `components/LoanDetail.vue` (handler `:109`),
  ambos `disburseLoan.mutateAsync` (`useLoans.ts:152`) — `catch` com toast genérico.

## Fase 1 — `ProductAccounts` (2 adições)

```elixir
def missing_for_product(%{model_type: mt, historic: historic, cosif_account: cosif}) do
  hist = case historic do
    %LoanProductsHistoric{} = h -> h
    _ -> nil
  end
  missing_historic =
    required_historic_fields(mt)
    |> Enum.reject(fn f -> filled?(hist && Map.get(hist, f)) end)
  missing_cosif =
    if requires_cosif_account?(mt) and not filled?(cosif), do: [:cosif_account], else: []
  missing_historic ++ missing_cosif
end
def missing_for_product(_), do: [:product]  # sem produto → não libera

@account_labels %{...}  # movido pra cá (fonte única)
def account_label(field), do: Map.get(@account_labels, field, Atom.to_string(field))
```

**Aceite:** produto completo → `[]`; incompleto → campos certos; sem historic → tudo faltando.

## Fase 2 — `Loans.disburse_loan/2` (fail-closed)

- Passo `:loan`: `repo.preload(loan, [:member, loan_product: :historic])`.
- Novo passo após `:loan`:
  ```elixir
  |> Multi.run(:validate_product_accounts, fn _repo, %{loan: loan} ->
    case ProductAccounts.missing_for_product(loan.loan_product) do
      [] -> {:ok, :valid}
      missing -> {:error, {:liberation_accounts_missing, missing}}
    end
  end)
  ```
- `alias Monetarie.UseCases.Credit.ProductAccounts` no topo de `loans.ex` (se ainda não houver).

**Aceite:** disburse de produto incompleto aborta a Multi; loan segue `approved`.

## Fase 3 — Controllers

- `FallbackController`: `def call(conn, {:error, {:liberation_accounts_missing, fields}})` →
  422 com `ProductAccounts.account_label` nos campos.
- `LoanProductController`: substituir o `@account_labels`/`account_label` local por
  `ProductAccounts.account_label/1` (DRY).

**Aceite:** disburse HTTP sem contas → 422 com a lista; cadastro continua 422 (sem regressão).

## Fase 4 — Frontend

- `LoanDetailView.vue` e `components/LoanDetail.vue`: no `catch` do `handleDisburse`,
  `detail = err?.response?.data?.error?.message || '<fallback>'`.

**Aceite:** ao liberar produto incompleto, o toast mostra o motivo (contas faltantes).

## Fase 5 — Testes

- `product_accounts_test.exs`: `missing_for_product` — struct completo → `[]`; incompleto por
  tipo; `historic` `nil`/NotLoaded → tudo faltando; limite (liberation+cosif).
- `disburse_loan` (DataCase, em `loans_test.exs` ou novo): setup com produto **incompleto**
  (historic sem alguma conta) → `{:error, {:liberation_accounts_missing, _}}` e `Repo.get`
  do loan segue `"approved"`; produto **completo** → `{:ok, _}` `status "active"`.

## Fase 6 — Validação empírica

- `docker build` core-api + admin.
- HTTP: produto **incompleto** → criar+aprovar loan → `disburse` = **422** (loan segue
  approved); produto **completo** → `disburse` = **200** (loan active). Limpar dados de teste.
- Tela: liberar contrato de produto incompleto → aviso com o motivo.

## Riscos

| Risco | Mitigação |
|---|---|
| Preload aninhado `loan_product: :historic` alterar comportamento | Teste de disburse feliz (produto completo) segue passando |
| `missing_for_product` com `historic` `%NotLoaded{}` | Cláusula trata como `nil` → tudo faltando; caller preloada |
| Bloquear liberações legítimas de produtos já completos | Reusa a MESMA matriz do cadastro; produto criado pós-feature já nasce completo |
