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.
@@ -0,0 +1,15 @@
-- Correção de segurança: as views de dashboard criadas na migration
-- `20260812161100_rls_views_constraints` (dono = role bootstrap
-- `seconectai`, superuser) rodam, por padrão, com os privilégios do
-- *dono da view* — não de quem consulta. Como o dono é superuser, ele
-- ignora Row Level Security, então qualquer role com apenas `SELECT` na
-- view (ex: `app_tenant`) conseguiria ler dados de *todos* os tenants
-- através dela, mesmo com as policies de RLS corretas nas tabelas por
-- trás (`sales`, `sale_payments`). `security_invoker = true` (Postgres
-- 15+, disponível na imagem `postgres:16-alpine` do docker-compose) faz a
-- view rodar com os privilégios de quem a consultou, restaurando o
-- isolamento por tenant também através das views.
ALTER VIEW "vw_resumo_vendas_semanal" SET (security_invoker = true);
ALTER VIEW "vw_resumo_vendas_mensal" SET (security_invoker = true);
ALTER VIEW "vw_recebimentos" SET (security_invoker = true);
+2
View File
@@ -13,6 +13,7 @@ import { EvolutionModule } from './modules/evolution/evolution.module';
import { HealthModule } from './modules/health/health.module';
import { PlanosModule } from './modules/planos/planos.module';
import { ProductsModule } from './modules/products/products.module';
import { SalesModule } from './modules/sales/sales.module';
import { TenantsModule } from './modules/tenants/tenants.module';
import { WhatsappModule } from './modules/whatsapp/whatsapp.module';
@@ -35,6 +36,7 @@ import { WhatsappModule } from './modules/whatsapp/whatsapp.module';
WhatsappModule,
CustomersModule,
ProductsModule,
SalesModule,
],
providers: [{ provide: APP_GUARD, useClass: ThrottlerGuard }],
})
+29
View File
@@ -0,0 +1,29 @@
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { TenantGuard } from '../auth/guards/tenant.guard';
import { TenantDashboardService } from './dashboard.service';
import {
DashboardPeriodoQueryDto,
ProximasParcelasQueryDto,
} from './dto/dashboard-periodo.query.dto';
@UseGuards(JwtAuthGuard, TenantGuard)
@Controller('tenant/dashboard')
export class TenantDashboardController {
constructor(private readonly dashboardService: TenantDashboardService) {}
@Get('resumo-semanal')
resumoSemanal(@Query() query: DashboardPeriodoQueryDto) {
return this.dashboardService.resumoSemanal(query);
}
@Get('resumo-mensal')
resumoMensal(@Query() query: DashboardPeriodoQueryDto) {
return this.dashboardService.resumoMensal(query);
}
@Get('financeiro')
financeiro(@Query() query: ProximasParcelasQueryDto) {
return this.dashboardService.financeiro(query.limite ?? 5);
}
}
+109
View File
@@ -0,0 +1,109 @@
import { Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { TenantContextService } from '../../config/tenant-context.service';
import { DashboardPeriodoQueryDto } from './dto/dashboard-periodo.query.dto';
export interface ResumoSemanalRow {
semana: Date;
qtd_vendas: number;
total_vendido: number;
}
export interface ResumoMensalRow {
mes: Date;
qtd_vendas: number;
total_vendido: number;
}
function inicioDoMes(data: Date): Date {
return new Date(Date.UTC(data.getUTCFullYear(), data.getUTCMonth(), 1));
}
@Injectable()
export class TenantDashboardService {
constructor(private readonly tenantContext: TenantContextService) {}
/**
* Lê das views `vw_resumo_vendas_semanal`/`vw_resumo_vendas_mensal`
* (`prisma/migrations/.../rls_views_constraints`), como pedido pelo
* prompt desta etapa. As views foram alteradas nesta etapa para
* `security_invoker = true` (migration
* `dashboard_views_security_invoker`) — sem isso, o Postgres roda a
* query por trás da view com o *dono* da view (a role bootstrap
* `seconectai`, superuser), que ignora RLS e vazaria dados de todos os
* tenants pra qualquer um com `SELECT` na view. Com `security_invoker`,
* a view roda com os privilégios de quem chamou (`app_tenant`), e a
* policy de RLS de `sales` (a fonte por trás da view) volta a valer
* normalmente.
*/
async resumoSemanal(query: DashboardPeriodoQueryDto) {
const tx = this.tenantContext.getTx();
const condicoes: Prisma.Sql[] = [];
if (query.inicio) condicoes.push(Prisma.sql`semana >= ${new Date(query.inicio)}`);
if (query.fim) condicoes.push(Prisma.sql`semana <= ${new Date(query.fim)}`);
const where = condicoes.length
? Prisma.sql`WHERE ${Prisma.join(condicoes, ' AND ')}`
: Prisma.empty;
return tx.$queryRaw<ResumoSemanalRow[]>(Prisma.sql`
SELECT semana, qtd_vendas::int AS qtd_vendas, COALESCE(total_vendido, 0)::float8 AS total_vendido
FROM vw_resumo_vendas_semanal
${where}
ORDER BY semana ASC
`);
}
async resumoMensal(query: DashboardPeriodoQueryDto) {
const tx = this.tenantContext.getTx();
const condicoes: Prisma.Sql[] = [];
if (query.inicio) condicoes.push(Prisma.sql`mes >= ${new Date(query.inicio)}`);
if (query.fim) condicoes.push(Prisma.sql`mes <= ${new Date(query.fim)}`);
const where = condicoes.length
? Prisma.sql`WHERE ${Prisma.join(condicoes, ' AND ')}`
: Prisma.empty;
return tx.$queryRaw<ResumoMensalRow[]>(Prisma.sql`
SELECT mes, qtd_vendas::int AS qtd_vendas, COALESCE(total_vendido, 0)::float8 AS total_vendido
FROM vw_resumo_vendas_mensal
${where}
ORDER BY mes ASC
`);
}
async financeiro(limiteProximas: number) {
const tx = this.tenantContext.getTx();
const agora = new Date();
const inicioMesAtual = inicioDoMes(agora);
const inicioProximoMes = new Date(
Date.UTC(inicioMesAtual.getUTCFullYear(), inicioMesAtual.getUTCMonth() + 1, 1),
);
const recebidoMes = await tx.sale_payments.aggregate({
_sum: { valor: true },
where: { status: 'pago', pago_em: { gte: inicioMesAtual, lt: inicioProximoMes } },
});
const aReceber = await tx.sale_payments.aggregate({
_sum: { valor: true },
where: { status: { in: ['pendente', 'atrasado'] } },
});
const atrasado = await tx.sale_payments.aggregate({
_sum: { valor: true },
where: { status: 'atrasado' },
});
const proximasParcelas = await tx.sale_payments.findMany({
where: { status: 'pendente' },
orderBy: { vencimento: 'asc' },
take: limiteProximas,
include: {
sale: { select: { numero_venda: true, customer: { select: { razao_social: true } } } },
},
});
return {
total_recebido_mes: (recebidoMes._sum.valor ?? new Prisma.Decimal(0)).toFixed(2),
total_a_receber: (aReceber._sum.valor ?? new Prisma.Decimal(0)).toFixed(2),
total_atrasado: (atrasado._sum.valor ?? new Prisma.Decimal(0)).toFixed(2),
proximas_parcelas: proximasParcelas,
};
}
}
@@ -0,0 +1,9 @@
import { venda_status } from '@prisma/client';
import { IsIn } from 'class-validator';
const STATUS_DESTINO: venda_status[] = ['confirmada', 'concluida', 'cancelada'];
export class ChangeSaleStatusDto {
@IsIn(STATUS_DESTINO)
status: venda_status;
}
+84
View File
@@ -0,0 +1,84 @@
import { venda_status } from '@prisma/client';
import { Type } from 'class-transformer';
import {
ArrayMinSize,
IsArray,
IsDateString,
IsIn,
IsInt,
IsNumber,
IsOptional,
IsPositive,
IsString,
IsUUID,
Min,
ValidateNested,
} from 'class-validator';
const STATUS_CRIACAO: venda_status[] = ['orcamento', 'confirmada'];
export class CreateSaleItemDto {
@IsUUID()
product_service_id: string;
@IsNumber({ maxDecimalPlaces: 2 })
@IsPositive()
quantidade: number;
/** Se omitido, usa `valor_venda` do catálogo — informar só para sobrescrever com desconto pontual do item. */
@IsOptional()
@IsNumber({ maxDecimalPlaces: 2 })
@IsPositive()
valor_unitario?: number;
}
export class CreateSaleParcelaDto {
@IsNumber({ maxDecimalPlaces: 2 })
@IsPositive()
valor: number;
@IsDateString()
vencimento: string;
}
export class CreateSaleDto {
@IsUUID()
customer_id: string;
@IsOptional()
@IsDateString()
data_venda?: string;
@IsOptional()
@IsNumber({ maxDecimalPlaces: 2 })
@Min(0)
desconto?: number;
@IsOptional()
@IsString()
observacoes?: string;
/** `orcamento` ou `confirmada` — demais status só são alcançados via `PATCH .../status`. */
@IsOptional()
@IsIn(STATUS_CRIACAO)
status?: venda_status;
@IsArray()
@ArrayMinSize(1)
@ValidateNested({ each: true })
@Type(() => CreateSaleItemDto)
itens: CreateSaleItemDto[];
/** Informar `numero_parcelas` OU `parcelas` (lista explícita) — nunca os dois. */
@IsOptional()
@IsInt()
@Min(1)
numero_parcelas?: number;
@IsOptional()
@IsArray()
@ArrayMinSize(1)
@ValidateNested({ each: true })
@Type(() => CreateSaleParcelaDto)
parcelas?: CreateSaleParcelaDto[];
}
@@ -0,0 +1,21 @@
import { Type } from 'class-transformer';
import { IsDateString, IsInt, IsOptional, Max, Min } from 'class-validator';
export class DashboardPeriodoQueryDto {
@IsOptional()
@IsDateString()
inicio?: string;
@IsOptional()
@IsDateString()
fim?: string;
}
export class ProximasParcelasQueryDto {
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(50)
limite?: number = 5;
}
@@ -0,0 +1,36 @@
import { parcela_status } from '@prisma/client';
import { Type } from 'class-transformer';
import { IsDateString, IsIn, IsInt, IsOptional, IsUUID, Max, Min } from 'class-validator';
const STATUSES: parcela_status[] = ['pendente', 'pago', 'atrasado', 'cancelado'];
export class ListRecebimentosQueryDto {
@IsOptional()
@IsIn(STATUSES)
status?: parcela_status;
@IsOptional()
@IsUUID()
customer_id?: string;
@IsOptional()
@IsDateString()
vencimento_inicio?: string;
@IsOptional()
@IsDateString()
vencimento_fim?: string;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number = 1;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
limit?: number = 20;
}
@@ -0,0 +1,36 @@
import { venda_status } from '@prisma/client';
import { Type } from 'class-transformer';
import { IsDateString, IsIn, IsInt, IsOptional, IsUUID, Max, Min } from 'class-validator';
const STATUSES: venda_status[] = ['orcamento', 'confirmada', 'cancelada', 'concluida'];
export class ListSalesQueryDto {
@IsOptional()
@IsIn(STATUSES)
status?: venda_status;
@IsOptional()
@IsUUID()
customer_id?: string;
@IsOptional()
@IsDateString()
data_inicio?: string;
@IsOptional()
@IsDateString()
data_fim?: string;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number = 1;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
limit?: number = 20;
}
+8
View File
@@ -0,0 +1,8 @@
import { IsIn } from 'class-validator';
const FORMAS_PAGAMENTO = ['pix', 'boleto', 'cartao', 'dinheiro'] as const;
export class MarcarPagoDto {
@IsIn(FORMAS_PAGAMENTO)
forma_pagamento: (typeof FORMAS_PAGAMENTO)[number];
}
+55
View File
@@ -0,0 +1,55 @@
import { Type } from 'class-transformer';
import {
ArrayMinSize,
IsArray,
IsDateString,
IsInt,
IsNumber,
IsOptional,
IsString,
IsUUID,
Min,
ValidateNested,
} from 'class-validator';
import { CreateSaleItemDto, CreateSaleParcelaDto } from './create-sale.dto';
/**
* Redefine a venda inteira (itens + parcelas) — só aceito com a venda em
* `orcamento` (`SalesService.update`). Sem `status`: transição de status é
* exclusividade do `PATCH .../status`, mesmo padrão de `UpdateTenantDto`.
*/
export class UpdateSaleDto {
@IsUUID()
customer_id: string;
@IsOptional()
@IsDateString()
data_venda?: string;
@IsOptional()
@IsNumber({ maxDecimalPlaces: 2 })
@Min(0)
desconto?: number;
@IsOptional()
@IsString()
observacoes?: string;
@IsArray()
@ArrayMinSize(1)
@ValidateNested({ each: true })
@Type(() => CreateSaleItemDto)
itens: CreateSaleItemDto[];
@IsOptional()
@IsInt()
@Min(1)
numero_parcelas?: number;
@IsOptional()
@IsArray()
@ArrayMinSize(1)
@ValidateNested({ each: true })
@Type(() => CreateSaleParcelaDto)
parcelas?: CreateSaleParcelaDto[];
}
+39
View File
@@ -0,0 +1,39 @@
/** Arredonda pra 2 casas decimais operando em centavos (evita erro de ponto flutuante em valores monetários). */
export function roundMoney(valor: number): number {
return Math.round(valor * 100) / 100;
}
export interface ParcelaCalculada {
numero_parcela: number;
valor: number;
vencimento: Date;
}
/**
* Distribui `valorTotal` em `quantidade` parcelas iguais (em centavos, pra
* nunca perder/sobrar 1 centavo por arredondamento) — o resto da divisão
* fica na última parcela. Vencimentos mensais (30 em 30 dias) a partir de
* `dataBase`, começando 30 dias depois dela — o schema não define essa
* regra explicitamente, então é uma decisão de produto registrada no
* controle de progresso.
*/
export function distribuirParcelasIguais(
valorTotal: number,
quantidade: number,
dataBase: Date,
): ParcelaCalculada[] {
const totalCentavos = Math.round(valorTotal * 100);
const baseCentavos = Math.floor(totalCentavos / quantidade);
const resto = totalCentavos - baseCentavos * quantidade;
return Array.from({ length: quantidade }, (_, i) => {
const centavos = baseCentavos + (i === quantidade - 1 ? resto : 0);
const vencimento = new Date(dataBase);
vencimento.setUTCDate(vencimento.getUTCDate() + 30 * (i + 1));
return { numero_parcela: i + 1, valor: centavos / 100, vencimento };
});
}
export function somaCentavos(valores: number[]): number {
return valores.reduce((acc, v) => acc + Math.round(v * 100), 0);
}
@@ -0,0 +1,46 @@
import {
Body,
Controller,
Get,
Param,
ParseUUIDPipe,
Patch,
Post,
Query,
Req,
UseGuards,
} from '@nestjs/common';
import { Request } from 'express';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { TenantGuard } from '../auth/guards/tenant.guard';
import { JwtPayload } from '../auth/types/jwt-payload.type';
import { ListRecebimentosQueryDto } from './dto/list-recebimentos.query.dto';
import { MarcarPagoDto } from './dto/marcar-pago.dto';
import { SalePaymentsService } from './sale-payments.service';
@UseGuards(JwtAuthGuard, TenantGuard)
@Controller('tenant/recebimentos')
export class SalePaymentsController {
constructor(private readonly salePaymentsService: SalePaymentsService) {}
@Get()
findAll(@Query() query: ListRecebimentosQueryDto) {
return this.salePaymentsService.findAll(query);
}
@Patch(':id/marcar-pago')
marcarPago(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: MarcarPagoDto,
@CurrentUser() user: JwtPayload,
@Req() req: Request,
) {
return this.salePaymentsService.marcarPago(id, dto, user, req.ip);
}
@Post('atualizar-atrasadas')
atualizarAtrasadas(@CurrentUser() user: JwtPayload, @Req() req: Request) {
return this.salePaymentsService.atualizarAtrasadas(user, req.ip);
}
}
+111
View File
@@ -0,0 +1,111 @@
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { writeAuditLog } from '../../common/audit-log.util';
import { TenantContextService } from '../../config/tenant-context.service';
import { JwtPayload } from '../auth/types/jwt-payload.type';
import { ListRecebimentosQueryDto } from './dto/list-recebimentos.query.dto';
import { MarcarPagoDto } from './dto/marcar-pago.dto';
const RECEBIMENTO_INCLUDE = {
sale: {
select: {
id: true,
numero_venda: true,
customer_id: true,
customer: { select: { razao_social: true } },
},
},
} satisfies Prisma.sale_paymentsInclude;
@Injectable()
export class SalePaymentsService {
constructor(private readonly tenantContext: TenantContextService) {}
async findAll(query: ListRecebimentosQueryDto) {
const tx = this.tenantContext.getTx();
const page = query.page ?? 1;
const limit = query.limit ?? 20;
const where: Prisma.sale_paymentsWhereInput = {
...(query.status ? { status: query.status } : {}),
...(query.customer_id ? { sale: { customer_id: query.customer_id } } : {}),
...(query.vencimento_inicio || query.vencimento_fim
? {
vencimento: {
...(query.vencimento_inicio ? { gte: new Date(query.vencimento_inicio) } : {}),
...(query.vencimento_fim ? { lte: new Date(query.vencimento_fim) } : {}),
},
}
: {}),
};
const data = await tx.sale_payments.findMany({
where,
include: RECEBIMENTO_INCLUDE,
orderBy: { vencimento: 'asc' },
skip: (page - 1) * limit,
take: limit,
});
const total = await tx.sale_payments.count({ where });
return { data, meta: { total, page, limit } };
}
async marcarPago(id: string, dto: MarcarPagoDto, actor: JwtPayload, ip?: string) {
const tx = this.tenantContext.getTx();
const parcela = await tx.sale_payments.findUnique({ where: { id } });
if (!parcela) {
throw new NotFoundException('Parcela não encontrada');
}
if (parcela.status === 'pago') {
throw new ConflictException('Parcela já está paga');
}
if (parcela.status === 'cancelado') {
throw new ConflictException('Parcela cancelada não pode ser marcada como paga');
}
const atualizada = await tx.sale_payments.update({
where: { id },
data: { status: 'pago', pago_em: new Date(), forma_pagamento: dto.forma_pagamento },
});
await writeAuditLog(tx, {
usuario_tipo: 'tenant_user',
usuario_id: actor.sub,
tenant_id: this.tenantContext.getTenantId(),
acao: 'marcar_parcela_paga',
entidade: 'sale_payments',
entidade_id: id,
detalhes: { forma_pagamento: dto.forma_pagamento },
ip_origem: ip,
});
return atualizada;
}
/** Preparando a Etapa 7 (job agendado) — aqui só o endpoint manual, como pedido pelo prompt desta etapa. */
async atualizarAtrasadas(actor: JwtPayload, ip?: string) {
const tx = this.tenantContext.getTx();
const hoje = new Date(
Date.UTC(new Date().getUTCFullYear(), new Date().getUTCMonth(), new Date().getUTCDate()),
);
const resultado = await tx.sale_payments.updateMany({
where: { status: 'pendente', vencimento: { lt: hoje } },
data: { status: 'atrasado' },
});
await writeAuditLog(tx, {
usuario_tipo: 'tenant_user',
usuario_id: actor.sub,
tenant_id: this.tenantContext.getTenantId(),
acao: 'atualizar_parcelas_atrasadas',
entidade: 'sale_payments',
detalhes: { atualizadas: resultado.count },
ip_origem: ip,
});
return { atualizadas: resultado.count };
}
}
+64
View File
@@ -0,0 +1,64 @@
import {
Body,
Controller,
Get,
Param,
ParseUUIDPipe,
Patch,
Post,
Put,
Query,
Req,
UseGuards,
} from '@nestjs/common';
import { Request } from 'express';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { TenantGuard } from '../auth/guards/tenant.guard';
import { JwtPayload } from '../auth/types/jwt-payload.type';
import { ChangeSaleStatusDto } from './dto/change-sale-status.dto';
import { CreateSaleDto } from './dto/create-sale.dto';
import { ListSalesQueryDto } from './dto/list-sales.query.dto';
import { UpdateSaleDto } from './dto/update-sale.dto';
import { SalesService } from './sales.service';
@UseGuards(JwtAuthGuard, TenantGuard)
@Controller('tenant/vendas')
export class SalesController {
constructor(private readonly salesService: SalesService) {}
@Post()
create(@Body() dto: CreateSaleDto, @CurrentUser() user: JwtPayload, @Req() req: Request) {
return this.salesService.create(dto, user, req.ip);
}
@Get()
findAll(@Query() query: ListSalesQueryDto) {
return this.salesService.findAll(query);
}
@Get(':id')
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.salesService.findOne(id);
}
@Put(':id')
update(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateSaleDto,
@CurrentUser() user: JwtPayload,
@Req() req: Request,
) {
return this.salesService.update(id, dto, user, req.ip);
}
@Patch(':id/status')
changeStatus(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: ChangeSaleStatusDto,
@CurrentUser() user: JwtPayload,
@Req() req: Request,
) {
return this.salesService.changeStatus(id, dto.status, user, req.ip);
}
}
+13
View File
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { TenantDashboardController } from './dashboard.controller';
import { TenantDashboardService } from './dashboard.service';
import { SalePaymentsController } from './sale-payments.controller';
import { SalePaymentsService } from './sale-payments.service';
import { SalesController } from './sales.controller';
import { SalesService } from './sales.service';
@Module({
controllers: [SalesController, SalePaymentsController, TenantDashboardController],
providers: [SalesService, SalePaymentsService, TenantDashboardService],
})
export class SalesModule {}
+313
View File
@@ -0,0 +1,313 @@
import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { Prisma, venda_status } from '@prisma/client';
import { writeAuditLog } from '../../common/audit-log.util';
import { TenantContextService } from '../../config/tenant-context.service';
import { JwtPayload } from '../auth/types/jwt-payload.type';
import { CreateSaleDto, CreateSaleItemDto, CreateSaleParcelaDto } from './dto/create-sale.dto';
import { ListSalesQueryDto } from './dto/list-sales.query.dto';
import { UpdateSaleDto } from './dto/update-sale.dto';
import { distribuirParcelasIguais, roundMoney, somaCentavos } from './sale-calculo.util';
/** orçamento -> confirmada/cancelada; confirmada -> concluida/cancelada; concluida/cancelada são terminais. */
const TRANSICOES_VALIDAS: Record<venda_status, venda_status[]> = {
orcamento: ['confirmada', 'cancelada'],
confirmada: ['concluida', 'cancelada'],
concluida: [],
cancelada: [],
};
const SALE_INCLUDE = {
itens: { include: { produto: true } },
parcelas: { orderBy: { numero_parcela: 'asc' as const } },
customer: { select: { id: true, razao_social: true } },
} satisfies Prisma.salesInclude;
@Injectable()
export class SalesService {
constructor(private readonly tenantContext: TenantContextService) {}
private async resolverItens(itens: CreateSaleItemDto[]) {
const tx = this.tenantContext.getTx();
return Promise.all(
itens.map(async (item) => {
const produto = await tx.products_services.findUnique({
where: { id: item.product_service_id },
});
if (!produto) {
throw new BadRequestException(
`Produto/serviço ${item.product_service_id} não encontrado ou não pertence a este tenant`,
);
}
if (!produto.ativo) {
throw new BadRequestException(`Produto/serviço "${produto.nome}" está inativo`);
}
const valor_unitario = item.valor_unitario ?? Number(produto.valor_venda);
const valor_total = roundMoney(item.quantidade * valor_unitario);
return {
product_service_id: item.product_service_id,
quantidade: item.quantidade,
valor_unitario,
valor_total,
};
}),
);
}
private resolverParcelas(
valorTotal: number,
dataVenda: Date,
numeroParcelas?: number,
parcelas?: CreateSaleParcelaDto[],
) {
if (numeroParcelas && parcelas) {
throw new BadRequestException('Informe `numero_parcelas` OU `parcelas`, nunca os dois');
}
if (!numeroParcelas && !parcelas) {
throw new BadRequestException('Informe `numero_parcelas` ou a lista explícita de `parcelas`');
}
if (parcelas) {
const soma = somaCentavos(parcelas.map((p) => p.valor));
if (soma !== Math.round(valorTotal * 100)) {
throw new BadRequestException(
`Soma das parcelas (R$ ${(soma / 100).toFixed(2)}) não bate com o valor total da venda (R$ ${valorTotal.toFixed(2)})`,
);
}
return parcelas.map((p, i) => ({
numero_parcela: i + 1,
valor: p.valor,
vencimento: new Date(p.vencimento),
}));
}
return distribuirParcelasIguais(valorTotal, numeroParcelas as number, dataVenda);
}
private async calcularTotais(dto: { itens: CreateSaleItemDto[]; desconto?: number }) {
const itensCalculados = await this.resolverItens(dto.itens);
const somaItens = roundMoney(itensCalculados.reduce((acc, i) => acc + i.valor_total, 0));
const desconto = dto.desconto ?? 0;
const valor_total = roundMoney(somaItens - desconto);
if (valor_total < 0) {
throw new BadRequestException('Desconto maior que a soma dos itens');
}
return { itensCalculados, desconto, valor_total };
}
async create(dto: CreateSaleDto, actor: JwtPayload, ip?: string) {
const tenantId = this.tenantContext.getTenantId();
const tx = this.tenantContext.getTx();
const customer = await tx.customers.findUnique({ where: { id: dto.customer_id } });
if (!customer) {
throw new BadRequestException('Cliente não encontrado ou não pertence a este tenant');
}
if (!customer.ativo) {
throw new BadRequestException('Cliente está inativo');
}
const { itensCalculados, desconto, valor_total } = await this.calcularTotais(dto);
const dataVenda = dto.data_venda ? new Date(dto.data_venda) : new Date();
const parcelasCalculadas = this.resolverParcelas(
valor_total,
dataVenda,
dto.numero_parcelas,
dto.parcelas,
);
const venda = await tx.sales.create({
data: {
tenant_id: tenantId,
customer_id: dto.customer_id,
vendedor_id: actor.sub,
status: dto.status ?? 'confirmada',
valor_total,
desconto,
observacoes: dto.observacoes,
data_venda: dataVenda,
},
});
await tx.sale_items.createMany({
data: itensCalculados.map((item) => ({ ...item, sale_id: venda.id })),
});
await tx.sale_payments.createMany({
data: parcelasCalculadas.map((p) => ({ ...p, sale_id: venda.id })),
});
await writeAuditLog(tx, {
usuario_tipo: 'tenant_user',
usuario_id: actor.sub,
tenant_id: tenantId,
acao: 'criar_venda',
entidade: 'sales',
entidade_id: venda.id,
detalhes: {
valor_total,
desconto,
qtd_itens: itensCalculados.length,
},
ip_origem: ip,
});
return this.findOne(venda.id);
}
async findAll(query: ListSalesQueryDto) {
const tx = this.tenantContext.getTx();
const page = query.page ?? 1;
const limit = query.limit ?? 20;
const where: Prisma.salesWhereInput = {
...(query.status ? { status: query.status } : {}),
...(query.customer_id ? { customer_id: query.customer_id } : {}),
...(query.data_inicio || query.data_fim
? {
data_venda: {
...(query.data_inicio ? { gte: new Date(query.data_inicio) } : {}),
...(query.data_fim ? { lte: new Date(query.data_fim) } : {}),
},
}
: {}),
};
const data = await tx.sales.findMany({
where,
include: SALE_INCLUDE,
orderBy: { data_venda: 'desc' },
skip: (page - 1) * limit,
take: limit,
});
const total = await tx.sales.count({ where });
return { data, meta: { total, page, limit } };
}
async findOne(id: string) {
const tx = this.tenantContext.getTx();
const venda = await tx.sales.findUnique({ where: { id }, include: SALE_INCLUDE });
if (!venda) {
throw new NotFoundException('Venda não encontrada');
}
return venda;
}
/** Só permitido com a venda em `orcamento` — redefine itens/parcelas inteiros, mesmo espírito de "cancele e recrie" pra vendas já confirmadas. */
async update(id: string, dto: UpdateSaleDto, actor: JwtPayload, ip?: string) {
const venda = await this.findOne(id);
if (venda.status !== 'orcamento') {
throw new ConflictException(
'Só é possível editar uma venda em orçamento — cancele e crie uma nova para alterar uma venda confirmada',
);
}
const tx = this.tenantContext.getTx();
const tenantId = this.tenantContext.getTenantId();
const customer = await tx.customers.findUnique({ where: { id: dto.customer_id } });
if (!customer) {
throw new BadRequestException('Cliente não encontrado ou não pertence a este tenant');
}
if (!customer.ativo) {
throw new BadRequestException('Cliente está inativo');
}
const { itensCalculados, desconto, valor_total } = await this.calcularTotais(dto);
const dataVenda = dto.data_venda ? new Date(dto.data_venda) : venda.data_venda;
const parcelasCalculadas = this.resolverParcelas(
valor_total,
dataVenda,
dto.numero_parcelas,
dto.parcelas,
);
await tx.sale_items.deleteMany({ where: { sale_id: id } });
await tx.sale_payments.deleteMany({ where: { sale_id: id } });
await tx.sales.update({
where: { id },
data: {
customer_id: dto.customer_id,
valor_total,
desconto,
observacoes: dto.observacoes,
data_venda: dataVenda,
atualizado_em: new Date(),
},
});
await tx.sale_items.createMany({
data: itensCalculados.map((item) => ({ ...item, sale_id: id })),
});
await tx.sale_payments.createMany({
data: parcelasCalculadas.map((p) => ({ ...p, sale_id: id })),
});
await writeAuditLog(tx, {
usuario_tipo: 'tenant_user',
usuario_id: actor.sub,
tenant_id: tenantId,
acao: 'atualizar_venda',
entidade: 'sales',
entidade_id: id,
detalhes: {
valor_total,
desconto,
qtd_itens: itensCalculados.length,
},
ip_origem: ip,
});
return this.findOne(id);
}
async changeStatus(id: string, novoStatus: venda_status, actor: JwtPayload, ip?: string) {
const venda = await this.findOne(id);
if (!TRANSICOES_VALIDAS[venda.status].includes(novoStatus)) {
throw new ConflictException(
`Transição de status inválida: "${venda.status}" → "${novoStatus}"`,
);
}
const tx = this.tenantContext.getTx();
const tenantId = this.tenantContext.getTenantId();
const atualizada = await tx.sales.update({
where: { id },
data: { status: novoStatus, atualizado_em: new Date() },
});
if (novoStatus === 'cancelada') {
await tx.sale_payments.updateMany({
where: { sale_id: id, status: { in: ['pendente', 'atrasado'] } },
data: { status: 'cancelado' },
});
}
await writeAuditLog(tx, {
usuario_tipo: 'tenant_user',
usuario_id: actor.sub,
tenant_id: tenantId,
acao: 'mudar_status_venda',
entidade: 'sales',
entidade_id: id,
detalhes: { de: venda.status, para: novoStatus },
ip_origem: ip,
});
return atualizada;
}
}
+728
View File
@@ -0,0 +1,728 @@
import { randomUUID } from 'node:crypto';
import { INestApplication, ValidationPipe } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import request from 'supertest';
import { AppModule } from '../src/app.module';
import { PrismaService } from '../src/config/prisma.service';
import { hashPassword } from '../src/modules/auth/password.util';
interface TokenPair {
access_token: string;
refresh_token: string;
}
describe('Painel Tenant — Vendas, Parcelas e Dashboards (e2e)', () => {
let app: INestApplication;
let prisma: PrismaService;
let httpServer: Parameters<typeof request>[0];
const senha = 'SenhaForteDemo#26';
const suffix = randomUUID().slice(0, 8);
const masterEmail = `master-etapa6-${suffix}@teste.com`;
let masterId: string;
let masterToken: string;
const tenantIds: string[] = [];
const planoIds: string[] = [];
const tenantUserIds: string[] = [];
async function authMaster(): Promise<string> {
const res = await request(httpServer)
.post('/auth/master/login')
.send({ email: masterEmail, senha })
.expect(200);
return (res.body as TokenPair).access_token;
}
function asMaster() {
return {
post: (url: string) =>
request(httpServer).post(url).set('Authorization', `Bearer ${masterToken}`),
};
}
function asTenant(token: string) {
return {
get: (url: string) => request(httpServer).get(url).set('Authorization', `Bearer ${token}`),
post: (url: string) => request(httpServer).post(url).set('Authorization', `Bearer ${token}`),
put: (url: string) => request(httpServer).put(url).set('Authorization', `Bearer ${token}`),
patch: (url: string) =>
request(httpServer).patch(url).set('Authorization', `Bearer ${token}`),
delete: (url: string) =>
request(httpServer).delete(url).set('Authorization', `Bearer ${token}`),
};
}
async function criarPlano(overrides: Partial<Record<string, unknown>> = {}) {
const res = await asMaster()
.post('/master/planos')
.send({
nome: `Plano ${suffix} ${randomUUID().slice(0, 4)}`,
valor_mensal: 199.9,
limite_instancias_whatsapp: 1,
...overrides,
})
.expect(201);
planoIds.push((res.body as { id: string }).id);
return res.body as { id: string };
}
async function criarTenantComAdmin() {
const plano = await criarPlano();
const emailAdmin = `admin-${randomUUID().slice(0, 8)}-${suffix}@teste.com`;
const res = await asMaster()
.post('/master/tenants')
.send({
razao_social: `Tenant Etapa6 ${suffix} ${randomUUID().slice(0, 6)}`,
email_contato: `contato-${randomUUID().slice(0, 6)}@teste.com`,
plano_id: plano.id,
admin: { nome: 'Admin de Teste', email: emailAdmin },
})
.expect(201);
const body = res.body as {
tenant: { id: string };
admin: { id: string; email: string; senha_provisoria: string };
};
tenantIds.push(body.tenant.id);
tenantUserIds.push(body.admin.id);
await prisma.tenants.update({ where: { id: body.tenant.id }, data: { status: 'ativo' } });
const login = await request(httpServer)
.post('/auth/tenant/login')
.send({ email: body.admin.email, senha: body.admin.senha_provisoria })
.expect(200);
return { tenant: body.tenant, token: (login.body as TokenPair).access_token };
}
async function criarCustomer(token: string, overrides: Partial<Record<string, unknown>> = {}) {
const res = await asTenant(token)
.post('/tenant/customers')
.send({ razao_social: `Cliente ${randomUUID().slice(0, 8)}`, ...overrides })
.expect(201);
return res.body as { id: string };
}
async function criarProduto(token: string, overrides: Partial<Record<string, unknown>> = {}) {
const res = await asTenant(token)
.post('/tenant/produtos')
.send({
tipo: 'produto',
nome: `Produto ${randomUUID().slice(0, 8)}`,
valor_venda: 100,
...overrides,
})
.expect(201);
return res.body as { id: string };
}
beforeAll(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
app.useGlobalPipes(
new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }),
);
await app.init();
httpServer = app.getHttpServer() as Parameters<typeof request>[0];
prisma = app.get(PrismaService);
const senha_hash = await hashPassword(senha);
const master = await prisma.master_admins.create({
data: { nome: 'Master Etapa 6', email: masterEmail, senha_hash },
});
masterId = master.id;
masterToken = await authMaster();
});
afterAll(async () => {
// `sale_items.product_service_id`/`sale_payments` não têm CASCADE de
// `products_services`/`sales` no schema (de propósito — histórico de
// vendas não pode sumir só porque o produto foi excluído). Sem apagar
// `sales` primeiro, o cascade de `tenants` → `products_services` pode
// rodar antes do cascade `tenants` → `sales` → `sale_items` e violar a
// FK. Apagar `sales` explicitamente já cascateia pra `sale_items`/
// `sale_payments` (essas duas têm `ON DELETE CASCADE` em `sale_id`).
await prisma.sales.deleteMany({ where: { tenant_id: { in: tenantIds } } });
await prisma.tenants.deleteMany({ where: { id: { in: tenantIds } } });
await prisma.planos.deleteMany({ where: { id: { in: planoIds } } });
await prisma.auth_refresh_tokens.deleteMany({
where: { usuario_id: { in: [masterId, ...tenantUserIds] } },
});
await prisma.login_attempts.deleteMany({
where: { usuario_tipo: 'master', email: masterEmail },
});
await prisma.audit_logs.deleteMany({ where: { usuario_id: masterId } });
await prisma.master_admins.deleteMany({ where: { id: masterId } });
await app.close();
});
describe('POST /tenant/vendas — criação com itens e parcelas em transação', () => {
let token: string;
let customerId: string;
let produtoId: string;
beforeAll(async () => {
const tenant = await criarTenantComAdmin();
token = tenant.token;
customerId = (await criarCustomer(token)).id;
produtoId = (await criarProduto(token, { valor_venda: 50 })).id;
});
it('cria venda com numero_parcelas — divide o valor_total igualmente', async () => {
const res = await asTenant(token)
.post('/tenant/vendas')
.send({
customer_id: customerId,
itens: [{ product_service_id: produtoId, quantidade: 3 }],
numero_parcelas: 3,
})
.expect(201);
const venda = res.body as {
valor_total: string;
itens: Array<{ valor_total: string }>;
parcelas: Array<{ valor: string; numero_parcela: number }>;
};
expect(Number(venda.valor_total)).toBe(150);
expect(venda.parcelas).toHaveLength(3);
expect(venda.parcelas.reduce((acc, p) => acc + Number(p.valor), 0)).toBeCloseTo(150, 2);
});
it('cria venda com parcelas explícitas cuja soma bate com valor_total', async () => {
const res = await asTenant(token)
.post('/tenant/vendas')
.send({
customer_id: customerId,
itens: [{ product_service_id: produtoId, quantidade: 2, valor_unitario: 40 }],
parcelas: [
{ valor: 40, vencimento: '2026-09-01' },
{ valor: 40, vencimento: '2026-10-01' },
],
})
.expect(201);
expect(Number((res.body as { valor_total: string }).valor_total)).toBe(80);
});
it('desconto aplicado reduz o valor_total', async () => {
const res = await asTenant(token)
.post('/tenant/vendas')
.send({
customer_id: customerId,
itens: [{ product_service_id: produtoId, quantidade: 2, valor_unitario: 50 }],
desconto: 20,
numero_parcelas: 1,
})
.expect(201);
expect(Number((res.body as { valor_total: string }).valor_total)).toBe(80);
});
it('soma das parcelas não bate com valor_total retorna 400', async () => {
await asTenant(token)
.post('/tenant/vendas')
.send({
customer_id: customerId,
itens: [{ product_service_id: produtoId, quantidade: 1, valor_unitario: 100 }],
parcelas: [{ valor: 50, vencimento: '2026-09-01' }],
})
.expect(400);
});
it('desconto maior que a soma dos itens retorna 400', async () => {
await asTenant(token)
.post('/tenant/vendas')
.send({
customer_id: customerId,
itens: [{ product_service_id: produtoId, quantidade: 1, valor_unitario: 100 }],
desconto: 200,
numero_parcelas: 1,
})
.expect(400);
});
it('numero_parcelas e parcelas juntos retorna 400', async () => {
await asTenant(token)
.post('/tenant/vendas')
.send({
customer_id: customerId,
itens: [{ product_service_id: produtoId, quantidade: 1, valor_unitario: 100 }],
numero_parcelas: 2,
parcelas: [{ valor: 100, vencimento: '2026-09-01' }],
})
.expect(400);
});
it('nem numero_parcelas nem parcelas retorna 400', async () => {
await asTenant(token)
.post('/tenant/vendas')
.send({
customer_id: customerId,
itens: [{ product_service_id: produtoId, quantidade: 1, valor_unitario: 100 }],
})
.expect(400);
});
it('customer_id inexistente retorna 400', async () => {
await asTenant(token)
.post('/tenant/vendas')
.send({
customer_id: randomUUID(),
itens: [{ product_service_id: produtoId, quantidade: 1, valor_unitario: 100 }],
numero_parcelas: 1,
})
.expect(400);
});
it('product_service_id inexistente retorna 400 e não cria nada (transação revertida)', async () => {
const antes = await asTenant(token).get('/tenant/vendas?limit=1').expect(200);
const totalAntes = (antes.body as { meta: { total: number } }).meta.total;
await asTenant(token)
.post('/tenant/vendas')
.send({
customer_id: customerId,
itens: [
{ product_service_id: produtoId, quantidade: 1, valor_unitario: 10 },
{ product_service_id: randomUUID(), quantidade: 1, valor_unitario: 10 },
],
numero_parcelas: 1,
})
.expect(400);
const depois = await asTenant(token).get('/tenant/vendas?limit=1').expect(200);
expect((depois.body as { meta: { total: number } }).meta.total).toBe(totalAntes);
});
it('produto inativo retorna 400', async () => {
const produtoInativoId = (await criarProduto(token)).id;
await asTenant(token).delete(`/tenant/produtos/${produtoInativoId}`).expect(200);
await asTenant(token)
.post('/tenant/vendas')
.send({
customer_id: customerId,
itens: [{ product_service_id: produtoInativoId, quantidade: 1, valor_unitario: 10 }],
numero_parcelas: 1,
})
.expect(400);
});
});
describe('GET /tenant/vendas — listagem e detalhe', () => {
let token: string;
let customerId: string;
let produtoId: string;
let vendaId: string;
beforeAll(async () => {
const tenant = await criarTenantComAdmin();
token = tenant.token;
customerId = (await criarCustomer(token)).id;
produtoId = (await criarProduto(token, { valor_venda: 30 })).id;
const venda = await asTenant(token)
.post('/tenant/vendas')
.send({
customer_id: customerId,
itens: [{ product_service_id: produtoId, quantidade: 1 }],
numero_parcelas: 1,
})
.expect(201);
vendaId = (venda.body as { id: string }).id;
});
it('lista com filtro por customer_id e status', async () => {
const res = await asTenant(token)
.get(`/tenant/vendas?customer_id=${customerId}&status=confirmada`)
.expect(200);
const body = res.body as { data: Array<{ id: string }> };
expect(body.data.some((v) => v.id === vendaId)).toBe(true);
});
it('detalhe traz itens e parcelas', async () => {
const res = await asTenant(token).get(`/tenant/vendas/${vendaId}`).expect(200);
const venda = res.body as { itens: unknown[]; parcelas: unknown[] };
expect(venda.itens.length).toBe(1);
expect(venda.parcelas.length).toBe(1);
});
it('venda inexistente retorna 404', async () => {
await asTenant(token).get(`/tenant/vendas/${randomUUID()}`).expect(404);
});
});
describe('PATCH /tenant/vendas/:id/status — transições e cancelamento em cascata', () => {
let token: string;
let customerId: string;
let produtoId: string;
beforeAll(async () => {
const tenant = await criarTenantComAdmin();
token = tenant.token;
customerId = (await criarCustomer(token)).id;
produtoId = (await criarProduto(token, { valor_venda: 60 })).id;
});
async function criarVenda(status?: string) {
const res = await asTenant(token)
.post('/tenant/vendas')
.send({
customer_id: customerId,
itens: [{ product_service_id: produtoId, quantidade: 1 }],
numero_parcelas: 2,
...(status ? { status } : {}),
})
.expect(201);
return res.body as { id: string };
}
it('orcamento → confirmada → concluida funciona', async () => {
const venda = await criarVenda('orcamento');
await asTenant(token)
.patch(`/tenant/vendas/${venda.id}/status`)
.send({ status: 'confirmada' })
.expect(200);
await asTenant(token)
.patch(`/tenant/vendas/${venda.id}/status`)
.send({ status: 'concluida' })
.expect(200);
});
it('orcamento → concluida direto é transição inválida (409)', async () => {
const venda = await criarVenda('orcamento');
await asTenant(token)
.patch(`/tenant/vendas/${venda.id}/status`)
.send({ status: 'concluida' })
.expect(409);
});
it('cancelar venda cancela as parcelas pendentes associadas', async () => {
const venda = await criarVenda();
await asTenant(token)
.patch(`/tenant/vendas/${venda.id}/status`)
.send({ status: 'cancelada' })
.expect(200);
const detalhe = await asTenant(token).get(`/tenant/vendas/${venda.id}`).expect(200);
const parcelas = (detalhe.body as { parcelas: Array<{ status: string }> }).parcelas;
expect(parcelas.every((p) => p.status === 'cancelado')).toBe(true);
});
it('venda já cancelada não aceita nova transição (409)', async () => {
const venda = await criarVenda();
await asTenant(token)
.patch(`/tenant/vendas/${venda.id}/status`)
.send({ status: 'cancelada' })
.expect(200);
await asTenant(token)
.patch(`/tenant/vendas/${venda.id}/status`)
.send({ status: 'confirmada' })
.expect(409);
});
});
describe('PUT /tenant/vendas/:id — só edita venda em orcamento', () => {
let token: string;
let customerId: string;
let produtoId: string;
beforeAll(async () => {
const tenant = await criarTenantComAdmin();
token = tenant.token;
customerId = (await criarCustomer(token)).id;
produtoId = (await criarProduto(token, { valor_venda: 25 })).id;
});
it('edita itens/valores de uma venda em orcamento', async () => {
const criada = await asTenant(token)
.post('/tenant/vendas')
.send({
customer_id: customerId,
itens: [{ product_service_id: produtoId, quantidade: 1 }],
numero_parcelas: 1,
status: 'orcamento',
})
.expect(201);
const id = (criada.body as { id: string }).id;
const editada = await asTenant(token)
.put(`/tenant/vendas/${id}`)
.send({
customer_id: customerId,
itens: [{ product_service_id: produtoId, quantidade: 4 }],
numero_parcelas: 2,
})
.expect(200);
expect(Number((editada.body as { valor_total: string }).valor_total)).toBe(100);
expect((editada.body as { parcelas: unknown[] }).parcelas).toHaveLength(2);
});
it('venda confirmada não pode ser editada via PUT (409)', async () => {
const criada = await asTenant(token)
.post('/tenant/vendas')
.send({
customer_id: customerId,
itens: [{ product_service_id: produtoId, quantidade: 1 }],
numero_parcelas: 1,
})
.expect(201);
const id = (criada.body as { id: string }).id;
await asTenant(token)
.put(`/tenant/vendas/${id}`)
.send({
customer_id: customerId,
itens: [{ product_service_id: produtoId, quantidade: 2 }],
numero_parcelas: 1,
})
.expect(409);
});
});
describe('recebimentos — listagem, marcar-pago e atualizar-atrasadas', () => {
let token: string;
let customerId: string;
let produtoId: string;
beforeAll(async () => {
const tenant = await criarTenantComAdmin();
token = tenant.token;
customerId = (await criarCustomer(token)).id;
produtoId = (await criarProduto(token, { valor_venda: 90 })).id;
});
it('marca parcela como paga — pago_em e forma_pagamento preenchidos', async () => {
const venda = await asTenant(token)
.post('/tenant/vendas')
.send({
customer_id: customerId,
itens: [{ product_service_id: produtoId, quantidade: 1 }],
numero_parcelas: 1,
})
.expect(201);
const parcelaId = (venda.body as { parcelas: Array<{ id: string }> }).parcelas[0].id;
const pago = await asTenant(token)
.patch(`/tenant/recebimentos/${parcelaId}/marcar-pago`)
.send({ forma_pagamento: 'pix' })
.expect(200);
const body = pago.body as { status: string; pago_em: string | null; forma_pagamento: string };
expect(body.status).toBe('pago');
expect(body.pago_em).not.toBeNull();
expect(body.forma_pagamento).toBe('pix');
});
it('marcar como paga uma parcela já paga retorna 409', async () => {
const venda = await asTenant(token)
.post('/tenant/vendas')
.send({
customer_id: customerId,
itens: [{ product_service_id: produtoId, quantidade: 1 }],
numero_parcelas: 1,
})
.expect(201);
const parcelaId = (venda.body as { parcelas: Array<{ id: string }> }).parcelas[0].id;
await asTenant(token)
.patch(`/tenant/recebimentos/${parcelaId}/marcar-pago`)
.send({ forma_pagamento: 'pix' })
.expect(200);
await asTenant(token)
.patch(`/tenant/recebimentos/${parcelaId}/marcar-pago`)
.send({ forma_pagamento: 'pix' })
.expect(409);
});
it('atualizar-atrasadas muda parcela pendente vencida para atrasado', async () => {
const venda = await asTenant(token)
.post('/tenant/vendas')
.send({
customer_id: customerId,
itens: [{ product_service_id: produtoId, quantidade: 1, valor_unitario: 90 }],
parcelas: [{ valor: 90, vencimento: '2020-01-01' }],
})
.expect(201);
const parcelaId = (venda.body as { parcelas: Array<{ id: string }> }).parcelas[0].id;
await asTenant(token).post('/tenant/recebimentos/atualizar-atrasadas').expect(201);
const lista = await asTenant(token).get(`/tenant/recebimentos?status=atrasado`).expect(200);
const ids = (lista.body as { data: Array<{ id: string }> }).data.map((p) => p.id);
expect(ids).toContain(parcelaId);
});
it('lista recebimentos filtrando por customer_id', async () => {
const res = await asTenant(token)
.get(`/tenant/recebimentos?customer_id=${customerId}`)
.expect(200);
const body = res.body as { data: Array<{ sale: { customer_id: string } }> };
expect(body.data.every((p) => p.sale.customer_id === customerId)).toBe(true);
});
});
describe('dashboard do tenant', () => {
let token: string;
let customerId: string;
let produtoId: string;
beforeAll(async () => {
const tenant = await criarTenantComAdmin();
token = tenant.token;
customerId = (await criarCustomer(token)).id;
produtoId = (await criarProduto(token, { valor_venda: 200 })).id;
// venda confirmada (conta nos resumos) e uma em orçamento (não deve contar)
await asTenant(token)
.post('/tenant/vendas')
.send({
customer_id: customerId,
itens: [{ product_service_id: produtoId, quantidade: 1 }],
numero_parcelas: 1,
})
.expect(201);
await asTenant(token)
.post('/tenant/vendas')
.send({
customer_id: customerId,
itens: [{ product_service_id: produtoId, quantidade: 1 }],
numero_parcelas: 1,
status: 'orcamento',
})
.expect(201);
});
it('resumo-mensal inclui a venda confirmada do mês corrente', async () => {
const res = await asTenant(token).get('/tenant/dashboard/resumo-mensal').expect(200);
const linhas = res.body as Array<{ qtd_vendas: number; total_vendido: number }>;
expect(linhas.length).toBe(1);
expect(linhas[0].qtd_vendas).toBe(1);
expect(linhas[0].total_vendido).toBe(200);
});
it('resumo-semanal inclui a venda confirmada da semana corrente', async () => {
const res = await asTenant(token).get('/tenant/dashboard/resumo-semanal').expect(200);
const linhas = res.body as Array<{ qtd_vendas: number }>;
expect(linhas.length).toBe(1);
expect(linhas[0].qtd_vendas).toBe(1);
});
it('financeiro reflete parcela paga no total recebido do mês', async () => {
const antes = await asTenant(token).get('/tenant/dashboard/financeiro').expect(200);
const totalAntes = Number((antes.body as { total_recebido_mes: string }).total_recebido_mes);
const vendaComPendencia = await asTenant(token)
.post('/tenant/vendas')
.send({
customer_id: customerId,
itens: [{ product_service_id: produtoId, quantidade: 1, valor_unitario: 200 }],
numero_parcelas: 1,
})
.expect(201);
const parcelaId = (vendaComPendencia.body as { parcelas: Array<{ id: string }> }).parcelas[0]
.id;
await asTenant(token)
.patch(`/tenant/recebimentos/${parcelaId}/marcar-pago`)
.send({ forma_pagamento: 'dinheiro' })
.expect(200);
const depois = await asTenant(token).get('/tenant/dashboard/financeiro').expect(200);
const totalDepois = Number(
(depois.body as { total_recebido_mes: string }).total_recebido_mes,
);
expect(totalDepois - totalAntes).toBeCloseTo(200, 2);
});
});
describe('isolamento entre tenants (RLS) — reforçado nesta etapa', () => {
let tokenA: string;
let tokenB: string;
let vendaIdA: string;
let parcelaIdA: string;
beforeAll(async () => {
const tenantA = await criarTenantComAdmin();
tokenA = tenantA.token;
const tenantB = await criarTenantComAdmin();
tokenB = tenantB.token;
const customerA = await criarCustomer(tokenA);
const produtoA = await criarProduto(tokenA, { valor_venda: 333 });
const venda = await asTenant(tokenA)
.post('/tenant/vendas')
.send({
customer_id: customerA.id,
itens: [{ product_service_id: produtoA.id, quantidade: 1 }],
numero_parcelas: 1,
})
.expect(201);
vendaIdA = (venda.body as { id: string }).id;
parcelaIdA = (venda.body as { parcelas: Array<{ id: string }> }).parcelas[0].id;
});
it('tenant B não enxerga venda do tenant A (404, não 403)', async () => {
await asTenant(tokenB).get(`/tenant/vendas/${vendaIdA}`).expect(404);
await asTenant(tokenB)
.patch(`/tenant/vendas/${vendaIdA}/status`)
.send({ status: 'cancelada' })
.expect(404);
const lista = await asTenant(tokenB).get('/tenant/vendas').expect(200);
const ids = (lista.body as { data: Array<{ id: string }> }).data.map((v) => v.id);
expect(ids).not.toContain(vendaIdA);
});
it('tenant B não enxerga nem consegue marcar como paga a parcela do tenant A', async () => {
const lista = await asTenant(tokenB).get('/tenant/recebimentos').expect(200);
const ids = (lista.body as { data: Array<{ id: string }> }).data.map((p) => p.id);
expect(ids).not.toContain(parcelaIdA);
await asTenant(tokenB)
.patch(`/tenant/recebimentos/${parcelaIdA}/marcar-pago`)
.send({ forma_pagamento: 'pix' })
.expect(404);
});
it('dashboard do tenant B não inclui a venda do tenant A (confirma o fix de security_invoker das views)', async () => {
const res = await asTenant(tokenB).get('/tenant/dashboard/resumo-mensal').expect(200);
const linhas = res.body as Array<{ total_vendido: number }>;
expect(linhas.some((l) => l.total_vendido === 333)).toBe(false);
});
});
describe('acesso master sem contexto de tenant', () => {
it('master "puro" recebe 403 nos endpoints desta etapa', async () => {
await request(httpServer)
.get('/tenant/vendas')
.set('Authorization', `Bearer ${masterToken}`)
.expect(403);
await request(httpServer)
.get('/tenant/recebimentos')
.set('Authorization', `Bearer ${masterToken}`)
.expect(403);
await request(httpServer)
.get('/tenant/dashboard/financeiro')
.set('Authorization', `Bearer ${masterToken}`)
.expect(403);
});
});
});