feat(api): Quotes, Client Registration e esqueleto de Contratos

Fecha o núcleo comercial da Fase 2/3: motor de precificação de Ofertas
(feito diretamente, dado o risco de erro de arredondamento/regra de
negócio), Cadastro de Cliente e esqueleto de Contratos (via 2 subagentes
em paralelo, salvos manualmente após ambos baterem no limite de gasto
mensal da conta — trabalho não commitado nos worktrees foi revisado
arquivo a arquivo e copiado para main, com as peças faltantes
completadas diretamente).

- Quotes: pricing.ts porta as fórmulas do legado (eden.md §7/§8) —
  needsApproval, faixas de condição de pagamento, efeito legal da
  fidelidade condicionado a approval_status==='approved'. Fluxo
  criar→decidir aprovação→fechar negócio (vinculado a um cadastro de
  cliente, com guarda contra fechamento duplo).
- Client Registration: cadastro direto (eden.md §3), MVP autenticado-only
  (sem token público de autoatendimento, upload ou lookup CNPJ/CEP nesta
  fase — documentado em comentário).
- Contracts: máquina de estados do ADR-0006 imposta via SELECT...FOR
  UPDATE + tabela de transições, com signed_at/activated_at e histórico
  de status; 4 das 11 tabelas do ADR-0006 (as demais adiadas).

2 bugs próprios corrigidos antes do commit: UpdateQuoteInput usava
COALESCE para campos financeiros (undefined do JS apagaria valor em
update parcial) — campos tornados obrigatórios; User.reseller_id/
can_approve_discount faltavam em types.ts apesar das colunas já
existirem no banco desde migrations anteriores.

Verificado: pnpm turbo run typecheck build (28/28), pnpm test (15/15),
e todo o fluxo testado ao vivo via curl contra a stack Docker real,
incluindo cenários de erro (transição de status inválida, FK de produto
inexistente, fechamento duplo, 401 sem token) e trilha de auditoria
conferida no Postgres.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-03 12:25:49 -03:00
parent e8014b4135
commit 18838a9031
36 changed files with 3199 additions and 0 deletions

View File

@@ -11,6 +11,9 @@ import { ProductsModule } from "./products/products.module.js";
import { ResellersModule } from "./resellers/resellers.module.js";
import { LeadsModule } from "./leads/leads.module.js";
import { OpportunitiesModule } from "./opportunities/opportunities.module.js";
import { QuotesModule } from "./quotes/quotes.module.js";
import { ClientRegistrationsModule } from "./client-registrations/client-registrations.module.js";
import { ContractsModule } from "./contracts/contracts.module.js";
@Module({
imports: [
@@ -25,6 +28,9 @@ import { OpportunitiesModule } from "./opportunities/opportunities.module.js";
ResellersModule,
LeadsModule,
OpportunitiesModule,
QuotesModule,
ClientRegistrationsModule,
ContractsModule,
],
controllers: [HealthController],
})

View File

@@ -11,6 +11,10 @@ export interface EdenAuthenticatedUser {
roleKey: string;
applicationKey: "core" | "reseller" | "subscriber";
permissions: ResolvedPermissions;
/** Nullable — scopes the actor to a reseller for scope='reseller' permission checks (eden.md §7, ADR-0004). */
resellerId: string | null;
/** eden.md §1.1/§3.3 — only meaningful combined with roleKey==='admin'; super_admin bypasses regardless. */
canApproveDiscount: boolean;
}
declare module "express" {
@@ -59,6 +63,8 @@ export class JwtAuthGuard implements CanActivate {
roleKey: user.role_key,
applicationKey: payload.app,
permissions,
resellerId: user.reseller_id,
canApproveDiscount: user.can_approve_discount,
};
return true;
}

View File

@@ -0,0 +1,88 @@
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, UseGuards } from "@nestjs/common";
import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
import { ClientRegistrationsService } from "./client-registrations.service.js";
import { CreateDirectClientRegistrationDto } from "./dto/create-direct-client-registration.dto.js";
import { UpdateClientRegistrationDto } from "./dto/update-client-registration.dto.js";
import { CreatePartnerDto } from "./dto/create-partner.dto.js";
import { UpdatePartnerDto } from "./dto/update-partner.dto.js";
import { JwtAuthGuard } from "../auth/jwt-auth.guard.js";
import { PermissionGuard } from "../auth/permission.guard.js";
import { RequirePermission } from "../auth/decorators/require-permission.decorator.js";
import { CurrentUser } from "../auth/decorators/current-user.decorator.js";
import type { EdenAuthenticatedUser } from "../auth/jwt-auth.guard.js";
/**
* Customer 360 / Client Registration (eden.md §3, Master Prompt §6.5) —
* authenticated-only MVP. Unlike Companies/Products/Resellers (open read),
* every route here — including reads — requires both authentication and
* the `client-registrations` resource permission, since this data carries
* customer PII (CPF/CNPJ, contacts, address). Deliberately out of scope
* for this round: the public unauthenticated token-based self-service
* form, file attachments, CNPJ/CEP lookup — see the migration and
* repository comments for the full list.
*/
@ApiTags("client-registrations")
@ApiBearerAuth()
@UseGuards(JwtAuthGuard, PermissionGuard)
@Controller("v1/client-registrations")
export class ClientRegistrationsController {
constructor(private readonly clientRegistrations: ClientRegistrationsService) {}
@Get()
@RequirePermission("client-registrations", "view")
list(@CurrentUser() user: EdenAuthenticatedUser) {
return this.clientRegistrations.list(user);
}
@Post("direct")
@RequirePermission("client-registrations", "edit")
createDirect(
@Body() dto: CreateDirectClientRegistrationDto,
@CurrentUser() user: EdenAuthenticatedUser,
) {
return this.clientRegistrations.createDirect(dto, user);
}
@Get(":id")
@RequirePermission("client-registrations", "view")
get(@Param("id", ParseUUIDPipe) id: string, @CurrentUser() user: EdenAuthenticatedUser) {
return this.clientRegistrations.get(id, user);
}
@Patch(":id")
@RequirePermission("client-registrations", "edit")
update(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: UpdateClientRegistrationDto,
@CurrentUser() user: EdenAuthenticatedUser,
) {
return this.clientRegistrations.update(id, dto, user);
}
@Get(":id/partners")
@RequirePermission("client-registrations", "view")
listPartners(@Param("id", ParseUUIDPipe) id: string, @CurrentUser() user: EdenAuthenticatedUser) {
return this.clientRegistrations.listPartners(id, user);
}
@Post(":id/partners")
@RequirePermission("client-registrations", "edit")
createPartner(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: CreatePartnerDto,
@CurrentUser() user: EdenAuthenticatedUser,
) {
return this.clientRegistrations.createPartner(id, dto, user);
}
@Patch(":id/partners/:partnerId")
@RequirePermission("client-registrations", "edit")
updatePartner(
@Param("id", ParseUUIDPipe) id: string,
@Param("partnerId", ParseUUIDPipe) partnerId: string,
@Body() dto: UpdatePartnerDto,
@CurrentUser() user: EdenAuthenticatedUser,
) {
return this.clientRegistrations.updatePartner(id, partnerId, dto, user);
}
}

View File

@@ -0,0 +1,12 @@
import { Module } from "@nestjs/common";
import { AuthModule } from "../auth/auth.module.js";
import { ClientRegistrationsController } from "./client-registrations.controller.js";
import { ClientRegistrationsService } from "./client-registrations.service.js";
@Module({
imports: [AuthModule],
controllers: [ClientRegistrationsController],
providers: [ClientRegistrationsService],
exports: [ClientRegistrationsService],
})
export class ClientRegistrationsModule {}

View File

