# Conta contábil de IOF no produto de investimento

> Data: **2026-07-20**. Estado: spec aprovada, implementação não iniciada.
> Origem: verificação de resgate na tela pelo dono, que notou a ausência do lançamento de IOF.

## Problema

No resgate antecipado de investimento (< 30 dias), o IOF é **calculado corretamente, retido do
cliente e nunca contabilizado**.

Evidência viva (contrato `INV-2026-386387`, resgate em 2026-07-20, 7 dias de aplicação):

| Onde | IRRF | IOF |
|---|---|---|
| `investment_transactions` | `irrf_amount: 6` | `iof_amount: 95` |
| `investment_movements` (extrato da aplicação) | movimento `kind: :tax` | **ausente** |
| `cosif_journal_entries` | "IRRF retido no resgate" | **ausente** |

Lançamentos COSIF gerados pelo resgate:

```
D 8.1.1.10.01.10.001  C 4.9.8.10.01.10.000    125   Juros pagos no resgate
D 4.9.8.10.01.10.000  C 4.9.2.10.01.10.002      6   IRRF retido no resgate
D 4.9.8.10.01.10.000  C 4.9.8.10.01.10.002  50125   Resgate de investimento
```

O cliente tinha R$ 501,25 e recebeu R$ 500,24. Dos R$ 1,01 retidos, o IRRF (R$ 0,06) vira obrigação
tributária; o IOF (R$ 0,95) **não credita conta nenhuma** — o passivo do investidor é baixado por
R$ 501,25 e o valor some da contabilidade.

Causa: `InvestmentJournal` tem `journal_accrual`, `journal_correction` e `journal_irrf`. **Não existe
`journal_iof`.** O `product.tax_account` atende só o IRRF; o caminho do IOF nunca foi construído.

Consequência regulatória: IOF é tributo que a instituição **recolhe ao fisco**. Sem conta de obrigação
creditada, o recolhimento não tem lastro contábil. E o extrato da aplicação exibe o movimento do IRRF
mas não o do IOF — o cliente vê R$ 0,95 desaparecerem sem linha que explique.

O cálculo em si está correto: tabela regressiva do Decreto 6.306/2007 (96% no dia 1 … 3% no dia 29,
0% a partir do 30º), incidindo sobre o rendimento, com IRRF aplicado sobre o rendimento já líquido de
IOF (regra RFB). **Nada a corrigir no cálculo.**

## Solução

Nova configuração `iof_account` no produto de investimento, mais a perna contábil e o movimento que
faltam, espelhando a estrutura já existente do IRRF.

### 1. Migration

Adiciona `iof_account` (`:string`, nullable) em `investment_products`. Nullable porque produtos
legados existem sem a conta — a obrigatoriedade é no changeset, só para produtos novos (§2).

### 2. Schema — `investment_product.ex`

- `field :iof_account, :string`
- Incluir em `cast/3`
- Incluir na lista de `require_cosif_for_new/1`

`require_cosif_for_new/1` já valida as contas **apenas quando `data.id == nil`**, ou seja, só em
produto novo. Produtos legados seguem editáveis com o campo nulo. O `iof_account` entra nesse mesmo
molde — nenhum produto existente quebra ao ser salvo.

### 3. `InvestmentJournal.resolve_accounts/1`

Passa a devolver `iof_id: product.iof_account`.

**Não** entra no mapa `required` (que hoje exige `cosif_id`, `provision_id`, `expense_id`). Entrar ali
faria a apropriação diária e a aplicação falharem por falta de uma conta que só o resgate antecipado
usa. A obrigatoriedade do IOF é validada no ponto de uso (§5).

### 4. `InvestmentJournal.journal_iof/6`

Espelho de `journal_irrf/6`:

```
D  account_ids.cosif_id      (passivo do investidor)
C  account_ids.iof_id        (obrigação de IOF a recolher)
amount          iof
description     "IOF retido no resgate"
reference_type  "INVESTMENT_REDEMPTION"
reference_id    "#{investment.id}:iof:#{event_id}"
```

O `reference_id` com sufixo `:iof:` mantém a idempotência pelo mesmo índice parcial
`(reference_type, reference_id, entry_date)` usado pelos outros lançamentos, sem colidir com o
`:irrf:` nem com o `:correction:` do mesmo resgate.

### 5. `Investments.maybe_insert_iof/7` e a guarda fail-closed

Espelho de `maybe_insert_tax/7`: insere movimento `kind: :tax` com `amount` do IOF, descrição
**"IOF sobre rendimento"**, `debit_account: product.cosif_account`, `credit_account:
product.iof_account`; em seguida encadeia `journal_iof`.

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

**Guarda fail-closed, condicionada ao IOF existir:**

