diff --git a/Etapas/09-controle-de-progresso.md b/Etapas/09-controle-de-progresso.md index 543ef31..7a6aa0b 100644 --- a/Etapas/09-controle-de-progresso.md +++ b/Etapas/09-controle-de-progresso.md @@ -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. diff --git a/prisma/migrations/20260815090000_dashboard_views_security_invoker/migration.sql b/prisma/migrations/20260815090000_dashboard_views_security_invoker/migration.sql new file mode 100644 index 0000000..68a632b --- /dev/null +++ b/prisma/migrations/20260815090000_dashboard_views_security_invoker/migration.sql @@ -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); diff --git a/src/app.module.ts b/src/app.module.ts index 41fc63d..a54868b 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -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 }], }) diff --git a/src/modules/sales/dashboard.controller.ts b/src/modules/sales/dashboard.controller.ts new file mode 100644 index 0000000..bbbb135 --- /dev/null +++ b/src/modules/sales/dashboard.controller.ts @@ -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); + } +} diff --git a/src/modules/sales/dashboard.service.ts b/src/modules/sales/dashboard.service.ts new file mode 100644 index 0000000..2493c77 --- /dev/null +++ b/src/modules/sales/dashboard.service.ts @@ -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(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(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, + }; + } +} diff --git a/src/modules/sales/dto/change-sale-status.dto.ts b/src/modules/sales/dto/change-sale-status.dto.ts new file mode 100644 index 0000000..8dc1237 --- /dev/null +++ b/src/modules/sales/dto/change-sale-status.dto.ts @@ -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; +} diff --git a/src/modules/sales/dto/create-sale.dto.ts b/src/modules/sales/dto/create-sale.dto.ts new file mode 100644 index 0000000..8712d88 --- /dev/null +++ b/src/modules/sales/dto/create-sale.dto.ts @@ -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[]; +} diff --git a/src/modules/sales/dto/dashboard-periodo.query.dto.ts b/src/modules/sales/dto/dashboard-periodo.query.dto.ts new file mode 100644 index 0000000..ccccf4e --- /dev/null +++ b/src/modules/sales/dto/dashboard-periodo.query.dto.ts @@ -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; +} diff --git a/src/modules/sales/dto/list-recebimentos.query.dto.ts b/src/modules/sales/dto/list-recebimentos.query.dto.ts new file mode 100644 index 0000000..fa01f23 --- /dev/null +++ b/src/modules/sales/dto/list-recebimentos.query.dto.ts @@ -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; +} diff --git a/src/modules/sales/dto/list-sales.query.dto.ts b/src/modules/sales/dto/list-sales.query.dto.ts new file mode 100644 index 0000000..7ff7063 --- /dev/null +++ b/src/modules/sales/dto/list-sales.query.dto.ts @@ -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; +} diff --git a/src/modules/sales/dto/marcar-pago.dto.ts b/src/modules/sales/dto/marcar-pago.dto.ts new file mode 100644 index 0000000..67b1300 --- /dev/null +++ b/src/modules/sales/dto/marcar-pago.dto.ts @@ -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]; +} diff --git a/src/modules/sales/dto/update-sale.dto.ts b/src/modules/sales/dto/update-sale.dto.ts new file mode 100644 index 0000000..8839d64 --- /dev/null +++ b/src/modules/sales/dto/update-sale.dto.ts @@ -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[]; +} diff --git a/src/modules/sales/sale-calculo.util.ts b/src/modules/sales/sale-calculo.util.ts new file mode 100644 index 0000000..9635799 --- /dev/null +++ b/src/modules/sales/sale-calculo.util.ts @@ -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); +} diff --git a/src/modules/sales/sale-payments.controller.ts b/src/modules/sales/sale-payments.controller.ts new file mode 100644 index 0000000..60d8bae --- /dev/null +++ b/src/modules/sales/sale-payments.controller.ts @@ -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); + } +} diff --git a/src/modules/sales/sale-payments.service.ts b/src/modules/sales/sale-payments.service.ts new file mode 100644 index 0000000..1e9fd78 --- /dev/null +++ b/src/modules/sales/sale-payments.service.ts @@ -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 }; + } +} diff --git a/src/modules/sales/sales.controller.ts b/src/modules/sales/sales.controller.ts new file mode 100644 index 0000000..924e8ac --- /dev/null +++ b/src/modules/sales/sales.controller.ts @@ -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); + } +} diff --git a/src/modules/sales/sales.module.ts b/src/modules/sales/sales.module.ts new file mode 100644 index 0000000..2eb928e --- /dev/null +++ b/src/modules/sales/sales.module.ts @@ -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 {} diff --git a/src/modules/sales/sales.service.ts b/src/modules/sales/sales.service.ts new file mode 100644 index 0000000..46bf786 --- /dev/null +++ b/src/modules/sales/sales.service.ts @@ -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 = { + 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; + } +} diff --git a/test/vendas-dashboards.e2e-spec.ts b/test/vendas-dashboards.e2e-spec.ts new file mode 100644 index 0000000..b6fa1d3 --- /dev/null +++ b/test/vendas-dashboards.e2e-spec.ts @@ -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[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 { + 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> = {}) { + 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> = {}) { + 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> = {}) { + 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[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); + }); + }); +});