@@ -0,0 +1,237 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import {
auditRepo,
clientRegistrationsRepo,
clientRegistrationPartnersRepo,
type ClientRegistration,
type ClientRegistrationPartner,
} from "@eden/database";
import { maxGrantedScope } from "../auth/permissions.js";
import type { EdenAuthenticatedUser } from "../auth/jwt-auth.guard.js";
import type { CreateDirectClientRegistrationDto } from "./dto/create-direct-client-registration.dto.js";
import type { UpdateClientRegistrationDto } from "./dto/update-client-registration.dto.js";
import type { CreatePartnerDto } from "./dto/create-partner.dto.js";
import type { UpdatePartnerDto } from "./dto/update-partner.dto.js";
type ResolvedScope =
| { kind: "all" }
| { kind: "own"; actorId: string }
| { kind: "reseller"; resellerId: string };
@Injectable()
export class ClientRegistrationsService {
/**
* eden.md §7.1 documents two hardcoded feature keys with two different
* scoping rules: `clientes_todos` (full backoffice view, scoped by
* *authorship* — `created_by_id = user.id` when the actor lacks the
* "todos" grant) vs. `clientes_revenda` (minimal directory, scoped by
* *reseller* — `reseller_id = user.reseller_id`). EDEN generalizes both
* into the single resource+action+scope permission model (ADR-0004)
* instead of two separate feature keys — a deliberate generalization,
* not a literal port:
* - granted scope `all` → see everything.
* - granted scope `reseller` (actor has a reseller) → scoped to that
* reseller_id, mirroring `clientes_revenda`.
* - anything narrower (`own`/`team`/`legal_entity`), or `reseller`
* without an actual reseller_id on the actor → falls back to
* authorship (`own`), mirroring `clientes_todos`'s non-admin default.
*/
private resolveScope(user: EdenAuthenticatedUser, action: "view" | "edit"): ResolvedScope {
const granted = maxGrantedScope(user.permissions, "client-registrations", action);
if (granted === "all") return { kind: "all" };
if (granted === "reseller" && user.resellerId) {
return { kind: "reseller", resellerId: user.resellerId };
}
return { kind: "own", actorId: user.id };
}
private isVisible(registration: ClientRegistration, scope: ResolvedScope): boolean {
if (scope.kind === "all") return true;
if (scope.kind === "own") return registration.created_by === scope.actorId;
return registration.reseller_id === scope.resellerId;
}
list(user: EdenAuthenticatedUser): Promise<ClientRegistration[]> {
const scope = this.resolveScope(user, "view");
if (scope.kind === "all") {
return clientRegistrationsRepo.listClientRegistrations({ scope: "all" });
}
if (scope.kind === "reseller") {
return clientRegistrationsRepo.listClientRegistrations({
scope: "reseller",
resellerId: scope.resellerId,
});
}
return clientRegistrationsRepo.listClientRegistrations({ scope: "own", actorId: scope.actorId });
}
/** 404, never 403, for a record outside the actor's scope — don't confirm a UUID exists (eden-commercial rules). */
async get(id: string, user: EdenAuthenticatedUser): Promise<ClientRegistration> {
const scope = this.resolveScope(user, "view");
const registration = await clientRegistrationsRepo.getClientRegistrationById(id);
if (!registration || !this.isVisible(registration, scope)) {
throw new NotFoundException("Cadastro de cliente não encontrado");
}
return registration;
}
/** Same 404-not-403 treatment, but scoped by the actor's *edit* grant — write access shouldn't leak past what read visibility would allow either. */
private async getForEdit(id: string, user: EdenAuthenticatedUser): Promise<ClientRegistration> {
const scope = this.resolveScope(user, "edit");
const registration = await clientRegistrationsRepo.getClientRegistrationById(id);
if (!registration || !this.isVisible(registration, scope)) {
throw new NotFoundException("Cadastro de cliente não encontrado");
}
return registration;
}
/**
* Cadastro "direto" (eden.md §2.6). `quoteId` is never accepted here —
* no Quotes integration in this worktree yet (built in parallel).
*
* Legacy rule (eden.md §2.6): "quem não é admin/backoffice só cria para
* a própria revenda (req.user.reseller_id); admin/backoffice pode
* opcionalmente indicar reseller_id." Replicated here, generalized
* through the scope model instead of a role check: only an actor
* holding `all` edit scope for client-registrations may set an
* arbitrary `resellerId`; anyone narrower has their *own* reseller_id
* forced server-side — the body's `resellerId` is ignored in that case,
* never trusted for an authorization-relevant assignment.
*/
async createDirect(
dto: CreateDirectClientRegistrationDto,
user: EdenAuthenticatedUser,
): Promise<ClientRegistration> {
const editScope = maxGrantedScope(user.permissions, "client-registrations", "edit");
const resellerId = editScope === "all" ? (dto.resellerId ?? null) : (user.resellerId ?? null);
const registration = await clientRegistrationsRepo.createClientRegistration({
personType: dto.personType,
isPortability: dto.isPortability,
resellerId,
createdBy: user.id,
});
await auditRepo.appendAuditEvent({
actorType: "user",
actorId: user.id,
action: "client_registration.created",
resourceType: "client_registration",
resourceId: registration.id,
metadata: { personType: registration.person_type, isPortability: registration.is_portability },
});
return registration;
}
/**
* Single generic PATCH, matching the legacy (eden.md §1.1.1) — no
* dedicated status-transition endpoint/guard. `dto.registrationStatus`,
* if present, is applied via `updateClientRegistrationStatus` (which
* also stamps `activated_at` the first time it becomes `ativo`); every
* other field goes through `updateClientRegistrationProfile`, which
* structurally cannot touch pf_cpf/pj_cnpj/pj_rep_cpf (see that
* function's Input type).
*/
async update(
id: string,
dto: UpdateClientRegistrationDto,
user: EdenAuthenticatedUser,
): Promise<ClientRegistration> {
await this.getForEdit(id, user); // 404 if missing or outside the actor's edit scope
let updated: ClientRegistration | null = null;
if (dto.registrationStatus) {
updated = await clientRegistrationsRepo.updateClientRegistrationStatus(id, dto.registrationStatus);
}
const { registrationStatus: _registrationStatus, ...profileFields } = dto;
const hasProfileFields = Object.values(profileFields).some((value) => value !== undefined);
if (hasProfileFields) {
updated = await clientRegistrationsRepo.updateClientRegistrationProfile(id, profileFields);
}
if (!updated) {
updated = await clientRegistrationsRepo.getClientRegistrationById(id);
}
if (!updated) throw new NotFoundException("Cadastro de cliente não encontrado");
await auditRepo.appendAuditEvent({
actorType: "user",
actorId: user.id,
action: "client_registration.updated",
resourceType: "client_registration",
resourceId: id,
metadata: { fields: Object.keys(dto) },
});
return updated;
}
async listPartners(id: string, user: EdenAuthenticatedUser): Promise<ClientRegistrationPartner[]> {
await this.get(id, user); // reuse the view-scope visibility check
return clientRegistrationPartnersRepo.listPartnersForRegistration(id);
}
async createPartner(
id: string,
dto: CreatePartnerDto,
user: EdenAuthenticatedUser,
): Promise<ClientRegistrationPartner> {
await this.getForEdit(id, user);
const partner = await clientRegistrationPartnersRepo.createPartner({
clientRegistrationId: id,
name: dto.name,
document: dto.document,
qualification: dto.qualification,
cpf: dto.cpf,
willSign: dto.willSign,
signatureEmail: dto.signatureEmail,
});
await auditRepo.appendAuditEvent({
actorType: "user",
actorId: user.id,
action: "client_registration.partner_created",
resourceType: "client_registration_partner",
resourceId: partner.id,
metadata: { clientRegistrationId: id },
});
return partner;
}
async updatePartner(
id: string,
partnerId: string,
dto: UpdatePartnerDto,
user: EdenAuthenticatedUser,
): Promise<ClientRegistrationPartner> {
await this.getForEdit(id, user);
const existing = await clientRegistrationPartnersRepo.getPartnerById(partnerId);
if (!existing || existing.client_registration_id !== id) {
throw new NotFoundException("Sócio não encontrado");
}
const partner = await clientRegistrationPartnersRepo.updatePartner(partnerId, {
cpf: dto.cpf,
willSign: dto.willSign,
signatureEmail: dto.signatureEmail,
});
if (!partner) throw new NotFoundException("Sócio não encontrado");
await auditRepo.appendAuditEvent({
actorType: "user",
actorId: user.id,
action: "client_registration.partner_updated",
resourceType: "client_registration_partner",
resourceId: partnerId,
metadata: { clientRegistrationId: id, fields: Object.keys(dto) },
});
return partner;
}
}

