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; const isText = (value: unknown, limit = 80) => typeof value === 'string' && value.length <= limit; const loan = data.loan as Record | null; const breakEven = data.breakEven as Record | null; const funding = data.funding as Record | 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 = { value: T | null; error: string | null; }; function calculateIndependently( operation: () => T, ): IndependentCalculation { 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')}`; }