# Fase 2 — a quinta fatia: o programa de fidelidade

Escolhida em 07/09/2026, e ela já estava escolhida: a quarta fatia deixou de
fora "pontuação, créditos, pacotes (7 ações)" com o motivo escrito — *"é um
assunto próprio; programa de fidelidade tem regra de vencimento, resgate e
código, e merece a fatia dele"*.

Ver `docs/f2-fatia-clientes.md` para o recorte que a antecedeu.

> **ELA PAROU NO MEIO ANTES DE COMEÇAR, e valeu a pena.** Ler o
> `ClientesController` para escrevê-la revelou que a conferência de privilégio
> das quatro fatias anteriores era inerte — **`recusou 0 de 10`**, medido em
> HTTP (D123). Construir sete endpoints novos sobre uma guarda que não guarda
> seria multiplicar o problema, então o conserto veio primeiro. A fatia foi
> retomada depois, e é isto aqui.

---

## Por que ela é diferente das quatro anteriores

**Porque aqui o dinheiro sai.** As fatias da agenda, do PDV, do caixa e do
cadastro criam, leem e alteram linhas. Esta faz três coisas que nenhuma delas
fazia:

| ação | o que ela move |
|---|---|
| comprar crédito | cria uma **venda**, cria um **pagamento** e sobe o valor do **caixa** |
| devolver crédito | tira dinheiro do saldo do cliente, **com senha de gerente** |
| resgatar pontuação | zera os pontos e credita `valorPf`, **com código do cliente ou senha de gerente** |

Duas das sete ações passam por autorização privilegiada, que é o assunto do
**D86/D88** — e o `scripts/conferir-autorizacao-privilegiada.php` vigia cada
guarda pelo nome. Esta fatia acrescenta entradas àquela lista, e não é zelo: o
resgate sem código já teve uma chave-mestra de seis zeros.

---

## O recorte: as sete ações, e nada além

| ação de origem | vira |
|---|---|
| `pacotesAction` | `GET /adm/api/clientes/:id/pacotes` |
| `pacoteRemoverAction` | `POST /adm/api/clientes/:id/pacotes/:pacote/cancelamento` |
| `creditoDetalhesAction` | `GET /adm/api/clientes/:id/creditos` |
| `creditoComprarAction` | `POST /adm/api/clientes/:id/creditos/compras` |
| `creditoDevolucaoAction` | `POST /adm/api/clientes/:id/creditos/devolucoes` |
| `programaFidelidadeResgatarPontuacaoGerarCodigoAction` | `POST /adm/api/clientes/:id/pontuacao/codigo` |
| `programaFidelidadeResgatarPontuacaoAction` | `POST /adm/api/clientes/:id/pontuacao/resgates` |

**Ficam de fora, e o motivo de cada um:**

| fica de fora | por quê |
|---|---|
| `historicoConsumo` e `historicoPontuacao` | devolvem **arquivo**, e arquivo não é recurso REST — mesmo motivo da quarta fatia |
| as regras de pontuação da venda | quem pontua é o fechamento da venda, na fatia do PDV; aqui só se **gasta** o que ela creditou |
| a criação de pacote | nasce da venda de um pacote, não desta tela |

---

## O verbo vem do que o serviço faz com a linha

É a emenda à regra 1 da `docs/convencao-api.md`, e nesta fatia ela decide
quatro casos:

**`pacoteRemover` não é `DELETE`.** O serviço faz `setStatus(false)` e grava. O
pacote continua existindo — ele tem sessões já consumidas e uma venda apontando
para ele. `DELETE` prometeria um sumiço que não acontece, e a lista de pacotes
de amanhã ainda vai poder explicar a de ontem.

**Nenhuma das escritas de dinheiro é `PUT`.** `PUT` promete que repetir não muda
mais nada, e elas **acumulam**: comprar duas vezes credita duas vezes, devolver
duas vezes tira duas vezes. O resgate é `POST` por outro motivo — ele **cria uma
linha de histórico**, e repetir falha porque a pontuação já é zero.

**O código de verificação é `POST`, e não `GET`.** Ele não lê nada:
`setCodigoApp()` gera um código novo e grava. Um `GET` que muda a linha é a
armadilha que a regra 1 existe para evitar — e aqui teria consequência de
verdade, porque qualquer prefetch invalidaria o código que o cliente acabou de
receber.