View File

@@ -0,0 +1,28 @@
import { IsBoolean, IsIn, IsOptional, IsUUID } from "class-validator";
export const PERSON_TYPES = ["pf", "pj"] as const;
export type PersonType = (typeof PERSON_TYPES)[number];
/**
* Cadastro "direto" (eden.md §2.6) — the only creation path in this MVP,
* `quote_id` stays null (no Quotes integration yet, being built in
* parallel — see the migration comment). `quoteId` is deliberately NOT a
* field here.
*/
export class CreateDirectClientRegistrationDto {
@IsIn(PERSON_TYPES)
personType!: PersonType;
@IsBoolean()
isPortability!: boolean;
/**
* Optional; only actually honored server-side when the actor holds
* `all` edit scope for client-registrations — see the service's
* `createDirect` comment. Never trusted at face value for a narrower
* scope (cross-tenant isolation, ADR-0004).
*/
@IsOptional()
@IsUUID()
resellerId?: string;
}

View File

@@ -0,0 +1,19 @@
import { IsBoolean, IsEmail, IsOptional, IsString, MinLength } from "class-validator";
/**
* `source` is not client-settable — always defaults to `'manual'`
* (DB default, packages/database/migrations/*_client-registrations.cjs)
* since this MVP has no CNPJ-lookup integration to attribute it to
* (eden.md §1.2 §4 documents the legacy default as `'brasilapi'`).
*/
export class CreatePartnerDto {
@IsString()
@MinLength(1)
name!: string;
@IsOptional() @IsString() document?: string;
@IsOptional() @IsString() qualification?: string;
@IsOptional() @IsString() cpf?: string;
@IsOptional() @IsBoolean() willSign?: boolean;
@IsOptional() @IsEmail() signatureEmail?: string;
}

View File

@@ -0,0 +1,107 @@
import { Type } from "class-transformer";
import {
IsArray,
IsBoolean,
IsDateString,
IsEmail,
IsIn,
IsOptional,
IsString,
ValidateNested,
} from "class-validator";
export const REGISTRATION_STATUSES = [
"rascunho",
"pendente_validacao",
"ativo",
"bloqueado",
"inativo",
] as const;
export type RegistrationStatus = (typeof REGISTRATION_STATUSES)[number];
export class PortabilityRangeDto {
@IsString()
first!: string;
@IsString()
last!: string;
}
/**
* Administratively editable fields per eden.md §1.1.2 — and *only* those.
* `pfCpf`, `pjCnpj`, and `pjRepCpf` are deliberately not declared here:
* with the global ValidationPipe's `forbidNonWhitelisted` (main.ts), any
* request that tries to send them is rejected outright rather than
* silently ignored. Changing the document itself would need a dedicated
* re-verification flow — never a side effect of a generic admin PATCH.
*
* `registrationStatus` is validated as one of the 5 legal *values* here;
* full state-machine transition legality (e.g. blocking `rascunho`→`ativo`
* directly) is NOT enforced in this MVP — matches the legacy, which also
* has no dedicated transition endpoint (eden.md §1.1.1: "não há endpoint
* dedicado de transição", just this same generic PATCH).
*/
export class UpdateClientRegistrationDto {
@IsOptional()
@IsIn(REGISTRATION_STATUSES)
registrationStatus?: RegistrationStatus;
@IsOptional() @IsString() internalNotes?: string;
@IsOptional() @IsString() ixcClientId?: string;
@IsOptional() @IsString() ixcContractNumber?: string;
@IsOptional() @IsString() addressZip?: string;
@IsOptional() @IsString() addressStreet?: string;
@IsOptional() @IsString() addressNumber?: string;
@IsOptional() @IsString() addressComplement?: string;
@IsOptional() @IsString() addressNeighborhood?: string;
@IsOptional() @IsString() addressCity?: string;
@IsOptional() @IsString() addressState?: string;
@IsOptional() @IsString() addressCountry?: string;
@IsOptional() @IsString() contactPrincipalName?: string;
@IsOptional() @IsEmail() contactPrincipalEmail?: string;
@IsOptional() @IsString() contactPrincipalPhone?: string;
@IsOptional() @IsString() contactFinancialName?: string;
@IsOptional() @IsEmail() contactFinancialEmail?: string;
@IsOptional() @IsString() contactFinancialPhone?: string;
@IsOptional() @IsString() contactTechnicalName?: string;
@IsOptional() @IsEmail() contactTechnicalEmail?: string;
@IsOptional() @IsString() contactTechnicalPhone?: string;
// PF — pfCpf intentionally absent, see class comment.
@IsOptional() @IsString() pfFullName?: string;
@IsOptional() @IsDateString() pfBirthDate?: string;
@IsOptional() @IsEmail() pfEmail?: string;
@IsOptional() @IsString() pfMobilePhone?: string;
@IsOptional() @IsString() pfAltPhone?: string;
@IsOptional() @IsString() pfSocialName?: string;
@IsOptional() @IsString() pfIdDocument?: string;
@IsOptional() @IsString() pfIdIssuer?: string;
// PJ — pjCnpj and pjRepCpf intentionally absent, see class comment.
@IsOptional() @IsString() pjCompanyName?: string;
@IsOptional() @IsString() pjTradeName?: string;
@IsOptional() @IsString() pjStateRegistration?: string;
@IsOptional() @IsString() pjMunicipalRegistration?: string;
@IsOptional() @IsDateString() pjOpeningDate?: string;
@IsOptional() @IsString() pjLegalNature?: string;
@IsOptional() @IsString() pjCnpjSituation?: string;
@IsOptional() @IsString() pjMainActivity?: string;
@IsOptional() @IsString() pjRepFullName?: string;
@IsOptional() @IsString() pjRepRole?: string;
@IsOptional() @IsEmail() pjRepEmail?: string;
@IsOptional() @IsString() pjRepMobilePhone?: string;
@IsOptional() @IsBoolean() pjRepAuthorizationAck?: boolean;
@IsOptional()
@IsArray()
@IsString({ each: true })
portabilityNumbers?: string[];
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => PortabilityRangeDto)
portabilityRanges?: PortabilityRangeDto[];
}

View File

@@ -0,0 +1,8 @@
import { IsBoolean, IsEmail, IsOptional, IsString } from "class-validator";
/** eden.md §2.5: backoffice can correct only these 3 fields post-submission. */
export class UpdatePartnerDto {
@IsOptional() @IsString() cpf?: string;
@IsOptional() @IsBoolean() willSign?: boolean;
@IsOptional() @IsEmail() signatureEmail?: string;
}

View File

