# Fase 2 — a décima segunda fatia: as saídas

Escolhida em 08/09/2026, e a razão é curta: **por onde o dinheiro sai era a
única área do sistema sem uma linha de teste e sem uma linha de fixture.**

Quatro defeitos apareceram na primeira leitura. Três foram corrigidos antes de a
fatia existir; o quarto está medido e espera decisão.

---

## O recorte

| endpoint | verbo | origem |
|---|---|---|
| `/adm/api/saidas/parcelas` | `GET` | `indexAction` -- desde 04/11/2026 (a fatia 77) com `situacao=vencidas` (a aberta vencida antes de hoje, pelo dia, que não seja a paga sem a marca -- a verificação, 04/11/2026); a linha traz `saida.tipo`, `saida.origem` (`tela` ou `sangria`), `valorFaltante` e `dispensado`; a resposta, a `moeda` da unidade e, com o mês, o `vencidasAntes` (as abertas vencidas antes do mês e de hoje, nos últimos 12 meses, sem a paga sem a marca, com o `desde`) |
| `/adm/api/saidas/tipos` | `GET` | `SaidaTipoRepository::ativos` -- os tipos ativos (04/11/2026, a fatia 77) |
| `/adm/api/saidas/anos` | `GET` | `SaidaParcelaRepository::anosComParcela` -- os anos com parcela na unidade da sessão (04/11/2026) |
| `/adm/api/saidas/sem-parcela` | `GET` | `SaidaRepository::semParcela` -- as despesas ativas da unidade sem parcela ativa (04/11/2026) |
| `/adm/api/saidas` | `POST` | `identificacaoAction` → `insertIdentificacao` |
| `/adm/api/saidas/:id` | `GET` | `identificacaoAction` + `parcelasAction`, leitura -- desde 04/11/2026 (a fatia 77) com a `origem` e a `moeda` da unidade da despesa, e cada pagamento do extrato com o `estornadoEm` (nulo no ativo); desde 04/11/2026 (a fatia 78) a parcela com o `quitacaoMotivo` (só na quitada que o tem) e o estornado com o `estornoMotivo`, lidos das colunas fora do mapeamento -- nulos no banco sem elas; as respostas das escritas da parcela (pagar, dar por quitada, alterar, excluir) e do estorno também (`ContasAPagarTelaNovaTest`). Quem estornou: desde 06/11/2026 (a fatia 96, a resposta 307 (b) do dono, o D711) cada pagamento leva o `estornadoPor` -- o nome de quem ENTROU (`estornado_por`, e não o `estornado_em_nome_de`), nulo no ativo, no estorno sem autor gravado, no nome em branco e no banco sem as colunas -- SÓ quando a sessão tem o `escrever` de Contas a Pagar (na personificação, o da pessoa assumida); quem só lê recebe a resposta SEM a chave, nem nula (`ContasAPagarQuemEstornouTest`) |
| `/adm/api/saidas/:id` | `PUT` | `updateIdentificacao` |
| `/adm/api/saidas/:id/exclusao` | `POST` | `excluirSaidaAction` |
| `/adm/api/saidas/:id/parcelas` | `POST` | `insertParcelas` -- 409 na despesa da sangria com a retirada ativa desde 14/10/2026 (o D375, a resposta 174: "o que for de fora vira outra despesa") |
| `/adm/api/saidas/:id/parcelas/:parcela` | `PUT` | `parcelaEditarAction` |
| `/adm/api/saidas/:id/parcelas/:parcela/exclusao` | `POST` | `excluirParcelaAction` |
| `/adm/api/saidas/:id/parcelas/:parcela/quitacao` | `POST` | `parcelaQuitarAction` |
| `/adm/api/saidas/:id/parcelas/:parcela/quitacao-sem-pagamento` | `POST` | `quitarSemPagamentoAction` -- `{"motivo": "..."}` obrigatório (422 sem ele); 409 na já quitada e na excluída; vale também para a parcela sem dinheiro (a resposta 139 (b)); 04/11/2026, a fatia 77 |
| `…/pagamentos/:pagamento/estorno` | `POST` | `pagamentoEstornarAction` -- com `{"motivo": "..."}` obrigatório desde 13/10/2026 (a resposta 140; 422 sem ele), e 409 na despesa da sangria de caixa já fechado, ou com mais de um pagamento ativo (o D375 e a verificação dele, 13/10/2026), ou com o caixa aberto de outro operador (a resposta 175, 14/10/2026: só quem poderia cancelar a retirada no caixa); a sangria cancelada pelo estorno sai de Contas a Pagar junto (a 172). A resposta (14/10/2026, a verificação do item CAIXA-E-SANGRIA) é a parcela, com o `estado` dela, e ao lado `saida: {id, estado}` e `retirada` (`null` na despesa da tela; `{id, estado}`, `ativa` ou `cancelada`, na da sangria) -- `SaidaJson::estorno` |

