Etapa 6 — Vendas, parcelas e dashboards

Adiciona o módulo sales: vendas com itens/parcelas em transação, transições
de status controladas (orçamento→confirmada→concluída, cancelamento em
cascata pras parcelas), edição só em orçamento, recebimentos (marcar-pago,
atualizar-atrasadas) e dashboards semanal/mensal/financeiro do tenant.

Corrige uma falha de isolamento entre tenants nas views de dashboard
(rodavam com privilégio do dono, ignorando RLS) via
security_invoker = true nas views, coberta por teste e2e dedicado.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-14 22:07:00 -03:00
co-authored by Claude Sonnet 5
parent 24b3bb9ae4
commit 7daaa16935
19 changed files with 1891 additions and 9 deletions
+173 -9
View File
@@ -35,7 +35,7 @@ qualquer nova etapa começar.
| 3 | Painel Master (tenants/planos/faturas) | Concluída | 2026-08-13 |
| 4 | Integrações Evolution API + E-mail | Concluída | 2026-08-14 |
| 5 | Painel Tenant (clientes/produtos) | Concluída | 2026-08-14 |
| 6 | Vendas, Parcelas e Dashboards | [ ] Não iniciada | |
| 6 | Vendas, Parcelas e Dashboards | Concluída | 2026-08-14 |
| 7 | Motor de Alertas (BullMQ) | [ ] Não iniciada | |
| 8 | Personalização Whitelabel e Ajustes Finais | [ ] Não iniciada | |
@@ -545,17 +545,149 @@ propositalmente para depois — detalhar em Observações).
## Etapa 6 — Vendas, Parcelas e Dashboards
- [ ] Criar venda com itens e parcelas funciona em transação única
- [ ] Soma das parcelas validada contra o valor total da venda
- [ ] Cancelar venda cancela as parcelas pendentes associadas
- [ ] Venda confirmada não editável em itens/valores
- [ ] Marcar parcela como paga reflete no dashboard financeiro
- [ ] Dashboards semanal/mensal batem com dados de teste
- [ ] Endpoint de atualizar atrasadas funciona
- [ ] Isolamento por RLS reforçado e testado nesta etapa
- [x] Criar venda com itens e parcelas funciona em transação única
- [x] Soma das parcelas validada contra o valor total da venda
- [x] Cancelar venda cancela as parcelas pendentes associadas
- [x] Venda confirmada não editável em itens/valores
- [x] Marcar parcela como paga reflete no dashboard financeiro
- [x] Dashboards semanal/mensal batem com dados de teste
- [x] Endpoint de atualizar atrasadas funciona
- [x] Isolamento por RLS reforçado e testado nesta etapa
**Observações:**
- Módulo novo `src/modules/sales/`, agrupando os três blocos pedidos pelo
prompt no mesmo módulo Nest (mesmo padrão de agrupamento já usado no
`TenantsModule` da Etapa 3): `SalesController`/`Service` em
`/tenant/vendas`, `SalePaymentsController`/`Service` em
`/tenant/recebimentos` e `TenantDashboardController`/`Service` em
`/tenant/dashboard`. Todos usam só `TenantGuard` (sem `RolesGuard`),
mesmo critério já decidido e registrado na Etapa 5.
- **Achado de segurança desta etapa, corrigido antes de expor os
endpoints de dashboard:** o prompt pede usar as views
`vw_resumo_vendas_semanal`/`vw_resumo_vendas_mensal`/`vw_recebimentos`
(criadas na Etapa 1). Views do Postgres rodam, por padrão, com os
privilégios de quem as *criou* (a role bootstrap `seconectai`,
superuser) — não de quem consulta. Como superuser ignora RLS, qualquer
role com só `SELECT` na view (ex: `app_tenant`) conseguiria ler dados de
**todos os tenants** através dela, mesmo com as policies de RLS
corretas em `sales`/`sale_payments` por trás. Corrigido com uma
migration nova, `20260815090000_dashboard_views_security_invoker`
(`ALTER VIEW ... SET (security_invoker = true)`, suportado a partir do
Postgres 15 — a imagem do compose já é `postgres:16-alpine`): com
`security_invoker`, a view roda com os privilégios de quem chamou, e a
RLS de `sales`/`sale_payments` volta a valer também através das views.
Coberto por um teste e2e dedicado (ver abaixo) que teria falhado sem
essa migration.
- `GET /tenant/dashboard/resumo-semanal`/`resumo-mensal` consultam as
views via `tx.$queryRaw` (com `Prisma.sql`/`Prisma.join` — sempre
parametrizado, nunca concatenando string do usuário) dentro da mesma
transação por-request (`TenantContextService.getTx()`), então o
`SET LOCAL app.current_tenant_id` já ativo na transação vale também
para a query raw. Prisma não modela views nativamente sem o preview
feature `views` (que exigiria declarar manualmente cada view no schema
com uma chave só pra satisfazer o Prisma) — raw query parametrizada foi
a via mais simples e direta pro que são, no fim, duas agregações
(`GROUP BY date_trunc(...)`) que o query builder do Prisma não expressa
de forma declarativa.
- **Desvio do prompt, registrado com o motivo:** `GET
/tenant/recebimentos` **não** usa `vw_recebimentos` como pedido
literalmente — usa `tx.sale_payments.findMany` (Prisma normal,
tipado) com `include` trazendo `sale.numero_venda`/`sale.customer`. A
view só expõe `id, tenant_id, customer_id, valor, vencimento, status,
pago_em` — sem `sale_id`/`numero_parcela`/`forma_pagamento`, campos
claramente úteis numa listagem de recebimentos de verdade. Como
`sale_payments` já tem RLS própria (via subquery em `sale_id`, não
precisa do fix de `security_invoker` acima) e o Prisma tipado dá o
mesmo resultado com mais segurança de tipo, não haveria ganho em
reescrever como raw SQL só pra "usar a view" — mesmo espírito das
Etapas 1/3 que já preferiram o caminho mais direto quando o Prisma DSL
ou uma view não encaixava perfeitamente. `security_invoker` foi
aplicado na view mesmo assim, por robustez (qualquer consulta futura
direta a ela, via `prisma studio` ou uma etapa nova, já nasce segura).
- Regras de transição de status de venda
(`orcamento→confirmada/cancelada`, `confirmada→concluida/cancelada`,
`concluida`/`cancelada` terminais) implementadas como uma tabela
(`TRANSICOES_VALIDAS`) em `sales.service.ts`, mesmo espírito de
`TENANT_BLOCKED_STATUSES` da Etapa 3 — uma fonte única, fácil de
auditar visualmente. Transição fora da tabela → 409, não 400 (é um
conflito de estado, não um payload inválido).
Cancelar uma venda cancela `sale_payments` com status `pendente`
**ou** `atrasado` (o prompt cita só "parcelas pendentes", mas uma
parcela `atrasado` é uma parcela pendente vencida — deixá-la pendurada
numa venda cancelada não faz sentido).
- `PUT /tenant/vendas/:id` (só com a venda em `orcamento`) redefine itens
e parcelas por inteiro (`deleteMany` + `createMany` dentro da mesma
transação da request) em vez de tentar um diff item a item — mais
simples e viável porque só se aplica a uma venda ainda não confirmada
(sem consequência em `audit_logs`/histórico financeiro real).
- Cálculo de valores todo em centavos inteiros
(`sale-calculo.util.ts`, `roundMoney`/`somaCentavos`/
`distribuirParcelasIguais`) pra nunca perder/sobrar 1 centavo por
ponto-flutuante — mesma preocupação já registrada no dashboard master
da Etapa 3 (lá resolvida com `Prisma.Decimal`; aqui, como as operações
são só soma/divisão/arredondamento em valores de entrada já `number`,
aritmética de centavos inteiros é mais simples e igualmente exata).
Divisão de `numero_parcelas` parcelas iguais deixa o resto da divisão
(0 a N-1 centavos) na última parcela.
- **Decisão de produto não especificada pelo prompt, registrada:**
quando `numero_parcelas` é informado (em vez da lista explícita), os
vencimentos são gerados de 30 em 30 dias a partir de `data_venda`
(1ª parcela vence 30 dias depois da venda). O prompt não define essa
regra — fica como comportamento assumido, fácil de trocar numa etapa
futura se o negócio pedir outra periodicidade (ex: mesmo dia do mês
seguinte, ou vencimento configurável por venda).
Validação explícita de negócio (RLS ajuda, mas retorna erro de negócio
claro, como pedido no prompt): `customer_id` e todo
`product_service_id` são resolvidos por `findUnique` dentro da mesma
transação com RLS ativa — se não existir ou não pertencer ao tenant,
404 do ponto de vista do banco vira `BadRequestException` (400, não
404, porque o problema está no corpo do `POST`, não numa rota com
identificador na URL) explicando qual item falhou; cliente ou produto
inativo (soft-deletados na Etapa 5) também bloqueiam a criação/edição
da venda com mensagem própria.
- "Falha em qualquer parte reverte tudo" já é garantido estruturalmente
desde a Etapa 2: o `TenantRlsInterceptor` já envolve a request inteira
numa única transação Prisma — criar venda + itens + parcelas nunca
precisou de um `$transaction` aninhado (que nem seria possível: o `tx`
aqui já é um `Prisma.TransactionClient`). Testado no e2e validando que,
após uma tentativa de criação que falha (produto inexistente num dos
itens), a contagem de vendas do tenant não muda.
- Teste e2e novo, `test/vendas-dashboards.e2e-spec.ts` (30 testes),
cobrindo todos os critérios de aceite: criação com `numero_parcelas`
(divisão igual) e com `parcelas` explícitas, desconto reduzindo
`valor_total`, soma de parcelas divergente (400), desconto maior que os
itens (400), `numero_parcelas`+`parcelas` juntos ou nenhum dos dois
(400), cliente/produto inexistente ou de outro tenant (400), produto
inativo (400), reversão total numa criação que falha no meio,
listagem/detalhe, todas as transições de status válidas e inválidas
(409), cancelamento cascateando pra `sale_payments`, `PUT` só em
`orcamento` (409 em venda confirmada), `marcar-pago` (com rejeição 409
em parcela já paga/cancelada), `atualizar-atrasadas`, dashboards
semanal/mensal/financeiro batendo com os dados inseridos no teste (só
venda `confirmada`/`concluida` conta — uma venda em `orcamento` criada
de propósito no teste confirma que não aparece nos resumos), isolamento
RLS entre dois tenants reais nas vendas/recebimentos, **e um teste
dedicado provando que o dashboard do tenant B não inclui uma venda de
valor único do tenant A** (regressão direta do achado de segurança do
`security_invoker` acima — sem a migration, esse teste falha). Rodado
com `docker compose up -d postgres redis` + `prisma migrate deploy`
(aplicou a migration nova de `security_invoker` além das 3 já
existentes) + `jest --config ./test/jest-e2e.json --runInBand` — **104/
104 testes e2e passando** (os 74 das Etapas 1-5 + os 30 novos). `npm
run lint`, `npm run test` (unit) e `npm run build` também passam sem
erros — o build pegou um erro real (`TS4053`, interfaces de retorno das
rows do `$queryRaw` não exportadas, `ResumoSemanalRow`/
`ResumoMensalRow`), corrigido exportando as duas interfaces.
Limpeza do `afterAll` do teste precisou apagar `sales` explicitamente
antes de `tenants` — `sale_items`/`sale_payments` não têm `ON DELETE
CASCADE` de `products_services` no schema (de propósito, é o que
garante que produto usado numa venda não pode ser hard-deletado), então
apagar um tenant cujas `sales` ainda não foram removidas pode disparar
o cascade de `products_services` antes do cascade de `sale_items` e
violar a FK — só um detalhe de limpeza de teste, não afeta a aplicação
(que nunca hard-deleta nada nesse grafo).
---
## Etapa 7 — Motor de Alertas (BullMQ)
@@ -702,3 +834,35 @@ diagnóstico/atendimento vier a ser necessário, deve ser uma etapa própria
(endpoints `/master/tenants/:tenantId/...` read-only, `MasterGuard` +
auditoria de cada acesso), não uma extensão das rotas `/tenant/*`
existentes.
[2026-08-14] Etapa 6 — Achado de segurança corrigido durante a
implementação, não uma escolha de produto: as views de dashboard
(`vw_resumo_vendas_semanal`/`mensal`/`vw_recebimentos`, criadas na Etapa
1) rodavam com os privilégios do *dono* da view (a role bootstrap
`seconectai`, superuser), que ignora Row Level Security — qualquer role
com `SELECT` na view (ex: `app_tenant`) leria dados de **todos os
tenants** através dela, mesmo com as policies de RLS corretas nas tabelas
por trás. Corrigido com `ALTER VIEW ... SET (security_invoker = true)`
nas três views (migration `20260815090000_dashboard_views_security_invoker`,
Postgres 15+). Sem essa correção, `GET /tenant/dashboard/resumo-mensal`
vazaria vendas de outros tenants — o teste e2e "dashboard do tenant B não
inclui a venda do tenant A" foi escrito justamente pra pegar essa
regressão se a migration for revertida por engano no futuro.
[2026-08-14] Etapa 6 — `GET /tenant/recebimentos` implementado com
`tx.sale_payments.findMany` (Prisma tipado) em vez de `vw_recebimentos`
como o prompt pedia literalmente. Motivo: a view só expõe `id,
tenant_id, customer_id, valor, vencimento, status, pago_em` — sem
`sale_id`/`numero_parcela`/`forma_pagamento`, úteis numa listagem real de
recebimentos — e `sale_payments` já tem RLS própria (não depende do fix
de `security_invoker` acima), então a query tipada dá o mesmo resultado
com mais segurança de tipo e sem reintroduzir SQL raw sem necessidade.
`security_invoker` foi aplicado em `vw_recebimentos` mesmo assim, por
robustez, mesmo sem uso direto no momento.
[2026-08-14] Etapa 6 — Vencimento das parcelas quando `numero_parcelas`
é informado (em vez da lista explícita de `{valor, vencimento}`): 30 em
30 dias a partir de `data_venda`, começando 30 dias depois dela. O
prompt não especifica essa regra — assumida como comportamento padrão
razoável, documentada aqui para poder ser revisada/parametrizada numa
etapa futura se o negócio precisar de outra periodicidade.