@@ -0,0 +1,74 @@
import { Body, Controller, Get, Param, Patch, Post, UseGuards } from "@nestjs/common";
import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
import { ContractsService } from "./contracts.service.js";
import { CreateContractDto } from "./dto/create-contract.dto.js";
import { UpdateContractStatusDto } from "./dto/update-contract-status.dto.js";
import { ContractItemInputDto } from "./dto/contract-item-input.dto.js";
import { ContractPartyInputDto } from "./dto/contract-party-input.dto.js";
import { JwtAuthGuard } from "../auth/jwt-auth.guard.js";
import { PermissionGuard } from "../auth/permission.guard.js";
import { RequirePermission } from "../auth/decorators/require-permission.decorator.js";
import { CurrentUser } from "../auth/decorators/current-user.decorator.js";
import type { EdenAuthenticatedUser } from "../auth/jwt-auth.guard.js";
/**
* Contracts as a first-class aggregate — docs/adr/0006-contract-first-class.md.
* Every write recalculates monthly_value/contract_total server-side from
* the items array (Master Prompt §6.6/§24) — a client-supplied total is
* never trusted.
*/
@ApiTags("contracts")
@ApiBearerAuth()
@UseGuards(JwtAuthGuard, PermissionGuard)
@Controller("v1/contracts")
export class ContractsController {
constructor(private readonly contracts: ContractsService) {}
@Get()
@RequirePermission("contracts", "view")
list(@CurrentUser() user: EdenAuthenticatedUser) {
return this.contracts.list(user);
}
@Get(":id")
@RequirePermission("contracts", "view")
get(@Param("id") id: string, @CurrentUser() user: EdenAuthenticatedUser) {
return this.contracts.get(id, user);
}
@Post()
@RequirePermission("contracts", "edit")
create(@Body() dto: CreateContractDto, @CurrentUser() user: EdenAuthenticatedUser) {
return this.contracts.create(dto, user);
}
@Patch(":id/status")
@RequirePermission("contracts", "edit")
updateStatus(
@Param("id") id: string,
@Body() dto: UpdateContractStatusDto,
@CurrentUser() user: EdenAuthenticatedUser,
) {
return this.contracts.updateStatus(id, dto, user);
}
@Post(":id/items")
@RequirePermission("contracts", "edit")
addItem(
@Param("id") id: string,
@Body() dto: ContractItemInputDto,
@CurrentUser() user: EdenAuthenticatedUser,
) {
return this.contracts.addItem(id, dto, user);
}
@Post(":id/parties")
@RequirePermission("contracts", "edit")
addParty(
@Param("id") id: string,
@Body() dto: ContractPartyInputDto,
@CurrentUser() user: EdenAuthenticatedUser,
) {
return this.contracts.addParty(id, dto, user);
}
}

View File

@@ -0,0 +1,12 @@
import { Module } from "@nestjs/common";
import { AuthModule } from "../auth/auth.module.js";
import { ContractsController } from "./contracts.controller.js";
import { ContractsService } from "./contracts.service.js";
@Module({
imports: [AuthModule],
controllers: [ContractsController],
providers: [ContractsService],
exports: [ContractsService],
})
export class ContractsModule {}

View File

@@ -0,0 +1,188 @@
import {
BadRequestException,
ConflictException,
ForbiddenException,
Injectable,
NotFoundException,
} from "@nestjs/common";
import {
auditRepo,
contractsRepo,
productsRepo,
type Contract,
type ContractWithDetails,
type CreateContractItemInput,
} from "@eden/database";
import type { EdenAuthenticatedUser } from "../auth/jwt-auth.guard.js";
import { maxGrantedScope } from "../auth/permissions.js";
import type { CreateContractDto } from "./dto/create-contract.dto.js";
import type { UpdateContractStatusDto } from "./dto/update-contract-status.dto.js";
import type { ContractItemInputDto } from "./dto/contract-item-input.dto.js";
import type { ContractPartyInputDto } from "./dto/contract-party-input.dto.js";
const { ContractNotFoundError, InvalidContractTransitionError } = contractsRepo;
@Injectable()
export class ContractsService {
async list(user: EdenAuthenticatedUser): Promise<Contract[]> {
const scope = maxGrantedScope(user.permissions, "contracts", "view");
if (!scope) throw new ForbiddenException("Sem permissão para 'view' em 'contracts'");
if (scope === "all") return contractsRepo.listContracts({ scope: "all" });
return contractsRepo.listContracts({ scope: "reseller", resellerId: user.resellerId });
}
async get(id: string, user: EdenAuthenticatedUser): Promise<ContractWithDetails> {
const scope = maxGrantedScope(user.permissions, "contracts", "view");
const contract = await contractsRepo.getContractById(id);
if (!contract || !scope) throw new NotFoundException("Contrato não encontrado");
if (scope !== "all" && contract.reseller_id !== user.resellerId) {
throw new NotFoundException("Contrato não encontrado");
}
return contract;
}
async create(dto: CreateContractDto, user: EdenAuthenticatedUser): Promise<ContractWithDetails> {
if (dto.fidelityPeriod != null && dto.fidelityPeriod > dto.contractPeriod) {
throw new BadRequestException("fidelityPeriod não pode ser maior que contractPeriod");
}
const items = await this.buildItems(dto.items, dto.contractPeriod);
const monthlyValue = round2(items.reduce((sum, item) => sum + item.total, 0));
const implementationFee = dto.implementationFee ?? 0;
const contractTotal = round2(monthlyValue * (dto.contractPeriod || 1) + implementationFee);
const contract = await contractsRepo.createContract({
quoteId: dto.quoteId ?? null,
clientRegistrationId: dto.clientRegistrationId ?? null,
resellerId: dto.resellerId ?? null,
companyId: dto.companyId ?? null,
contractPeriod: dto.contractPeriod,
fidelityPeriod: dto.fidelityPeriod ?? null,
// fidelity_approved always starts false — approval is a distinct,
// explicit act (mirrors quotes.approval_status never starting
// 'approved' on creation either); no endpoint to set it yet in this
// MVP since the approval workflow lives on Quotes for now — a
// contract created from an already-approved quote will carry that
// forward once the Quotes→Contract integration lands.
fidelityApproved: false,
monthlyValue,
contractTotal,
implementationFee,
notes: dto.notes ?? null,
createdBy: user.id,
items: items.map((item, index) => ({ ...item, sortOrder: index })),
parties: dto.parties.map(this.mapParty),
});
await auditRepo.appendAuditEvent({
actorType: "user",
actorId: user.id,
action: "contract.created",
resourceType: "contract",
resourceId: contract.id,
metadata: { contractTotal, itemCount: items.length },
});
return contract;
}
async updateStatus(id: string, dto: UpdateContractStatusDto, user: EdenAuthenticatedUser): Promise<Contract> {
await this.get(id, user); // scope check — 404 before attempting the transition
try {
const updated = await contractsRepo.updateContractStatus(id, dto.toStatus, dto.reason ?? null, user.id);
await auditRepo.appendAuditEvent({
actorType: "user",
actorId: user.id,
action: "contract.status_changed",
resourceType: "contract",
resourceId: id,
metadata: { toStatus: dto.toStatus, reason: dto.reason ?? null },
});
return updated;
} catch (err) {
if (err instanceof ContractNotFoundError) throw new NotFoundException("Contrato não encontrado");
if (err instanceof InvalidContractTransitionError) {
throw new ConflictException(err.message);
}
throw err;
}
}
async addItem(id: string, dto: ContractItemInputDto, user: EdenAuthenticatedUser) {
const contract = await this.get(id, user);
const [item] = await this.buildItems([dto], contract.contract_period);
const created = await contractsRepo.addContractItem(id, { ...item!, sortOrder: contract.items.length });
await auditRepo.appendAuditEvent({
actorType: "user",
actorId: user.id,
action: "contract.item_added",
resourceType: "contract",
resourceId: id,
metadata: { productId: dto.productId },
});
return created;
}
async addParty(id: string, dto: ContractPartyInputDto, user: EdenAuthenticatedUser) {
await this.get(id, user);
const created = await contractsRepo.addContractParty(id, this.mapParty(dto));
await auditRepo.appendAuditEvent({
actorType: "user",
actorId: user.id,
action: "contract.party_added",
resourceType: "contract",
resourceId: id,
metadata: { partyRole: dto.partyRole },
});
return created;
}
private mapParty(dto: ContractPartyInputDto) {
return {
partyRole: dto.partyRole,
personType: dto.personType ?? null,
fullName: dto.fullName,
document: dto.document ?? null,
email: dto.email ?? null,
phone: dto.phone ?? null,
roleTitle: dto.roleTitle ?? null,
};
}
private async buildItems(
inputs: ContractItemInputDto[],
contractPeriod: number,
): Promise<CreateContractItemInput[]> {
const priceKey = `price_${contractPeriod}` as "price_0" | "price_12" | "price_24" | "price_36" | "price_48";
const items: CreateContractItemInput[] = [];
for (const input of inputs) {
const product = await productsRepo.getProductById(input.productId);
if (!product) throw new BadRequestException(`Produto ${input.productId} não encontrado`);
const unitPriceRaw = product[priceKey];
const unitPrice = unitPriceRaw != null ? Number(unitPriceRaw) : 0;
items.push({
productId: product.id,
productName: product.name,
quantity: input.quantity,
unitPrice,
total: round2(unitPrice * input.quantity),
sortOrder: 0,
});
}
return items;
}
}
function round2(value: number): number {
return Math.round((value + Number.EPSILON) * 100) / 100;
}