**`GET /creditos` devolve a lista por unidade, não um número.** A tela antiga
montava um `<b>...</b><br/>` por unidade dentro de uma string HTML. O saldo de
um cliente é uma linha **por unidade** — ele pode ter crédito em três — e somar
tudo num campo só apagaria a informação que o operador precisa.

---

## Três traduções na fronteira

**1. Dinheiro.** `Monetario::removeMask()` come o ponto como separador de
milhar: `"100.00"` vira `10000`. A tela manda `"1.234,56"` e está certa; um
cliente JSON manda `1234.56` e seria gravado como cento e vinte três mil. A API
recebe **número**, e a tradução acontece na borda. É o mesmo cuidado das fatias
do caixa e do PDV.

**2. Pontos viram dinheiro a 20 por 1.** `programaFidelidadeResgatarPontuacao()`
faz `getPontuacao() / 20`. O número não está em configuração nenhuma — está no
código, e a resposta do resgate diz **quantos pontos** e **quanto virou**, para
que quem consome a API não precise saber da divisão nem reimplementá-la.

**3. O piso de pontos vem do `CrudController`.** `autorizarTrocarPontuacao()` é
quem diz quantos pontos são necessários. A API o consulta pelo mesmo método e o
devolve em `GET /creditos` — sem isso, uma tela nova teria de adivinhar quando
habilitar o botão, e adivinharia diferente da tela antiga.

---

## A autorização, e por que ela é copiada e não abstraída

Duas ações pedem gerente, e as duas repetem a mesma sequência que já existe em
quatro lugares: `autenticacao()`, depois `getGerente()`, depois `getUnidade()`
comparado com a unidade da sessão **ou** `getAdministrador()`.

Extrair isso para um método compartilhado é tentador e seria errado agora. O
`conferir-autorizacao-privilegiada.php` vigia **cada sítio pelo nome dos termos
que ele contém**; um método comum trocaria verificações independentes por uma
só, e o dia em que ela regredisse levaria todas juntas. A duplicação aqui é o
que mantém as guardas independentes — e o portão é o que impede que uma delas
seja esquecida.

Quando todas estiverem na Fase 2, com o mesmo formato de resposta, a extração
fica barata e o portão passa a vigiar o método único. Hoje não.

---

## O cenário precisou de um cliente só dele, e isso custou onze vermelhos

A primeira versão pendurou o crédito e o pacote no cliente 90001000, que já
existia. **Onze testes de outras fatias ficaram vermelhos**, e nenhum por
colisão de id:

| o que quebrou | por quê |
|---|---|
| `ConvenioDescontoTest` (3) | `VendaProdutoService::insert()` debita a sessão do pacote **em vez** de aplicar o desconto do convênio — pacote tem precedência. O teste via 89,90 onde esperava 71,92 |
| `FidelidadePontuacaoTest` (5) | conta pontos a partir do zero, e uma linha parada com 400 pontos não é zero |
| `PacoteDebitadoTest` (2) | monta a própria lista de pacotes, e a minha entrou no meio |
| `PagamentoTrocoDebitoTest` (1) | soma o troco ao saldo, e o saldo já tinha 100,00 |

Não é acidente de fixture: é regra de negócio. **Cenário de fatia nova não pode
mudar o cenário das anteriores** — e o jeito de garantir isso é dar a ela um
cliente próprio, como o `90001100` e o `90001150` já fazem para outros assuntos.
O cliente da fidelidade é o **90001004**, e o medidor sabe disso: as rotas com
`/creditos`, `/pacotes` ou `/pontuacao` recebem o id dele, e não o das outras.

---

## O que a fatia não resolve, e está anotado

O **D18** continua: os ids 1 (avulso) e 2 (SEM CADASTRO) são marcadores, não
pessoas. Comprar crédito para o avulso não faz sentido — o crédito ficaria num
cliente que todo mundo usa. As ações de escrita recusam os dois, como já fazem
as da quarta fatia.

A duplicata de `clientes_creditos` medida em 05/09/2026 — *"uma linha com
R$ 408,00 e outra com R$ 0,00, mesmo cliente, mesma unidade"* — também continua.
O repositório já ordena para escolher a mais antiga, e esta fatia usa o mesmo
caminho; a unicidade que resolveria de vez é o **D29**, e é decisão de operação.

