diff --git a/.gitignore b/.gitignore index bce5d58..c86452f 100644 --- a/.gitignore +++ b/.gitignore @@ -49,3 +49,6 @@ infra/docker/pgdata/ # Temporary git worktrees created for parallel subagent work — each is its # own nested git checkout; never vendor these into the main repo history. .claude/worktrees/ + +# Session-local lock file for scheduled wakeups, not project content. +.claude/scheduled_tasks.lock diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 68b3294..a318f55 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -11,6 +11,9 @@ import { ProductsModule } from "./products/products.module.js"; import { ResellersModule } from "./resellers/resellers.module.js"; import { LeadsModule } from "./leads/leads.module.js"; import { OpportunitiesModule } from "./opportunities/opportunities.module.js"; +import { QuotesModule } from "./quotes/quotes.module.js"; +import { ClientRegistrationsModule } from "./client-registrations/client-registrations.module.js"; +import { ContractsModule } from "./contracts/contracts.module.js"; @Module({ imports: [ @@ -25,6 +28,9 @@ import { OpportunitiesModule } from "./opportunities/opportunities.module.js"; ResellersModule, LeadsModule, OpportunitiesModule, + QuotesModule, + ClientRegistrationsModule, + ContractsModule, ], controllers: [HealthController], }) diff --git a/apps/api/src/auth/jwt-auth.guard.ts b/apps/api/src/auth/jwt-auth.guard.ts index 339ef72..a75de61 100644 --- a/apps/api/src/auth/jwt-auth.guard.ts +++ b/apps/api/src/auth/jwt-auth.guard.ts @@ -11,6 +11,10 @@ export interface EdenAuthenticatedUser { roleKey: string; applicationKey: "core" | "reseller" | "subscriber"; permissions: ResolvedPermissions; + /** Nullable — scopes the actor to a reseller for scope='reseller' permission checks (eden.md §7, ADR-0004). */ + resellerId: string | null; + /** eden.md §1.1/§3.3 — only meaningful combined with roleKey==='admin'; super_admin bypasses regardless. */ + canApproveDiscount: boolean; } declare module "express" { @@ -59,6 +63,8 @@ export class JwtAuthGuard implements CanActivate { roleKey: user.role_key, applicationKey: payload.app, permissions, + resellerId: user.reseller_id, + canApproveDiscount: user.can_approve_discount, }; return true; } diff --git a/apps/api/src/client-registrations/client-registrations.controller.ts b/apps/api/src/client-registrations/client-registrations.controller.ts new file mode 100644 index 0000000..d386eba --- /dev/null +++ b/apps/api/src/client-registrations/client-registrations.controller.ts @@ -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); + } +} diff --git a/apps/api/src/client-registrations/client-registrations.module.ts b/apps/api/src/client-registrations/client-registrations.module.ts new file mode 100644 index 0000000..5f3fa5b --- /dev/null +++ b/apps/api/src/client-registrations/client-registrations.module.ts @@ -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 {} diff --git a/apps/api/src/client-registrations/client-registrations.service.ts b/apps/api/src/client-registrations/client-registrations.service.ts new file mode 100644 index 0000000..4e76fd6 --- /dev/null +++ b/apps/api/src/client-registrations/client-registrations.service.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + await this.get(id, user); // reuse the view-scope visibility check + return clientRegistrationPartnersRepo.listPartnersForRegistration(id); + } + + async createPartner( + id: string, + dto: CreatePartnerDto, + user: EdenAuthenticatedUser, + ): Promise { + 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 { + 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; + } +} diff --git a/apps/api/src/client-registrations/dto/create-direct-client-registration.dto.ts b/apps/api/src/client-registrations/dto/create-direct-client-registration.dto.ts new file mode 100644 index 0000000..0ccb1b5 --- /dev/null +++ b/apps/api/src/client-registrations/dto/create-direct-client-registration.dto.ts @@ -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; +} diff --git a/apps/api/src/client-registrations/dto/create-partner.dto.ts b/apps/api/src/client-registrations/dto/create-partner.dto.ts new file mode 100644 index 0000000..8c12470 --- /dev/null +++ b/apps/api/src/client-registrations/dto/create-partner.dto.ts @@ -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; +} diff --git a/apps/api/src/client-registrations/dto/update-client-registration.dto.ts b/apps/api/src/client-registrations/dto/update-client-registration.dto.ts new file mode 100644 index 0000000..3a53939 --- /dev/null +++ b/apps/api/src/client-registrations/dto/update-client-registration.dto.ts @@ -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[]; +} diff --git a/apps/api/src/client-registrations/dto/update-partner.dto.ts b/apps/api/src/client-registrations/dto/update-partner.dto.ts new file mode 100644 index 0000000..b9e332b --- /dev/null +++ b/apps/api/src/client-registrations/dto/update-partner.dto.ts @@ -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; +} diff --git a/apps/api/src/contracts/contracts.controller.ts b/apps/api/src/contracts/contracts.controller.ts new file mode 100644 index 0000000..f234f46 --- /dev/null +++ b/apps/api/src/contracts/contracts.controller.ts @@ -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); + } +} diff --git a/apps/api/src/contracts/contracts.module.ts b/apps/api/src/contracts/contracts.module.ts new file mode 100644 index 0000000..4c48c92 --- /dev/null +++ b/apps/api/src/contracts/contracts.module.ts @@ -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 {} diff --git a/apps/api/src/contracts/contracts.service.ts b/apps/api/src/contracts/contracts.service.ts new file mode 100644 index 0000000..09ae965 --- /dev/null +++ b/apps/api/src/contracts/contracts.service.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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; +} diff --git a/apps/api/src/contracts/dto/contract-item-input.dto.ts b/apps/api/src/contracts/dto/contract-item-input.dto.ts new file mode 100644 index 0000000..79380c4 --- /dev/null +++ b/apps/api/src/contracts/dto/contract-item-input.dto.ts @@ -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; +} diff --git a/apps/api/src/contracts/dto/contract-party-input.dto.ts b/apps/api/src/contracts/dto/contract-party-input.dto.ts new file mode 100644 index 0000000..18cfc01 --- /dev/null +++ b/apps/api/src/contracts/dto/contract-party-input.dto.ts @@ -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; +} diff --git a/apps/api/src/contracts/dto/create-contract.dto.ts b/apps/api/src/contracts/dto/create-contract.dto.ts new file mode 100644 index 0000000..ec1b08f --- /dev/null +++ b/apps/api/src/contracts/dto/create-contract.dto.ts @@ -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; +} diff --git a/apps/api/src/contracts/dto/update-contract-status.dto.ts b/apps/api/src/contracts/dto/update-contract-status.dto.ts new file mode 100644 index 0000000..2ebb098 --- /dev/null +++ b/apps/api/src/contracts/dto/update-contract-status.dto.ts @@ -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; +} diff --git a/apps/api/src/quotes/dto/close-deal.dto.ts b/apps/api/src/quotes/dto/close-deal.dto.ts new file mode 100644 index 0000000..8b1315c --- /dev/null +++ b/apps/api/src/quotes/dto/close-deal.dto.ts @@ -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; +} diff --git a/apps/api/src/quotes/dto/create-quote.dto.ts b/apps/api/src/quotes/dto/create-quote.dto.ts new file mode 100644 index 0000000..1f1aff3 --- /dev/null +++ b/apps/api/src/quotes/dto/create-quote.dto.ts @@ -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; +} diff --git a/apps/api/src/quotes/dto/decide-approval.dto.ts b/apps/api/src/quotes/dto/decide-approval.dto.ts new file mode 100644 index 0000000..041aaad --- /dev/null +++ b/apps/api/src/quotes/dto/decide-approval.dto.ts @@ -0,0 +1,10 @@ +import { IsIn, IsOptional, IsString } from "class-validator"; + +export class DecideApprovalDto { + @IsIn(["approved", "rejected"]) + decision!: "approved" | "rejected"; + + @IsOptional() + @IsString() + notes?: string; +} diff --git a/apps/api/src/quotes/dto/quote-item-input.dto.ts b/apps/api/src/quotes/dto/quote-item-input.dto.ts new file mode 100644 index 0000000..982ad66 --- /dev/null +++ b/apps/api/src/quotes/dto/quote-item-input.dto.ts @@ -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; +} diff --git a/apps/api/src/quotes/dto/update-quote.dto.ts b/apps/api/src/quotes/dto/update-quote.dto.ts new file mode 100644 index 0000000..c5612e8 --- /dev/null +++ b/apps/api/src/quotes/dto/update-quote.dto.ts @@ -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; +} diff --git a/apps/api/src/quotes/pricing.ts b/apps/api/src/quotes/pricing.ts new file mode 100644 index 0000000..b66ebec --- /dev/null +++ b/apps/api/src/quotes/pricing.ts @@ -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; +} diff --git a/apps/api/src/quotes/quotes.controller.ts b/apps/api/src/quotes/quotes.controller.ts new file mode 100644 index 0000000..d336f8e --- /dev/null +++ b/apps/api/src/quotes/quotes.controller.ts @@ -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); + } +} diff --git a/apps/api/src/quotes/quotes.module.ts b/apps/api/src/quotes/quotes.module.ts new file mode 100644 index 0000000..89c7f10 --- /dev/null +++ b/apps/api/src/quotes/quotes.module.ts @@ -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 {} diff --git a/apps/api/src/quotes/quotes.service.ts b/apps/api/src/quotes/quotes.service.ts new file mode 100644 index 0000000..0803af2 --- /dev/null +++ b/apps/api/src/quotes/quotes.service.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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; +} diff --git a/docs/progress.md b/docs/progress.md index b7543ee..b366bd7 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -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). - 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. + +--- + +## 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. diff --git a/packages/database/migrations/1788445280742_quotes.cjs b/packages/database/migrations/1788445280742_quotes.cjs new file mode 100644 index 0000000..a4c5a07 --- /dev/null +++ b/packages/database/migrations/1788445280742_quotes.cjs @@ -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; + `); +}; diff --git a/packages/database/migrations/1788447739229_client-registrations.cjs b/packages/database/migrations/1788447739229_client-registrations.cjs new file mode 100644 index 0000000..7ff9d8c --- /dev/null +++ b/packages/database/migrations/1788447739229_client-registrations.cjs @@ -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; + `); +}; diff --git a/packages/database/migrations/1788447802216_contracts.cjs b/packages/database/migrations/1788447802216_contracts.cjs new file mode 100644 index 0000000..a0ed3fb --- /dev/null +++ b/packages/database/migrations/1788447802216_contracts.cjs @@ -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; + `); +}; diff --git a/packages/database/src/index.ts b/packages/database/src/index.ts index ca3cb6e..4a0c3a9 100644 --- a/packages/database/src/index.ts +++ b/packages/database/src/index.ts @@ -6,6 +6,10 @@ export * as auditRepo from "./repositories/audit.js"; export * as companiesRepo from "./repositories/companies.js"; export * as productsRepo from "./repositories/products.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 { listLeads, getLead, @@ -37,3 +41,36 @@ export type { CreateOpportunityInput, UpdateOpportunityInput, } 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"; diff --git a/packages/database/src/repositories/client-registration-partners.ts b/packages/database/src/repositories/client-registration-partners.ts new file mode 100644 index 0000000..023a631 --- /dev/null +++ b/packages/database/src/repositories/client-registration-partners.ts @@ -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 { + const { rows } = await query( + "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 { + const { rows } = await query( + "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 { + const { rows } = await query( + `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 { + const { rows } = await query( + `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; +} diff --git a/packages/database/src/repositories/client-registrations.ts b/packages/database/src/repositories/client-registrations.ts new file mode 100644 index 0000000..6855887 --- /dev/null +++ b/packages/database/src/repositories/client-registrations.ts @@ -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 { + if (filter.scope === "own") { + const { rows } = await query( + "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( + "SELECT * FROM client_registrations WHERE reseller_id = $1 ORDER BY created_at DESC", + [filter.resellerId ?? null], + ); + return rows; + } + const { rows } = await query( + "SELECT * FROM client_registrations ORDER BY created_at DESC", + ); + return rows; +} + +export async function getClientRegistrationById(id: string): Promise { + const { rows } = await query( + "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 { + const token = randomBytes(24).toString("hex"); + const { rows } = await query( + `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 { + const { rows } = await query( + `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 { + const { rows } = await query( + `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; +} diff --git a/packages/database/src/repositories/contracts.ts b/packages/database/src/repositories/contracts.ts new file mode 100644 index 0000000..580c10d --- /dev/null +++ b/packages/database/src/repositories/contracts.ts @@ -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 = { + 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 { + 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( + "SELECT * FROM contracts WHERE reseller_id = $1 ORDER BY created_at DESC", + [filter.resellerId ?? null], + ); + return rows; + } + const { rows } = await query("SELECT * FROM contracts ORDER BY created_at DESC"); + return rows; +} + +async function listContractItems(contractId: string): Promise { + const { rows } = await query( + "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 { + const { rows } = await query( + "SELECT * FROM contract_parties WHERE contract_id = $1 ORDER BY created_at ASC", + [contractId], + ); + return rows; +} + +export async function listContractStatusHistory(contractId: string): Promise { + const { rows } = await query( + "SELECT * FROM contract_status_history WHERE contract_id = $1 ORDER BY changed_at ASC", + [contractId], + ); + return rows; +} + +export async function getContractById(id: string): Promise { + const { rows } = await query("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 { + 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( + `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( + `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( + `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 { + const { rows } = await query( + `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 { + const { rows } = await query( + `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 { + 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( + `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; + }); +} diff --git a/packages/database/src/repositories/quotes.ts b/packages/database/src/repositories/quotes.ts new file mode 100644 index 0000000..ef6c702 --- /dev/null +++ b/packages/database/src/repositories/quotes.ts @@ -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 { + 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( + `SELECT * FROM quotes ${where} ORDER BY created_at DESC`, + params, + ); + return rows; +} + +export async function getQuoteById(id: string): Promise { + const { rows } = await query("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 { + const { rows } = await query( + `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 { + const { rows } = await query( + `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 { + const { rows } = await query( + "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 { + const { rows } = await query( + `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; +} diff --git a/packages/database/src/repositories/types.ts b/packages/database/src/repositories/types.ts index 21e75cd..7d62b78 100644 --- a/packages/database/src/repositories/types.ts +++ b/packages/database/src/repositories/types.ts @@ -31,6 +31,10 @@ export interface User { reset_token_expires_at: Date | null; mfa_enabled: boolean; 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 {