View File

@@ -0,0 +1,11 @@
import { IsNumber, IsUUID, Min } from "class-validator";
/** Same principle as Quotes' item DTO: only productId/quantity are client-supplied; unitPrice/productName are always derived server-side from the live product record. */
export class ContractItemInputDto {
@IsUUID()
productId!: string;
@IsNumber()
@Min(0.01)
quantity!: number;
}

View File

@@ -0,0 +1,17 @@
import { IsEmail, IsIn, IsOptional, IsString, MinLength } from "class-validator";
export class ContractPartyInputDto {
@IsIn(["contratada", "contratante"])
partyRole!: "contratada" | "contratante";
@IsOptional() @IsIn(["pf", "pj"]) personType?: "pf" | "pj";
@IsString()
@MinLength(1)
fullName!: string;
@IsOptional() @IsString() document?: string;
@IsOptional() @IsEmail() email?: string;
@IsOptional() @IsString() phone?: string;
@IsOptional() @IsString() roleTitle?: string;
}

View File

@@ -0,0 +1,45 @@
import { Type } from "class-transformer";
import {
ArrayMinSize,
IsArray,
IsIn,
IsInt,
IsNumber,
IsOptional,
IsString,
IsUUID,
Min,
ValidateNested,
} from "class-validator";
import { ContractItemInputDto } from "./contract-item-input.dto.js";
import { ContractPartyInputDto } from "./contract-party-input.dto.js";
export class CreateContractDto {
@IsOptional() @IsUUID() resellerId?: string;
@IsOptional() @IsUUID() companyId?: string;
@IsOptional() @IsUUID() quoteId?: string;
@IsOptional() @IsUUID() clientRegistrationId?: string;
@IsIn([0, 12, 24, 36, 48])
contractPeriod!: number;
@IsOptional()
@IsInt()
@Min(0)
fidelityPeriod?: number;
@IsArray()
@ArrayMinSize(1)
@ValidateNested({ each: true })
@Type(() => ContractItemInputDto)
items!: ContractItemInputDto[];
@IsArray()
@ArrayMinSize(1)
@ValidateNested({ each: true })
@Type(() => ContractPartyInputDto)
parties!: ContractPartyInputDto[];
@IsOptional() @IsNumber() @Min(0) implementationFee?: number;
@IsOptional() @IsString() notes?: string;
}

View File

@@ -0,0 +1,16 @@
import { IsIn, IsOptional, IsString } from "class-validator";
export class UpdateContractStatusDto {
@IsIn(["draft", "pending_signature", "active", "suspended", "cancelled", "terminated", "expired", "renewed"])
toStatus!:
| "draft"
| "pending_signature"
| "active"
| "suspended"
| "cancelled"
| "terminated"
| "expired"
| "renewed";
@IsOptional() @IsString() reason?: string;
}

View File

@@ -0,0 +1,13 @@
import { IsUUID } from "class-validator";
/**
* MVP scope: closing a deal requires an already-existing client
* registration id (Customer 360 module lands in parallel — once merged,
* a richer "create+link in one step" flow can be added, mirroring
* eden.md §6.1's two-path close-deal flow). For now this just performs
* the lock (deal_status='fechado' + client_registration_id set).
*/
export class CloseDealDto {
@IsUUID()
clientRegistrationId!: string;
}

View File

@@ -0,0 +1,53 @@
import { Type } from "class-transformer";
import {
ArrayMinSize,
IsArray,
IsEmail,
IsIn,
IsInt,
IsNumber,
IsOptional,
IsString,
IsUUID,
Min,
MinLength,
ValidateNested,
} from "class-validator";
import { QuoteItemInputDto } from "./quote-item-input.dto.js";
export class CreateQuoteDto {
@IsUUID()
resellerId!: string;
@IsString()
@MinLength(1)
clientName!: string;
@IsOptional() @IsString() clientCompany?: string;
@IsOptional() @IsEmail() clientEmail?: string;
@IsString()
@MinLength(1)
clientPhone!: string;
@IsIn([0, 12, 24, 36, 48])
contractPeriod!: number;
/** Only meaningful if strictly less than contractPeriod — see pricing.ts. */
@IsOptional()
@IsInt()
@Min(0)
fidelityPeriod?: number;
@IsArray()
@ArrayMinSize(1)
@ValidateNested({ each: true })
@Type(() => QuoteItemInputDto)
items!: QuoteItemInputDto[];
@IsOptional() @IsNumber() @Min(0) implGeral?: number;
@IsOptional() @IsNumber() @Min(0.01) proposedMonthlyTotal?: number;
@IsOptional() @IsIn(["À vista", "1+1", "1+2"]) implPaymentCondition?: string;
@IsOptional() @IsString() notes?: string;
}

View File

@@ -0,0 +1,10 @@
import { IsIn, IsOptional, IsString } from "class-validator";
export class DecideApprovalDto {
@IsIn(["approved", "rejected"])
decision!: "approved" | "rejected";
@IsOptional()
@IsString()
notes?: string;
}

View File

@@ -0,0 +1,19 @@
import { IsNumber, IsUUID, Min } from "class-validator";
/**
* Deliberately minimal — only what the client can legitimately choose
* (which product, how many). Unit price, product name, and the 5 price-
* tier snapshots are always derived server-side from the live product
* record + the quote's contract_period (see QuotesService.buildItems) —
* a client-supplied price is never trusted, strengthening the legacy's
* own principle (which snapshotted at add-time but still took unit_price
* from client state at that moment).
*/
export class QuoteItemInputDto {
@IsUUID()
productId!: string;
@IsNumber()
@Min(0.01)
quantity!: number;
}

View File

@@ -0,0 +1,51 @@
import { Type } from "class-transformer";
import {
ArrayMinSize,
IsArray,
IsEmail,
IsIn,
IsInt,
IsNumber,
IsOptional,
IsString,
Min,
MinLength,
ValidateNested,
} from "class-validator";
import { QuoteItemInputDto } from "./quote-item-input.dto.js";
/**
* Every edit resubmits the full commercial state (contractPeriod, items,
* etc.) rather than a sparse patch — the service always recomputes the
* entire pricing picture together (pricing.ts), so a partial edit that
* only touched `notes` would still need the rest of the state to
* recompute against; simplest and safest is to require it every time,
* matching how the legacy's own edit screen always resubmits the whole
* form. `resellerId` is intentionally absent — not changeable post-creation.
*/
export class UpdateQuoteDto {
@IsOptional() @IsString() clientName?: string;
@IsOptional() @IsString() clientCompany?: string;
@IsOptional() @IsEmail() clientEmail?: string;
@IsOptional() @IsString() clientPhone?: string;
@IsIn([0, 12, 24, 36, 48])
contractPeriod!: number;
@IsOptional()
@IsInt()
@Min(0)
fidelityPeriod?: number;
@IsArray()
@ArrayMinSize(1)
@ValidateNested({ each: true })
@Type(() => QuoteItemInputDto)
items!: QuoteItemInputDto[];
@IsOptional() @IsNumber() @Min(0) implGeral?: number;
@IsOptional() @IsNumber() @Min(0.01) proposedMonthlyTotal?: number;
@IsOptional() @IsIn(["À vista", "1+1", "1+2"]) implPaymentCondition?: string;
@IsOptional() @IsString() @MinLength(0) notes?: string;
}

