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

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

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

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

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

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

View File

@@ -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";

View File

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

View File

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

View File

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

View File

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

View File

@@ -31,6 +31,10 @@ export interface User {
reset_token_expires_at: Date | null;
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 {