**Fica de fora:**

| o quê | por quê |
|---|---|
| `excluirParcelas` (lote) | exclusão em lote é conveniência de tela, e a API já tem o endereço unitário; foi ela que trouxe o D132 |

---

## Duas coisas diferentes, e a tela mistura as duas

A tela de saídas **lista parcelas**, não despesas: cada linha é uma conta a pagar
num mês, com o nome da despesa ao lado. A despesa em si — "Aluguel" — só aparece
na tela de identificação.

O recurso separa o que a tela junta:

- `GET /adm/api/saidas/parcelas` — o que há para pagar num mês
- `GET /adm/api/saidas/:id` — a despesa, com todas as parcelas dela

São perguntas diferentes, e responder as duas pelo mesmo endereço obrigaria quem
consome a adivinhar qual das duas chegou.

---

## `quitada` é fato; `situacao` é leitura

A coluna diz apenas se deram a parcela por quitada. Estar **vencida** depende do
dia de hoje, e a tela calcula isso na hora de pintar a linha.

O recurso devolve os **dois** — e nunca um a partir do outro em silêncio: quem
consome precisa distinguir *"ninguém pagou"* de *"ninguém pagou **e** já passou
do prazo"*.

O corte é por **dia**, e não por instante: quem vence hoje ainda está aberta.
Comparar um `DateTime` com hora contra uma coluna `date` faria a linha mudar de
cor no meio do expediente.

**E `estado` diz se a linha está de pé** (14/10/2026, a verificação do item
CAIXA-E-SANGRIA): `ativa` ou `excluida`, o vocabulário do `estado` da despesa. A
exclusão da parcela e o estorno da sangria (que a exclui junto) devolvem a parcela
excluída, e ela vinha com `quitada` falso e `situacao` "aberta" -- com cara de conta
a pagar viva. `situacao` continua sendo a leitura de `quitada` e do vencimento.

---

## A fronteira do dinheiro morde nos dois sentidos

```php
setValorPrevisto($v, $mascara = true)   // -> Monetario::removeMask($v)
setDataVencimento($d, $mascara = true)  // -> DataHora::stringToDate($d)
```

**`removeMask("1500.00")` devolve `150000`.** O ponto é separador de **milhar** no
formato brasileiro. Cento e cinquenta mil reais no lugar de mil e quinhentos, e
calado.

**`stringToDate("2026-03-10")` devolve `false`** — é `createFromFormat("d/m/Y")`,
e não lança.

Tudo o que sai do controlador para o serviço passa pelas duas traduções:
`Monetario::deDecimal()` para o valor e `dd/mm/AAAA` para a data. O teste prova
as duas pontas — o erro que existiria e a ida e volta correta —, porque uma
tradução sem teste é uma tradução esperando ser "simplificada".

E há uma terceira, menor e igualmente calada: `parcelaQuitar()` compara
`$data['quitar'] == "true"` — com o **texto**. Mandar o booleano `true` faria a
comparação falhar, e a parcela ficaria aberta depois de paga.

---

## Os quatro defeitos

### D132 — a exclusão em lote atravessava a franquia

```php
public function excluirParcelas($data) {
    foreach ($data['ids'] as $pacelaId) {
        $parcela = ...->find($pacelaId);
        if (!empty($parcela)) { $parcela->setStatus(false); ...
```

Os ids vinham **crus do POST**, e o serviço não perguntava de quem eram. A ação
irmã, `excluirParcelaAction`, passa por `selecionarParcela()`, que confere
`unidade == sessão OU administrador`. A de lote não passava por lugar nenhum.

**Medido antes de corrigir**, com duas parcelas de unidades diferentes: a parcela
da unidade 90001100 saiu num pedido feito pela 90001000. São ~32 franquias no
mesmo banco, e bastava o privilégio `financeiro/saidas` em qualquer uma delas.

A conferência entrou no **serviço**, e não apenas no controlador — pelo motivo do
D126: serviço que confia em quem chama protege só o caminho que existe hoje.

### D133 — a guarda da despesa era contornável

`excluirSaidaAction` recusa excluir despesa com parcela quitada. A contagem
percorria `getParcelas()`, que **filtra `status = true`** — então bastava excluir
a parcela paga primeiro, pelo botão da mesma tela, e a despesa passava.

`Saida::totalParcelasQuitadas()` percorre a coleção crua. O pagamento continua em
`saidas_parcelas_pagamentos` faça-se o que fizer: o dinheiro saiu.