View File

@@ -0,0 +1,161 @@
import type { QuoteItem } from "@eden/database";
/**
* Quote pricing/fidelity/approval engine — ported verbatim from eden.md §8
* (formulas) and §3–§4 (fidelity/discount business rules). These formulas
* are exact, not approximate — do not "simplify" without an ADR. The
* backend is always the source of truth (Master Prompt §6.4: "nunca
* confiar no frontend para cálculo financeiro"); the frontend may mirror
* this logic for UX, but every quote write recomputes from scratch here.
*/
export interface PricingInput {
items: QuoteItem[];
contractPeriod: number;
fidelityPeriod: number | null;
implGeral: number;
proposedMonthlyTotal: number | null;
}
export interface PricingResult {
monthlyTotal: number;
implFromProducts: number;
totalImpl: number;
hasSpecialPrice: boolean;
isDiscount: boolean;
isMarkup: boolean;
diffMonthly: number;
effectiveMonthly: number;
hasReducedFidelity: boolean;
needsApproval: boolean;
contractTotal: number;
/** What to persist to quotes.fidelity_period — null unless it's genuinely a reduced-fidelity exception (eden.md §8 "só persiste se for de fato menor que a faixa"). */
persistedFidelityPeriod: number | null;
/** What to persist to quotes.proposed_monthly_total — null unless a special price is actually active. */
persistedProposedMonthlyTotal: number | null;
}
export function computeQuotePricing(input: PricingInput): PricingResult {
const monthlyTotal = round2(input.items.reduce((sum, item) => sum + item.total, 0));
const implFromProducts = round2(input.items.reduce((sum, item) => sum + item.implTotal, 0));
const totalImpl = round2(implFromProducts + (input.implGeral || 0));
const proposedMonthly = input.proposedMonthlyTotal;
const hasSpecialPrice =
proposedMonthly != null && proposedMonthly > 0 && proposedMonthly !== monthlyTotal;
const isDiscount = hasSpecialPrice && proposedMonthly! < monthlyTotal;
const isMarkup = hasSpecialPrice && proposedMonthly! > monthlyTotal;
const diffMonthly = hasSpecialPrice ? round2(monthlyTotal - proposedMonthly!) : 0;
const effectiveMonthly = hasSpecialPrice ? proposedMonthly! : monthlyTotal;
const hasReducedFidelity =
input.fidelityPeriod != null && input.fidelityPeriod < input.contractPeriod;
const needsApproval = isDiscount || hasReducedFidelity;
const contractTotal = round2(effectiveMonthly * (input.contractPeriod || 1) + totalImpl);
return {
monthlyTotal,
implFromProducts,
totalImpl,
hasSpecialPrice,
isDiscount,
isMarkup,
diffMonthly,
effectiveMonthly,
hasReducedFidelity,
needsApproval,
contractTotal,
persistedFidelityPeriod: hasReducedFidelity ? input.fidelityPeriod : null,
persistedProposedMonthlyTotal: hasSpecialPrice ? proposedMonthly : null,
};
}
/**
* Resolves what `approval_status` should be persisted, given the previous
* status and whether the actor is submitting an approval decision.
* Mirrors eden.md §8: `needsApproval ? (approvalStatus === 'none' ?
* 'pending' : approvalStatus) : 'none'` — but generalized to accept an
* explicit decision (approve/reject) from a privileged actor, which the
* legacy handled as a separate code path (§3.3) that this folds in here
* for a single source of truth.
*/
export function resolveApprovalStatus(
needsApproval: boolean,
previousStatus: "none" | "pending" | "approved" | "rejected",
decision?: "approved" | "rejected",
): "none" | "pending" | "approved" | "rejected" {
if (!needsApproval) return "none";
if (decision) return decision;
return previousStatus === "none" ? "pending" : previousStatus;
}
/** eden.md §3.3: super_admin always; admin only with the explicit per-user flag — never inferred from role alone. */
export function canApproveDiscount(user: { roleKey: string; canApproveDiscount?: boolean }): boolean {
if (user.roleKey === "super_admin") return true;
return user.roleKey === "admin" && user.canApproveDiscount === true;
}
/**
* The fidelity that actually has legal effect (vigência/vencimento/multa)
* — eden.md §3.4/§10.4, repeated everywhere in the legacy as the single
* most important rule to preserve: a pending/rejected exception NEVER
* affects anything legal, only an approved one does.
*/
export function resolveEffectiveFidelity(
contractPeriod: number,
fidelityPeriod: number | null,
approvalStatus: "none" | "pending" | "approved" | "rejected",
): number {
return fidelityPeriod != null && approvalStatus === "approved" ? fidelityPeriod : contractPeriod || 0;
}
/** eden.md §7 (paymentConditions.js) — progressive by implementation value. */
export const PAYMENT_CONDITIONS = ["À vista", "1+1", "1+2"] as const;
export function getAvailablePaymentConditions(implTotal: number): readonly string[] {
const value = implTotal || 0;
if (value <= 1500) return ["À vista"];
if (value <= 3000) return ["À vista", "1+1"];
return PAYMENT_CONDITIONS;
}
export interface SavingsResult {
totalDescontoMensal: number;
totalEconomiaMensal: number;
economiaTotalContrato: number;
beneficioTotalFidelidade: number;
}
/**
* eden.md §3.5/§4.4 — two DIFFERENT numbers that must never be confused:
* `beneficioTotalFidelidade` (fidelity discount only, used as the legal
* cap for the RGC art. 37 early-termination penalty) vs.
* `totalEconomiaMensal`/`economiaTotalContrato` (fidelity + special
* discount combined, shown as commercial "savings" info to the client).
*/
export function computeSavings(
items: QuoteItem[],
contractPeriod: number,
effectiveFidelityPeriod: number,
monthlyTotal: number,
proposedMonthlyTotal: number | null,
): SavingsResult {
const totalDescontoMensal = round2(
items.reduce((sum, item) => sum + ((item.price0 ?? 0) - item.unitPrice) * item.quantity, 0),
);
const hasSpecialPrice =
proposedMonthlyTotal != null && proposedMonthlyTotal > 0 && proposedMonthlyTotal !== monthlyTotal;
const specialDiscount = hasSpecialPrice ? round2(monthlyTotal - proposedMonthlyTotal!) : 0;
const totalEconomiaMensal = round2(totalDescontoMensal + specialDiscount);
const economiaTotalContrato =
totalEconomiaMensal > 0 && contractPeriod > 0 ? round2(totalEconomiaMensal * contractPeriod) : 0;
const beneficioTotalFidelidade = round2(totalDescontoMensal * effectiveFidelityPeriod);
return { totalDescontoMensal, totalEconomiaMensal, economiaTotalContrato, beneficioTotalFidelidade };
}
function round2(value: number): number {
return Math.round((value + Number.EPSILON) * 100) / 100;
}

View File