```
iof > 0  e  product.iof_account ausente   ->  {:error, :missing_iof_account}  (aborta o resgate)
iof > 0  e  conta presente                ->  movimento + journal
iof == 0 (resgate com 30+ dias)           ->  no-op, não bloqueia
```

A condição `iof > 0` é o que torna o fail-closed aceitável: resgates a partir do 30º dia têm IOF zero
por lei, não têm o que contabilizar e por isso não travam. Produtos legados só são bloqueados no caso
em que o tributo realmente existe.

Decisão registrada: fail-closed em vez do fail-soft que o `journal_irrf` pratica. O fail-soft do IRRF
é tolerável porque ele tem fallback dinâmico por `user_type` do member; o IOF não tem fallback algum,
e omitir seria reproduzir exatamente o defeito que esta spec corrige — reter tributo do cliente sem
registrá-lo.

### 6. Ligação no pipeline do resgate

Em `investments.ex`, no Multi do resgate, imediatamente após a linha 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)
```

### 7. Erro legível na tela

`{:error, :missing_iof_account}` deve chegar ao operador como mensagem acionável, indicando que o
produto precisa da conta contábil de IOF configurada. Sem isso o operador vê erro genérico e não sabe
o que fazer.

### 8. Frontend

- `types/investments.ts`: `iof_account: string | null` em `InvestmentProduct`
- `InvestmentProductsView.vue`: default do form, união `CosifField`, mapa `cosifSuggestions` e o campo
  no template, usando o **mesmo AutoComplete de busca COSIF** das outras quatro contas. Rótulo:
  "Conta de IOF a recolher".

## Fluxo de dados

```
resgate antecipado
  -> calcula iof (InvestmentEngine.early_redemption_iof, tabela Dec. 6.306)
  -> net_redemption = current_balance - iof - irrf
  -> [NOVO] guarda: iof > 0 e sem iof_account -> aborta
  -> movimento kind :tax "IOF sobre rendimento"        (extrato da aplicacao)
  -> journal_iof: D cosif_id / C iof_id                 (COSIF)
  -> credito no extrato PG e no TigerBeetle pelo LIQUIDO (inalterado)
```

O crédito ao cliente já é pelo líquido e **não muda**. Esta spec só acrescenta o registro do valor
retido.

## Tratamento de erro

| Situação | Comportamento |
|---|---|
| `iof > 0`, conta ausente | `{:error, :missing_iof_account}`, resgate abortado, nada persistido |
| `iof == 0` | nenhum movimento, nenhum journal, resgate segue |
| Reprocessamento do mesmo resgate | idempotente pelo `reference_id` com sufixo `:iof:` |

## Testes

1. Resgate com menos de 30 dias e conta configurada gera **um** movimento `:tax` com a 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) não gera movimento nem journal de IOF, e **não** é bloqueado por
   produto sem `iof_account`.
3. Produto sem `iof_account` e IOF maior que zero devolve `{:error, :missing_iof_account}` e **não
   persiste nada** (investimento continua ativo, sem movimentos novos).
4. Partidas dobradas do resgate fecham: soma dos débitos == soma dos créditos, agora incluindo o IOF.
5. Produto novo sem `iof_account` é rejeitado no changeset; produto legado sem a conta continua
   editável.

Os testes que tocam o razão usam a tag `:integration` (excluída por padrão), no padrão de
`investment_ledger_unit_test.exs`.

## Fora de escopo

Achados desta sessão que **não** entram aqui, cada um merecendo frente própria:

1. **Par de contas da apropriação diária.** `journal_accrual` debita `4.9.8.10.01.10.002`
   (passivo) contra `4.9.8.10.01.10.000` (passivo), sem reconhecer despesa. Já o resgate debita
   `8.1.1.10.01.10.001` (despesa). O mesmo evento econômico é tratado de duas formas, e a despesa de
   captação só aparece no resgate, em vez de ser apropriada por competência. Decisão do dono com o
   contador; cruza com a pendência do plano de contas oficial.
2. **Tabela de IOF duplicada** entre `investment_engine.ex` e `InvestmentResumoTab.vue`, cada um com
   sua cópia dos 30 percentuais. Hoje batem; se o decreto mudar e só um lado for atualizado, a tela
   prevê um valor e o resgate cobra outro.

## Consertos de dados (fora do código)

1. O produto **TESTE** (código 001) não tem `iof_account`. Precisa ser preenchido, ou todo resgate
   antecipado dele passará a falhar após esta mudança.
2. O resgate de `INV-2026-386387` reteve **R$ 0,95 sem lançamento**. Decisão do dono: lançar
   retroativamente ou deixar registrado como divergência conhecida.