### D134 — dois filtros derrubavam a tela

O controlador lia `?categoria=` e `?local=` da query, e a consulta os usava. **A
entidade `Saida` não tem esses campos** — nem a tabela, nem o formulário de
filtro da própria tela.

```
com ?categoria=1   QUEBROU -- Class ISFinanceiro\Entity\Saida has no field
                              or association named categoria
```

Um 500 por parâmetro de URL. Removidos: nunca funcionaram.

### D135 — a soma da tela conta o que a lista não mostra — **decisão pendente**

`getParcelas()` filtra as excluídas; `getTotalValorPrevisto()` não. Depois de
qualquer exclusão, o total deixa de bater com as linhas exibidas.

| | |
|---|---:|
| saídas ativas | 13.236 |
| com ao menos uma parcela excluída | **144** |
| diferença somada | **R$ 864.595,41** |

São duas leituras defensáveis — "o total deve bater com a lista" e "parcela paga
e excluída é dinheiro que saiu de verdade" — e elas dão números diferentes. **Não
escolhi por quem responde pela contabilidade.**

**O recurso não precisou esperar por essa decisão.** Ele não usa aqueles getters:
soma o que devolve e devolve o que soma. `parcelas` e `totais` falam das mesmas
linhas, e a resposta é coerente consigo mesma seja qual for a escolha lá na
frente.

---

## O portão pegou o meu próprio desenho

`parcelaDaRota()` começou delegando a `saidaDaRota()`, que é onde o `getAcesso()`
morava. `scripts/conferir-acl-da-api.php` acusou **quatro ações** desta classe:
ele segue **um** nível de delegação, de propósito.

Ele estava certo. Duas camadas de indireção entre a ação e a conferência são
exatamente o que o D123 deixou passar: a guarda existia, e ninguém via de onde. A
guarda foi repetida no segundo método — cinco linhas que se pagam por serem
visíveis.

---

## Um detalhe que a API corrige de graça

A tela manda `situacao=0` para pedir "abertas", e a consulta faz
`if (!empty($data['situacao']))`. **`0` é vazio**, então o filtro é ignorado:
pedir "abertas" na tela nunca filtrou nada.

O recurso aceita `quitadas` e `abertas` e converte para booleano de verdade, e o
filtro funciona. A tela antiga fica como está — mexer nela é outro trabalho, e
este é o tipo de coisa que a substituição resolve sozinha.

> **04/10/2026 — o parágrafo acima estava errado (o D357).** O recurso convertia
> para booleano, mas a consulta continuava testando `!empty()`: `abertas` virava
> `false`, e `empty(false)` é verdadeiro — o filtro caía também pela API. Desde a
> S1 de Contas a Pagar, `SaidaRepository::situacao()` lê o booleano da API e o
> `0`/`1` da tela antiga, e as duas portas filtram.

---

## 04/10/2026 — a S1 de Contas a Pagar mudou o recurso

O desenho da tela nova (`docs/f3-telas-restantes/tela-saidas.md`, seção 2.3)
listou dez consertos sem decisão do dono; o que muda para quem consome:

- **O extrato traz os estornados**, com `estornado: true` — o laço percorria só os
  ativos, e o campo nunca era verdadeiro (o D358). Os totais não mudam.
- **A parcela quitada não tem falta:** `valorFaltante` é zero nela, e o campo novo
  `dispensado` diz o que a quitação deixou sem pagar. `totais.faltante` soma só as
  abertas (o D358).
- **409 do serviço** para pagar parcela quitada e para alterar parcela com
  pagamento (o D358); para alterar a despesa excluída e lançar parcela nela (antes,
  422 "Confira valor"); e para excluir de novo (a regra foi para o serviço, o D385).
- **422 nos filtros** `mes`, `ano` e `tipo` que não servem — antes iam crus à
  consulta, e no PostgreSQL davam 500 (o D361).
- **A descrição tem teto de 100 como fica gravada** (a normalização põe um espaço
  depois de cada ponto), e o **tipo precisa estar ativo** (`RegraDaSaida`).
- **Toda escrita passa pela trava da despesa** e relê a parcela e o pagamento —
  o desenho do D228 (`SaidaSobTravaTest`).
- **O `PUT` parcial só grava o que veio** (a revisão do item): sem `tipo` no corpo,
  o tipo fica o do banco — antes ia o que a ação leu antes da trava, e desfazia a
  troca que outra sessão acabara de gravar.
- **A 409 de pagar a quitada** só diz *"Para pagar de novo, estorne antes."* quando
  a parcela tem pagamento ativo; a quitada sem pagamento não tem o que estornar (a
  revisão do item; reabri-la é a P1 do desenho).
