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 |
|
| 3 | Painel Master (tenants/planos/faturas) | Concluída | 2026-08-13 |
|
||||||
| 4 | Integrações Evolution API + E-mail | Concluída | 2026-08-14 |
|
| 4 | Integrações Evolution API + E-mail | Concluída | 2026-08-14 |
|
||||||
| 5 | Painel Tenant (clientes/produtos) | 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 | |
|
| 7 | Motor de Alertas (BullMQ) | [ ] Não iniciada | |
|
||||||
| 8 | Personalização Whitelabel e Ajustes Finais | [ ] 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
|
## Etapa 6 — Vendas, Parcelas e Dashboards
|
||||||
|
|
||||||
- [ ] Criar venda com itens e parcelas funciona em transação única
|
- [x] Criar venda com itens e parcelas funciona em transação única
|
||||||
- [ ] Soma das parcelas validada contra o valor total da venda
|
- [x] Soma das parcelas validada contra o valor total da venda
|
||||||
- [ ] Cancelar venda cancela as parcelas pendentes associadas
|
- [x] Cancelar venda cancela as parcelas pendentes associadas
|
||||||
- [ ] Venda confirmada não editável em itens/valores
|
- [x] Venda confirmada não editável em itens/valores
|
||||||
- [ ] Marcar parcela como paga reflete no dashboard financeiro
|
- [x] Marcar parcela como paga reflete no dashboard financeiro
|
||||||
- [ ] Dashboards semanal/mensal batem com dados de teste
|
- [x] Dashboards semanal/mensal batem com dados de teste
|
||||||
- [ ] Endpoint de atualizar atrasadas funciona
|
- [x] Endpoint de atualizar atrasadas funciona
|
||||||
- [ ] Isolamento por RLS reforçado e testado nesta etapa
|
- [x] Isolamento por RLS reforçado e testado nesta etapa
|
||||||
|
|
||||||
**Observações:**
|
**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)
|
## 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` +
|
(endpoints `/master/tenants/:tenantId/...` read-only, `MasterGuard` +
|
||||||
auditoria de cada acesso), não uma extensão das rotas `/tenant/*`
|
auditoria de cada acesso), não uma extensão das rotas `/tenant/*`
|
||||||
existentes.
|
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 { HealthModule } from './modules/health/health.module';
|
||||||
import { PlanosModule } from './modules/planos/planos.module';
|
import { PlanosModule } from './modules/planos/planos.module';
|
||||||
import { ProductsModule } from './modules/products/products.module';
|
import { ProductsModule } from './modules/products/products.module';
|
||||||
|
import { SalesModule } from './modules/sales/sales.module';
|
||||||
import { TenantsModule } from './modules/tenants/tenants.module';
|
import { TenantsModule } from './modules/tenants/tenants.module';
|
||||||
import { WhatsappModule } from './modules/whatsapp/whatsapp.module';
|
import { WhatsappModule } from './modules/whatsapp/whatsapp.module';
|
||||||
|
|
||||||
@@ -35,6 +36,7 @@ import { WhatsappModule } from './modules/whatsapp/whatsapp.module';
|
|||||||
WhatsappModule,
|
WhatsappModule,
|
||||||
CustomersModule,
|
CustomersModule,
|
||||||
ProductsModule,
|
ProductsModule,
|
||||||
|
SalesModule,
|
||||||
],
|
],
|
||||||
providers: [{ provide: APP_GUARD, useClass: ThrottlerGuard }],
|
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