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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user