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