@@ -0,0 +1,88 @@
import { Body, Controller, Get, Param, Patch, Post, Query, UseGuards } from "@nestjs/common";
import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
import { QuotesService } from "./quotes.service.js";
import { CreateQuoteDto } from "./dto/create-quote.dto.js";
import { UpdateQuoteDto } from "./dto/update-quote.dto.js";
import { DecideApprovalDto } from "./dto/decide-approval.dto.js";
import { CloseDealDto } from "./dto/close-deal.dto.js";
import { JwtAuthGuard } from "../auth/jwt-auth.guard.js";
import { PermissionGuard } from "../auth/permission.guard.js";
import { SuperAdminGuard } from "../auth/super-admin.guard.js";
import { RequirePermission } from "../auth/decorators/require-permission.decorator.js";
import { CurrentUser } from "../auth/decorators/current-user.decorator.js";
import type { EdenAuthenticatedUser } from "../auth/jwt-auth.guard.js";
/**
* Ofertas comerciais (eden.md módulo 2). Scope resolution (own/reseller/all)
* happens in the service, driven by the actor's granted permission scope —
* see QuotesService.list/get. Every write recomputes pricing server-side
* (Master Prompt §6.4) — the client never supplies a trusted total.
*/
@ApiTags("quotes")
@ApiBearerAuth()
@UseGuards(JwtAuthGuard, PermissionGuard)
@Controller("v1/quotes")
export class QuotesController {
constructor(private readonly quotes: QuotesService) {}
@Get()
@RequirePermission("quotes", "view")
list(@CurrentUser() user: EdenAuthenticatedUser, @Query("dealStatus") dealStatus?: string) {
return this.quotes.list(user, dealStatus);
}
@Get(":id")
@RequirePermission("quotes", "view")
get(@Param("id") id: string, @CurrentUser() user: EdenAuthenticatedUser) {
return this.quotes.get(id, user);
}
@Post()
@RequirePermission("quotes", "edit")
create(@Body() dto: CreateQuoteDto, @CurrentUser() user: EdenAuthenticatedUser) {
return this.quotes.create(dto, user);
}
@Patch(":id")
@RequirePermission("quotes", "edit")
update(
@Param("id") id: string,
@Body() dto: UpdateQuoteDto,
@CurrentUser() user: EdenAuthenticatedUser,
) {
return this.quotes.update(id, dto, user);
}
/** Approve/reject a pending discount or reduced-fidelity exception — eden.md §3.3. Gated inside the service by canApproveDiscount(), not by a generic permission (mirrors the legacy's per-user flag, not a role-based permission). */
@Patch(":id/approval")
@RequirePermission("quotes", "approve")
decideApproval(
@Param("id") id: string,
@Body() dto: DecideApprovalDto,
@CurrentUser() user: EdenAuthenticatedUser,
) {
return this.quotes.decideApproval(id, dto, user);
}
/** Closes the deal, locking the quote — eden.md §5.1/§6. */
@Post(":id/close")
@RequirePermission("quotes", "edit")
closeDeal(
@Param("id") id: string,
@Body() dto: CloseDealDto,
@CurrentUser() user: EdenAuthenticatedUser,
) {
return this.quotes.closeDeal(id, dto, user);
}
/** super_admin-only shortcut, works even on a locked quote — eden.md §5.2. Route-level guard for defense in depth, on top of the service-layer check. */
@Patch(":id/deal-status")
@UseGuards(SuperAdminGuard)
dealStatus(
@Param("id") id: string,
@Body("dealStatus") dealStatus: "orcamento" | "fechado" | "perdido",
@CurrentUser() user: EdenAuthenticatedUser,
) {
return this.quotes.changeDealStatus(id, dealStatus, user);
}
}

View File

@@ -0,0 +1,12 @@
import { Module } from "@nestjs/common";
import { AuthModule } from "../auth/auth.module.js";
import { QuotesController } from "./quotes.controller.js";
import { QuotesService } from "./quotes.service.js";
@Module({
imports: [AuthModule],
controllers: [QuotesController],
providers: [QuotesService],
exports: [QuotesService],
})
export class QuotesModule {}

View File

