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:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -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
|
||||||
|
|||||||
@@ -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],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, UseGuards } from "@nestjs/common";
|
||||||
|
import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
|
||||||
|
import { ClientRegistrationsService } from "./client-registrations.service.js";
|
||||||
|
import { CreateDirectClientRegistrationDto } from "./dto/create-direct-client-registration.dto.js";
|
||||||
|
import { UpdateClientRegistrationDto } from "./dto/update-client-registration.dto.js";
|
||||||
|
import { CreatePartnerDto } from "./dto/create-partner.dto.js";
|
||||||
|
import { UpdatePartnerDto } from "./dto/update-partner.dto.js";
|
||||||
|
import { JwtAuthGuard } from "../auth/jwt-auth.guard.js";
|
||||||
|
import { PermissionGuard } from "../auth/permission.guard.js";
|
||||||
|
import { RequirePermission } from "../auth/decorators/require-permission.decorator.js";
|
||||||
|
import { CurrentUser } from "../auth/decorators/current-user.decorator.js";
|
||||||
|
import type { EdenAuthenticatedUser } from "../auth/jwt-auth.guard.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Customer 360 / Client Registration (eden.md §3, Master Prompt §6.5) —
|
||||||
|
* authenticated-only MVP. Unlike Companies/Products/Resellers (open read),
|
||||||
|
* every route here — including reads — requires both authentication and
|
||||||
|
* the `client-registrations` resource permission, since this data carries
|
||||||
|
* customer PII (CPF/CNPJ, contacts, address). Deliberately out of scope
|
||||||
|
* for this round: the public unauthenticated token-based self-service
|
||||||
|
* form, file attachments, CNPJ/CEP lookup — see the migration and
|
||||||
|
* repository comments for the full list.
|
||||||
|
*/
|
||||||
|
@ApiTags("client-registrations")
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@UseGuards(JwtAuthGuard, PermissionGuard)
|
||||||
|
@Controller("v1/client-registrations")
|
||||||
|
export class ClientRegistrationsController {
|
||||||
|
constructor(private readonly clientRegistrations: ClientRegistrationsService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@RequirePermission("client-registrations", "view")
|
||||||
|
list(@CurrentUser() user: EdenAuthenticatedUser) {
|
||||||
|
return this.clientRegistrations.list(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("direct")
|
||||||
|
@RequirePermission("client-registrations", "edit")
|
||||||
|
createDirect(
|
||||||
|
@Body() dto: CreateDirectClientRegistrationDto,
|
||||||
|
@CurrentUser() user: EdenAuthenticatedUser,
|
||||||
|
) {
|
||||||
|
return this.clientRegistrations.createDirect(dto, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(":id")
|
||||||
|
@RequirePermission("client-registrations", "view")
|
||||||
|
get(@Param("id", ParseUUIDPipe) id: string, @CurrentUser() user: EdenAuthenticatedUser) {
|
||||||
|
return this.clientRegistrations.get(id, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(":id")
|
||||||
|
@RequirePermission("client-registrations", "edit")
|
||||||
|
update(
|
||||||
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
|
@Body() dto: UpdateClientRegistrationDto,
|
||||||
|
@CurrentUser() user: EdenAuthenticatedUser,
|
||||||
|
) {
|
||||||
|
return this.clientRegistrations.update(id, dto, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(":id/partners")
|
||||||
|
@RequirePermission("client-registrations", "view")
|
||||||
|
listPartners(@Param("id", ParseUUIDPipe) id: string, @CurrentUser() user: EdenAuthenticatedUser) {
|
||||||
|
return this.clientRegistrations.listPartners(id, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(":id/partners")
|
||||||
|
@RequirePermission("client-registrations", "edit")
|
||||||
|
createPartner(
|
||||||
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
|
@Body() dto: CreatePartnerDto,
|
||||||
|
@CurrentUser() user: EdenAuthenticatedUser,
|
||||||
|
) {
|
||||||
|
return this.clientRegistrations.createPartner(id, dto, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(":id/partners/:partnerId")
|
||||||
|
@RequirePermission("client-registrations", "edit")
|
||||||
|
updatePartner(
|
||||||
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
|
@Param("partnerId", ParseUUIDPipe) partnerId: string,
|
||||||
|
@Body() dto: UpdatePartnerDto,
|
||||||
|
@CurrentUser() user: EdenAuthenticatedUser,
|
||||||
|
) {
|
||||||
|
return this.clientRegistrations.updatePartner(id, partnerId, dto, user);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { AuthModule } from "../auth/auth.module.js";
|
||||||
|
import { ClientRegistrationsController } from "./client-registrations.controller.js";
|
||||||
|
import { ClientRegistrationsService } from "./client-registrations.service.js";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [AuthModule],
|
||||||
|
controllers: [ClientRegistrationsController],
|
||||||
|
providers: [ClientRegistrationsService],
|
||||||
|
exports: [ClientRegistrationsService],
|
||||||
|
})
|
||||||
|
export class ClientRegistrationsModule {}
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
import { Injectable, NotFoundException } from "@nestjs/common";
|
||||||
|
import {
|
||||||
|
auditRepo,
|
||||||
|
clientRegistrationsRepo,
|
||||||
|
clientRegistrationPartnersRepo,
|
||||||
|
type ClientRegistration,
|
||||||
|
type ClientRegistrationPartner,
|
||||||
|
} from "@eden/database";
|
||||||
|
import { maxGrantedScope } from "../auth/permissions.js";
|
||||||
|
import type { EdenAuthenticatedUser } from "../auth/jwt-auth.guard.js";
|
||||||
|
import type { CreateDirectClientRegistrationDto } from "./dto/create-direct-client-registration.dto.js";
|
||||||
|
import type { UpdateClientRegistrationDto } from "./dto/update-client-registration.dto.js";
|
||||||
|
import type { CreatePartnerDto } from "./dto/create-partner.dto.js";
|
||||||
|
import type { UpdatePartnerDto } from "./dto/update-partner.dto.js";
|
||||||
|
|
||||||
|
type ResolvedScope =
|
||||||
|
| { kind: "all" }
|
||||||
|
| { kind: "own"; actorId: string }
|
||||||
|
| { kind: "reseller"; resellerId: string };
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ClientRegistrationsService {
|
||||||
|
/**
|
||||||
|
* eden.md §7.1 documents two hardcoded feature keys with two different
|
||||||
|
* scoping rules: `clientes_todos` (full backoffice view, scoped by
|
||||||
|
* *authorship* — `created_by_id = user.id` when the actor lacks the
|
||||||
|
* "todos" grant) vs. `clientes_revenda` (minimal directory, scoped by
|
||||||
|
* *reseller* — `reseller_id = user.reseller_id`). EDEN generalizes both
|
||||||
|
* into the single resource+action+scope permission model (ADR-0004)
|
||||||
|
* instead of two separate feature keys — a deliberate generalization,
|
||||||
|
* not a literal port:
|
||||||
|
* - granted scope `all` → see everything.
|
||||||
|
* - granted scope `reseller` (actor has a reseller) → scoped to that
|
||||||
|
* reseller_id, mirroring `clientes_revenda`.
|
||||||
|
* - anything narrower (`own`/`team`/`legal_entity`), or `reseller`
|
||||||
|
* without an actual reseller_id on the actor → falls back to
|
||||||
|
* authorship (`own`), mirroring `clientes_todos`'s non-admin default.
|
||||||
|
*/
|
||||||
|
private resolveScope(user: EdenAuthenticatedUser, action: "view" | "edit"): ResolvedScope {
|
||||||
|
const granted = maxGrantedScope(user.permissions, "client-registrations", action);
|
||||||
|
if (granted === "all") return { kind: "all" };
|
||||||
|
if (granted === "reseller" && user.resellerId) {
|
||||||
|
return { kind: "reseller", resellerId: user.resellerId };
|
||||||
|
}
|
||||||
|
return { kind: "own", actorId: user.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
private isVisible(registration: ClientRegistration, scope: ResolvedScope): boolean {
|
||||||
|
if (scope.kind === "all") return true;
|
||||||
|
if (scope.kind === "own") return registration.created_by === scope.actorId;
|
||||||
|
return registration.reseller_id === scope.resellerId;
|
||||||
|
}
|
||||||
|
|
||||||
|
list(user: EdenAuthenticatedUser): Promise<ClientRegistration[]> {
|
||||||
|
const scope = this.resolveScope(user, "view");
|
||||||
|
if (scope.kind === "all") {
|
||||||
|
return clientRegistrationsRepo.listClientRegistrations({ scope: "all" });
|
||||||
|
}
|
||||||
|
if (scope.kind === "reseller") {
|
||||||
|
return clientRegistrationsRepo.listClientRegistrations({
|
||||||
|
scope: "reseller",
|
||||||
|
resellerId: scope.resellerId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return clientRegistrationsRepo.listClientRegistrations({ scope: "own", actorId: scope.actorId });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 404, never 403, for a record outside the actor's scope — don't confirm a UUID exists (eden-commercial rules). */
|
||||||
|
async get(id: string, user: EdenAuthenticatedUser): Promise<ClientRegistration> {
|
||||||
|
const scope = this.resolveScope(user, "view");
|
||||||
|
const registration = await clientRegistrationsRepo.getClientRegistrationById(id);
|
||||||
|
if (!registration || !this.isVisible(registration, scope)) {
|
||||||
|
throw new NotFoundException("Cadastro de cliente não encontrado");
|
||||||
|
}
|
||||||
|
return registration;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Same 404-not-403 treatment, but scoped by the actor's *edit* grant — write access shouldn't leak past what read visibility would allow either. */
|
||||||
|
private async getForEdit(id: string, user: EdenAuthenticatedUser): Promise<ClientRegistration> {
|
||||||
|
const scope = this.resolveScope(user, "edit");
|
||||||
|
const registration = await clientRegistrationsRepo.getClientRegistrationById(id);
|
||||||
|
if (!registration || !this.isVisible(registration, scope)) {
|
||||||
|
throw new NotFoundException("Cadastro de cliente não encontrado");
|
||||||
|
}
|
||||||
|
return registration;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cadastro "direto" (eden.md §2.6). `quoteId` is never accepted here —
|
||||||
|
* no Quotes integration in this worktree yet (built in parallel).
|
||||||
|
*
|
||||||
|
* Legacy rule (eden.md §2.6): "quem não é admin/backoffice só cria para
|
||||||
|
* a própria revenda (req.user.reseller_id); admin/backoffice pode
|
||||||
|
* opcionalmente indicar reseller_id." Replicated here, generalized
|
||||||
|
* through the scope model instead of a role check: only an actor
|
||||||
|
* holding `all` edit scope for client-registrations may set an
|
||||||
|
* arbitrary `resellerId`; anyone narrower has their *own* reseller_id
|
||||||
|
* forced server-side — the body's `resellerId` is ignored in that case,
|
||||||
|
* never trusted for an authorization-relevant assignment.
|
||||||
|
*/
|
||||||
|
async createDirect(
|
||||||
|
dto: CreateDirectClientRegistrationDto,
|
||||||
|
user: EdenAuthenticatedUser,
|
||||||
|
): Promise<ClientRegistration> {
|
||||||
|
const editScope = maxGrantedScope(user.permissions, "client-registrations", "edit");
|
||||||
|
const resellerId = editScope === "all" ? (dto.resellerId ?? null) : (user.resellerId ?? null);
|
||||||
|
|
||||||
|
const registration = await clientRegistrationsRepo.createClientRegistration({
|
||||||
|
personType: dto.personType,
|
||||||
|
isPortability: dto.isPortability,
|
||||||
|
resellerId,
|
||||||
|
createdBy: user.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
await auditRepo.appendAuditEvent({
|
||||||
|
actorType: "user",
|
||||||
|
actorId: user.id,
|
||||||
|
action: "client_registration.created",
|
||||||
|
resourceType: "client_registration",
|
||||||
|
resourceId: registration.id,
|
||||||
|
metadata: { personType: registration.person_type, isPortability: registration.is_portability },
|
||||||
|
});
|
||||||
|
|
||||||
|
return registration;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single generic PATCH, matching the legacy (eden.md §1.1.1) — no
|
||||||
|
* dedicated status-transition endpoint/guard. `dto.registrationStatus`,
|
||||||
|
* if present, is applied via `updateClientRegistrationStatus` (which
|
||||||
|
* also stamps `activated_at` the first time it becomes `ativo`); every
|
||||||
|
* other field goes through `updateClientRegistrationProfile`, which
|
||||||
|
* structurally cannot touch pf_cpf/pj_cnpj/pj_rep_cpf (see that
|
||||||
|
* function's Input type).
|
||||||
|
*/
|
||||||
|
async update(
|
||||||
|
id: string,
|
||||||
|
dto: UpdateClientRegistrationDto,
|
||||||
|
user: EdenAuthenticatedUser,
|
||||||
|
): Promise<ClientRegistration> {
|
||||||
|
await this.getForEdit(id, user); // 404 if missing or outside the actor's edit scope
|
||||||
|
|
||||||
|
let updated: ClientRegistration | null = null;
|
||||||
|
|
||||||
|
if (dto.registrationStatus) {
|
||||||
|
updated = await clientRegistrationsRepo.updateClientRegistrationStatus(id, dto.registrationStatus);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { registrationStatus: _registrationStatus, ...profileFields } = dto;
|
||||||
|
const hasProfileFields = Object.values(profileFields).some((value) => value !== undefined);
|
||||||
|
if (hasProfileFields) {
|
||||||
|
updated = await clientRegistrationsRepo.updateClientRegistrationProfile(id, profileFields);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!updated) {
|
||||||
|
updated = await clientRegistrationsRepo.getClientRegistrationById(id);
|
||||||
|
}
|
||||||
|
if (!updated) throw new NotFoundException("Cadastro de cliente não encontrado");
|
||||||
|
|
||||||
|
await auditRepo.appendAuditEvent({
|
||||||
|
actorType: "user",
|
||||||
|
actorId: user.id,
|
||||||
|
action: "client_registration.updated",
|
||||||
|
resourceType: "client_registration",
|
||||||
|
resourceId: id,
|
||||||
|
metadata: { fields: Object.keys(dto) },
|
||||||
|
});
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
async listPartners(id: string, user: EdenAuthenticatedUser): Promise<ClientRegistrationPartner[]> {
|
||||||
|
await this.get(id, user); // reuse the view-scope visibility check
|
||||||
|
return clientRegistrationPartnersRepo.listPartnersForRegistration(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async createPartner(
|
||||||
|
id: string,
|
||||||
|
dto: CreatePartnerDto,
|
||||||
|
user: EdenAuthenticatedUser,
|
||||||
|
): Promise<ClientRegistrationPartner> {
|
||||||
|
await this.getForEdit(id, user);
|
||||||
|
|
||||||
|
const partner = await clientRegistrationPartnersRepo.createPartner({
|
||||||
|
clientRegistrationId: id,
|
||||||
|
name: dto.name,
|
||||||
|
document: dto.document,
|
||||||
|
qualification: dto.qualification,
|
||||||
|
cpf: dto.cpf,
|
||||||
|
willSign: dto.willSign,
|
||||||
|
signatureEmail: dto.signatureEmail,
|
||||||
|
});
|
||||||
|
|
||||||
|
await auditRepo.appendAuditEvent({
|
||||||
|
actorType: "user",
|
||||||
|
actorId: user.id,
|
||||||
|
action: "client_registration.partner_created",
|
||||||
|
resourceType: "client_registration_partner",
|
||||||
|
resourceId: partner.id,
|
||||||
|
metadata: { clientRegistrationId: id },
|
||||||
|
});
|
||||||
|
|
||||||
|
return partner;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updatePartner(
|
||||||
|
id: string,
|
||||||
|
partnerId: string,
|
||||||
|
dto: UpdatePartnerDto,
|
||||||
|
user: EdenAuthenticatedUser,
|
||||||
|
): Promise<ClientRegistrationPartner> {
|
||||||
|
await this.getForEdit(id, user);
|
||||||
|
|
||||||
|
const existing = await clientRegistrationPartnersRepo.getPartnerById(partnerId);
|
||||||
|
if (!existing || existing.client_registration_id !== id) {
|
||||||
|
throw new NotFoundException("Sócio não encontrado");
|
||||||
|
}
|
||||||
|
|
||||||
|
const partner = await clientRegistrationPartnersRepo.updatePartner(partnerId, {
|
||||||
|
cpf: dto.cpf,
|
||||||
|
willSign: dto.willSign,
|
||||||
|
signatureEmail: dto.signatureEmail,
|
||||||
|
});
|
||||||
|
if (!partner) throw new NotFoundException("Sócio não encontrado");
|
||||||
|
|
||||||
|
await auditRepo.appendAuditEvent({
|
||||||
|
actorType: "user",
|
||||||
|
actorId: user.id,
|
||||||
|
action: "client_registration.partner_updated",
|
||||||
|
resourceType: "client_registration_partner",
|
||||||
|
resourceId: partnerId,
|
||||||
|
metadata: { clientRegistrationId: id, fields: Object.keys(dto) },
|
||||||
|
});
|
||||||
|
|
||||||
|
return partner;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { IsBoolean, IsIn, IsOptional, IsUUID } from "class-validator";
|
||||||
|
|
||||||
|
export const PERSON_TYPES = ["pf", "pj"] as const;
|
||||||
|
export type PersonType = (typeof PERSON_TYPES)[number];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cadastro "direto" (eden.md §2.6) — the only creation path in this MVP,
|
||||||
|
* `quote_id` stays null (no Quotes integration yet, being built in
|
||||||
|
* parallel — see the migration comment). `quoteId` is deliberately NOT a
|
||||||
|
* field here.
|
||||||
|
*/
|
||||||
|
export class CreateDirectClientRegistrationDto {
|
||||||
|
@IsIn(PERSON_TYPES)
|
||||||
|
personType!: PersonType;
|
||||||
|
|
||||||
|
@IsBoolean()
|
||||||
|
isPortability!: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional; only actually honored server-side when the actor holds
|
||||||
|
* `all` edit scope for client-registrations — see the service's
|
||||||
|
* `createDirect` comment. Never trusted at face value for a narrower
|
||||||
|
* scope (cross-tenant isolation, ADR-0004).
|
||||||
|
*/
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
resellerId?: string;
|
||||||
|
}
|
||||||
19
apps/api/src/client-registrations/dto/create-partner.dto.ts
Normal file
19
apps/api/src/client-registrations/dto/create-partner.dto.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { IsBoolean, IsEmail, IsOptional, IsString, MinLength } from "class-validator";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `source` is not client-settable — always defaults to `'manual'`
|
||||||
|
* (DB default, packages/database/migrations/*_client-registrations.cjs)
|
||||||
|
* since this MVP has no CNPJ-lookup integration to attribute it to
|
||||||
|
* (eden.md §1.2 §4 documents the legacy default as `'brasilapi'`).
|
||||||
|
*/
|
||||||
|
export class CreatePartnerDto {
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@IsOptional() @IsString() document?: string;
|
||||||
|
@IsOptional() @IsString() qualification?: string;
|
||||||
|
@IsOptional() @IsString() cpf?: string;
|
||||||
|
@IsOptional() @IsBoolean() willSign?: boolean;
|
||||||
|
@IsOptional() @IsEmail() signatureEmail?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { Type } from "class-transformer";
|
||||||
|
import {
|
||||||
|
IsArray,
|
||||||
|
IsBoolean,
|
||||||
|
IsDateString,
|
||||||
|
IsEmail,
|
||||||
|
IsIn,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
ValidateNested,
|
||||||
|
} from "class-validator";
|
||||||
|
|
||||||
|
export const REGISTRATION_STATUSES = [
|
||||||
|
"rascunho",
|
||||||
|
"pendente_validacao",
|
||||||
|
"ativo",
|
||||||
|
"bloqueado",
|
||||||
|
"inativo",
|
||||||
|
] as const;
|
||||||
|
export type RegistrationStatus = (typeof REGISTRATION_STATUSES)[number];
|
||||||
|
|
||||||
|
export class PortabilityRangeDto {
|
||||||
|
@IsString()
|
||||||
|
first!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
last!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Administratively editable fields per eden.md §1.1.2 — and *only* those.
|
||||||
|
* `pfCpf`, `pjCnpj`, and `pjRepCpf` are deliberately not declared here:
|
||||||
|
* with the global ValidationPipe's `forbidNonWhitelisted` (main.ts), any
|
||||||
|
* request that tries to send them is rejected outright rather than
|
||||||
|
* silently ignored. Changing the document itself would need a dedicated
|
||||||
|
* re-verification flow — never a side effect of a generic admin PATCH.
|
||||||
|
*
|
||||||
|
* `registrationStatus` is validated as one of the 5 legal *values* here;
|
||||||
|
* full state-machine transition legality (e.g. blocking `rascunho`→`ativo`
|
||||||
|
* directly) is NOT enforced in this MVP — matches the legacy, which also
|
||||||
|
* has no dedicated transition endpoint (eden.md §1.1.1: "não há endpoint
|
||||||
|
* dedicado de transição", just this same generic PATCH).
|
||||||
|
*/
|
||||||
|
export class UpdateClientRegistrationDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(REGISTRATION_STATUSES)
|
||||||
|
registrationStatus?: RegistrationStatus;
|
||||||
|
|
||||||
|
@IsOptional() @IsString() internalNotes?: string;
|
||||||
|
@IsOptional() @IsString() ixcClientId?: string;
|
||||||
|
@IsOptional() @IsString() ixcContractNumber?: string;
|
||||||
|
|
||||||
|
@IsOptional() @IsString() addressZip?: string;
|
||||||
|
@IsOptional() @IsString() addressStreet?: string;
|
||||||
|
@IsOptional() @IsString() addressNumber?: string;
|
||||||
|
@IsOptional() @IsString() addressComplement?: string;
|
||||||
|
@IsOptional() @IsString() addressNeighborhood?: string;
|
||||||
|
@IsOptional() @IsString() addressCity?: string;
|
||||||
|
@IsOptional() @IsString() addressState?: string;
|
||||||
|
@IsOptional() @IsString() addressCountry?: string;
|
||||||
|
|
||||||
|
@IsOptional() @IsString() contactPrincipalName?: string;
|
||||||
|
@IsOptional() @IsEmail() contactPrincipalEmail?: string;
|
||||||
|
@IsOptional() @IsString() contactPrincipalPhone?: string;
|
||||||
|
@IsOptional() @IsString() contactFinancialName?: string;
|
||||||
|
@IsOptional() @IsEmail() contactFinancialEmail?: string;
|
||||||
|
@IsOptional() @IsString() contactFinancialPhone?: string;
|
||||||
|
@IsOptional() @IsString() contactTechnicalName?: string;
|
||||||
|
@IsOptional() @IsEmail() contactTechnicalEmail?: string;
|
||||||
|
@IsOptional() @IsString() contactTechnicalPhone?: string;
|
||||||
|
|
||||||
|
// PF — pfCpf intentionally absent, see class comment.
|
||||||
|
@IsOptional() @IsString() pfFullName?: string;
|
||||||
|
@IsOptional() @IsDateString() pfBirthDate?: string;
|
||||||
|
@IsOptional() @IsEmail() pfEmail?: string;
|
||||||
|
@IsOptional() @IsString() pfMobilePhone?: string;
|
||||||
|
@IsOptional() @IsString() pfAltPhone?: string;
|
||||||
|
@IsOptional() @IsString() pfSocialName?: string;
|
||||||
|
@IsOptional() @IsString() pfIdDocument?: string;
|
||||||
|
@IsOptional() @IsString() pfIdIssuer?: string;
|
||||||
|
|
||||||
|
// PJ — pjCnpj and pjRepCpf intentionally absent, see class comment.
|
||||||
|
@IsOptional() @IsString() pjCompanyName?: string;
|
||||||
|
@IsOptional() @IsString() pjTradeName?: string;
|
||||||
|
@IsOptional() @IsString() pjStateRegistration?: string;
|
||||||
|
@IsOptional() @IsString() pjMunicipalRegistration?: string;
|
||||||
|
@IsOptional() @IsDateString() pjOpeningDate?: string;
|
||||||
|
@IsOptional() @IsString() pjLegalNature?: string;
|
||||||
|
@IsOptional() @IsString() pjCnpjSituation?: string;
|
||||||
|
@IsOptional() @IsString() pjMainActivity?: string;
|
||||||
|
@IsOptional() @IsString() pjRepFullName?: string;
|
||||||
|
@IsOptional() @IsString() pjRepRole?: string;
|
||||||
|
@IsOptional() @IsEmail() pjRepEmail?: string;
|
||||||
|
@IsOptional() @IsString() pjRepMobilePhone?: string;
|
||||||
|
@IsOptional() @IsBoolean() pjRepAuthorizationAck?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
portabilityNumbers?: string[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => PortabilityRangeDto)
|
||||||
|
portabilityRanges?: PortabilityRangeDto[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { IsBoolean, IsEmail, IsOptional, IsString } from "class-validator";
|
||||||
|
|
||||||
|
/** eden.md §2.5: backoffice can correct only these 3 fields post-submission. */
|
||||||
|
export class UpdatePartnerDto {
|
||||||
|
@IsOptional() @IsString() cpf?: string;
|
||||||
|
@IsOptional() @IsBoolean() willSign?: boolean;
|
||||||
|
@IsOptional() @IsEmail() signatureEmail?: string;
|
||||||
|
}
|
||||||
74
apps/api/src/contracts/contracts.controller.ts
Normal file
74
apps/api/src/contracts/contracts.controller.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { Body, Controller, Get, Param, Patch, Post, UseGuards } from "@nestjs/common";
|
||||||
|
import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
|
||||||
|
import { ContractsService } from "./contracts.service.js";
|
||||||
|
import { CreateContractDto } from "./dto/create-contract.dto.js";
|
||||||
|
import { UpdateContractStatusDto } from "./dto/update-contract-status.dto.js";
|
||||||
|
import { ContractItemInputDto } from "./dto/contract-item-input.dto.js";
|
||||||
|
import { ContractPartyInputDto } from "./dto/contract-party-input.dto.js";
|
||||||
|
import { JwtAuthGuard } from "../auth/jwt-auth.guard.js";
|
||||||
|
import { PermissionGuard } from "../auth/permission.guard.js";
|
||||||
|
import { RequirePermission } from "../auth/decorators/require-permission.decorator.js";
|
||||||
|
import { CurrentUser } from "../auth/decorators/current-user.decorator.js";
|
||||||
|
import type { EdenAuthenticatedUser } from "../auth/jwt-auth.guard.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contracts as a first-class aggregate — docs/adr/0006-contract-first-class.md.
|
||||||
|
* Every write recalculates monthly_value/contract_total server-side from
|
||||||
|
* the items array (Master Prompt §6.6/§24) — a client-supplied total is
|
||||||
|
* never trusted.
|
||||||
|
*/
|
||||||
|
@ApiTags("contracts")
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@UseGuards(JwtAuthGuard, PermissionGuard)
|
||||||
|
@Controller("v1/contracts")
|
||||||
|
export class ContractsController {
|
||||||
|
constructor(private readonly contracts: ContractsService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@RequirePermission("contracts", "view")
|
||||||
|
list(@CurrentUser() user: EdenAuthenticatedUser) {
|
||||||
|
return this.contracts.list(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(":id")
|
||||||
|
@RequirePermission("contracts", "view")
|
||||||
|
get(@Param("id") id: string, @CurrentUser() user: EdenAuthenticatedUser) {
|
||||||
|
return this.contracts.get(id, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@RequirePermission("contracts", "edit")
|
||||||
|
create(@Body() dto: CreateContractDto, @CurrentUser() user: EdenAuthenticatedUser) {
|
||||||
|
return this.contracts.create(dto, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(":id/status")
|
||||||
|
@RequirePermission("contracts", "edit")
|
||||||
|
updateStatus(
|
||||||
|
@Param("id") id: string,
|
||||||
|
@Body() dto: UpdateContractStatusDto,
|
||||||
|
@CurrentUser() user: EdenAuthenticatedUser,
|
||||||
|
) {
|
||||||
|
return this.contracts.updateStatus(id, dto, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(":id/items")
|
||||||
|
@RequirePermission("contracts", "edit")
|
||||||
|
addItem(
|
||||||
|
@Param("id") id: string,
|
||||||
|
@Body() dto: ContractItemInputDto,
|
||||||
|
@CurrentUser() user: EdenAuthenticatedUser,
|
||||||
|
) {
|
||||||
|
return this.contracts.addItem(id, dto, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(":id/parties")
|
||||||
|
@RequirePermission("contracts", "edit")
|
||||||
|
addParty(
|
||||||
|
@Param("id") id: string,
|
||||||
|
@Body() dto: ContractPartyInputDto,
|
||||||
|
@CurrentUser() user: EdenAuthenticatedUser,
|
||||||
|
) {
|
||||||
|
return this.contracts.addParty(id, dto, user);
|
||||||
|
}
|
||||||
|
}
|
||||||
12
apps/api/src/contracts/contracts.module.ts
Normal file
12
apps/api/src/contracts/contracts.module.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { AuthModule } from "../auth/auth.module.js";
|
||||||
|
import { ContractsController } from "./contracts.controller.js";
|
||||||
|
import { ContractsService } from "./contracts.service.js";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [AuthModule],
|
||||||
|
controllers: [ContractsController],
|
||||||
|
providers: [ContractsService],
|
||||||
|
exports: [ContractsService],
|
||||||
|
})
|
||||||
|
export class ContractsModule {}
|
||||||
188
apps/api/src/contracts/contracts.service.ts
Normal file
188
apps/api/src/contracts/contracts.service.ts
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ConflictException,
|
||||||
|
ForbiddenException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import {
|
||||||
|
auditRepo,
|
||||||
|
contractsRepo,
|
||||||
|
productsRepo,
|
||||||
|
type Contract,
|
||||||
|
type ContractWithDetails,
|
||||||
|
type CreateContractItemInput,
|
||||||
|
} from "@eden/database";
|
||||||
|
import type { EdenAuthenticatedUser } from "../auth/jwt-auth.guard.js";
|
||||||
|
import { maxGrantedScope } from "../auth/permissions.js";
|
||||||
|
import type { CreateContractDto } from "./dto/create-contract.dto.js";
|
||||||
|
import type { UpdateContractStatusDto } from "./dto/update-contract-status.dto.js";
|
||||||
|
import type { ContractItemInputDto } from "./dto/contract-item-input.dto.js";
|
||||||
|
import type { ContractPartyInputDto } from "./dto/contract-party-input.dto.js";
|
||||||
|
|
||||||
|
const { ContractNotFoundError, InvalidContractTransitionError } = contractsRepo;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ContractsService {
|
||||||
|
async list(user: EdenAuthenticatedUser): Promise<Contract[]> {
|
||||||
|
const scope = maxGrantedScope(user.permissions, "contracts", "view");
|
||||||
|
if (!scope) throw new ForbiddenException("Sem permissão para 'view' em 'contracts'");
|
||||||
|
if (scope === "all") return contractsRepo.listContracts({ scope: "all" });
|
||||||
|
return contractsRepo.listContracts({ scope: "reseller", resellerId: user.resellerId });
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(id: string, user: EdenAuthenticatedUser): Promise<ContractWithDetails> {
|
||||||
|
const scope = maxGrantedScope(user.permissions, "contracts", "view");
|
||||||
|
const contract = await contractsRepo.getContractById(id);
|
||||||
|
if (!contract || !scope) throw new NotFoundException("Contrato não encontrado");
|
||||||
|
if (scope !== "all" && contract.reseller_id !== user.resellerId) {
|
||||||
|
throw new NotFoundException("Contrato não encontrado");
|
||||||
|
}
|
||||||
|
return contract;
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: CreateContractDto, user: EdenAuthenticatedUser): Promise<ContractWithDetails> {
|
||||||
|
if (dto.fidelityPeriod != null && dto.fidelityPeriod > dto.contractPeriod) {
|
||||||
|
throw new BadRequestException("fidelityPeriod não pode ser maior que contractPeriod");
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = await this.buildItems(dto.items, dto.contractPeriod);
|
||||||
|
const monthlyValue = round2(items.reduce((sum, item) => sum + item.total, 0));
|
||||||
|
const implementationFee = dto.implementationFee ?? 0;
|
||||||
|
const contractTotal = round2(monthlyValue * (dto.contractPeriod || 1) + implementationFee);
|
||||||
|
|
||||||
|
const contract = await contractsRepo.createContract({
|
||||||
|
quoteId: dto.quoteId ?? null,
|
||||||
|
clientRegistrationId: dto.clientRegistrationId ?? null,
|
||||||
|
resellerId: dto.resellerId ?? null,
|
||||||
|
companyId: dto.companyId ?? null,
|
||||||
|
contractPeriod: dto.contractPeriod,
|
||||||
|
fidelityPeriod: dto.fidelityPeriod ?? null,
|
||||||
|
// fidelity_approved always starts false — approval is a distinct,
|
||||||
|
// explicit act (mirrors quotes.approval_status never starting
|
||||||
|
// 'approved' on creation either); no endpoint to set it yet in this
|
||||||
|
// MVP since the approval workflow lives on Quotes for now — a
|
||||||
|
// contract created from an already-approved quote will carry that
|
||||||
|
// forward once the Quotes→Contract integration lands.
|
||||||
|
fidelityApproved: false,
|
||||||
|
monthlyValue,
|
||||||
|
contractTotal,
|
||||||
|
implementationFee,
|
||||||
|
notes: dto.notes ?? null,
|
||||||
|
createdBy: user.id,
|
||||||
|
items: items.map((item, index) => ({ ...item, sortOrder: index })),
|
||||||
|
parties: dto.parties.map(this.mapParty),
|
||||||
|
});
|
||||||
|
|
||||||
|
await auditRepo.appendAuditEvent({
|
||||||
|
actorType: "user",
|
||||||
|
actorId: user.id,
|
||||||
|
action: "contract.created",
|
||||||
|
resourceType: "contract",
|
||||||
|
resourceId: contract.id,
|
||||||
|
metadata: { contractTotal, itemCount: items.length },
|
||||||
|
});
|
||||||
|
|
||||||
|
return contract;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateStatus(id: string, dto: UpdateContractStatusDto, user: EdenAuthenticatedUser): Promise<Contract> {
|
||||||
|
await this.get(id, user); // scope check — 404 before attempting the transition
|
||||||
|
|
||||||
|
try {
|
||||||
|
const updated = await contractsRepo.updateContractStatus(id, dto.toStatus, dto.reason ?? null, user.id);
|
||||||
|
|
||||||
|
await auditRepo.appendAuditEvent({
|
||||||
|
actorType: "user",
|
||||||
|
actorId: user.id,
|
||||||
|
action: "contract.status_changed",
|
||||||
|
resourceType: "contract",
|
||||||
|
resourceId: id,
|
||||||
|
metadata: { toStatus: dto.toStatus, reason: dto.reason ?? null },
|
||||||
|
});
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ContractNotFoundError) throw new NotFoundException("Contrato não encontrado");
|
||||||
|
if (err instanceof InvalidContractTransitionError) {
|
||||||
|
throw new ConflictException(err.message);
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async addItem(id: string, dto: ContractItemInputDto, user: EdenAuthenticatedUser) {
|
||||||
|
const contract = await this.get(id, user);
|
||||||
|
const [item] = await this.buildItems([dto], contract.contract_period);
|
||||||
|
const created = await contractsRepo.addContractItem(id, { ...item!, sortOrder: contract.items.length });
|
||||||
|
|
||||||
|
await auditRepo.appendAuditEvent({
|
||||||
|
actorType: "user",
|
||||||
|
actorId: user.id,
|
||||||
|
action: "contract.item_added",
|
||||||
|
resourceType: "contract",
|
||||||
|
resourceId: id,
|
||||||
|
metadata: { productId: dto.productId },
|
||||||
|
});
|
||||||
|
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
async addParty(id: string, dto: ContractPartyInputDto, user: EdenAuthenticatedUser) {
|
||||||
|
await this.get(id, user);
|
||||||
|
const created = await contractsRepo.addContractParty(id, this.mapParty(dto));
|
||||||
|
|
||||||
|
await auditRepo.appendAuditEvent({
|
||||||
|
actorType: "user",
|
||||||
|
actorId: user.id,
|
||||||
|
action: "contract.party_added",
|
||||||
|
resourceType: "contract",
|
||||||
|
resourceId: id,
|
||||||
|
metadata: { partyRole: dto.partyRole },
|
||||||
|
});
|
||||||
|
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapParty(dto: ContractPartyInputDto) {
|
||||||
|
return {
|
||||||
|
partyRole: dto.partyRole,
|
||||||
|
personType: dto.personType ?? null,
|
||||||
|
fullName: dto.fullName,
|
||||||
|
document: dto.document ?? null,
|
||||||
|
email: dto.email ?? null,
|
||||||
|
phone: dto.phone ?? null,
|
||||||
|
roleTitle: dto.roleTitle ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async buildItems(
|
||||||
|
inputs: ContractItemInputDto[],
|
||||||
|
contractPeriod: number,
|
||||||
|
): Promise<CreateContractItemInput[]> {
|
||||||
|
const priceKey = `price_${contractPeriod}` as "price_0" | "price_12" | "price_24" | "price_36" | "price_48";
|
||||||
|
|
||||||
|
const items: CreateContractItemInput[] = [];
|
||||||
|
for (const input of inputs) {
|
||||||
|
const product = await productsRepo.getProductById(input.productId);
|
||||||
|
if (!product) throw new BadRequestException(`Produto ${input.productId} não encontrado`);
|
||||||
|
|
||||||
|
const unitPriceRaw = product[priceKey];
|
||||||
|
const unitPrice = unitPriceRaw != null ? Number(unitPriceRaw) : 0;
|
||||||
|
|
||||||
|
items.push({
|
||||||
|
productId: product.id,
|
||||||
|
productName: product.name,
|
||||||
|
quantity: input.quantity,
|
||||||
|
unitPrice,
|
||||||
|
total: round2(unitPrice * input.quantity),
|
||||||
|
sortOrder: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function round2(value: number): number {
|
||||||
|
return Math.round((value + Number.EPSILON) * 100) / 100;
|
||||||
|
}
|
||||||
11
apps/api/src/contracts/dto/contract-item-input.dto.ts
Normal file
11
apps/api/src/contracts/dto/contract-item-input.dto.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { IsNumber, IsUUID, Min } from "class-validator";
|
||||||
|
|
||||||
|
/** Same principle as Quotes' item DTO: only productId/quantity are client-supplied; unitPrice/productName are always derived server-side from the live product record. */
|
||||||
|
export class ContractItemInputDto {
|
||||||
|
@IsUUID()
|
||||||
|
productId!: string;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0.01)
|
||||||
|
quantity!: number;
|
||||||
|
}
|
||||||
17
apps/api/src/contracts/dto/contract-party-input.dto.ts
Normal file
17
apps/api/src/contracts/dto/contract-party-input.dto.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { IsEmail, IsIn, IsOptional, IsString, MinLength } from "class-validator";
|
||||||
|
|
||||||
|
export class ContractPartyInputDto {
|
||||||
|
@IsIn(["contratada", "contratante"])
|
||||||
|
partyRole!: "contratada" | "contratante";
|
||||||
|
|
||||||
|
@IsOptional() @IsIn(["pf", "pj"]) personType?: "pf" | "pj";
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
fullName!: string;
|
||||||
|
|
||||||
|
@IsOptional() @IsString() document?: string;
|
||||||
|
@IsOptional() @IsEmail() email?: string;
|
||||||
|
@IsOptional() @IsString() phone?: string;
|
||||||
|
@IsOptional() @IsString() roleTitle?: string;
|
||||||
|
}
|
||||||
45
apps/api/src/contracts/dto/create-contract.dto.ts
Normal file
45
apps/api/src/contracts/dto/create-contract.dto.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { Type } from "class-transformer";
|
||||||
|
import {
|
||||||
|
ArrayMinSize,
|
||||||
|
IsArray,
|
||||||
|
IsIn,
|
||||||
|
IsInt,
|
||||||
|
IsNumber,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
IsUUID,
|
||||||
|
Min,
|
||||||
|
ValidateNested,
|
||||||
|
} from "class-validator";
|
||||||
|
import { ContractItemInputDto } from "./contract-item-input.dto.js";
|
||||||
|
import { ContractPartyInputDto } from "./contract-party-input.dto.js";
|
||||||
|
|
||||||
|
export class CreateContractDto {
|
||||||
|
@IsOptional() @IsUUID() resellerId?: string;
|
||||||
|
@IsOptional() @IsUUID() companyId?: string;
|
||||||
|
@IsOptional() @IsUUID() quoteId?: string;
|
||||||
|
@IsOptional() @IsUUID() clientRegistrationId?: string;
|
||||||
|
|
||||||
|
@IsIn([0, 12, 24, 36, 48])
|
||||||
|
contractPeriod!: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
fidelityPeriod?: number;
|
||||||
|
|
||||||
|
@IsArray()
|
||||||
|
@ArrayMinSize(1)
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => ContractItemInputDto)
|
||||||
|
items!: ContractItemInputDto[];
|
||||||
|
|
||||||
|
@IsArray()
|
||||||
|
@ArrayMinSize(1)
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => ContractPartyInputDto)
|
||||||
|
parties!: ContractPartyInputDto[];
|
||||||
|
|
||||||
|
@IsOptional() @IsNumber() @Min(0) implementationFee?: number;
|
||||||
|
@IsOptional() @IsString() notes?: string;
|
||||||
|
}
|
||||||
16
apps/api/src/contracts/dto/update-contract-status.dto.ts
Normal file
16
apps/api/src/contracts/dto/update-contract-status.dto.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { IsIn, IsOptional, IsString } from "class-validator";
|
||||||
|
|
||||||
|
export class UpdateContractStatusDto {
|
||||||
|
@IsIn(["draft", "pending_signature", "active", "suspended", "cancelled", "terminated", "expired", "renewed"])
|
||||||
|
toStatus!:
|
||||||
|
| "draft"
|
||||||
|
| "pending_signature"
|
||||||
|
| "active"
|
||||||
|
| "suspended"
|
||||||
|
| "cancelled"
|
||||||
|
| "terminated"
|
||||||
|
| "expired"
|
||||||
|
| "renewed";
|
||||||
|
|
||||||
|
@IsOptional() @IsString() reason?: string;
|
||||||
|
}
|
||||||
13
apps/api/src/quotes/dto/close-deal.dto.ts
Normal file
13
apps/api/src/quotes/dto/close-deal.dto.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { IsUUID } from "class-validator";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MVP scope: closing a deal requires an already-existing client
|
||||||
|
* registration id (Customer 360 module lands in parallel — once merged,
|
||||||
|
* a richer "create+link in one step" flow can be added, mirroring
|
||||||
|
* eden.md §6.1's two-path close-deal flow). For now this just performs
|
||||||
|
* the lock (deal_status='fechado' + client_registration_id set).
|
||||||
|
*/
|
||||||
|
export class CloseDealDto {
|
||||||
|
@IsUUID()
|
||||||
|
clientRegistrationId!: string;
|
||||||
|
}
|
||||||
53
apps/api/src/quotes/dto/create-quote.dto.ts
Normal file
53
apps/api/src/quotes/dto/create-quote.dto.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { Type } from "class-transformer";
|
||||||
|
import {
|
||||||
|
ArrayMinSize,
|
||||||
|
IsArray,
|
||||||
|
IsEmail,
|
||||||
|
IsIn,
|
||||||
|
IsInt,
|
||||||
|
IsNumber,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
IsUUID,
|
||||||
|
Min,
|
||||||
|
MinLength,
|
||||||
|
ValidateNested,
|
||||||
|
} from "class-validator";
|
||||||
|
import { QuoteItemInputDto } from "./quote-item-input.dto.js";
|
||||||
|
|
||||||
|
export class CreateQuoteDto {
|
||||||
|
@IsUUID()
|
||||||
|
resellerId!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
clientName!: string;
|
||||||
|
|
||||||
|
@IsOptional() @IsString() clientCompany?: string;
|
||||||
|
@IsOptional() @IsEmail() clientEmail?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
clientPhone!: string;
|
||||||
|
|
||||||
|
@IsIn([0, 12, 24, 36, 48])
|
||||||
|
contractPeriod!: number;
|
||||||
|
|
||||||
|
/** Only meaningful if strictly less than contractPeriod — see pricing.ts. */
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
fidelityPeriod?: number;
|
||||||
|
|
||||||
|
@IsArray()
|
||||||
|
@ArrayMinSize(1)
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => QuoteItemInputDto)
|
||||||
|
items!: QuoteItemInputDto[];
|
||||||
|
|
||||||
|
@IsOptional() @IsNumber() @Min(0) implGeral?: number;
|
||||||
|
@IsOptional() @IsNumber() @Min(0.01) proposedMonthlyTotal?: number;
|
||||||
|
|
||||||
|
@IsOptional() @IsIn(["À vista", "1+1", "1+2"]) implPaymentCondition?: string;
|
||||||
|
@IsOptional() @IsString() notes?: string;
|
||||||
|
}
|
||||||
10
apps/api/src/quotes/dto/decide-approval.dto.ts
Normal file
10
apps/api/src/quotes/dto/decide-approval.dto.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { IsIn, IsOptional, IsString } from "class-validator";
|
||||||
|
|
||||||
|
export class DecideApprovalDto {
|
||||||
|
@IsIn(["approved", "rejected"])
|
||||||
|
decision!: "approved" | "rejected";
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
19
apps/api/src/quotes/dto/quote-item-input.dto.ts
Normal file
19
apps/api/src/quotes/dto/quote-item-input.dto.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { IsNumber, IsUUID, Min } from "class-validator";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deliberately minimal — only what the client can legitimately choose
|
||||||
|
* (which product, how many). Unit price, product name, and the 5 price-
|
||||||
|
* tier snapshots are always derived server-side from the live product
|
||||||
|
* record + the quote's contract_period (see QuotesService.buildItems) —
|
||||||
|
* a client-supplied price is never trusted, strengthening the legacy's
|
||||||
|
* own principle (which snapshotted at add-time but still took unit_price
|
||||||
|
* from client state at that moment).
|
||||||
|
*/
|
||||||
|
export class QuoteItemInputDto {
|
||||||
|
@IsUUID()
|
||||||
|
productId!: string;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0.01)
|
||||||
|
quantity!: number;
|
||||||
|
}
|
||||||
51
apps/api/src/quotes/dto/update-quote.dto.ts
Normal file
51
apps/api/src/quotes/dto/update-quote.dto.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { Type } from "class-transformer";
|
||||||
|
import {
|
||||||
|
ArrayMinSize,
|
||||||
|
IsArray,
|
||||||
|
IsEmail,
|
||||||
|
IsIn,
|
||||||
|
IsInt,
|
||||||
|
IsNumber,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
Min,
|
||||||
|
MinLength,
|
||||||
|
ValidateNested,
|
||||||
|
} from "class-validator";
|
||||||
|
import { QuoteItemInputDto } from "./quote-item-input.dto.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every edit resubmits the full commercial state (contractPeriod, items,
|
||||||
|
* etc.) rather than a sparse patch — the service always recomputes the
|
||||||
|
* entire pricing picture together (pricing.ts), so a partial edit that
|
||||||
|
* only touched `notes` would still need the rest of the state to
|
||||||
|
* recompute against; simplest and safest is to require it every time,
|
||||||
|
* matching how the legacy's own edit screen always resubmits the whole
|
||||||
|
* form. `resellerId` is intentionally absent — not changeable post-creation.
|
||||||
|
*/
|
||||||
|
export class UpdateQuoteDto {
|
||||||
|
@IsOptional() @IsString() clientName?: string;
|
||||||
|
@IsOptional() @IsString() clientCompany?: string;
|
||||||
|
@IsOptional() @IsEmail() clientEmail?: string;
|
||||||
|
@IsOptional() @IsString() clientPhone?: string;
|
||||||
|
|
||||||
|
@IsIn([0, 12, 24, 36, 48])
|
||||||
|
contractPeriod!: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
fidelityPeriod?: number;
|
||||||
|
|
||||||
|
@IsArray()
|
||||||
|
@ArrayMinSize(1)
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => QuoteItemInputDto)
|
||||||
|
items!: QuoteItemInputDto[];
|
||||||
|
|
||||||
|
@IsOptional() @IsNumber() @Min(0) implGeral?: number;
|
||||||
|
@IsOptional() @IsNumber() @Min(0.01) proposedMonthlyTotal?: number;
|
||||||
|
|
||||||
|
@IsOptional() @IsIn(["À vista", "1+1", "1+2"]) implPaymentCondition?: string;
|
||||||
|
@IsOptional() @IsString() @MinLength(0) notes?: string;
|
||||||
|
}
|
||||||
161
apps/api/src/quotes/pricing.ts
Normal file
161
apps/api/src/quotes/pricing.ts
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
import type { QuoteItem } from "@eden/database";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Quote pricing/fidelity/approval engine — ported verbatim from eden.md §8
|
||||||
|
* (formulas) and §3–§4 (fidelity/discount business rules). These formulas
|
||||||
|
* are exact, not approximate — do not "simplify" without an ADR. The
|
||||||
|
* backend is always the source of truth (Master Prompt §6.4: "nunca
|
||||||
|
* confiar no frontend para cálculo financeiro"); the frontend may mirror
|
||||||
|
* this logic for UX, but every quote write recomputes from scratch here.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface PricingInput {
|
||||||
|
items: QuoteItem[];
|
||||||
|
contractPeriod: number;
|
||||||
|
fidelityPeriod: number | null;
|
||||||
|
implGeral: number;
|
||||||
|
proposedMonthlyTotal: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PricingResult {
|
||||||
|
monthlyTotal: number;
|
||||||
|
implFromProducts: number;
|
||||||
|
totalImpl: number;
|
||||||
|
hasSpecialPrice: boolean;
|
||||||
|
isDiscount: boolean;
|
||||||
|
isMarkup: boolean;
|
||||||
|
diffMonthly: number;
|
||||||
|
effectiveMonthly: number;
|
||||||
|
hasReducedFidelity: boolean;
|
||||||
|
needsApproval: boolean;
|
||||||
|
contractTotal: number;
|
||||||
|
/** What to persist to quotes.fidelity_period — null unless it's genuinely a reduced-fidelity exception (eden.md §8 "só persiste se for de fato menor que a faixa"). */
|
||||||
|
persistedFidelityPeriod: number | null;
|
||||||
|
/** What to persist to quotes.proposed_monthly_total — null unless a special price is actually active. */
|
||||||
|
persistedProposedMonthlyTotal: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeQuotePricing(input: PricingInput): PricingResult {
|
||||||
|
const monthlyTotal = round2(input.items.reduce((sum, item) => sum + item.total, 0));
|
||||||
|
const implFromProducts = round2(input.items.reduce((sum, item) => sum + item.implTotal, 0));
|
||||||
|
const totalImpl = round2(implFromProducts + (input.implGeral || 0));
|
||||||
|
|
||||||
|
const proposedMonthly = input.proposedMonthlyTotal;
|
||||||
|
const hasSpecialPrice =
|
||||||
|
proposedMonthly != null && proposedMonthly > 0 && proposedMonthly !== monthlyTotal;
|
||||||
|
const isDiscount = hasSpecialPrice && proposedMonthly! < monthlyTotal;
|
||||||
|
const isMarkup = hasSpecialPrice && proposedMonthly! > monthlyTotal;
|
||||||
|
const diffMonthly = hasSpecialPrice ? round2(monthlyTotal - proposedMonthly!) : 0;
|
||||||
|
const effectiveMonthly = hasSpecialPrice ? proposedMonthly! : monthlyTotal;
|
||||||
|
|
||||||
|
const hasReducedFidelity =
|
||||||
|
input.fidelityPeriod != null && input.fidelityPeriod < input.contractPeriod;
|
||||||
|
|
||||||
|
const needsApproval = isDiscount || hasReducedFidelity;
|
||||||
|
|
||||||
|
const contractTotal = round2(effectiveMonthly * (input.contractPeriod || 1) + totalImpl);
|
||||||
|
|
||||||
|
return {
|
||||||
|
monthlyTotal,
|
||||||
|
implFromProducts,
|
||||||
|
totalImpl,
|
||||||
|
hasSpecialPrice,
|
||||||
|
isDiscount,
|
||||||
|
isMarkup,
|
||||||
|
diffMonthly,
|
||||||
|
effectiveMonthly,
|
||||||
|
hasReducedFidelity,
|
||||||
|
needsApproval,
|
||||||
|
contractTotal,
|
||||||
|
persistedFidelityPeriod: hasReducedFidelity ? input.fidelityPeriod : null,
|
||||||
|
persistedProposedMonthlyTotal: hasSpecialPrice ? proposedMonthly : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves what `approval_status` should be persisted, given the previous
|
||||||
|
* status and whether the actor is submitting an approval decision.
|
||||||
|
* Mirrors eden.md §8: `needsApproval ? (approvalStatus === 'none' ?
|
||||||
|
* 'pending' : approvalStatus) : 'none'` — but generalized to accept an
|
||||||
|
* explicit decision (approve/reject) from a privileged actor, which the
|
||||||
|
* legacy handled as a separate code path (§3.3) that this folds in here
|
||||||
|
* for a single source of truth.
|
||||||
|
*/
|
||||||
|
export function resolveApprovalStatus(
|
||||||
|
needsApproval: boolean,
|
||||||
|
previousStatus: "none" | "pending" | "approved" | "rejected",
|
||||||
|
decision?: "approved" | "rejected",
|
||||||
|
): "none" | "pending" | "approved" | "rejected" {
|
||||||
|
if (!needsApproval) return "none";
|
||||||
|
if (decision) return decision;
|
||||||
|
return previousStatus === "none" ? "pending" : previousStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** eden.md §3.3: super_admin always; admin only with the explicit per-user flag — never inferred from role alone. */
|
||||||
|
export function canApproveDiscount(user: { roleKey: string; canApproveDiscount?: boolean }): boolean {
|
||||||
|
if (user.roleKey === "super_admin") return true;
|
||||||
|
return user.roleKey === "admin" && user.canApproveDiscount === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The fidelity that actually has legal effect (vigência/vencimento/multa)
|
||||||
|
* — eden.md §3.4/§10.4, repeated everywhere in the legacy as the single
|
||||||
|
* most important rule to preserve: a pending/rejected exception NEVER
|
||||||
|
* affects anything legal, only an approved one does.
|
||||||
|
*/
|
||||||
|
export function resolveEffectiveFidelity(
|
||||||
|
contractPeriod: number,
|
||||||
|
fidelityPeriod: number | null,
|
||||||
|
approvalStatus: "none" | "pending" | "approved" | "rejected",
|
||||||
|
): number {
|
||||||
|
return fidelityPeriod != null && approvalStatus === "approved" ? fidelityPeriod : contractPeriod || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** eden.md §7 (paymentConditions.js) — progressive by implementation value. */
|
||||||
|
export const PAYMENT_CONDITIONS = ["À vista", "1+1", "1+2"] as const;
|
||||||
|
|
||||||
|
export function getAvailablePaymentConditions(implTotal: number): readonly string[] {
|
||||||
|
const value = implTotal || 0;
|
||||||
|
if (value <= 1500) return ["À vista"];
|
||||||
|
if (value <= 3000) return ["À vista", "1+1"];
|
||||||
|
return PAYMENT_CONDITIONS;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SavingsResult {
|
||||||
|
totalDescontoMensal: number;
|
||||||
|
totalEconomiaMensal: number;
|
||||||
|
economiaTotalContrato: number;
|
||||||
|
beneficioTotalFidelidade: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* eden.md §3.5/§4.4 — two DIFFERENT numbers that must never be confused:
|
||||||
|
* `beneficioTotalFidelidade` (fidelity discount only, used as the legal
|
||||||
|
* cap for the RGC art. 37 early-termination penalty) vs.
|
||||||
|
* `totalEconomiaMensal`/`economiaTotalContrato` (fidelity + special
|
||||||
|
* discount combined, shown as commercial "savings" info to the client).
|
||||||
|
*/
|
||||||
|
export function computeSavings(
|
||||||
|
items: QuoteItem[],
|
||||||
|
contractPeriod: number,
|
||||||
|
effectiveFidelityPeriod: number,
|
||||||
|
monthlyTotal: number,
|
||||||
|
proposedMonthlyTotal: number | null,
|
||||||
|
): SavingsResult {
|
||||||
|
const totalDescontoMensal = round2(
|
||||||
|
items.reduce((sum, item) => sum + ((item.price0 ?? 0) - item.unitPrice) * item.quantity, 0),
|
||||||
|
);
|
||||||
|
const hasSpecialPrice =
|
||||||
|
proposedMonthlyTotal != null && proposedMonthlyTotal > 0 && proposedMonthlyTotal !== monthlyTotal;
|
||||||
|
const specialDiscount = hasSpecialPrice ? round2(monthlyTotal - proposedMonthlyTotal!) : 0;
|
||||||
|
const totalEconomiaMensal = round2(totalDescontoMensal + specialDiscount);
|
||||||
|
const economiaTotalContrato =
|
||||||
|
totalEconomiaMensal > 0 && contractPeriod > 0 ? round2(totalEconomiaMensal * contractPeriod) : 0;
|
||||||
|
const beneficioTotalFidelidade = round2(totalDescontoMensal * effectiveFidelityPeriod);
|
||||||
|
|
||||||
|
return { totalDescontoMensal, totalEconomiaMensal, economiaTotalContrato, beneficioTotalFidelidade };
|
||||||
|
}
|
||||||
|
|
||||||
|
function round2(value: number): number {
|
||||||
|
return Math.round((value + Number.EPSILON) * 100) / 100;
|
||||||
|
}
|
||||||
88
apps/api/src/quotes/quotes.controller.ts
Normal file
88
apps/api/src/quotes/quotes.controller.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import { Body, Controller, Get, Param, Patch, Post, Query, UseGuards } from "@nestjs/common";
|
||||||
|
import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
|
||||||
|
import { QuotesService } from "./quotes.service.js";
|
||||||
|
import { CreateQuoteDto } from "./dto/create-quote.dto.js";
|
||||||
|
import { UpdateQuoteDto } from "./dto/update-quote.dto.js";
|
||||||
|
import { DecideApprovalDto } from "./dto/decide-approval.dto.js";
|
||||||
|
import { CloseDealDto } from "./dto/close-deal.dto.js";
|
||||||
|
import { JwtAuthGuard } from "../auth/jwt-auth.guard.js";
|
||||||
|
import { PermissionGuard } from "../auth/permission.guard.js";
|
||||||
|
import { SuperAdminGuard } from "../auth/super-admin.guard.js";
|
||||||
|
import { RequirePermission } from "../auth/decorators/require-permission.decorator.js";
|
||||||
|
import { CurrentUser } from "../auth/decorators/current-user.decorator.js";
|
||||||
|
import type { EdenAuthenticatedUser } from "../auth/jwt-auth.guard.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ofertas comerciais (eden.md módulo 2). Scope resolution (own/reseller/all)
|
||||||
|
* happens in the service, driven by the actor's granted permission scope —
|
||||||
|
* see QuotesService.list/get. Every write recomputes pricing server-side
|
||||||
|
* (Master Prompt §6.4) — the client never supplies a trusted total.
|
||||||
|
*/
|
||||||
|
@ApiTags("quotes")
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@UseGuards(JwtAuthGuard, PermissionGuard)
|
||||||
|
@Controller("v1/quotes")
|
||||||
|
export class QuotesController {
|
||||||
|
constructor(private readonly quotes: QuotesService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@RequirePermission("quotes", "view")
|
||||||
|
list(@CurrentUser() user: EdenAuthenticatedUser, @Query("dealStatus") dealStatus?: string) {
|
||||||
|
return this.quotes.list(user, dealStatus);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(":id")
|
||||||
|
@RequirePermission("quotes", "view")
|
||||||
|
get(@Param("id") id: string, @CurrentUser() user: EdenAuthenticatedUser) {
|
||||||
|
return this.quotes.get(id, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@RequirePermission("quotes", "edit")
|
||||||
|
create(@Body() dto: CreateQuoteDto, @CurrentUser() user: EdenAuthenticatedUser) {
|
||||||
|
return this.quotes.create(dto, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(":id")
|
||||||
|
@RequirePermission("quotes", "edit")
|
||||||
|
update(
|
||||||
|
@Param("id") id: string,
|
||||||
|
@Body() dto: UpdateQuoteDto,
|
||||||
|
@CurrentUser() user: EdenAuthenticatedUser,
|
||||||
|
) {
|
||||||
|
return this.quotes.update(id, dto, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Approve/reject a pending discount or reduced-fidelity exception — eden.md §3.3. Gated inside the service by canApproveDiscount(), not by a generic permission (mirrors the legacy's per-user flag, not a role-based permission). */
|
||||||
|
@Patch(":id/approval")
|
||||||
|
@RequirePermission("quotes", "approve")
|
||||||
|
decideApproval(
|
||||||
|
@Param("id") id: string,
|
||||||
|
@Body() dto: DecideApprovalDto,
|
||||||
|
@CurrentUser() user: EdenAuthenticatedUser,
|
||||||
|
) {
|
||||||
|
return this.quotes.decideApproval(id, dto, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Closes the deal, locking the quote — eden.md §5.1/§6. */
|
||||||
|
@Post(":id/close")
|
||||||
|
@RequirePermission("quotes", "edit")
|
||||||
|
closeDeal(
|
||||||
|
@Param("id") id: string,
|
||||||
|
@Body() dto: CloseDealDto,
|
||||||
|
@CurrentUser() user: EdenAuthenticatedUser,
|
||||||
|
) {
|
||||||
|
return this.quotes.closeDeal(id, dto, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** super_admin-only shortcut, works even on a locked quote — eden.md §5.2. Route-level guard for defense in depth, on top of the service-layer check. */
|
||||||
|
@Patch(":id/deal-status")
|
||||||
|
@UseGuards(SuperAdminGuard)
|
||||||
|
dealStatus(
|
||||||
|
@Param("id") id: string,
|
||||||
|
@Body("dealStatus") dealStatus: "orcamento" | "fechado" | "perdido",
|
||||||
|
@CurrentUser() user: EdenAuthenticatedUser,
|
||||||
|
) {
|
||||||
|
return this.quotes.changeDealStatus(id, dealStatus, user);
|
||||||
|
}
|
||||||
|
}
|
||||||
12
apps/api/src/quotes/quotes.module.ts
Normal file
12
apps/api/src/quotes/quotes.module.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { AuthModule } from "../auth/auth.module.js";
|
||||||
|
import { QuotesController } from "./quotes.controller.js";
|
||||||
|
import { QuotesService } from "./quotes.service.js";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [AuthModule],
|
||||||
|
controllers: [QuotesController],
|
||||||
|
providers: [QuotesService],
|
||||||
|
exports: [QuotesService],
|
||||||
|
})
|
||||||
|
export class QuotesModule {}
|
||||||
345
apps/api/src/quotes/quotes.service.ts
Normal file
345
apps/api/src/quotes/quotes.service.ts
Normal file
@@ -0,0 +1,345 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ConflictException,
|
||||||
|
ForbiddenException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import {
|
||||||
|
auditRepo,
|
||||||
|
productsRepo,
|
||||||
|
quotesRepo,
|
||||||
|
type Quote,
|
||||||
|
type QuoteItem,
|
||||||
|
} from "@eden/database";
|
||||||
|
import type { EdenAuthenticatedUser } from "../auth/jwt-auth.guard.js";
|
||||||
|
import { maxGrantedScope } from "../auth/permissions.js";
|
||||||
|
import {
|
||||||
|
canApproveDiscount,
|
||||||
|
computeQuotePricing,
|
||||||
|
getAvailablePaymentConditions,
|
||||||
|
resolveApprovalStatus,
|
||||||
|
} from "./pricing.js";
|
||||||
|
import type { CreateQuoteDto } from "./dto/create-quote.dto.js";
|
||||||
|
import type { UpdateQuoteDto } from "./dto/update-quote.dto.js";
|
||||||
|
import type { QuoteItemInputDto } from "./dto/quote-item-input.dto.js";
|
||||||
|
import type { DecideApprovalDto } from "./dto/decide-approval.dto.js";
|
||||||
|
import type { CloseDealDto } from "./dto/close-deal.dto.js";
|
||||||
|
|
||||||
|
const SUPER_ADMIN = "super_admin";
|
||||||
|
const ADMIN_ROLES = new Set(["admin", "super_admin"]);
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class QuotesService {
|
||||||
|
async list(user: EdenAuthenticatedUser, dealStatus?: string): Promise<Quote[]> {
|
||||||
|
const scope = maxGrantedScope(user.permissions, "quotes", "view");
|
||||||
|
if (!scope) throw new ForbiddenException("Sem permissão para 'view' em 'quotes'");
|
||||||
|
if (scope === "all") {
|
||||||
|
return quotesRepo.listQuotes({ scope: "all", dealStatus });
|
||||||
|
}
|
||||||
|
if (scope === "reseller") {
|
||||||
|
return quotesRepo.listQuotes({ scope: "reseller", resellerId: user.resellerId ?? undefined, dealStatus });
|
||||||
|
}
|
||||||
|
return quotesRepo.listQuotes({ scope: "own", actorId: user.id, dealStatus });
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(id: string, user: EdenAuthenticatedUser): Promise<Quote> {
|
||||||
|
const quote = await quotesRepo.getQuoteById(id);
|
||||||
|
if (!quote || !this.isVisibleToActor(quote, user)) {
|
||||||
|
throw new NotFoundException("Oferta não encontrada");
|
||||||
|
}
|
||||||
|
return quote;
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: CreateQuoteDto, user: EdenAuthenticatedUser): Promise<Quote> {
|
||||||
|
const items = await this.buildItems(dto.items, dto.contractPeriod);
|
||||||
|
const proposedMonthlyTotal = dto.proposedMonthlyTotal ?? null;
|
||||||
|
const implGeral = dto.implGeral ?? 0;
|
||||||
|
const fidelityPeriod = dto.fidelityPeriod ?? null;
|
||||||
|
|
||||||
|
if (fidelityPeriod != null && fidelityPeriod > dto.contractPeriod) {
|
||||||
|
throw new BadRequestException("fidelityPeriod não pode ser maior que contractPeriod");
|
||||||
|
}
|
||||||
|
|
||||||
|
const pricing = computeQuotePricing({
|
||||||
|
items,
|
||||||
|
contractPeriod: dto.contractPeriod,
|
||||||
|
fidelityPeriod,
|
||||||
|
implGeral,
|
||||||
|
proposedMonthlyTotal,
|
||||||
|
});
|
||||||
|
|
||||||
|
const implPaymentCondition = this.validatePaymentCondition(
|
||||||
|
dto.implPaymentCondition,
|
||||||
|
pricing.totalImpl,
|
||||||
|
);
|
||||||
|
|
||||||
|
const approvalStatus = resolveApprovalStatus(pricing.needsApproval, "none");
|
||||||
|
|
||||||
|
const quote = await quotesRepo.createQuote({
|
||||||
|
resellerId: dto.resellerId,
|
||||||
|
clientName: dto.clientName,
|
||||||
|
clientCompany: dto.clientCompany ?? null,
|
||||||
|
clientEmail: dto.clientEmail ?? null,
|
||||||
|
clientPhone: dto.clientPhone,
|
||||||
|
contractPeriod: dto.contractPeriod,
|
||||||
|
fidelityPeriod: pricing.persistedFidelityPeriod,
|
||||||
|
items,
|
||||||
|
implementationFee: pricing.implFromProducts,
|
||||||
|
implGeral,
|
||||||
|
monthlyTotal: pricing.monthlyTotal,
|
||||||
|
contractTotal: pricing.contractTotal,
|
||||||
|
proposedMonthlyTotal: pricing.persistedProposedMonthlyTotal,
|
||||||
|
approvalStatus,
|
||||||
|
implPaymentCondition,
|
||||||
|
notes: dto.notes ?? null,
|
||||||
|
createdBy: user.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
await auditRepo.appendAuditEvent({
|
||||||
|
actorType: "user",
|
||||||
|
actorId: user.id,
|
||||||
|
action: "quote.created",
|
||||||
|
resourceType: "quote",
|
||||||
|
resourceId: quote.id,
|
||||||
|
metadata: { needsApproval: pricing.needsApproval, contractTotal: pricing.contractTotal },
|
||||||
|
});
|
||||||
|
|
||||||
|
return quote;
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, dto: UpdateQuoteDto, user: EdenAuthenticatedUser): Promise<Quote> {
|
||||||
|
const existing = await this.get(id, user);
|
||||||
|
this.assertNotLocked(existing, user);
|
||||||
|
|
||||||
|
const items = await this.buildItems(dto.items, dto.contractPeriod);
|
||||||
|
const proposedMonthlyTotal = dto.proposedMonthlyTotal ?? null;
|
||||||
|
const implGeral = dto.implGeral ?? Number(existing.impl_geral);
|
||||||
|
// Changing contract_period resets any pending fidelity exception —
|
||||||
|
// eden.md §2: "trocar de faixa invalida qualquer valor pendente".
|
||||||
|
const fidelityPeriod =
|
||||||
|
dto.contractPeriod !== existing.contract_period ? (dto.fidelityPeriod ?? null) : dto.fidelityPeriod ?? null;
|
||||||
|
|
||||||
|
if (fidelityPeriod != null && fidelityPeriod > dto.contractPeriod) {
|
||||||
|
throw new BadRequestException("fidelityPeriod não pode ser maior que contractPeriod");
|
||||||
|
}
|
||||||
|
|
||||||
|
const pricing = computeQuotePricing({
|
||||||
|
items,
|
||||||
|
contractPeriod: dto.contractPeriod,
|
||||||
|
fidelityPeriod,
|
||||||
|
implGeral,
|
||||||
|
proposedMonthlyTotal,
|
||||||
|
});
|
||||||
|
|
||||||
|
const implPaymentCondition = this.validatePaymentCondition(
|
||||||
|
dto.implPaymentCondition,
|
||||||
|
pricing.totalImpl,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Any commercial change (period/fidelity/special price) resets
|
||||||
|
// approval to require a fresh decision — eden.md §3.3: "nova
|
||||||
|
// negociação = nova aprovação necessária".
|
||||||
|
const commercialTermsChanged =
|
||||||
|
dto.contractPeriod !== existing.contract_period ||
|
||||||
|
pricing.persistedFidelityPeriod !== existing.fidelity_period ||
|
||||||
|
pricing.persistedProposedMonthlyTotal !== (existing.proposed_monthly_total != null ? Number(existing.proposed_monthly_total) : null);
|
||||||
|
|
||||||
|
const previousStatus = commercialTermsChanged ? "none" : existing.approval_status;
|
||||||
|
const approvalStatus = resolveApprovalStatus(pricing.needsApproval, previousStatus);
|
||||||
|
const approvedById = approvalStatus === "approved" ? existing.approved_by_id : null;
|
||||||
|
|
||||||
|
const quote = await quotesRepo.updateQuote(id, {
|
||||||
|
clientName: dto.clientName,
|
||||||
|
clientCompany: dto.clientCompany,
|
||||||
|
clientEmail: dto.clientEmail,
|
||||||
|
clientPhone: dto.clientPhone,
|
||||||
|
contractPeriod: dto.contractPeriod,
|
||||||
|
fidelityPeriod: pricing.persistedFidelityPeriod,
|
||||||
|
items,
|
||||||
|
implementationFee: pricing.implFromProducts,
|
||||||
|
implGeral,
|
||||||
|
monthlyTotal: pricing.monthlyTotal,
|
||||||
|
contractTotal: pricing.contractTotal,
|
||||||
|
proposedMonthlyTotal: pricing.persistedProposedMonthlyTotal,
|
||||||
|
approvalStatus,
|
||||||
|
approvedById,
|
||||||
|
implPaymentCondition,
|
||||||
|
notes: dto.notes,
|
||||||
|
});
|
||||||
|
if (!quote) throw new NotFoundException("Oferta não encontrada");
|
||||||
|
|
||||||
|
await auditRepo.appendAuditEvent({
|
||||||
|
actorType: "user",
|
||||||
|
actorId: user.id,
|
||||||
|
action: "quote.updated",
|
||||||
|
resourceType: "quote",
|
||||||
|
resourceId: id,
|
||||||
|
metadata: { needsApproval: pricing.needsApproval, resetApproval: commercialTermsChanged },
|
||||||
|
});
|
||||||
|
|
||||||
|
return quote;
|
||||||
|
}
|
||||||
|
|
||||||
|
async decideApproval(
|
||||||
|
id: string,
|
||||||
|
dto: DecideApprovalDto,
|
||||||
|
user: EdenAuthenticatedUser,
|
||||||
|
): Promise<Quote> {
|
||||||
|
const quote = await this.get(id, user);
|
||||||
|
|
||||||
|
if (!canApproveDiscount({ roleKey: user.roleKey, canApproveDiscount: user.canApproveDiscount })) {
|
||||||
|
throw new ForbiddenException("Sem permissão para aprovar/recusar condição especial");
|
||||||
|
}
|
||||||
|
if (quote.approval_status !== "pending") {
|
||||||
|
throw new ConflictException("Esta oferta não tem uma aprovação pendente");
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await quotesRepo.updateQuote(id, {
|
||||||
|
clientName: undefined,
|
||||||
|
clientCompany: undefined,
|
||||||
|
clientEmail: undefined,
|
||||||
|
clientPhone: undefined,
|
||||||
|
contractPeriod: quote.contract_period,
|
||||||
|
fidelityPeriod: quote.fidelity_period,
|
||||||
|
items: quote.items,
|
||||||
|
implementationFee: Number(quote.implementation_fee),
|
||||||
|
implGeral: Number(quote.impl_geral),
|
||||||
|
monthlyTotal: Number(quote.monthly_total),
|
||||||
|
contractTotal: Number(quote.contract_total),
|
||||||
|
proposedMonthlyTotal: quote.proposed_monthly_total != null ? Number(quote.proposed_monthly_total) : null,
|
||||||
|
approvalStatus: dto.decision,
|
||||||
|
approvedById: dto.decision === "approved" ? user.id : null,
|
||||||
|
approvalNotes: dto.notes,
|
||||||
|
implPaymentCondition: quote.impl_payment_condition,
|
||||||
|
notes: undefined,
|
||||||
|
});
|
||||||
|
if (!updated) throw new NotFoundException("Oferta não encontrada");
|
||||||
|
|
||||||
|
await auditRepo.appendAuditEvent({
|
||||||
|
actorType: "user",
|
||||||
|
actorId: user.id,
|
||||||
|
action: dto.decision === "approved" ? "quote.approval_approved" : "quote.approval_rejected",
|
||||||
|
resourceType: "quote",
|
||||||
|
resourceId: id,
|
||||||
|
metadata: { notes: dto.notes ?? null },
|
||||||
|
});
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
async closeDeal(id: string, dto: CloseDealDto, user: EdenAuthenticatedUser): Promise<Quote> {
|
||||||
|
const quote = await this.get(id, user);
|
||||||
|
if (quote.client_registration_id) {
|
||||||
|
throw new ConflictException("Esta oferta já foi fechada");
|
||||||
|
}
|
||||||
|
const updated = await quotesRepo.closeQuoteDeal(id, dto.clientRegistrationId);
|
||||||
|
if (!updated) throw new ConflictException("Esta oferta já foi fechada");
|
||||||
|
|
||||||
|
await auditRepo.appendAuditEvent({
|
||||||
|
actorType: "user",
|
||||||
|
actorId: user.id,
|
||||||
|
action: "quote.closed",
|
||||||
|
resourceType: "quote",
|
||||||
|
resourceId: id,
|
||||||
|
metadata: { clientRegistrationId: dto.clientRegistrationId },
|
||||||
|
});
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* super_admin-only shortcut to correct deal_status even on a locked
|
||||||
|
* quote (client_registration_id already set) — eden.md §5.2, e.g.
|
||||||
|
* "reverter um fechado que caiu". Never touches client_registration_id
|
||||||
|
* itself, only the status label.
|
||||||
|
*/
|
||||||
|
async changeDealStatus(
|
||||||
|
id: string,
|
||||||
|
dealStatus: "orcamento" | "fechado" | "perdido",
|
||||||
|
user: EdenAuthenticatedUser,
|
||||||
|
): Promise<Quote> {
|
||||||
|
if (user.roleKey !== SUPER_ADMIN) {
|
||||||
|
throw new ForbiddenException("Exclusivo do super_admin");
|
||||||
|
}
|
||||||
|
const updated = await quotesRepo.setQuoteDealStatus(id, dealStatus);
|
||||||
|
if (!updated) throw new NotFoundException("Oferta não encontrada");
|
||||||
|
|
||||||
|
await auditRepo.appendAuditEvent({
|
||||||
|
actorType: "user",
|
||||||
|
actorId: user.id,
|
||||||
|
action: "quote.deal_status_overridden",
|
||||||
|
resourceType: "quote",
|
||||||
|
resourceId: id,
|
||||||
|
metadata: { dealStatus },
|
||||||
|
});
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async buildItems(inputs: QuoteItemInputDto[], contractPeriod: number): Promise<QuoteItem[]> {
|
||||||
|
const priceKey = `price_${contractPeriod}` as "price_0" | "price_12" | "price_24" | "price_36" | "price_48";
|
||||||
|
|
||||||
|
const items: QuoteItem[] = [];
|
||||||
|
for (const input of inputs) {
|
||||||
|
const product = await productsRepo.getProductById(input.productId);
|
||||||
|
if (!product) {
|
||||||
|
throw new BadRequestException(`Produto ${input.productId} não encontrado`);
|
||||||
|
}
|
||||||
|
const unitPriceRaw = product[priceKey];
|
||||||
|
const unitPrice = unitPriceRaw != null ? Number(unitPriceRaw) : 0;
|
||||||
|
const implUnitPrice = Number(product.impl_unit_price ?? 0);
|
||||||
|
|
||||||
|
items.push({
|
||||||
|
productId: product.id,
|
||||||
|
productName: product.name,
|
||||||
|
quantity: input.quantity,
|
||||||
|
unitPrice,
|
||||||
|
total: round2(unitPrice * input.quantity),
|
||||||
|
implUnitPrice,
|
||||||
|
implTotal: round2(implUnitPrice * input.quantity),
|
||||||
|
price0: product.price_0 != null ? Number(product.price_0) : null,
|
||||||
|
price12: product.price_12 != null ? Number(product.price_12) : null,
|
||||||
|
price24: product.price_24 != null ? Number(product.price_24) : null,
|
||||||
|
price36: product.price_36 != null ? Number(product.price_36) : null,
|
||||||
|
price48: product.price_48 != null ? Number(product.price_48) : null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
private validatePaymentCondition(condition: string | undefined, implTotal: number): string {
|
||||||
|
const allowed = getAvailablePaymentConditions(implTotal);
|
||||||
|
if (condition === undefined) {
|
||||||
|
// Mirror the legacy default-selection behavior: fall back to the
|
||||||
|
// richest option still valid for this implementation value.
|
||||||
|
return allowed[allowed.length - 1]!;
|
||||||
|
}
|
||||||
|
if (!allowed.includes(condition)) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Condição de pagamento da implantação inválida para o valor de implantação (R$ ${implTotal.toFixed(2)}). Opções disponíveis: ${allowed.join(", ")}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return condition;
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertNotLocked(quote: Quote, user: EdenAuthenticatedUser): void {
|
||||||
|
if (quote.client_registration_id && user.roleKey !== SUPER_ADMIN) {
|
||||||
|
throw new ConflictException(
|
||||||
|
"Esta oferta está travada — o cadastro do cliente já foi iniciado.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private isVisibleToActor(quote: Quote, user: EdenAuthenticatedUser): boolean {
|
||||||
|
const scope = maxGrantedScope(user.permissions, "quotes", "view");
|
||||||
|
if (!scope) return false;
|
||||||
|
if (scope === "all") return true;
|
||||||
|
if (scope === "reseller") return quote.reseller_id === user.resellerId;
|
||||||
|
return quote.created_by === user.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function round2(value: number): number {
|
||||||
|
return Math.round((value + Number.EPSILON) * 100) / 100;
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
|||||||
82
packages/database/migrations/1788445280742_quotes.cjs
Normal file
82
packages/database/migrations/1788445280742_quotes.cjs
Normal 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;
|
||||||
|
`);
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
`);
|
||||||
|
};
|
||||||
168
packages/database/migrations/1788447802216_contracts.cjs
Normal file
168
packages/database/migrations/1788447802216_contracts.cjs
Normal 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;
|
||||||
|
`);
|
||||||
|
};
|
||||||
@@ -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";
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
341
packages/database/src/repositories/client-registrations.ts
Normal file
341
packages/database/src/repositories/client-registrations.ts
Normal 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;
|
||||||
|
}
|
||||||
437
packages/database/src/repositories/contracts.ts
Normal file
437
packages/database/src/repositories/contracts.ts
Normal 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;
|
||||||
|
});
|
||||||
|
}
|
||||||
242
packages/database/src/repositories/quotes.ts
Normal file
242
packages/database/src/repositories/quotes.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user