# Plano de implementação — Conta contábil de IOF no produto de investimento

> Spec: `docs/superpowers/specs/2026-07-20-iof-conta-contabil-investimento-design.md` (commit `eaa374a0`).
> Branch: `feat/loan-product-required-cosif-accounts`. Base: `eaa374a0`.
> Ordem TDD: teste RED antes de cada implementação, conforme prática do projeto.

## Pré-requisitos

- Stack local no ar (`monetarie-local`), TigerBeetle acessível.
- Imagem de teste `monetarie-core-test:local` construída. Receita e gotchas em
  `SUBIR GIT MON/CODE-REVIEW-razao-desembolso-e-unidade-investimento.md`:
  `DB_PORT=5432` dentro da rede, `TB_ADDRESSES` com **IP** (TigerBeetlex rejeita DNS),
  `--privileged` para io_uring, e **montar o fonte de uma cópia gravável** — mount `:ro` quebra o
  ciclo de recompilação do Mix em mudanças grandes.

---

## Passo 1 — Migration

**Arquivo novo:** `core/backend/priv/repo/migrations/20260720230000_add_iof_account_to_investment_products.exs`

Timestamp escolhido acima da última existente (`20260720210000_create_ccs_accs002_orphans.exs`).

```elixir
def change do
  alter table(:investment_products) do
    add :iof_account, :string
  end
end
```

Nullable de propósito: produtos legados existem sem a conta; a obrigatoriedade é no changeset e só
para produto novo (Passo 3).

**Verificação:** `mix ecto.migrate` no banco de teste; conferir a coluna em
`information_schema.columns`.

---

## Passo 2 — Teste RED do changeset

**Arquivo:** `core/backend/test/monetarie/use_cases/investments/investment_products_test.exs`
(criar se não existir).

1. Produto **novo** sem `iof_account` → changeset inválido, erro em `:iof_account`.
2. Produto **legado** (já persistido) editado sem `iof_account` → continua válido.

Deve falhar agora (o campo ainda não existe no schema).

---

## Passo 3 — Schema do produto

**Arquivo:** `core/backend/lib/monetarie/schemas/investments/investment_product.ex`

- Adicionar `field :iof_account, :string` junto às outras contas (linhas ~21-24).
- Incluir `:iof_account` na lista do `cast/3` (linha ~33-35).
- Incluir `:iof_account` no `validate_required` de `require_cosif_for_new/1` (linha ~50).

`require_cosif_for_new/1` já casa só quando `data.id == nil`. Não alterar essa cláusula — é ela que
preserva os produtos legados.

**Verificação:** Passo 2 passa a GREEN.

---

## Passo 4 — `resolve_accounts`

**Arquivo:** `core/backend/lib/monetarie/use_cases/investments/investment_journal.ex`

Em `resolve_accounts/1`, acrescentar `iof_id: product.iof_account` ao mapa devolvido.

**NÃO** incluir no mapa `required` (que hoje tem `cosif_id`, `provision_id`, `expense_id`). Incluir
faria a apropriação diária e a aplicação falharem por falta de uma conta que só o resgate antecipado
usa.

**Verificação:** teste unitário garantindo que `resolve_accounts` devolve `iof_id` e que produto sem
`iof_account` **continua** devolvendo `{:ok, _}` (não vira `{:error, :missing_account_code}`).

---

## Passo 5 — Teste RED do lançamento e do movimento

**Arquivo:** `core/backend/test/monetarie/use_cases/investments/investment_iof_journal_test.exs` (novo).
`@moduletag :integration` — toca o razão, no padrão de `investment_ledger_unit_test.exs`.

Casos (spec §Testes):

1. Resgate com **menos de 30 dias** e `iof_account` configurado → **um** movimento `kind: :tax` com
   descrição de IOF e **um** lançamento COSIF `D cosif / C iof` no valor exato de `redemption.iof`.
2. Resgate com **30+ dias** (IOF zero) → nenhum movimento e nenhum journal de IOF, e **não** bloqueia
   mesmo com produto sem `iof_account`.
3. Produto **sem** `iof_account` e IOF > 0 → `{:error, :missing_iof_account}` e **nada persistido**
   (investimento segue ativo, sem movimentos novos).
4. Partidas dobradas do resgate fecham: soma dos débitos == soma dos créditos, já com o IOF.

Fixtures: reaproveitar os de `investment_ledger_unit_test.exs` (usuário real por FK, `ensure_test_bank`,
instituição única por causa do trigger `trg_investment_products_default`, carteira TB da conta).

---

## Passo 6 — `journal_iof/6`

**Arquivo:** `investment_journal.ex`, junto de `journal_irrf/6` (~linha 341).

```
D  account_ids.cosif_id
C  account_ids.iof_id
amount          iof
description     "IOF retido no resgate"
reference_type  "INVESTMENT_REDEMPTION"
reference_id    "#{investment.id}:iof:#{event_id}"
```

