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

3
.gitignore vendored
View File

@@ -49,3 +49,6 @@ infra/docker/pgdata/
# Temporary git worktrees created for parallel subagent work — each is its # Temporary git worktrees created for parallel subagent work — each is its
# own nested git checkout; never vendor these into the main repo history. # own nested git checkout; never vendor these into the main repo history.
.claude/worktrees/ .claude/worktrees/
# Session-local lock file for scheduled wakeups, not project content.
.claude/scheduled_tasks.lock

View File

@@ -11,6 +11,9 @@ import { ProductsModule } from "./products/products.module.js";
import { ResellersModule } from "./resellers/resellers.module.js"; import { ResellersModule } from "./resellers/resellers.module.js";
import { LeadsModule } from "./leads/leads.module.js"; import { LeadsModule } from "./leads/leads.module.js";
import { OpportunitiesModule } from "./opportunities/opportunities.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({ @Module({
imports: [ imports: [
@@ -25,6 +28,9 @@ import { OpportunitiesModule } from "./opportunities/opportunities.module.js";
ResellersModule, ResellersModule,
LeadsModule, LeadsModule,
OpportunitiesModule, OpportunitiesModule,
QuotesModule,
ClientRegistrationsModule,
ContractsModule,
], ],
controllers: [HealthController], controllers: [HealthController],
}) })

View File

@@ -11,6 +11,10 @@ export interface EdenAuthenticatedUser {
roleKey: string; roleKey: string;
applicationKey: "core" | "reseller" | "subscriber"; applicationKey: "core" | "reseller" | "subscriber";
permissions: ResolvedPermissions; 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" { declare module "express" {
@@ -59,6 +63,8 @@ export class JwtAuthGuard implements CanActivate {
roleKey: user.role_key, roleKey: user.role_key,
applicationKey: payload.app, applicationKey: payload.app,
permissions, permissions,
resellerId: user.reseller_id,
canApproveDiscount: user.can_approve_discount,
}; };
return true; 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;
}

View File

@@ -119,3 +119,37 @@ Vitest + supertest + `@nestjs/testing` (bootstrap real do Nest, sem mock) contra
- Extração do Design System a partir do tema DreamsERP (`packages/ui` ainda é placeholder). - Extração do Design System a partir do tema DreamsERP (`packages/ui` ainda é placeholder).
- Wiring dos adapters S3/SMTP em algum módulo consumidor real (hoje só existem como biblioteca em `packages/integrations`, nada os chama ainda). - Wiring dos adapters S3/SMTP em algum módulo consumidor real (hoje só existem como biblioteca em `packages/integrations`, nada os chama ainda).
- Nota de dado de teste: a empresa "Handix Telecom LTDA" e a revenda "Handix (revenda própria)" criadas durante os testes usam dados de exemplo, não os dados reais da Handix — substituir/remover antes de qualquer ambiente que não seja dev local. - Nota de dado de teste: a empresa "Handix Telecom LTDA" e a revenda "Handix (revenda própria)" criadas durante os testes usam dados de exemplo, não os dados reais da Handix — substituir/remover antes de qualquer ambiente que não seja dev local.
---
## Fase 2/3 — Ofertas (Quotes), Cadastro de Cliente (Customer 360) e esqueleto de Contratos
Data: 2026-09-03.
### Ofertas / Quotes (`apps/api/src/quotes/` + migration `quotes`) — implementado diretamente
Núcleo comercial mais sensível a erro de arredondamento/regra de negócio (eden.md §7/§8), por isso feito sem delegar a subagente:
- `pricing.ts`: funções puras porteadas literalmente do legado — `monthlyTotal = Σitem.total`; `totalImpl = implFromProducts + implGeral`; `hasSpecialPrice/isDiscount/isMarkup` por comparação com `proposedMonthlyTotal`; `contractTotal = effectiveMonthly * (contractPeriod||1) + totalImpl`; `needsApproval = isDiscount || hasReducedFidelity`; fidelidade só tem efeito legal com `approval_status === 'approved'`. Faixas de condição de pagamento (`À vista` ≤1500; `+1+1` ≤3000; `+1+2` acima) replicadas do legado.
- Preço sempre derivado server-side do produto vivo no banco (nunca confia em preço vindo do cliente).
- Fluxo completo: criar oferta → decidir aprovação (`PATCH :id/approval`) → fechar negócio vinculando a um cadastro de cliente (`POST :id/close`, bloqueado por `client_registration_id IS NULL` — não fecha duas vezes).
### Customer 360 / Cadastro de Cliente (`apps/api/src/client-registrations/` + migration `client-registrations`) e Contratos (`apps/api/src/contracts/` + migration `contracts`) — subagentes + salvamento manual
Lançados 2 subagentes em paralelo (worktrees isolados) para Cadastro de Cliente e esqueleto de Contratos. Ambos falharam duas vezes: primeiro com rate limit de sessão (retomado após o usuário habilitar cobrança de excedente), depois com **limite de gasto mensal da conta** — um bloqueio de nível de conta que não se resolve tentando de novo. Em vez de insistir, os dois worktrees foram inspecionados (`git status`, diff arquivo a arquivo); o trabalho não commitado encontrado em ambos era substancial e de boa qualidade — foi copiado diretamente para `main` (sem passar por branch/merge, já que nada tinha sido commitado) e o que faltava foi completado manualmente:
- **Cadastro de Cliente**: repositórios e camada de API salvos do agente praticamente inalterados; só faltava o `client-registrations.module.ts`, escrito manualmente. MVP com escopo explícito (eden.md §3): só fluxo autenticado, sem token de autoatendimento público, sem upload de anexo, sem integração CNPJ/CEP — tudo documentado em comentário no código, não descartado silenciosamente.
- **Contratos**: repositório salvo do agente (com um import não usado removido); toda a camada de API (DTOs, service, controller, module) escrita manualmente, pois o agente não tinha chegado lá. Máquina de estados (ADR-0006) `draft→{pending_signature,cancelled}`, `pending_signature→{active,cancelled}`, `active→{suspended,cancelled,terminated,expired}`, `suspended→{active,cancelled,terminated}` — imposta via `SELECT...FOR UPDATE` + tabela de transições, com `signed_at`/`activated_at` carimbados na transição correta e histórico completo em `contract_status_history`. Escopo: 4 das 11 tabelas do ADR-0006 (contracts, contract_items, contract_parties, contract_status_history); as outras 7 explicitamente adiadas.
Wiring final feito manualmente: barrel (`packages/database/src/index.ts`) e `app.module.ts` atualizados; 3 migrations aplicadas ao Postgres real; `pnpm turbo run typecheck build` (28/28 tasks) e `pnpm --filter @eden/api test` (15/15 testes) verdes; container `eden-api` reconstruído e todo o fluxo testado ao vivo via curl contra a stack real:
- Oferta sem preço especial (sem aprovação) e oferta com desconto + fidelidade reduzida (`approval_status: pending`) — valores conferidos manualmente contra a fórmula.
- Aprovação da oferta, fechamento vinculando a um cadastro de cliente criado via `POST /client-registrations/direct`, e guarda de fechamento duplo (409 na segunda tentativa).
- Contrato criado a partir da oferta fechada, transições de status válidas e inválidas (draft→active direto rejeitado com 409; draft→pending_signature→active aceito, `signed_at`/`activated_at` carimbados corretamente), adição de item/parte, FK de produto inexistente rejeitada com 400.
- 401 sem token; trilha de auditoria (`audit_log`) conferida no Postgres com a sequência completa de eventos do lote (`quote.created`, `quote.approval_approved`, `quote.closed`, `client_registration.created`, `contract.created`, `contract.status_changed` ×2, `contract.party_added`, `client_registration.updated`).
**2 bugs reais encontrados e corrigidos antes do commit (nenhum via subagente — achados na própria implementação/revisão):**
1. Primeiro rascunho do `updateQuote` (escrito por mim) usava `COALESCE` para os campos financeiros (`fidelity_period`, `proposed_monthly_total`, `approved_by_id`) — como `undefined` do JS também vira `NULL` no SQL, qualquer update parcial que omitisse esses campos os apagaria silenciosamente. Corrigido tornando esses campos **obrigatórios** em `UpdateQuoteInput` (não mais opcionais), forçando a camada de serviço a sempre fornecer um valor deliberado.
2. `packages/database/src/repositories/types.ts` — a interface `User` estava sem `reseller_id`/`can_approve_discount`, mesmo essas colunas já existindo no banco desde as migrations de Resellers/Quotes (ninguém tinha atualizado o tipo compartilhado). Descoberto ao ligar o uso desses campos em Quotes; corrigido no `types.ts` e propagado para `EdenAuthenticatedUser` em `jwt-auth.guard.ts`.
### Pendências deste lote
- FKs cruzadas entre as 3 tabelas ainda não existem (ficaram como UUID nullable simples, documentado em comentário nas migrations): `quotes.client_registration_id → client_registrations(id)`, `client_registrations.quote_id → quotes(id)`, `contracts.quote_id → quotes(id)`, `contracts.client_registration_id → client_registrations(id)`.
- Fluxo de autoatendimento público (token) de Cadastro de Cliente, upload de anexo, integração CNPJ/CEP — todos fora do escopo desta MVP.
- 7 das 11 tabelas do ADR-0006 de Contratos (aditivos, faturas, documentos/e-signature, etc.) ainda não criadas.

