Files
Investidor-V2/resources/js/lib/financial-calculators.ts
T

533 lines
18 KiB
TypeScript

import { csvCell } from './csv.js';
export type FinancialToolsSnapshotData = {
budgetPeriod: string;
loan: {
principal: string;
rate: string;
months: string;
rateType: 'nominal' | 'effective';
periodMonths: string;
gracePeriods: string;
upfrontFee: string;
recurringFee: string;
};
breakEven: { fixed: string; price: string; variable: string };
funding: { total: string; secured: string };
incomes: { label: string; amount: string }[];
expenses: { label: string; amount: string }[];
};
export function serializeFinancialToolsSnapshot(
data: FinancialToolsSnapshotData,
): string {
return JSON.stringify({ version: 1, data });
}
export function restoreFinancialToolsSnapshot(
snapshot: string | undefined,
): FinancialToolsSnapshotData | null {
if (!snapshot || snapshot.length > 30_000) return null;
try {
const parsed = JSON.parse(snapshot) as {
version?: unknown;
data?: unknown;
};
if (
parsed.version !== 1 ||
!parsed.data ||
typeof parsed.data !== 'object'
)
return null;
const data = parsed.data as Record<string, unknown>;
const isText = (value: unknown, limit = 80) =>
typeof value === 'string' && value.length <= limit;
const loan = data.loan as Record<string, unknown> | null;
const breakEven = data.breakEven as Record<string, unknown> | null;
const funding = data.funding as Record<string, unknown> | null;
const rowsValid = (value: unknown) =>
Array.isArray(value) &&
value.length > 0 &&
value.length <= 50 &&
value.every(
(row) =>
row &&
typeof row === 'object' &&
isText(row.label, 120) &&
isText(row.amount, 80),
);
if (
!['Mensal', 'Trimestral', 'Anual', 'Outro período'].includes(
String(data.budgetPeriod),
) ||
!loan ||
!breakEven ||
!funding ||
![
'principal',
'rate',
'months',
'periodMonths',
'gracePeriods',
'upfrontFee',
'recurringFee',
].every((key) => isText(loan[key])) ||
!['nominal', 'effective'].includes(String(loan.rateType)) ||
!['fixed', 'price', 'variable'].every((key) =>
isText(breakEven[key]),
) ||
!['total', 'secured'].every((key) => isText(funding[key])) ||
!rowsValid(data.incomes) ||
!rowsValid(data.expenses)
)
return null;
return data as unknown as FinancialToolsSnapshotData;
} catch {
return null;
}
}
function decimalToInteger(value: string, scale: number, label: string): bigint {
const match = /^(\d+)(?:\.(\d+))?$/.exec(value.trim());
if (!match || (match[2]?.length ?? 0) > scale)
throw new Error(
`${label}: introduza um valor decimal não negativo com até ${scale} casas.`,
);
const fraction = (match[2] ?? '').padEnd(scale, '0');
return BigInt(match[1]) * 10n ** BigInt(scale) + BigInt(fraction || '0');
}
function money(cents: bigint): string {
const rounded = cents < 0n ? -cents : cents;
const text = `${rounded / 100n}.${String(rounded % 100n).padStart(2, '0')}`;
return cents < 0n ? `-${text}` : text;
}
function ratePercent(value: bigint): string {
const scaled = roundRatio(value * 100_000_000n, RATE_SCALE);
return `${scaled / 1_000_000n}.${String(scaled % 1_000_000n).padStart(6, '0')}`;
}
function roundRatio(numerator: bigint, denominator: bigint): bigint {
return (numerator + denominator / 2n) / denominator;
}
const RATE_SCALE = 1_000_000_000_000n;
const divideRound = (numerator: bigint, denominator: bigint) =>
roundRatio(numerator, denominator);
const multiplyFixed = (left: bigint, right: bigint) =>
divideRound(left * right, RATE_SCALE);
function fixedPower(base: bigint, exponent: number): bigint {
let result = RATE_SCALE;
let factor = base;
let power = exponent;
while (power > 0) {
if (power % 2 === 1) result = multiplyFixed(result, factor);
power = Math.floor(power / 2);
if (power > 0) factor = multiplyFixed(factor, factor);
}
return result;
}
function fixedRoot(value: bigint, degree: number): bigint {
let low = RATE_SCALE;
let high = value;
while (low < high) {
const middle = (low + high + 1n) / 2n;
if (fixedPower(middle, degree) <= value) low = middle;
else high = middle - 1n;
}
return low;
}
/** Compatibility wrapper: monthly nominal schedule, with fees and grace set to zero. */
export function calculateLoanPayment(
principal: string,
annualRatePercent: string,
months: number,
) {
const result = buildLoanSchedule({
principal,
annualRatePercent,
termMonths: months,
periodMonths: 1,
rateType: 'nominal',
});
return {
monthlyPayment: result.schedule[0].totalPayment,
totalPaid: result.totalPaid,
status: 'estimated' as const,
};
}
/** Contribution margin is calculated from user-entered sales and variable costs. */
export function calculateBreakEven(
fixedCosts: string,
unitPrice: string,
variableCostPerUnit: string,
) {
const fixed = decimalToInteger(fixedCosts, 2, 'Custos fixos');
const price = decimalToInteger(unitPrice, 2, 'Preço unitário');
const variable = decimalToInteger(variableCostPerUnit, 2, 'Custo variável');
if (price <= variable)
throw new Error(
'O preço unitário deve ser superior ao custo variável.',
);
const units = (fixed + (price - variable) - 1n) / (price - variable);
const revenue = units * price;
return {
contributionPerUnit: money(price - variable),
breakEvenUnits: units.toString(),
breakEvenRevenue: money(revenue),
status: 'estimated' as const,
};
}
/** Funding gap is not a lending decision; the inputs must use the same currency. */
export function calculateFundingGap(
totalInvestment: string,
securedFunding: string,
) {
const total = decimalToInteger(totalInvestment, 2, 'Investimento total');
const secured = decimalToInteger(
securedFunding,
2,
'Financiamento mobilizado',
);
return {
gap: money(total - secured),
reconciles: total === secured,
status: 'estimated' as const,
};
}
export type LoanScheduleInput = {
principal: string;
annualRatePercent: string;
termMonths: number;
periodMonths: number;
rateType: 'nominal' | 'effective';
gracePeriods?: number;
upfrontFee?: string;
recurringFee?: string;
};
/** Builds a reducing-balance schedule; fees remain separate from interest and principal. */
export function buildLoanSchedule(input: LoanScheduleInput) {
const principal = decimalToInteger(input.principal, 2, 'Capital');
const annualRate = decimalToInteger(
input.annualRatePercent,
6,
'Taxa anual',
);
const upfrontFee = decimalToInteger(
input.upfrontFee ?? '0',
2,
'Comissão inicial',
);
const recurringFee = decimalToInteger(
input.recurringFee ?? '0',
2,
'Comissão periódica',
);
if (
!Number.isSafeInteger(input.periodMonths) ||
![1, 2, 3, 4, 6, 12].includes(input.periodMonths)
)
throw new Error('Periodicidade: use 1, 2, 3, 4, 6 ou 12 meses.');
if (
!Number.isSafeInteger(input.termMonths) ||
input.termMonths < input.periodMonths ||
input.termMonths > 600 ||
input.termMonths % input.periodMonths !== 0
)
throw new Error(
'O prazo deve ser múltiplo da periodicidade e não exceder 600 meses.',
);
const periods = input.termMonths / input.periodMonths;
const gracePeriods = input.gracePeriods ?? 0;
if (
!Number.isSafeInteger(gracePeriods) ||
gracePeriods < 0 ||
gracePeriods >= periods
)
throw new Error(
'A carência deve ser inferior ao número total de prestações.',
);
if (upfrontFee > principal)
throw new Error('A comissão inicial não pode exceder o capital.');
const annualFraction = divideRound(annualRate * RATE_SCALE, 100_000_000n);
let periodicRate: bigint;
if (input.rateType === 'nominal') {
periodicRate = divideRound(
annualFraction,
BigInt(12 / input.periodMonths),
);
} else {
const periodsPerYear = 12 / input.periodMonths;
periodicRate =
fixedRoot(RATE_SCALE + annualFraction, periodsPerYear) - RATE_SCALE;
}
const amortizationPeriods = periods - gracePeriods;
const growth = fixedPower(RATE_SCALE + periodicRate, amortizationPeriods);
const regularPrincipalPayment =
periodicRate === 0n
? divideRound(principal, BigInt(amortizationPeriods))
: divideRound(
principal * periodicRate * growth,
RATE_SCALE * (growth - RATE_SCALE),
);
let balance = principal;
const schedule = [];
for (let period = 1; period <= periods; period++) {
const openingBalance = balance;
const interest = divideRound(balance * periodicRate, RATE_SCALE);
const isGrace = period <= gracePeriods;
const isFinal = period === periods;
const principalPayment = isGrace
? 0n
: isFinal
? balance
: regularPrincipalPayment - interest;
if (principalPayment < 0n)
throw new Error(
'As condições não amortizam o capital; reveja a taxa e o prazo.',
);
balance -= principalPayment;
const payment = principalPayment + interest;
schedule.push({
period,
openingBalance: money(openingBalance),
interest: money(interest),
principal: money(principalPayment),
fee: money(recurringFee),
totalPayment: money(payment + recurringFee),
closingBalance: money(balance),
type: isGrace
? ('interest_only' as const)
: ('amortization' as const),
});
}
const totalInterest = schedule.reduce(
(total, row) => total + decimalToInteger(row.interest, 2, 'Juro'),
0n,
);
const totalFees = upfrontFee + recurringFee * BigInt(periods);
return {
periodicRatePercent: ratePercent(periodicRate),
netDisbursed: money(principal - upfrontFee),
upfrontFee: money(upfrontFee),
totalInterest: money(totalInterest),
totalFees: money(totalFees),
totalPaid: money(principal + totalInterest + totalFees),
schedule,
status: 'estimated' as const,
};
}
/** A simple one-period budget allows an operating deficit; it is not forced to zero. */
export function calculateBudget(incomes: string[], expenses: string[]) {
if (incomes.length === 0 || expenses.length === 0)
throw new Error(
'Introduza pelo menos uma linha de receitas e uma linha de despesas; use zero apenas quando esse valor for conhecido.',
);
const incomeTotal = incomes.reduce(
(sum, value) => sum + decimalToInteger(value, 2, 'Receita'),
0n,
);
const expenseTotal = expenses.reduce(
(sum, value) => sum + decimalToInteger(value, 2, 'Despesa'),
0n,
);
return {
incomeTotal: money(incomeTotal),
expenseTotal: money(expenseTotal),
operatingBalance: money(incomeTotal - expenseTotal),
status: 'estimated' as const,
};
}
type IndependentCalculation<T> = {
value: T | null;
error: string | null;
};
function calculateIndependently<T>(
operation: () => T,
): IndependentCalculation<T> {
try {
return { value: operation(), error: null };
} catch (cause) {
return {
value: null,
error:
cause instanceof Error
? cause.message
: 'Verifique os valores introduzidos.',
};
}
}
export function calculateFinancialTools(input: {
loan: LoanScheduleInput;
breakEven: {
fixedCosts: string;
unitPrice: string;
variableCostPerUnit: string;
};
funding: { totalInvestment: string; securedFunding: string };
incomes: string[];
expenses: string[];
}) {
return {
loan: calculateIndependently(() => buildLoanSchedule(input.loan)),
breakEven: calculateIndependently(() =>
calculateBreakEven(
input.breakEven.fixedCosts,
input.breakEven.unitPrice,
input.breakEven.variableCostPerUnit,
),
),
funding: calculateIndependently(() =>
calculateFundingGap(
input.funding.totalInvestment,
input.funding.securedFunding,
),
),
budget: calculateIndependently(() =>
calculateBudget(input.incomes, input.expenses),
),
};
}
export type FinancialToolsReportInput = {
currency: string;
generatedAt?: string;
budgetPeriod: string;
loan: LoanScheduleInput;
breakEven: {
fixedCosts: string;
unitPrice: string;
variableCostPerUnit: string;
};
funding: { totalInvestment: string; securedFunding: string };
incomes: { label: string; amount: string }[];
expenses: { label: string; amount: string }[];
};
/** Reproducible public report matching the independent calculator outputs. */
export function exportFinancialToolsCsv(
input: FinancialToolsReportInput,
): string {
const loan = buildLoanSchedule(input.loan);
const breakEven = calculateBreakEven(
input.breakEven.fixedCosts,
input.breakEven.unitPrice,
input.breakEven.variableCostPerUnit,
);
const funding = calculateFundingGap(
input.funding.totalInvestment,
input.funding.securedFunding,
);
const budget = calculateBudget(
input.incomes.map((row) => row.amount),
input.expenses.map((row) => row.amount),
);
const rows: (string | number)[][] = [
['Relatório', 'Ferramentas de planeamento financeiro (indicativo)'],
['Versão do modelo', 'financial-tools-v1'],
['Data de exportação', input.generatedAt ?? new Date().toISOString()],
['Moeda', input.currency],
['Estado', 'Estimativa com pressupostos introduzidos pelo utilizador'],
[
'Fonte dos pressupostos',
'Valores editaveis desta ferramenta; os valores iniciais sao exemplos demonstrativos',
],
['Período do orçamento e dos custos fixos', input.budgetPeriod],
[
'Fórmula da prestação',
'Saldo decrescente; taxa nominal divide pelos períodos ou taxa efectiva convertida; última prestação ajusta resíduos',
],
[
'Fórmula do ponto de equilíbrio',
'custos fixos / (preço unitário - custo variável unitário), unidades arredondadas para cima',
],
[
'Fórmula do financiamento',
'investimento total - financiamento mobilizado; saldo negativo indica excedente declarado',
],
['Fórmula do orçamento', 'soma das receitas - soma das despesas'],
[
'Limitações',
'Não é proposta bancária nem aconselhamento; não inclui impostos; taxas e condições são pressupostos do utilizador.',
],
[],
['Pressupostos do empréstimo'],
['Capital', input.loan.principal],
['Taxa anual (%)', input.loan.annualRatePercent],
[
'Tipo de taxa',
input.loan.rateType === 'nominal' ? 'Nominal' : 'Efectiva',
],
['Prazo (meses)', input.loan.termMonths],
['Periodicidade (meses)', input.loan.periodMonths],
[
'Arredondamento',
'Montantes a duas casas decimais; juros e prestação arredondados à unidade monetária mínima, metade para cima; última prestação ajusta o saldo residual.',
],
['Carência (períodos)', input.loan.gracePeriods ?? 0],
['Comissão inicial', input.loan.upfrontFee ?? '0'],
['Comissão periódica', input.loan.recurringFee ?? '0'],
['Taxa por período (%)', loan.periodicRatePercent],
['Desembolso líquido', loan.netDisbursed],
['Juros totais', loan.totalInterest],
['Comissões totais', loan.totalFees],
['Total pago', loan.totalPaid],
[],
['Pressupostos do ponto de equilíbrio'],
['Custos fixos do período', input.breakEven.fixedCosts],
['Preço unitário', input.breakEven.unitPrice],
['Custo variável unitário', input.breakEven.variableCostPerUnit],
['Margem de contribuição unitária', breakEven.contributionPerUnit],
['Unidades de equilíbrio', breakEven.breakEvenUnits],
['Receita de equilíbrio', breakEven.breakEvenRevenue],
[],
['Necessidade de financiamento'],
['Investimento total', input.funding.totalInvestment],
['Financiamento mobilizado', input.funding.securedFunding],
['Diferença por financiar', funding.gap],
[],
['Orçamento', input.currency],
['Categoria', 'Descrição', 'Montante'],
...input.incomes.map((row) => ['Receita', row.label, row.amount]),
...input.expenses.map((row) => ['Despesa', row.label, row.amount]),
['Total de receitas', '', budget.incomeTotal],
['Total de despesas', '', budget.expenseTotal],
['Saldo operacional', '', budget.operatingBalance],
[],
['Plano de reembolso', input.currency],
[
'Período',
'Saldo inicial',
'Juros',
'Capital',
'Comissão',
'Prestação total',
'Saldo final',
],
...loan.schedule.map((row) => [
row.period,
row.openingBalance,
row.interest,
row.principal,
row.fee,
row.totalPayment,
row.closingBalance,
]),
];
return `\uFEFF${rows.map((row) => row.map(csvCell).join(',')).join('\r\n')}`;
}