@@ -0,0 +1,345 @@
import {
BadRequestException,
ConflictException,
ForbiddenException,
Injectable,
NotFoundException,
} from "@nestjs/common";
import {
auditRepo,
productsRepo,
quotesRepo,
type Quote,
type QuoteItem,
} from "@eden/database";
import type { EdenAuthenticatedUser } from "../auth/jwt-auth.guard.js";
import { maxGrantedScope } from "../auth/permissions.js";
import {
canApproveDiscount,
computeQuotePricing,
getAvailablePaymentConditions,
resolveApprovalStatus,
} from "./pricing.js";
import type { CreateQuoteDto } from "./dto/create-quote.dto.js";
import type { UpdateQuoteDto } from "./dto/update-quote.dto.js";
import type { QuoteItemInputDto } from "./dto/quote-item-input.dto.js";
import type { DecideApprovalDto } from "./dto/decide-approval.dto.js";
import type { CloseDealDto } from "./dto/close-deal.dto.js";
const SUPER_ADMIN = "super_admin";
const ADMIN_ROLES = new Set(["admin", "super_admin"]);
@Injectable()
export class QuotesService {
async list(user: EdenAuthenticatedUser, dealStatus?: string): Promise<Quote[]> {
const scope = maxGrantedScope(user.permissions, "quotes", "view");
if (!scope) throw new ForbiddenException("Sem permissão para 'view' em 'quotes'");
if (scope === "all") {
return quotesRepo.listQuotes({ scope: "all", dealStatus });
}
if (scope === "reseller") {
return quotesRepo.listQuotes({ scope: "reseller", resellerId: user.resellerId ?? undefined, dealStatus });
}
return quotesRepo.listQuotes({ scope: "own", actorId: user.id, dealStatus });
}
async get(id: string, user: EdenAuthenticatedUser): Promise<Quote> {
const quote = await quotesRepo.getQuoteById(id);
if (!quote || !this.isVisibleToActor(quote, user)) {
throw new NotFoundException("Oferta não encontrada");
}
return quote;
}
async create(dto: CreateQuoteDto, user: EdenAuthenticatedUser): Promise<Quote> {
const items = await this.buildItems(dto.items, dto.contractPeriod);
const proposedMonthlyTotal = dto.proposedMonthlyTotal ?? null;
const implGeral = dto.implGeral ?? 0;
const fidelityPeriod = dto.fidelityPeriod ?? null;
if (fidelityPeriod != null && fidelityPeriod > dto.contractPeriod) {
throw new BadRequestException("fidelityPeriod não pode ser maior que contractPeriod");
}
const pricing = computeQuotePricing({
items,
contractPeriod: dto.contractPeriod,
fidelityPeriod,
implGeral,
proposedMonthlyTotal,
});
const implPaymentCondition = this.validatePaymentCondition(
dto.implPaymentCondition,
pricing.totalImpl,
);
const approvalStatus = resolveApprovalStatus(pricing.needsApproval, "none");
const quote = await quotesRepo.createQuote({
resellerId: dto.resellerId,
clientName: dto.clientName,
clientCompany: dto.clientCompany ?? null,
clientEmail: dto.clientEmail ?? null,
clientPhone: dto.clientPhone,
contractPeriod: dto.contractPeriod,
fidelityPeriod: pricing.persistedFidelityPeriod,
items,
implementationFee: pricing.implFromProducts,
implGeral,
monthlyTotal: pricing.monthlyTotal,
contractTotal: pricing.contractTotal,
proposedMonthlyTotal: pricing.persistedProposedMonthlyTotal,
approvalStatus,
implPaymentCondition,
notes: dto.notes ?? null,
createdBy: user.id,
});
await auditRepo.appendAuditEvent({
actorType: "user",
actorId: user.id,
action: "quote.created",
resourceType: "quote",
resourceId: quote.id,
metadata: { needsApproval: pricing.needsApproval, contractTotal: pricing.contractTotal },
});
return quote;
}
async update(id: string, dto: UpdateQuoteDto, user: EdenAuthenticatedUser): Promise<Quote> {
const existing = await this.get(id, user);
this.assertNotLocked(existing, user);
const items = await this.buildItems(dto.items, dto.contractPeriod);
const proposedMonthlyTotal = dto.proposedMonthlyTotal ?? null;
const implGeral = dto.implGeral ?? Number(existing.impl_geral);
// Changing contract_period resets any pending fidelity exception —
// eden.md §2: "trocar de faixa invalida qualquer valor pendente".
const fidelityPeriod =
dto.contractPeriod !== existing.contract_period ? (dto.fidelityPeriod ?? null) : dto.fidelityPeriod ?? null;
if (fidelityPeriod != null && fidelityPeriod > dto.contractPeriod) {
throw new BadRequestException("fidelityPeriod não pode ser maior que contractPeriod");
}
const pricing = computeQuotePricing({
items,
contractPeriod: dto.contractPeriod,
fidelityPeriod,
implGeral,
proposedMonthlyTotal,
});
const implPaymentCondition = this.validatePaymentCondition(
dto.implPaymentCondition,
pricing.totalImpl,
);
// Any commercial change (period/fidelity/special price) resets
// approval to require a fresh decision — eden.md §3.3: "nova
// negociação = nova aprovação necessária".
const commercialTermsChanged =
dto.contractPeriod !== existing.contract_period ||
pricing.persistedFidelityPeriod !== existing.fidelity_period ||
pricing.persistedProposedMonthlyTotal !== (existing.proposed_monthly_total != null ? Number(existing.proposed_monthly_total) : null);
const previousStatus = commercialTermsChanged ? "none" : existing.approval_status;
const approvalStatus = resolveApprovalStatus(pricing.needsApproval, previousStatus);
const approvedById = approvalStatus === "approved" ? existing.approved_by_id : null;
const quote = await quotesRepo.updateQuote(id, {
clientName: dto.clientName,
clientCompany: dto.clientCompany,
clientEmail: dto.clientEmail,
clientPhone: dto.clientPhone,
contractPeriod: dto.contractPeriod,
fidelityPeriod: pricing.persistedFidelityPeriod,
items,
implementationFee: pricing.implFromProducts,
implGeral,
monthlyTotal: pricing.monthlyTotal,
contractTotal: pricing.contractTotal,
proposedMonthlyTotal: pricing.persistedProposedMonthlyTotal,
approvalStatus,
approvedById,
implPaymentCondition,
notes: dto.notes,
});
if (!quote) throw new NotFoundException("Oferta não encontrada");
await auditRepo.appendAuditEvent({
actorType: "user",
actorId: user.id,
action: "quote.updated",
resourceType: "quote",
resourceId: id,
metadata: { needsApproval: pricing.needsApproval, resetApproval: commercialTermsChanged },
});
return quote;
}
async decideApproval(
id: string,
dto: DecideApprovalDto,
user: EdenAuthenticatedUser,
): Promise<Quote> {
const quote = await this.get(id, user);
if (!canApproveDiscount({ roleKey: user.roleKey, canApproveDiscount: user.canApproveDiscount })) {
throw new ForbiddenException("Sem permissão para aprovar/recusar condição especial");
}
if (quote.approval_status !== "pending") {
throw new ConflictException("Esta oferta não tem uma aprovação pendente");
}
const updated = await quotesRepo.updateQuote(id, {
clientName: undefined,
clientCompany: undefined,
clientEmail: undefined,
clientPhone: undefined,
contractPeriod: quote.contract_period,
fidelityPeriod: quote.fidelity_period,
items: quote.items,
implementationFee: Number(quote.implementation_fee),
implGeral: Number(quote.impl_geral),
monthlyTotal: Number(quote.monthly_total),
contractTotal: Number(quote.contract_total),
proposedMonthlyTotal: quote.proposed_monthly_total != null ? Number(quote.proposed_monthly_total) : null,
approvalStatus: dto.decision,
approvedById: dto.decision === "approved" ? user.id : null,
approvalNotes: dto.notes,
implPaymentCondition: quote.impl_payment_condition,
notes: undefined,
});
if (!updated) throw new NotFoundException("Oferta não encontrada");
await auditRepo.appendAuditEvent({
actorType: "user",
actorId: user.id,
action: dto.decision === "approved" ? "quote.approval_approved" : "quote.approval_rejected",
resourceType: "quote",
resourceId: id,
metadata: { notes: dto.notes ?? null },
});
return updated;
}
async closeDeal(id: string, dto: CloseDealDto, user: EdenAuthenticatedUser): Promise<Quote> {
const quote = await this.get(id, user);
if (quote.client_registration_id) {
throw new ConflictException("Esta oferta já foi fechada");
}
const updated = await quotesRepo.closeQuoteDeal(id, dto.clientRegistrationId);
if (!updated) throw new ConflictException("Esta oferta já foi fechada");
await auditRepo.appendAuditEvent({
actorType: "user",
actorId: user.id,
action: "quote.closed",
resourceType: "quote",
resourceId: id,
metadata: { clientRegistrationId: dto.clientRegistrationId },
});
return updated;
}
/**
* super_admin-only shortcut to correct deal_status even on a locked
* quote (client_registration_id already set) — eden.md §5.2, e.g.
* "reverter um fechado que caiu". Never touches client_registration_id
* itself, only the status label.
*/
async changeDealStatus(
id: string,
dealStatus: "orcamento" | "fechado" | "perdido",
user: EdenAuthenticatedUser,
): Promise<Quote> {
if (user.roleKey !== SUPER_ADMIN) {
throw new ForbiddenException("Exclusivo do super_admin");
}
const updated = await quotesRepo.setQuoteDealStatus(id, dealStatus);
if (!updated) throw new NotFoundException("Oferta não encontrada");
await auditRepo.appendAuditEvent({
actorType: "user",
actorId: user.id,
action: "quote.deal_status_overridden",
resourceType: "quote",
resourceId: id,
metadata: { dealStatus },
});
return updated;
}
private async buildItems(inputs: QuoteItemInputDto[], contractPeriod: number): Promise<QuoteItem[]> {
const priceKey = `price_${contractPeriod}` as "price_0" | "price_12" | "price_24" | "price_36" | "price_48";
const items: QuoteItem[] = [];
for (const input of inputs) {
const product = await productsRepo.getProductById(input.productId);
if (!product) {
throw new BadRequestException(`Produto ${input.productId} não encontrado`);
}
const unitPriceRaw = product[priceKey];
const unitPrice = unitPriceRaw != null ? Number(unitPriceRaw) : 0;
const implUnitPrice = Number(product.impl_unit_price ?? 0);
items.push({
productId: product.id,
productName: product.name,
quantity: input.quantity,
unitPrice,
total: round2(unitPrice * input.quantity),
implUnitPrice,
implTotal: round2(implUnitPrice * input.quantity),
price0: product.price_0 != null ? Number(product.price_0) : null,
price12: product.price_12 != null ? Number(product.price_12) : null,
price24: product.price_24 != null ? Number(product.price_24) : null,
price36: product.price_36 != null ? Number(product.price_36) : null,
price48: product.price_48 != null ? Number(product.price_48) : null,
});
}
return items;
}
private validatePaymentCondition(condition: string | undefined, implTotal: number): string {
const allowed = getAvailablePaymentConditions(implTotal);
if (condition === undefined) {
// Mirror the legacy default-selection behavior: fall back to the
// richest option still valid for this implementation value.
return allowed[allowed.length - 1]!;
}
if (!allowed.includes(condition)) {
throw new BadRequestException(
`Condição de pagamento da implantação inválida para o valor de implantação (R$ ${implTotal.toFixed(2)}). Opções disponíveis: ${allowed.join(", ")}`,
);
}
return condition;
}
private assertNotLocked(quote: Quote, user: EdenAuthenticatedUser): void {
if (quote.client_registration_id && user.roleKey !== SUPER_ADMIN) {
throw new ConflictException(
"Esta oferta está travada — o cadastro do cliente já foi iniciado.",
);
}
}
private isVisibleToActor(quote: Quote, user: EdenAuthenticatedUser): boolean {
const scope = maxGrantedScope(user.permissions, "quotes", "view");
if (!scope) return false;
if (scope === "all") return true;
if (scope === "reseller") return quote.reseller_id === user.resellerId;
return quote.created_by === user.id;
}
}
function round2(value: number): number {
return Math.round((value + Number.EPSILON) * 100) / 100;
}