View File

@@ -0,0 +1,82 @@
/**
* Commercial bounded context: quotes (ofertas) — the pricing/fidelity/
* approval core of the legacy system, eden.md module 2. Ported with exact
* fidelity to the formulas documented there (see apps/api/src/quotes/
* pricing.ts for the calculation engine itself).
*
* `client_registration_id` is added as a plain nullable UUID WITHOUT a
* foreign key — the Customer 360 module (client_registrations table) is
* being built in parallel by another agent and doesn't exist yet in this
* migration's context. The FK is added in a follow-up migration once both
* land (this mirrors the legacy's own history: client_registration_id was
* itself added to quotes in a later migration, 1785513008756).
*
* `users.can_approve_discount` is added here too (small, single-column
* change) since it's exclusively needed by the discount-approval rule
* this migration's quotes table exists to support — eden.md §1.1:
* `canApproveDiscount(user) = super_admin || (admin && can_approve_discount)`.
*/
exports.shorthands = undefined;
exports.up = (pgm) => {
pgm.sql(`
ALTER TABLE users ADD COLUMN can_approve_discount BOOLEAN NOT NULL DEFAULT false;
COMMENT ON COLUMN users.can_approve_discount IS
'Business flag (not a role) — an admin can approve a quote''s discount/reduced-fidelity exception only if this is true; super_admin always can, regardless of this flag. eden.md §1.1, §3.3.';
CREATE SEQUENCE quotes_quote_number_seq START 1;
CREATE TABLE quotes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_by UUID REFERENCES users(id),
quote_number TEXT NOT NULL UNIQUE DEFAULT ('Q-' || to_char(now(), 'YYYY') || '-' || lpad(nextval('quotes_quote_number_seq')::text, 6, '0')),
reseller_id UUID NOT NULL REFERENCES resellers(id),
client_name TEXT NOT NULL,
client_company TEXT,
client_email TEXT,
client_phone TEXT NOT NULL,
contract_period INTEGER NOT NULL DEFAULT 0 CHECK (contract_period IN (0,12,24,36,48)),
fidelity_period INTEGER CHECK (fidelity_period IS NULL OR (fidelity_period >= 0 AND fidelity_period <= contract_period)),
items JSONB NOT NULL DEFAULT '[]'::jsonb,
implementation_fee NUMERIC(12,2) NOT NULL DEFAULT 0,
impl_geral NUMERIC(12,2) NOT NULL DEFAULT 0,
monthly_total NUMERIC(12,2) NOT NULL DEFAULT 0,
contract_total NUMERIC(12,2) NOT NULL DEFAULT 0,
proposed_monthly_total NUMERIC(12,2),
approval_status TEXT NOT NULL DEFAULT 'none' CHECK (approval_status IN ('none','pending','approved','rejected')),
approval_notes TEXT,
approved_by_id UUID REFERENCES users(id),
notes TEXT,
deal_status TEXT NOT NULL DEFAULT 'orcamento' CHECK (deal_status IN ('orcamento','fechado','perdido')),
impl_payment_condition TEXT NOT NULL DEFAULT 'À vista' CHECK (impl_payment_condition IN ('À vista','1+1','1+2')),
client_registration_id UUID
);
COMMENT ON TABLE quotes IS
'Ofertas comerciais — eden.md módulo 2. contract_period é a faixa de preço; fidelity_period é a permanência efetiva quando MENOR que a faixa, só produz efeito legal (vigência/multa) depois de approval_status=approved (eden.md §3.4). client_registration_id referencia client_registrations (Customer 360) sem FK por enquanto — ver comentário no topo desta migration.';
COMMENT ON COLUMN quotes.client_registration_id IS
'Setar este campo trava a oferta para edição (exceto super_admin) — eden.md §5.1. FK a ser adicionada em migration futura quando client_registrations existir.';
COMMENT ON COLUMN quotes.impl_payment_condition IS
'À vista / 1+1 / 1+2 — progressivo por faixa de valor de implantação, eden.md §7 (paymentConditions.js). Faixas validadas na aplicação, não aqui.';
CREATE TRIGGER trg_quotes_updated_at BEFORE UPDATE ON quotes
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
CREATE INDEX idx_quotes_reseller ON quotes(reseller_id);
CREATE INDEX idx_quotes_deal_status ON quotes(deal_status);
CREATE INDEX idx_quotes_created_by ON quotes(created_by);
CREATE INDEX idx_quotes_client_registration ON quotes(client_registration_id);
ALTER SEQUENCE quotes_quote_number_seq OWNED BY NONE;
`);
};
exports.down = (pgm) => {
pgm.sql(`
DROP TABLE IF EXISTS quotes;
DROP SEQUENCE IF EXISTS quotes_quote_number_seq;
ALTER TABLE users DROP COLUMN IF EXISTS can_approve_discount;
`);
};

View File

@@ -0,0 +1,128 @@
/**
* Customer 360 / Client Registration — eden.md §3 §1.1-1.2. Authenticated-
* only MVP (Master Prompt §6.5): deliberately out of scope for this round
* — the public unauthenticated token-based self-service form
* (`GET/POST /public-client-registration/:token`), file attachment
* upload/storage, CNPJ/CEP external lookup integration, the "official"
* `cnpj_address_*` Receita Federal address block, and the
* `*_externally_verified`/`*_verification_*` columns (only the format-
* check booleans `cpf_format_valid`/`cnpj_format_valid` are kept). Those
* all land in a later phase.
*
* `quote_id` is a plain nullable UUID WITHOUT a foreign key — the `quotes`
* table was built in a parallel migration; the FK linking the two is
* added in a follow-up migration once both are confirmed present (mirrors
* the legacy's own history: client_registration_id was itself added to
* quotes in a later migration, 1785513008756).
*/
exports.shorthands = undefined;
exports.up = (pgm) => {
pgm.sql(`
CREATE SEQUENCE client_registrations_client_code_seq START 1;
CREATE TABLE client_registrations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_by UUID REFERENCES users(id),
quote_id UUID,
reseller_id UUID REFERENCES resellers(id),
token TEXT NOT NULL UNIQUE,
token_created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
client_code INTEGER NOT NULL UNIQUE DEFAULT nextval('client_registrations_client_code_seq'),
is_portability BOOLEAN NOT NULL DEFAULT false,
person_type TEXT NOT NULL CHECK (person_type IN ('pf','pj')),
registration_status TEXT NOT NULL DEFAULT 'rascunho' CHECK (registration_status IN ('rascunho','pendente_validacao','ativo','bloqueado','inativo')),
submitted_at TIMESTAMPTZ,
activated_at TIMESTAMPTZ,
internal_notes TEXT,
address_zip TEXT,
address_street TEXT,
address_number TEXT,
address_complement TEXT,
address_neighborhood TEXT,
address_city TEXT,
address_state TEXT,
address_country TEXT NOT NULL DEFAULT 'Brasil',
contact_principal_name TEXT,
contact_principal_email TEXT,
contact_principal_phone TEXT,
contact_financial_name TEXT,
contact_financial_email TEXT,
contact_financial_phone TEXT,
contact_technical_name TEXT,
contact_technical_email TEXT,
contact_technical_phone TEXT,
pf_full_name TEXT,
pf_cpf TEXT,
pf_birth_date DATE,
pf_email TEXT,
pf_mobile_phone TEXT,
pf_alt_phone TEXT,
pf_social_name TEXT,
pf_id_document TEXT,
pf_id_issuer TEXT,
pj_cnpj TEXT,
pj_company_name TEXT,
pj_trade_name TEXT,
pj_state_registration TEXT,
pj_municipal_registration TEXT,
pj_opening_date DATE,
pj_legal_nature TEXT,
pj_cnpj_situation TEXT,
pj_main_activity TEXT,
pj_rep_full_name TEXT,
pj_rep_cpf TEXT,
pj_rep_role TEXT,
pj_rep_email TEXT,
pj_rep_mobile_phone TEXT,
pj_rep_authorization_ack BOOLEAN NOT NULL DEFAULT false,
cpf_format_valid BOOLEAN,
cnpj_format_valid BOOLEAN,
portability_numbers JSONB NOT NULL DEFAULT '[]'::jsonb,
portability_ranges JSONB NOT NULL DEFAULT '[]'::jsonb,
ixc_client_id TEXT,
ixc_contract_number TEXT
);
COMMENT ON TABLE client_registrations IS
'Customer 360 / cadastro de cliente PF/PJ — eden.md §3. MVP autenticado apenas (sem fluxo público por token, sem anexos, sem lookup externo de CNPJ/CEP) — ver comentário no topo desta migration.';
COMMENT ON COLUMN client_registrations.quote_id IS
'Referencia quotes(id) sem FK por enquanto — FK a ser adicionada em migration futura.';
COMMENT ON COLUMN client_registrations.pf_cpf IS
'Imutável fora do fluxo de submissão original — nunca exposto em rota de edição administrativa (eden.md §1.1.2). Mesma regra para pj_cnpj e pj_rep_cpf.';
CREATE TRIGGER trg_client_registrations_updated_at BEFORE UPDATE ON client_registrations
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
CREATE INDEX idx_client_registrations_status ON client_registrations(registration_status);
CREATE INDEX idx_client_registrations_reseller ON client_registrations(reseller_id);
CREATE INDEX idx_client_registrations_created_by ON client_registrations(created_by);
CREATE TABLE client_registration_partners (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
client_registration_id UUID NOT NULL REFERENCES client_registrations(id) ON DELETE CASCADE,
name TEXT NOT NULL,
document TEXT,
qualification TEXT,
source TEXT NOT NULL DEFAULT 'manual',
cpf TEXT,
will_sign BOOLEAN NOT NULL DEFAULT false,
signature_email TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
COMMENT ON TABLE client_registration_partners IS
'Sócios/QSA — eden.md §1.2, §4. source default "manual" (não "brasilapi" como no legado) — sem integração de lookup de CNPJ nesta fase.';
CREATE INDEX idx_client_registration_partners_registration ON client_registration_partners(client_registration_id);
`);
};
exports.down = (pgm) => {
pgm.sql(`
DROP TABLE IF EXISTS client_registration_partners;
DROP TABLE IF EXISTS client_registrations;
DROP SEQUENCE IF EXISTS client_registrations_client_code_seq;
`);
};