Cláusula `journal_iof(multi, _, _, 0, _, _), do: multi` para valor zero, igual às irmãs.

O sufixo `:iof:` no `reference_id` preserva a idempotência pelo índice parcial
`(reference_type, reference_id, entry_date)` sem colidir com `:irrf:` nem `:correction:` do mesmo
resgate.

---

## Passo 7 — `maybe_insert_iof/7` com a guarda fail-closed

**Arquivo:** `core/backend/lib/monetarie/use_cases/investments/investments.ex`, junto de
`maybe_insert_tax/7` (~linha 775).

Três cláusulas, nesta ordem:

1. `iof == 0` → devolve o multi intacto (resgate de 30+ dias não bloqueia).
2. `iof > 0` e `product.iof_account` nulo/vazio → `Multi.error(:iof_account, :missing_iof_account)`,
   abortando o resgate inteiro.
3. Caso normal → movimento `kind: :tax`, `amount: iof`, `balance_after: 0`, `debit_account:
   product.cosif_account`, `credit_account: product.iof_account`, descrição
   **"IOF sobre rendimento"**, `redemption_event_id: event_id`; encadeia `journal_iof`.

Reusa o kind `:tax` (valor 4, já no enum). Não criar kind novo — IRRF e IOF são ambos tributos
retidos e a descrição os distingue no extrato.

Usar `Multi.error/3` (não `raise`) para que o erro chegue ao `case` do `Repo.transaction` e vire
`{:error, :missing_iof_account}` pela cláusula `{:error, _step, reason, _}` já existente.

**Verificação:** Passo 5 passa a GREEN.

---

## Passo 8 — Ligação no pipeline do resgate

**Arquivo:** `investments.ex`, linha ~339, imediatamente após a chamada do IRRF:

```elixir
|> maybe_insert_tax(investment, product, account_ids, redemption.irrf, redemption_event_id, date)
|> maybe_insert_iof(investment, product, account_ids, redemption.iof, redemption_event_id, date)
```

---

## Passo 9 — Erro legível na tela

**Backend:** garantir que `:missing_iof_account` chegue ao controller e vire mensagem acionável, não
erro genérico.

**Frontend:** o handler de erro do resgate deve exibir algo como *"Produto sem conta contábil de IOF
configurada. Configure em Produtos de Investimento antes de resgatar."*

**Verificação:** teste de controller com produto sem conta, conferindo status e corpo da resposta.

---

## Passo 10 — Frontend: campo no produto

**`core/apps/admin/src/types/investments.ts`** (linhas ~15-18): `iof_account: string | null`.

**`core/apps/admin/src/views/investments/InvestmentProductsView.vue`:**

- default do form (~linha 112-115): `iof_account: null`
- união `CosifField` (~linha 179): acrescentar `'iof_account'`
- mapa `cosifSuggestions` (~linha 181-184): `iof_account: []`
- campo no template (~linha 382): mesmo `AutoComplete` de busca COSIF das outras quatro, rótulo
  **"Conta de IOF a recolher"**

**Verificação:** `npx vue-tsc --noEmit` limpo; `npx vitest run src/views/investments` verde; rebuild
do admin e conferência do campo na tela.

---

## Validação final

1. **Suíte completa do core** na imagem de teste, comparando o conjunto de falhas com o baseline do
   commit imediatamente anterior. Critério: **diff vazio**. Não aceitar "número parecido" — foi assim
   que 21 regressões apareceram na frente do razão.
2. **Prova viva:** criar aplicação, resgatar antes de 30 dias, e conferir:
   - movimento `:tax` de IOF no extrato da aplicação
   - lançamento COSIF `D cosif / C iof`
   - débitos == créditos no resgate
   - TigerBeetle e extrato PG batendo ao centavo (invariante da frente anterior)
3. **Teste do bloqueio:** produto sem `iof_account`, resgate antecipado → erro claro na tela, nada
   persistido.

---

## Consertos de dados (após o deploy do código)

1. **Produto TESTE (código 001)** não tem `iof_account`. Preencher, ou todo resgate antecipado dele
   passa a falhar. Fazer **antes** de anunciar a mudança aos operadores.
2. **Resgate `INV-2026-386387`** reteve R$ 0,95 sem lançamento. Decisão do dono: lançar
   retroativamente ou registrar como divergência conhecida. Se lançar, usar o mesmo `reference_id`
   (`<id>:iof:<event_id>`) para manter a idempotência.

---

## Riscos

| Risco | Mitigação |
|---|---|
| Fail-closed travar resgate em produção | A guarda só age com `iof > 0` (< 30 dias). Ainda assim, preencher o `iof_account` de todos os produtos ativos antes do deploy. |
| Conta de IOF errada no plano de contas | A escolha da conta é do dono com o contador. O código não presume conta padrão. |
| Colisão de idempotência entre IRRF e IOF | `reference_id` com sufixos distintos (`:irrf:` / `:iof:`), coberto pelo teste 1. |
