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:
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
19
apps/api/src/client-registrations/dto/create-partner.dto.ts
Normal file
19
apps/api/src/client-registrations/dto/create-partner.dto.ts
Normal 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;
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
74
apps/api/src/contracts/contracts.controller.ts
Normal file
74
apps/api/src/contracts/contracts.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
12
apps/api/src/contracts/contracts.module.ts
Normal file
12
apps/api/src/contracts/contracts.module.ts
Normal 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 {}
|
||||
188
apps/api/src/contracts/contracts.service.ts
Normal file
188
apps/api/src/contracts/contracts.service.ts
Normal 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;
|
||||
}
|
||||
11
apps/api/src/contracts/dto/contract-item-input.dto.ts
Normal file
11
apps/api/src/contracts/dto/contract-item-input.dto.ts
Normal 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;
|
||||
}
|
||||
17
apps/api/src/contracts/dto/contract-party-input.dto.ts
Normal file
17
apps/api/src/contracts/dto/contract-party-input.dto.ts
Normal 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;
|
||||
}
|
||||
45
apps/api/src/contracts/dto/create-contract.dto.ts
Normal file
45
apps/api/src/contracts/dto/create-contract.dto.ts
Normal 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;
|
||||
}
|
||||
16
apps/api/src/contracts/dto/update-contract-status.dto.ts
Normal file
16
apps/api/src/contracts/dto/update-contract-status.dto.ts
Normal 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;
|
||||
}
|
||||
13
apps/api/src/quotes/dto/close-deal.dto.ts
Normal file
13
apps/api/src/quotes/dto/close-deal.dto.ts
Normal 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;
|
||||
}
|
||||
53
apps/api/src/quotes/dto/create-quote.dto.ts
Normal file
53
apps/api/src/quotes/dto/create-quote.dto.ts
Normal 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;
|
||||
}
|
||||
10
apps/api/src/quotes/dto/decide-approval.dto.ts
Normal file
10
apps/api/src/quotes/dto/decide-approval.dto.ts
Normal 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;
|
||||
}
|
||||
19
apps/api/src/quotes/dto/quote-item-input.dto.ts
Normal file
19
apps/api/src/quotes/dto/quote-item-input.dto.ts
Normal 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;
|
||||
}
|
||||
51
apps/api/src/quotes/dto/update-quote.dto.ts
Normal file
51
apps/api/src/quotes/dto/update-quote.dto.ts
Normal 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;
|
||||
}
|
||||
161
apps/api/src/quotes/pricing.ts
Normal file
161
apps/api/src/quotes/pricing.ts
Normal 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;
|
||||
}
|
||||
88
apps/api/src/quotes/quotes.controller.ts
Normal file
88
apps/api/src/quotes/quotes.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
12
apps/api/src/quotes/quotes.module.ts
Normal file
12
apps/api/src/quotes/quotes.module.ts
Normal 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 {}
|
||||
345
apps/api/src/quotes/quotes.service.ts
Normal file
345
apps/api/src/quotes/quotes.service.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user