View File

@@ -0,0 +1,168 @@
/**
* Commercial bounded context: `contracts` as a first-class aggregate
* (docs/adr/0006-contract-first-class.md, Master Prompt §6.6). In the
* legacy OrçaFácil there is no `contracts` table at all — a "contrato" is
* a query-time join of `client_registrations` (active) with the `quotes`
* that originated it (eden.md §1.4, §10). ADR-0006 lists 11 related
* tables; this migration builds only the 4 that don't depend on a module
* that doesn't exist yet in this worktree:
*
* - contracts (this migration)
* - contract_items (this migration)
* - contract_parties (this migration)
* - contract_status_history (this migration)
*
* Explicitly DEFERRED, not forgotten — each needs a module that hasn't
* landed yet:
* - contract_versions — snapshot-on-signature (ADR-0006 invariant
* nº4); needs the signature/versioning workflow design to land with
* it, not just a bare table.
* - contract_documents — needs the Documents/e-signature module
* (Master Prompt §6.7, eden.md §4 "Documentos e Assinatura").
* - contract_amendments — needs contract_versions first (an amendment
* is a new version).
* - contract_renewals — needs the "renewed" terminal state's
* successor contract to point somewhere; the state machine below
* already reserves 'renewed' as a target but models no transition
* into/out of it yet.
* - contract_assets — needs Inventory/Warehouse (Master Prompt §6.8).
* - contract_services — needs the recurring service/billing-cycle
* model, not just line items.
* - contract_billing_rules — needs the Billing module.
*
* `contracts.quote_id` and `contracts.client_registration_id` are
* deliberately nullable with NO foreign key: `quotes` (Commercial/Ofertas)
* and `client_registrations` (Customer 360) are being built in parallel by
* other agents and don't exist in this worktree yet. Wiring those FKs is
* for the merge/integration step, not this migration.
*/
exports.shorthands = undefined;
exports.up = (pgm) => {
pgm.sql(`
-- =====================================================================
-- contracts — the core aggregate
-- =====================================================================
CREATE SEQUENCE contracts_contract_number_seq START 1;
CREATE TABLE contracts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_by UUID REFERENCES users(id),
contract_number TEXT UNIQUE NOT NULL,
quote_id UUID,
client_registration_id UUID,
reseller_id UUID REFERENCES resellers(id),
company_id UUID REFERENCES companies(id),
status TEXT NOT NULL DEFAULT 'draft'
CHECK (status IN (
'draft', 'pending_signature', 'active', 'suspended',
'cancelled', 'terminated', 'expired', 'renewed'
)),
contract_period INTEGER NOT NULL DEFAULT 0 CHECK (contract_period IN (0, 12, 24, 36, 48)),
fidelity_period INTEGER CHECK (fidelity_period IS NULL OR (fidelity_period >= 0 AND fidelity_period <= contract_period)),
fidelity_approved BOOLEAN NOT NULL DEFAULT false,
monthly_value NUMERIC(12,2),
contract_total NUMERIC(12,2),
implementation_fee NUMERIC(12,2) NOT NULL DEFAULT 0,
signed_at TIMESTAMPTZ,
activated_at TIMESTAMPTZ,
notes TEXT
);
COMMENT ON TABLE contracts IS
'Contrato como agregado de primeira classe — docs/adr/0006-contract-first-class.md, Master Prompt §6.6. Substitui a junção calculada client_registrations+quotes do legado (eden.md §10.1). quote_id/client_registration_id são nullable, sem FK: quotes e client_registrations estão sendo construídos em paralelo por outros agentes e ainda não existem neste worktree — a FK é trabalho de integração, não desta migration. contract_number é gerado na camada de repositório (packages/database/src/repositories/contracts.ts) a partir de contracts_contract_number_seq, formato CTR-{ano}-{sequência de 6 dígitos}, mesmo padrão de products.product_code.';
COMMENT ON COLUMN contracts.contract_period IS
'Faixa de preço comercial contratada (qual price_N do produto foi usado) — DISTINTA de fidelity_period, a permanência efetivamente assinada. Nunca usar contract_period para calcular vigência/vencimento/multa quando houver uma fidelidade reduzida aprovada (ver comentário de fidelity_approved).';
COMMENT ON COLUMN contracts.fidelity_period IS
'Prazo de permanência efetivamente negociado, quando menor que contract_period. NULL = sem exceção (fidelidade = contract_period, caso padrão). Só tem efeito jurídico se fidelity_approved = true (ver fidelity_approved).';
COMMENT ON COLUMN contracts.fidelity_approved IS
'INVARIANTE CRÍTICA, herdada do legado e que NÃO desaparece com contrato virando agregado de primeira classe — ela apenas migra de quotes.approval_status = ''approved'' (eden.md §10.2, §10.4) para este campo. Regra exata do legado, citada literalmente (eden.md §10.4):
fidelidadeEfetiva = (quote.fidelity_period != null && quote.approval_status === ''approved'')
? quote.fidelity_period
: (quote.contract_period || 0)
Ou seja: fidelity_period só tem efeito legal (cálculo de vigência/vencimento/multa) quando fidelity_approved = true; caso contrário, trate a fidelidade como igual a contract_period. contract_period continua sendo usado apenas para (a) qual price_N foi aplicado e (b) contract_total (eden.md §8, §10.4) — NUNCA para vencimento/multa quando há uma exceção de fidelidade já aprovada. A função pura resolveEffectiveFidelity() em packages/database/src/repositories/contracts.ts implementa exatamente esta regra e deve ser reusada por qualquer cálculo futuro de vigência/vencimento/multa (billing, relatórios) — nunca reimplementada ad-hoc.';
COMMENT ON COLUMN contracts.activated_at IS
'Quando o status se tornou ''active'' pela primeira vez — nunca sobrescrito depois (semântica COALESCE(activated_at, now()) na camada de repositório, mesmo padrão do client_registrations.activated_at do legado, eden.md §10.2). Base para o cálculo de vigência/vencimento junto com a fidelidade resolvida (resolveEffectiveFidelity).';
COMMENT ON COLUMN contracts.signed_at IS
'Quando o contrato foi de fato assinado — distinto de created_at (quando o rascunho foi criado). Setado pela camada de repositório ao transicionar de pending_signature para active, se ainda não setado.';
CREATE TRIGGER trg_contracts_updated_at BEFORE UPDATE ON contracts
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
CREATE INDEX idx_contracts_status ON contracts(status);
CREATE INDEX idx_contracts_reseller ON contracts(reseller_id);
ALTER SEQUENCE contracts_contract_number_seq OWNED BY contracts.contract_number;
-- =====================================================================
-- contract_items — line items, real/queryable (not a JSONB snapshot
-- like the legacy's quotes.items, eden.md §1.3)
-- =====================================================================
CREATE TABLE contract_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
contract_id UUID NOT NULL REFERENCES contracts(id) ON DELETE CASCADE,
product_id UUID REFERENCES products(id),
product_name TEXT NOT NULL,
quantity NUMERIC(12,2) NOT NULL DEFAULT 1,
unit_price NUMERIC(12,2) NOT NULL,
total NUMERIC(12,2) NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
COMMENT ON TABLE contract_items IS
'Itens do contrato — versão real/consultável do que seria o JSONB items de uma oferta uma vez vinculada (eden.md §1.3), por ADR-0006. product_id é nullable e product_name é sempre um snapshot: um contrato nunca pode mudar retroativamente porque um produto foi editado ou removido depois (invariante nº4 do Master Prompt §24, ADR-0006).';
CREATE INDEX idx_contract_items_contract ON contract_items(contract_id);
-- =====================================================================
-- contract_parties — legal parties, snapshotted at creation time
-- =====================================================================
CREATE TABLE contract_parties (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
contract_id UUID NOT NULL REFERENCES contracts(id) ON DELETE CASCADE,
party_role TEXT NOT NULL CHECK (party_role IN ('contratada', 'contratante')),
person_type TEXT CHECK (person_type IS NULL OR person_type IN ('pf', 'pj')),
full_name TEXT NOT NULL,
document TEXT,
email TEXT,
phone TEXT,
role_title TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
COMMENT ON TABLE contract_parties IS
'Partes do contrato (contratada = empresa emissora, contratante = cliente) — SEMPRE um snapshot tirado no momento da criação do contrato, nunca uma junção viva que poderia mudar se o registro de origem (company/client_registration) mudar depois. Mesma filosofia de snapshot de signature_signers no motor de assinatura eletrônica do legado (eden.md §4), mesmo o módulo de assinatura em si ainda não existindo neste worktree.';
COMMENT ON COLUMN contract_parties.document IS
'CPF/CNPJ, snapshot em texto — sem validação de dígito verificador no banco, igual companies.cnpj e resellers.document. Validado (quando presente) na camada de aplicação.';
CREATE INDEX idx_contract_parties_contract ON contract_parties(contract_id);
-- =====================================================================
-- contract_status_history — append-only UX-facing lifecycle log,
-- distinct from (and complementary to) the generic audit_log
-- =====================================================================
CREATE TABLE contract_status_history (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
contract_id UUID NOT NULL REFERENCES contracts(id) ON DELETE CASCADE,
from_status TEXT,
to_status TEXT NOT NULL,
reason TEXT,
changed_by UUID REFERENCES users(id),
changed_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
COMMENT ON TABLE contract_status_history IS
'Log de transições de status do contrato — append-only por convenção da CAMADA DE APLICAÇÃO (nenhuma rota de UPDATE/DELETE é exposta), não por trigger de banco como audit_log. Diferença deliberada: audit_log é a trilha de auditoria legal/hash-chained e cobre TODA ação crítica do sistema (inclui um evento contract.status_changed também, via auditRepo.appendAuditEvent); esta tabela é a versão estruturada, específica deste agregado, pensada para a UI mostrar o histórico de vida do contrato sem precisar parsear metadata genérico do audit_log. As duas tabelas coexistem deliberadamente — não são redundantes.';
CREATE INDEX idx_contract_status_history_contract ON contract_status_history(contract_id);
`);
};
exports.down = (pgm) => {
pgm.sql(`
DROP TABLE IF EXISTS contract_status_history;
DROP TABLE IF EXISTS contract_parties;
DROP TABLE IF EXISTS contract_items;
DROP TABLE IF EXISTS contracts;
DROP SEQUENCE IF EXISTS contracts_contract_number_seq;
`);
};

