Files
eden/apps/api/src/quotes/pricing.ts
Matheus (Handix) 18838a9031 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>
2026-09-03 12:25:49 -03:00

162 lines
6.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}