533 lines
18 KiB
TypeScript
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')}`;
|
|
}
|