View File

@@ -6,6 +6,10 @@ export * as auditRepo from "./repositories/audit.js";
export * as companiesRepo from "./repositories/companies.js"; export * as companiesRepo from "./repositories/companies.js";
export * as productsRepo from "./repositories/products.js"; export * as productsRepo from "./repositories/products.js";
export * as resellersRepo from "./repositories/resellers.js"; export * as resellersRepo from "./repositories/resellers.js";
export * as quotesRepo from "./repositories/quotes.js";
export * as contractsRepo from "./repositories/contracts.js";
export * as clientRegistrationsRepo from "./repositories/client-registrations.js";
export * as clientRegistrationPartnersRepo from "./repositories/client-registration-partners.js";
export { export {
listLeads, listLeads,
getLead, getLead,
@@ -37,3 +41,36 @@ export type {
CreateOpportunityInput, CreateOpportunityInput,
UpdateOpportunityInput, UpdateOpportunityInput,
} from "./repositories/opportunities.js"; } from "./repositories/opportunities.js";
export type {
Quote,
QuoteItem,
ListQuotesFilter,
CreateQuoteInput,
UpdateQuoteInput,
} from "./repositories/quotes.js";
export type {
Contract,
ContractItem,
ContractParty,
ContractStatus,
ContractStatusHistoryEntry,
ContractWithDetails,
ListContractsFilter,
CreateContractInput,
CreateContractItemInput,
CreateContractPartyInput,
} from "./repositories/contracts.js";
export type {
ClientRegistration,
RegistrationStatus,
PersonType,
PortabilityRange,
ListClientRegistrationsFilter,
CreateClientRegistrationInput,
UpdateClientRegistrationProfileInput,
} from "./repositories/client-registrations.js";
export type {
ClientRegistrationPartner,
CreatePartnerInput,
UpdatePartnerInput,
} from "./repositories/client-registration-partners.js";

View File

@@ -0,0 +1,97 @@
import { query } from "../pool.js";
/**
* `client_registration_partners` — sócios/QSA (eden.md §1.2, §4). In the
* legacy this table is populated from a public CNPJ lookup (cnpj.ws/
* BrasilAPI); that integration is out of scope for this MVP (see the
* migration comment — `source` defaults to `'manual'` here instead of the
* legacy's `'brasilapi'`, since nothing here comes from an external
* lookup). Only the backoffice-correction slice of the legacy flow
* (eden.md §2.5: correcting cpf/will_sign/signature_email after
* submission) is implemented — partners must be entered manually in this
* round.
*/
export interface ClientRegistrationPartner {
id: string;
client_registration_id: string;
name: string;
document: string | null;
qualification: string | null;
source: string;
cpf: string | null;
will_sign: boolean;
signature_email: string | null;
created_at: Date;
}
export async function listPartnersForRegistration(
registrationId: string,
): Promise<ClientRegistrationPartner[]> {
const { rows } = await query<ClientRegistrationPartner>(
"SELECT * FROM client_registration_partners WHERE client_registration_id = $1 ORDER BY created_at ASC",
[registrationId],
);
return rows;
}
export async function getPartnerById(id: string): Promise<ClientRegistrationPartner | null> {
const { rows } = await query<ClientRegistrationPartner>(
"SELECT * FROM client_registration_partners WHERE id = $1",
[id],
);
return rows[0] ?? null;
}
export interface CreatePartnerInput {
clientRegistrationId: string;
name: string;
document?: string | null;
qualification?: string | null;
cpf?: string | null;
willSign?: boolean;
signatureEmail?: string | null;
}
export async function createPartner(input: CreatePartnerInput): Promise<ClientRegistrationPartner> {
const { rows } = await query<ClientRegistrationPartner>(
`INSERT INTO client_registration_partners
(client_registration_id, name, document, qualification, cpf, will_sign, signature_email)
VALUES ($1, $2, $3, $4, $5, COALESCE($6, false), $7)
RETURNING *`,
[
input.clientRegistrationId,
input.name,
input.document ?? null,
input.qualification ?? null,
input.cpf ?? null,
input.willSign ?? null,
input.signatureEmail ?? null,
],
);
const partner = rows[0];
if (!partner) throw new Error("failed to create client registration partner");
return partner;
}
/** eden.md §2.5: backoffice can correct only cpf/will_sign/signature_email post-submission. */
export interface UpdatePartnerInput {
cpf?: string | null;
willSign?: boolean;
signatureEmail?: string | null;
}
export async function updatePartner(
id: string,
input: UpdatePartnerInput,
): Promise<ClientRegistrationPartner | null> {
const { rows } = await query<ClientRegistrationPartner>(
`UPDATE client_registration_partners SET
cpf = COALESCE($2, cpf),
will_sign = COALESCE($3, will_sign),
signature_email = COALESCE($4, signature_email)
WHERE id = $1
RETURNING *`,
[id, input.cpf ?? null, input.willSign ?? null, input.signatureEmail ?? null],
);
return rows[0] ?? null;
}

View File

@@ -0,0 +1,341 @@
import { randomBytes } from "node:crypto";
import { query } from "../pool.js";
export type RegistrationStatus = "rascunho" | "pendente_validacao" | "ativo" | "bloqueado" | "inativo";
export type PersonType = "pf" | "pj";
export interface PortabilityRange {
first: string;
last: string;
}
/**
* Customer 360 / Client Registration — eden.md §3 §1.1. Authenticated-only
* MVP (Master Prompt §6.5, docs/adr/0004-permission-model.md): deliberately
* out of scope for this round — the public unauthenticated token-based
* self-service form (`GET/POST /public-client-registration/:token`), file
* attachment upload/storage, CNPJ/CEP external lookup integration, the
* "official" `cnpj_address_*` Receita Federal address block, and the
* `*_externally_verified`/`*_verification_*` columns (only the format-check
* booleans `cpf_format_valid`/`cnpj_format_valid` are kept). Those all land
* in a later phase.
*/
export interface ClientRegistration {
id: string;
created_at: Date;
updated_at: Date;
created_by: string | null;
quote_id: string | null;
reseller_id: string | null;
token: string;
token_created_at: Date;
client_code: number;
is_portability: boolean;
person_type: PersonType;
registration_status: RegistrationStatus;
submitted_at: Date | null;
activated_at: Date | null;
internal_notes: string | null;
address_zip: string | null;
address_street: string | null;
address_number: string | null;
address_complement: string | null;
address_neighborhood: string | null;
address_city: string | null;
address_state: string | null;
address_country: string;
contact_principal_name: string | null;
contact_principal_email: string | null;
contact_principal_phone: string | null;
contact_financial_name: string | null;
contact_financial_email: string | null;
contact_financial_phone: string | null;
contact_technical_name: string | null;
contact_technical_email: string | null;
contact_technical_phone: string | null;
pf_full_name: string | null;
pf_cpf: string | null;
pf_birth_date: Date | null;
pf_email: string | null;
pf_mobile_phone: string | null;
pf_alt_phone: string | null;
pf_social_name: string | null;
pf_id_document: string | null;
pf_id_issuer: string | null;
pj_cnpj: string | null;
pj_company_name: string | null;
pj_trade_name: string | null;
pj_state_registration: string | null;
pj_municipal_registration: string | null;
pj_opening_date: Date | null;
pj_legal_nature: string | null;
pj_cnpj_situation: string | null;
pj_main_activity: string | null;
pj_rep_full_name: string | null;
pj_rep_cpf: string | null;
pj_rep_role: string | null;
pj_rep_email: string | null;
pj_rep_mobile_phone: string | null;
pj_rep_authorization_ack: boolean;
cpf_format_valid: boolean | null;
cnpj_format_valid: boolean | null;
portability_numbers: string[];
portability_ranges: PortabilityRange[];
ixc_client_id: string | null;
ixc_contract_number: string | null;
}
export interface ListClientRegistrationsFilter {
scope: "all" | "own" | "reseller";
actorId?: string;
resellerId?: string;
}
/**
* The *caller* (service layer) resolves which scope applies to the actor
* (own authorship vs. their reseller vs. unrestricted) from their granted
* permission scope (ADR-0004) — this function just applies the already-
* resolved filter. See eden.md §7.1 for the two-tier visibility this
* generalizes (`Clientes.jsx` full view by authorship/`clientes_todos`
* vs. `ClientesRevenda.jsx` directory scoped by reseller).
*/
export async function listClientRegistrations(
filter: ListClientRegistrationsFilter,
): Promise<ClientRegistration[]> {
if (filter.scope === "own") {
const { rows } = await query<ClientRegistration>(
"SELECT * FROM client_registrations WHERE created_by = $1 ORDER BY created_at DESC",
[filter.actorId ?? null],
);
return rows;
}
if (filter.scope === "reseller") {
const { rows } = await query<ClientRegistration>(
"SELECT * FROM client_registrations WHERE reseller_id = $1 ORDER BY created_at DESC",
[filter.resellerId ?? null],
);
return rows;
}
const { rows } = await query<ClientRegistration>(
"SELECT * FROM client_registrations ORDER BY created_at DESC",
);
return rows;
}
export async function getClientRegistrationById(id: string): Promise<ClientRegistration | null> {
const { rows } = await query<ClientRegistration>(
"SELECT * FROM client_registrations WHERE id = $1",
[id],
);
return rows[0] ?? null;
}
export interface CreateClientRegistrationInput {
personType: PersonType;
isPortability: boolean;
resellerId?: string | null;
createdBy?: string | null;
}
/**
* Cadastro "direto" (eden.md §2.6) — the only creation path in this MVP
* (no `quote_id`, since `quotes` isn't modeled in this worktree yet — see
* the module comment in the migration). The token is generated here,
* server-side, with a CSPRNG — never accepted from the client (eden.md §9:
* `crypto.randomBytes(24).toString('hex')`, mirrored from
* sessions.ts/token.service.ts's opaque-token pattern).
*/
export async function createClientRegistration(
input: CreateClientRegistrationInput,
): Promise<ClientRegistration> {
const token = randomBytes(24).toString("hex");
const { rows } = await query<ClientRegistration>(
`INSERT INTO client_registrations (person_type, is_portability, reseller_id, token, created_by)
VALUES ($1, $2, $3, $4, $5)
RETURNING *`,
[input.personType, input.isPortability, input.resellerId ?? null, token, input.createdBy ?? null],
);
const registration = rows[0];
if (!registration) throw new Error("failed to create client registration");
return registration;
}
/**
* Sole entry point for changing `registration_status` — no dedicated
* transition endpoint/legality guard in this MVP (eden.md §1.1.1: "não há
* endpoint dedicado de transição", legacy also just does a generic PATCH).
* Replicates the one real side effect the legacy has: `activated_at` is
* stamped the *first* time the status becomes `ativo` (`COALESCE`, never
* overwritten on a later reactivation).
*/
export async function updateClientRegistrationStatus(
id: string,
status: RegistrationStatus,
): Promise<ClientRegistration | null> {
const { rows } = await query<ClientRegistration>(
`UPDATE client_registrations SET
registration_status = $2,
activated_at = CASE WHEN $2 = 'ativo' THEN COALESCE(activated_at, now()) ELSE activated_at END
WHERE id = $1
RETURNING *`,
[id, status],
);
return rows[0] ?? null;
}
/**
* Fields administratively editable per eden.md §1.1.2 — and only those.
* `pf_cpf`, `pj_cnpj`, and `pj_rep_cpf` are deliberately absent from this
* interface entirely (not merely skipped at runtime) so the compiler
* enforces the "CPF/CNPJ never editable via administrative PATCH" rule.
* `registration_status` has its own function above; `quote_id`,
* `reseller_id`, `token`, `client_code`, `person_type`, `is_portability`,
* `submitted_at`, `created_by` aren't in the legacy's editable-fields list
* either.
*/
export interface UpdateClientRegistrationProfileInput {
internalNotes?: string | null;
ixcClientId?: string | null;
ixcContractNumber?: string | null;
addressZip?: string | null;
addressStreet?: string | null;
addressNumber?: string | null;
addressComplement?: string | null;
addressNeighborhood?: string | null;
addressCity?: string | null;
addressState?: string | null;
addressCountry?: string | null;
contactPrincipalName?: string | null;
contactPrincipalEmail?: string | null;
contactPrincipalPhone?: string | null;
contactFinancialName?: string | null;
contactFinancialEmail?: string | null;
contactFinancialPhone?: string | null;
contactTechnicalName?: string | null;
contactTechnicalEmail?: string | null;
contactTechnicalPhone?: string | null;
pfFullName?: string | null;
pfBirthDate?: string | null;
pfEmail?: string | null;
pfMobilePhone?: string | null;
pfAltPhone?: string | null;
pfSocialName?: string | null;
pfIdDocument?: string | null;
pfIdIssuer?: string | null;
pjCompanyName?: string | null;
pjTradeName?: string | null;
pjStateRegistration?: string | null;
pjMunicipalRegistration?: string | null;
pjOpeningDate?: string | null;
pjLegalNature?: string | null;
pjCnpjSituation?: string | null;
pjMainActivity?: string | null;
pjRepFullName?: string | null;
pjRepRole?: string | null;
pjRepEmail?: string | null;
pjRepMobilePhone?: string | null;
pjRepAuthorizationAck?: boolean;
portabilityNumbers?: string[];
portabilityRanges?: PortabilityRange[];
}
export async function updateClientRegistrationProfile(
id: string,
input: UpdateClientRegistrationProfileInput,
): Promise<ClientRegistration | null> {
const { rows } = await query<ClientRegistration>(
`UPDATE client_registrations SET
internal_notes = COALESCE($2, internal_notes),
ixc_client_id = COALESCE($3, ixc_client_id),
ixc_contract_number = COALESCE($4, ixc_contract_number),
address_zip = COALESCE($5, address_zip),
address_street = COALESCE($6, address_street),
address_number = COALESCE($7, address_number),
address_complement = COALESCE($8, address_complement),
address_neighborhood = COALESCE($9, address_neighborhood),
address_city = COALESCE($10, address_city),
address_state = COALESCE($11, address_state),
address_country = COALESCE($12, address_country),
contact_principal_name = COALESCE($13, contact_principal_name),
contact_principal_email = COALESCE($14, contact_principal_email),
contact_principal_phone = COALESCE($15, contact_principal_phone),
contact_financial_name = COALESCE($16, contact_financial_name),
contact_financial_email = COALESCE($17, contact_financial_email),
contact_financial_phone = COALESCE($18, contact_financial_phone),
contact_technical_name = COALESCE($19, contact_technical_name),
contact_technical_email = COALESCE($20, contact_technical_email),
contact_technical_phone = COALESCE($21, contact_technical_phone),
pf_full_name = COALESCE($22, pf_full_name),
pf_birth_date = COALESCE($23::date, pf_birth_date),
pf_email = COALESCE($24, pf_email),
pf_mobile_phone = COALESCE($25, pf_mobile_phone),
pf_alt_phone = COALESCE($26, pf_alt_phone),
pf_social_name = COALESCE($27, pf_social_name),
pf_id_document = COALESCE($28, pf_id_document),
pf_id_issuer = COALESCE($29, pf_id_issuer),
pj_company_name = COALESCE($30, pj_company_name),
pj_trade_name = COALESCE($31, pj_trade_name),
pj_state_registration = COALESCE($32, pj_state_registration),
pj_municipal_registration = COALESCE($33, pj_municipal_registration),
pj_opening_date = COALESCE($34::date, pj_opening_date),
pj_legal_nature = COALESCE($35, pj_legal_nature),
pj_cnpj_situation = COALESCE($36, pj_cnpj_situation),
pj_main_activity = COALESCE($37, pj_main_activity),
pj_rep_full_name = COALESCE($38, pj_rep_full_name),
pj_rep_role = COALESCE($39, pj_rep_role),
pj_rep_email = COALESCE($40, pj_rep_email),
pj_rep_mobile_phone = COALESCE($41, pj_rep_mobile_phone),
pj_rep_authorization_ack = COALESCE($42, pj_rep_authorization_ack),
portability_numbers = COALESCE($43::jsonb, portability_numbers),
portability_ranges = COALESCE($44::jsonb, portability_ranges)
WHERE id = $1
RETURNING *`,
[
id,
input.internalNotes ?? null,
input.ixcClientId ?? null,
input.ixcContractNumber ?? null,
input.addressZip ?? null,
input.addressStreet ?? null,
input.addressNumber ?? null,
input.addressComplement ?? null,
input.addressNeighborhood ?? null,
input.addressCity ?? null,
input.addressState ?? null,
input.addressCountry ?? null,
input.contactPrincipalName ?? null,
input.contactPrincipalEmail ?? null,
input.contactPrincipalPhone ?? null,
input.contactFinancialName ?? null,
input.contactFinancialEmail ?? null,
input.contactFinancialPhone ?? null,
input.contactTechnicalName ?? null,
input.contactTechnicalEmail ?? null,
input.contactTechnicalPhone ?? null,
input.pfFullName ?? null,
input.pfBirthDate ?? null,
input.pfEmail ?? null,
input.pfMobilePhone ?? null,
input.pfAltPhone ?? null,
input.pfSocialName ?? null,
input.pfIdDocument ?? null,
input.pfIdIssuer ?? null,
input.pjCompanyName ?? null,
input.pjTradeName ?? null,
input.pjStateRegistration ?? null,
input.pjMunicipalRegistration ?? null,
input.pjOpeningDate ?? null,
input.pjLegalNature ?? null,
input.pjCnpjSituation ?? null,
input.pjMainActivity ?? null,
input.pjRepFullName ?? null,
input.pjRepRole ?? null,
input.pjRepEmail ?? null,
input.pjRepMobilePhone ?? null,
input.pjRepAuthorizationAck ?? null,
input.portabilityNumbers ? JSON.stringify(input.portabilityNumbers) : null,
input.portabilityRanges ? JSON.stringify(input.portabilityRanges) : null,
],
);
return rows[0] ?? null;
}

View File

@@ -0,0 +1,437 @@
import { query, withTransaction } from "../pool.js";
/**
* Commercial bounded context — Contracts as a first-class aggregate
* (docs/adr/0006-contract-first-class.md, Master Prompt §6.6). See the
* migration's header comment for the full list of ADR-0006 tables this
* MVP builds (contracts, contract_items, contract_parties,
* contract_status_history) vs. explicitly deferred ones.
*
* Mirrors the conventions of companies.ts/resellers.ts (COALESCE-based
* partial writes, snake_case rows) and leads.ts (withTransaction for
* multi-row writes that must commit/rollback together).
*/
export type ContractStatus =
| "draft"
| "pending_signature"
| "active"
| "suspended"
| "cancelled"
| "terminated"
| "expired"
| "renewed";
export interface Contract {
id: string;
created_at: Date;
updated_at: Date;
created_by: string | null;
contract_number: string;
quote_id: string | null;
client_registration_id: string | null;
reseller_id: string | null;
company_id: string | null;
status: ContractStatus;
contract_period: number;
fidelity_period: number | null;
fidelity_approved: boolean;
monthly_value: string | null;
contract_total: string | null;
implementation_fee: string;
signed_at: Date | null;
activated_at: Date | null;
notes: string | null;
}
export interface ContractItem {
id: string;
contract_id: string;
product_id: string | null;
product_name: string;
quantity: string;
unit_price: string;
total: string;
sort_order: number;
created_at: Date;
}
export interface ContractParty {
id: string;
contract_id: string;
party_role: "contratada" | "contratante";
person_type: "pf" | "pj" | null;
full_name: string;
document: string | null;
email: string | null;
phone: string | null;
role_title: string | null;
created_at: Date;
}
export interface ContractStatusHistoryEntry {
id: string;
contract_id: string;
from_status: ContractStatus | null;
to_status: ContractStatus;
reason: string | null;
changed_by: string | null;
changed_at: Date;
}
export interface ContractWithDetails extends Contract {
items: ContractItem[];
parties: ContractParty[];
statusHistory: ContractStatusHistoryEntry[];
}
/**
* The eden.md §10.4 fidelity-resolution rule, ported verbatim from the
* legacy (where it read `quote.fidelity_period`/`quote.approval_status`)
* to the first-class contract's own columns. This rule doesn't disappear
* just because Contract is now a real table — it moves here:
*
* fidelidadeEfetiva = (fidelity_period != null && fidelity_approved)
* ? fidelity_period
* : (contract_period || 0)
*
* Pure, no DB access — reused by any future vigência/vencimento/multa
* calculation (billing, reports). Never reimplement this ad-hoc.
*/
export function resolveEffectiveFidelity(
contractPeriod: number,
fidelityPeriod: number | null,
fidelityApproved: boolean,
): number {
return fidelityApproved && fidelityPeriod != null ? fidelityPeriod : contractPeriod;
}
/**
* The MVP contract lifecycle state machine (docs/adr/0006, Master Prompt
* §6.6). `cancelled`/`terminated`/`expired`/`renewed` are terminal here —
* no `renewed -> ...` transition is modeled yet, that needs the
* not-yet-built contract_renewals table (see migration header comment).
*/
export const CONTRACT_STATUS_TRANSITIONS: Record<ContractStatus, ContractStatus[]> = {
draft: ["pending_signature", "cancelled"],
pending_signature: ["active", "cancelled"],
active: ["suspended", "cancelled", "terminated", "expired"],
suspended: ["active", "cancelled", "terminated"],
cancelled: [],
terminated: [],
expired: [],
renewed: [],
};
export class ContractNotFoundError extends Error {
constructor(public readonly contractId: string) {
super(`Contract ${contractId} not found`);
this.name = "ContractNotFoundError";
}
}
export class InvalidContractTransitionError extends Error {
constructor(
public readonly fromStatus: ContractStatus,
public readonly toStatus: ContractStatus,
) {
super(`Invalid contract status transition: '${fromStatus}' -> '${toStatus}'`);
this.name = "InvalidContractTransitionError";
}
}
export interface ListContractsFilter {
scope: "all" | "reseller";
/** Required (and enforced) when scope === 'reseller'. Ignored when scope === 'all'. */
resellerId?: string | null;
}
export async function listContracts(filter: ListContractsFilter): Promise<Contract[]> {
if (filter.scope === "reseller") {
// Deliberately never falls back to an unfiltered query: a caller with
// reseller-only scope and no resellerId (e.g. an actor not assigned to
// any reseller) sees nothing, not everything — deny-by-default.
const { rows } = await query<Contract>(
"SELECT * FROM contracts WHERE reseller_id = $1 ORDER BY created_at DESC",
[filter.resellerId ?? null],
);
return rows;
}
const { rows } = await query<Contract>("SELECT * FROM contracts ORDER BY created_at DESC");
return rows;
}
async function listContractItems(contractId: string): Promise<ContractItem[]> {
const { rows } = await query<ContractItem>(
"SELECT * FROM contract_items WHERE contract_id = $1 ORDER BY sort_order ASC, created_at ASC",
[contractId],
);
return rows;
}
async function listContractPartiesRows(contractId: string): Promise<ContractParty[]> {
const { rows } = await query<ContractParty>(
"SELECT * FROM contract_parties WHERE contract_id = $1 ORDER BY created_at ASC",
[contractId],
);
return rows;
}
export async function listContractStatusHistory(contractId: string): Promise<ContractStatusHistoryEntry[]> {
const { rows } = await query<ContractStatusHistoryEntry>(
"SELECT * FROM contract_status_history WHERE contract_id = $1 ORDER BY changed_at ASC",
[contractId],
);
return rows;
}
export async function getContractById(id: string): Promise<ContractWithDetails | null> {
const { rows } = await query<Contract>("SELECT * FROM contracts WHERE id = $1", [id]);
const contract = rows[0];
if (!contract) return null;
const [items, parties, statusHistory] = await Promise.all([
listContractItems(id),
listContractPartiesRows(id),
listContractStatusHistory(id),
]);
return { ...contract, items, parties, statusHistory };
}
export interface CreateContractItemInput {
productId?: string | null;
productName: string;
quantity: number;
unitPrice: number;
/** Never trust a client-supplied total — always server-recalculated by the caller (service layer) before reaching here. */
total: number;
sortOrder: number;
}
export interface CreateContractPartyInput {
partyRole: "contratada" | "contratante";
personType?: "pf" | "pj" | null;
fullName: string;
document?: string | null;
email?: string | null;
phone?: string | null;
roleTitle?: string | null;
}
export interface CreateContractInput {
quoteId?: string | null;
clientRegistrationId?: string | null;
resellerId?: string | null;
companyId?: string | null;
contractPeriod: number;
fidelityPeriod?: number | null;
fidelityApproved?: boolean;
/** Server-recalculated by the caller from `items` — never accepted raw from a client. */
monthlyValue: number;
/** Server-recalculated by the caller — never accepted raw from a client. */
contractTotal: number;
implementationFee?: number;
notes?: string | null;
createdBy?: string | null;
items: CreateContractItemInput[];
parties: CreateContractPartyInput[];
}
function formatContractNumber(seq: string | number): string {
const year = new Date().getFullYear();
return `CTR-${year}-${String(seq).padStart(6, "0")}`;
}
/**
* Creates the contract plus its items and parties in a single transaction
* (all-or-nothing) — mirrors leads.ts's convertLeadToOpportunity pattern.
* Status always starts 'draft'; contract_number is allocated here from
* contracts_contract_number_seq, same pattern as products.product_code.
*/
export async function createContract(input: CreateContractInput): Promise<ContractWithDetails> {
return withTransaction(async (client) => {
const { rows: seqRows } = await client.query<{ n: string }>(
"SELECT nextval('contracts_contract_number_seq') AS n",
);
const seq = seqRows[0]?.n;
if (seq === undefined) throw new Error("failed to allocate contract_number");
const contractNumber = formatContractNumber(seq);
const { rows } = await client.query<Contract>(
`INSERT INTO contracts (
contract_number, quote_id, client_registration_id, reseller_id, company_id,
contract_period, fidelity_period, fidelity_approved,
monthly_value, contract_total, implementation_fee, notes, created_by
) VALUES ($1,$2,$3,$4,$5,$6,$7,COALESCE($8,false),$9,$10,COALESCE($11,0),$12,$13)
RETURNING *`,
[
contractNumber,
input.quoteId ?? null,
input.clientRegistrationId ?? null,
input.resellerId ?? null,
input.companyId ?? null,
input.contractPeriod,
input.fidelityPeriod ?? null,
input.fidelityApproved ?? null,
input.monthlyValue,
input.contractTotal,
input.implementationFee ?? null,
input.notes ?? null,
input.createdBy ?? null,
],
);
const contract = rows[0];
if (!contract) throw new Error("failed to create contract");
const items: ContractItem[] = [];
for (const item of input.items) {
const { rows: itemRows } = await client.query<ContractItem>(
`INSERT INTO contract_items (contract_id, product_id, product_name, quantity, unit_price, total, sort_order)
VALUES ($1,$2,$3,$4,$5,$6,$7)
RETURNING *`,
[
contract.id,
item.productId ?? null,
item.productName,
item.quantity,
item.unitPrice,
item.total,
item.sortOrder,
],
);
const row = itemRows[0];
if (!row) throw new Error("failed to create contract item");
items.push(row);
}
const parties: ContractParty[] = [];
for (const party of input.parties) {
const { rows: partyRows } = await client.query<ContractParty>(
`INSERT INTO contract_parties (contract_id, party_role, person_type, full_name, document, email, phone, role_title)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8)
RETURNING *`,
[
contract.id,
party.partyRole,
party.personType ?? null,
party.fullName,
party.document ?? null,
party.email ?? null,
party.phone ?? null,
party.roleTitle ?? null,
],
);
const row = partyRows[0];
if (!row) throw new Error("failed to create contract party");
parties.push(row);
}
return { ...contract, items, parties, statusHistory: [] };
});
}
export async function addContractItem(
contractId: string,
input: CreateContractItemInput,
): Promise<ContractItem> {
const { rows } = await query<ContractItem>(
`INSERT INTO contract_items (contract_id, product_id, product_name, quantity, unit_price, total, sort_order)
VALUES ($1,$2,$3,$4,$5,$6,$7)
RETURNING *`,
[
contractId,
input.productId ?? null,
input.productName,
input.quantity,
input.unitPrice,
input.total,
input.sortOrder,
],
);
const row = rows[0];
if (!row) throw new Error("failed to add contract item");
return row;
}
export async function addContractParty(
contractId: string,
input: CreateContractPartyInput,
): Promise<ContractParty> {
const { rows } = await query<ContractParty>(
`INSERT INTO contract_parties (contract_id, party_role, person_type, full_name, document, email, phone, role_title)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8)
RETURNING *`,
[
contractId,
input.partyRole,
input.personType ?? null,
input.fullName,
input.document ?? null,
input.email ?? null,
input.phone ?? null,
input.roleTitle ?? null,
],
);
const row = rows[0];
if (!row) throw new Error("failed to add contract party");
return row;
}
/**
* Validates the transition against CONTRACT_STATUS_TRANSITIONS and, only
* if valid: updates contracts.status, sets activated_at via COALESCE the
* first time the contract enters 'active' (never overwritten afterwards —
* same semantics as the legacy's client_registrations.activated_at,
* eden.md §10.2), sets signed_at the first time it enters 'active' from
* 'pending_signature', and appends a contract_status_history row — all in
* one transaction. Throws ContractNotFoundError / InvalidContractTransitionError
* on failure instead of silently no-oping; the service layer maps these to
* 404/409.
*/
export async function updateContractStatus(
id: string,
toStatus: ContractStatus,
reason: string | null,
actorId: string | null,
): Promise<Contract> {
return withTransaction(async (client) => {
const { rows } = await client.query<{ status: ContractStatus; signed_at: Date | null }>(
"SELECT status, signed_at FROM contracts WHERE id = $1 FOR UPDATE",
[id],
);
const current = rows[0];
if (!current) {
throw new ContractNotFoundError(id);
}
const fromStatus = current.status;
const allowed = CONTRACT_STATUS_TRANSITIONS[fromStatus] ?? [];
if (!allowed.includes(toStatus)) {
throw new InvalidContractTransitionError(fromStatus, toStatus);
}
const enteringActive = toStatus === "active";
const settingSignedAt = enteringActive && fromStatus === "pending_signature" && current.signed_at === null;
const { rows: updatedRows } = await client.query<Contract>(
`UPDATE contracts SET
status = $2,
activated_at = CASE WHEN $3 THEN COALESCE(activated_at, now()) ELSE activated_at END,
signed_at = CASE WHEN $4 THEN now() ELSE signed_at END
WHERE id = $1
RETURNING *`,
[id, toStatus, enteringActive, settingSignedAt],
);
const updated = updatedRows[0];
if (!updated) throw new Error(`contract ${id} disappeared mid-transaction`);
await client.query(
`INSERT INTO contract_status_history (contract_id, from_status, to_status, reason, changed_by)
VALUES ($1,$2,$3,$4,$5)`,
[id, fromStatus, toStatus, reason ?? null, actorId ?? null],
);
return updated;
});
}

View File

@@ -0,0 +1,242 @@
import { query } from "../pool.js";
export interface QuoteItem {
productId: string;
productName: string;
quantity: number;
unitPrice: number;
total: number;
implUnitPrice: number;
implTotal: number;
price0: number | null;
price12: number | null;
price24: number | null;
price36: number | null;
price48: number | null;
}
export interface Quote {
id: string;
created_at: Date;
updated_at: Date;
created_by: string | null;
quote_number: string;
reseller_id: string;
client_name: string;
client_company: string | null;
client_email: string | null;
client_phone: string;
contract_period: number;
fidelity_period: number | null;
items: QuoteItem[];
implementation_fee: string;
impl_geral: string;
monthly_total: string;
contract_total: string;
proposed_monthly_total: string | null;
approval_status: "none" | "pending" | "approved" | "rejected";
approval_notes: string | null;
approved_by_id: string | null;
notes: string | null;
deal_status: "orcamento" | "fechado" | "perdido";
impl_payment_condition: "À vista" | "1+1" | "1+2";
client_registration_id: string | null;
}
export interface ListQuotesFilter {
scope: "all" | "own" | "reseller";
actorId?: string;
resellerId?: string;
dealStatus?: string;
}
export async function listQuotes(filter: ListQuotesFilter): Promise<Quote[]> {
const conditions: string[] = [];
const params: unknown[] = [];
if (filter.scope === "own" && filter.actorId) {
params.push(filter.actorId);
conditions.push(`created_by = $${params.length}`);
} else if (filter.scope === "reseller" && filter.resellerId) {
params.push(filter.resellerId);
conditions.push(`reseller_id = $${params.length}`);
}
if (filter.dealStatus) {
params.push(filter.dealStatus);
conditions.push(`deal_status = $${params.length}`);
}
const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
const { rows } = await query<Quote>(
`SELECT * FROM quotes ${where} ORDER BY created_at DESC`,
params,
);
return rows;
}
export async function getQuoteById(id: string): Promise<Quote | null> {
const { rows } = await query<Quote>("SELECT * FROM quotes WHERE id = $1", [id]);
return rows[0] ?? null;
}
export interface CreateQuoteInput {
resellerId: string;
clientName: string;
clientCompany?: string | null;
clientEmail?: string | null;
clientPhone: string;
contractPeriod: number;
fidelityPeriod?: number | null;
items: QuoteItem[];
implementationFee: number;
implGeral: number;
monthlyTotal: number;
contractTotal: number;
proposedMonthlyTotal?: number | null;
approvalStatus: "none" | "pending" | "approved" | "rejected";
implPaymentCondition: string;
notes?: string | null;
createdBy?: string | null;
}
export async function createQuote(input: CreateQuoteInput): Promise<Quote> {
const { rows } = await query<Quote>(
`INSERT INTO quotes (
reseller_id, client_name, client_company, client_email, client_phone,
contract_period, fidelity_period, items, implementation_fee, impl_geral,
monthly_total, contract_total, proposed_monthly_total, approval_status,
impl_payment_condition, notes, created_by
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17)
RETURNING *`,
[
input.resellerId,
input.clientName,
input.clientCompany ?? null,
input.clientEmail ?? null,
input.clientPhone,
input.contractPeriod,
input.fidelityPeriod ?? null,
JSON.stringify(input.items),
input.implementationFee,
input.implGeral,
input.monthlyTotal,
input.contractTotal,
input.proposedMonthlyTotal ?? null,
input.approvalStatus,
input.implPaymentCondition,
input.notes ?? null,
input.createdBy ?? null,
],
);
const quote = rows[0];
if (!quote) throw new Error("failed to create quote");
return quote;
}
/**
* Financial/commercial fields are ALL required here, never partially
* COALESCE'd — the service layer always recomputes the entire pricing
* picture (items/period/fidelity/approval) together on every edit that
* touches any of them (Master Prompt: "backend recalcula e valida antes
* de salvar"; eden.md §2: changing contract_period resets any pending
* fidelity exception). A prior version of this function tried to
* COALESCE fidelity_period/proposed_monthly_total/approved_by_id too,
* which silently wiped them to null on any update that didn't happen to
* re-pass them — fixed by making the caller always supply the real
* intended value (including explicit `null` to clear one).
*
* Only purely descriptive fields (contact info, free-text notes, payment
* condition) are optional/COALESCE'd.
*/
export interface UpdateQuoteInput {
clientName?: string;
clientCompany?: string | null;
clientEmail?: string | null;
clientPhone?: string;
contractPeriod: number;
fidelityPeriod: number | null;
items: QuoteItem[];
implementationFee: number;
implGeral: number;
monthlyTotal: number;
contractTotal: number;
proposedMonthlyTotal: number | null;
approvalStatus: "none" | "pending" | "approved" | "rejected";
approvedById: string | null;
approvalNotes?: string | null;
implPaymentCondition?: string;
notes?: string | null;
}
export async function updateQuote(id: string, input: UpdateQuoteInput): Promise<Quote | null> {
const { rows } = await query<Quote>(
`UPDATE quotes SET
client_name = COALESCE($2, client_name),
client_company = COALESCE($3, client_company),
client_email = COALESCE($4, client_email),
client_phone = COALESCE($5, client_phone),
contract_period = $6,
fidelity_period = $7,
items = $8,
implementation_fee = $9,
impl_geral = $10,
monthly_total = $11,
contract_total = $12,
proposed_monthly_total = $13,
approval_status = $14,
approved_by_id = $15,
approval_notes = COALESCE($16, approval_notes),
impl_payment_condition = COALESCE($17, impl_payment_condition),
notes = COALESCE($18, notes)
WHERE id = $1
RETURNING *`,
[
id,
input.clientName ?? null,
input.clientCompany ?? null,
input.clientEmail ?? null,
input.clientPhone ?? null,
input.contractPeriod,
input.fidelityPeriod,
JSON.stringify(input.items),
input.implementationFee,
input.implGeral,
input.monthlyTotal,
input.contractTotal,
input.proposedMonthlyTotal,
input.approvalStatus,
input.approvedById,
input.approvalNotes ?? null,
input.implPaymentCondition ?? null,
input.notes ?? null,
],
);
return rows[0] ?? null;
}
/** Super_admin-only shortcut to correct deal_status on a locked quote without touching anything else (eden.md §5.2). */
export async function setQuoteDealStatus(
id: string,
dealStatus: "orcamento" | "fechado" | "perdido",
): Promise<Quote | null> {
const { rows } = await query<Quote>(
"UPDATE quotes SET deal_status = $2 WHERE id = $1 RETURNING *",
[id, dealStatus],
);
return rows[0] ?? null;
}
/** Sets client_registration_id + deal_status='fechado' atomically — locks the quote (eden.md §5.1/§6). */
export async function closeQuoteDeal(
id: string,
clientRegistrationId: string,
): Promise<Quote | null> {
const { rows } = await query<Quote>(
`UPDATE quotes SET deal_status = 'fechado', client_registration_id = $2
WHERE id = $1 AND client_registration_id IS NULL
RETURNING *`,
[id, clientRegistrationId],
);
return rows[0] ?? null;
}

View File

@@ -31,6 +31,10 @@ export interface User {
reset_token_expires_at: Date | null; reset_token_expires_at: Date | null;
mfa_enabled: boolean; mfa_enabled: boolean;
mfa_secret_encrypted: string | null; mfa_secret_encrypted: string | null;
/** Added by the resellers migration — nullable, scopes the user to a reseller (eden.md §7). */
reseller_id: string | null;
/** Added by the quotes migration — eden.md §1.1: admin can approve a quote's discount/reduced-fidelity exception only if this is true. */
can_approve_discount: boolean;
} }
export interface Session { export interface Session {