---

## 28/09/2026 — a tela nova da fidelidade

O crédito, os pontos e os pacotes do cliente moram agora **no detalhe do cliente**,
abaixo do cadastro (`FidelidadeDoCliente.tsx`, `FidelidadeLigada.tsx`). No ERP antigo
eram quatro lugares: o saldo num modal da lista, comprar e devolver em páginas
próprias, os pacotes numa aba -- e o resgate **só no PDV**.

- **Nesta unidade**, com os botões: o crédito em dinheiro, o crédito do programa (o
  que os pontos resgatados viraram) e os pontos com quanto valem. As escritas são da
  unidade da sessão, e a tela diz isso; **nas outras unidades** é uma tabela de
  leitura, só com as que têm alguma coisa (um cliente chega a ter linha em 20).
- **Comprar crédito** confirma em duas etapas, com o foco no "voltar" -- cria uma
  venda e sobe o caixa. Sem caixa aberto, oferece "ir ao caixa".
- **Devolver** pede a credencial de um gerente desta unidade e diz, como a tela
  antiga dizia em vermelho, que **o dinheiro não sai do caixa**. *Desde 15/10/2026 (a
  resposta 89 do dono, a opção (b); a fatia 5c), o dinheiro SAI do caixa aberto de
  quem opera, como uma retirada, e a devolução fica registrada com quem autorizou e
  quem operou (`clientes_creditos_devolucoes`): as duas telas dizem isso, e sem caixa
  aberto a devolução é recusada -- a nova oferece "ir ao caixa", como na compra. Desde 16/10/2026 (a fatia 5e), sai mesmo maior que o
  saldo do caixa, que pode ficar negativo (a resposta 232 (c), o D501), e as duas telas
  dizem isso; e cancelar no caixa a retirada da devolução a desfaz -- o crédito volta ao
  cliente, e o registro fica com quem desfez e quando (a resposta 231 (a), o D500).*
- **Resgatar**: o código vai ao telefone do cliente -- a tela diz por onde, e que
  ele chega em até um minuto das 8h às 22h --, e "o cliente não recebeu o código"
  leva à autorização do gerente, que fica registrada. Sem canal, a tela vai direto
  ao gerente.
- **Os pacotes** abrem nos que têm sessão; os esgotados, vencidos e cancelados ficam
  a um clique. Cancelar pergunta no mesmo botão, e só o gerente vê.
- **O histórico dos pontos**, a pedido, com até quando os que valem valem.

### O recorte, de novo

| endpoint | o que mudou |
|---|---|
| `GET .../creditos` | uma linha por unidade (a gêmea do D29 sai); `unidadeDaSessao` e `formasAceitas` |
| `POST .../creditos/compras` | a forma que traz dinheiro, o teto, o caixa de outro dia e o `motivo` nos 409, a transação (D280) |
| `POST .../creditos/devolucoes` | a comparação em centavos (D283) |
| `GET .../pacotes` | a `situacao` de cada pacote, pelo relógio do servidor |
| `POST .../pacotes/:p/cancelamento` | a unidade da sessão e o papel de gerente (D281) |
| `POST .../pontuacao/codigo` | o `canal` (D282) |
| `POST .../pontuacao/resgates` | a credencial que não confere é 422 com os campos (D286) |
| `GET .../pontuacao/historico` | **novo**: a aba do ERP em JSON, paginada, com `valeAte` |

**Ficou de fora:** o histórico de consumo (é uma lista de vendas, e as vendas
realizadas já têm tela), os PDFs dos dois históricos, e **o resgate na comanda nova**
-- a próxima entrega, e uma pergunta ao dono (abaixo).

### As decisões que tomei, e o caminho de volta

1. **O resgate entrou na ficha do cliente**, e não na comanda. A API estava pronta
   para a ficha; na comanda nova ele pede mais desenho. **A volta:** a comanda ganha o
   resgate, e a ficha continua.
2. **O resgate espera a adesão** (`programaFidelidade`), como a interface do PDV --
   17.930 linhas de crédito estão acima do piso, e só 5.641 são de quem aderiu. **O
   servidor não confere**, como nunca conferiu. **A volta:** tirar a condição da tela,
   ou pô-la também no servidor -- é sua.
3. **Comprar e devolver pedem também `clientes/credito/escrever`**, a porta do menu
   antigo (168 pessoas, e não as 362 do cadastro). O servidor continua conferindo só o
   cadastro. **A volta:** uma linha no Console.
   **Decidida pelo dono em 12/10/2026 (a resposta 99 (a)), e aplicada em 07/11/2026 (a
   fatia 104, o D123):** o servidor passou a conferir as duas, na API e nas páginas de
   comprar e devolver do ERP; o 403 `sem_permissao` vem antes de buscar o cliente. O
   resgate segue no cadastro, e a pergunta dele está no lote (o D737).
4. **As formas da compra são as que trazem dinheiro**, e a Permuta fica, como no ERP.
5. **A compra recusa o caixa de outro dia** (o D28), como a comanda e o fiado; a tela
   antiga aceitava. **A volta:** tirar a conferência do controller.
6. **Cancelar pacote é do gerente, também no servidor.** **A volta:** a condição do
   controller.
7. **O histórico de consumo não entrou.**

### A revisão adversarial

Seis lentes -- o dinheiro, as guardas e o contrato no servidor; o foco, o estado e o
que a tela diz e mostra --, cada achado com um cético: 38 achados, 34 confirmados e
consertados, nenhum de gravidade alta, e nenhum agente perdido. Os que mudaram o que
acontece:

- **o resgate pela ficha pagava a pontuação da coluna**, que fica velha -- o
  cancelamento de venda a mexia errado (o D284, corrigido em 01/10/2026) --, e o PDV sempre a recalculava antes.
  Na cópia, 7 clientes teriam na ficha um resgate que o PDV recusa. Agora o código e o
  resgate recalculam antes de conferir o piso, e o `GET /creditos` mostra a soma do
  histórico, sem gravar;
- **a compra não travava o caixa**: a retirada feita no meio, em outra sessão, sumia
  do saldo. A compra passou a um método do serviço (`comprarCreditoTravado`), que
  trava o cliente, busca o caixa já travado e o relê; **e o resgate gravava em três
  passos soltos** -- agora é uma transação só;
- **o código "enviado ao aplicativo" ia a quem nunca o abriu** -- 1.382 aderidos sem
  acesso nenhum ao app. O push vai a quem já entrou nele, como nas rotinas de
  marketing;
- **o histórico** dizia a página pedida, e não a entregue, a página enorme sobre o
  histórico vazio dava 500, e a linha vencida aparecia "valendo" com a data de ontem
  até a rotina das 23h -- agora ela diz que sai do saldo esta noite;
- **o resgate aceitava um arranjo no código**, que virava aviso do PHP e um 422 com
  HTTP 200;
- **na tela, o foco caía no `body`** ao paginar o histórico, em todo "tentar de novo"
  e nas três passagens para a etapa do gerente; a recusa com campos não era dita a
  ninguém; e a regra `:empty { display: none }` tirava as regiões de status da árvore
  de acessibilidade -- o aviso de cada escrita chegava numa região que nasce cheia;
- **o 409 e o 404 das escritas** releem por baixo, e **a compra no ar segura**
  "voltar", "atualizar" e "editar" do detalhe, que a desmontariam;
- **no telefone**, a tabela das outras unidades alargava o cartão inteiro; o
  "confirmar" do cancelamento não ficava vermelho; os botões de cancelar do mesmo
  pacote tinham o mesmo nome (agora dizem quando foi comprado); "faltam 1 ponto"; e a
  frase da credencial dizia que não podia ser a de quem opera -- o servidor aceita, e
  a mesma frase estava na comanda.

**O que não entrou, e está anotado:** a idempotência das escritas de dinheiro (o
D285) e o código sem validade nem limite de tentativas (o D286).

**A idempotência entrou em 07/11/2026** (a fatia 103; a resposta 87 (a) do dono, "um número por
confirmação"): a compra e a devolução de crédito pela API aceitam o número da confirmação no cabeçalho
`Idempotency-Key`, e a tela da fidelidade o manda e o repete na nova tentativa -- a regra 12 de
`docs/convencao-api.md`.
