339 lines
12 KiB
JavaScript
339 lines
12 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
import {
|
|
buildLoanSchedule,
|
|
calculateBreakEven,
|
|
calculateBudget,
|
|
calculateFinancialTools,
|
|
calculateFundingGap,
|
|
calculateLoanPayment,
|
|
exportFinancialToolsCsv,
|
|
restoreFinancialToolsSnapshot,
|
|
serializeFinancialToolsSnapshot,
|
|
} from '../../resources/js/lib/financial-calculators.ts';
|
|
|
|
function cents(value) {
|
|
const [whole, fraction = ''] = value.split('.');
|
|
return BigInt(whole) * 100n + BigInt(fraction.padEnd(2, '0'));
|
|
}
|
|
|
|
void test('financial tools snapshot persists editable calculations and rejects malformed data', () => {
|
|
const data = {
|
|
budgetPeriod: 'Mensal',
|
|
loan: {
|
|
principal: '100000',
|
|
rate: '18',
|
|
months: '24',
|
|
rateType: 'nominal',
|
|
periodMonths: '1',
|
|
gracePeriods: '0',
|
|
upfrontFee: '0',
|
|
recurringFee: '0',
|
|
},
|
|
breakEven: { fixed: '50000', price: '1000', variable: '600' },
|
|
funding: { total: '1000000', secured: '250000' },
|
|
incomes: [{ label: 'Vendas', amount: '200000' }],
|
|
expenses: [{ label: 'Renda', amount: '15000' }],
|
|
};
|
|
const snapshot = serializeFinancialToolsSnapshot(data);
|
|
assert.deepEqual(restoreFinancialToolsSnapshot(snapshot), data);
|
|
assert.equal(restoreFinancialToolsSnapshot('{"version":2}'), null);
|
|
assert.equal(restoreFinancialToolsSnapshot('x'.repeat(30001)), null);
|
|
assert.equal(
|
|
restoreFinancialToolsSnapshot(
|
|
serializeFinancialToolsSnapshot({ ...data, incomes: [] }),
|
|
),
|
|
null,
|
|
);
|
|
assert.equal(
|
|
restoreFinancialToolsSnapshot(
|
|
serializeFinancialToolsSnapshot({
|
|
...data,
|
|
incomes: Array.from({ length: 51 }, () => ({
|
|
label: 'x',
|
|
amount: '1',
|
|
})),
|
|
}),
|
|
),
|
|
null,
|
|
);
|
|
});
|
|
|
|
void test('loan payment handles zero interest exactly', () => {
|
|
assert.deepEqual(calculateLoanPayment('1200.00', '0', 12), {
|
|
monthlyPayment: '100.00',
|
|
totalPaid: '1200.00',
|
|
status: 'estimated',
|
|
});
|
|
});
|
|
|
|
void test('loan payment computes a reproducible amortized estimate', () => {
|
|
assert.deepEqual(calculateLoanPayment('1000.00', '12', 12), {
|
|
monthlyPayment: '88.85',
|
|
totalPaid: '1066.19',
|
|
status: 'estimated',
|
|
});
|
|
});
|
|
|
|
void test('break-even rounds required units upward and rejects nonpositive margin', () => {
|
|
assert.deepEqual(calculateBreakEven('100.00', '30.00', '20.00'), {
|
|
contributionPerUnit: '10.00',
|
|
breakEvenUnits: '10',
|
|
breakEvenRevenue: '300.00',
|
|
status: 'estimated',
|
|
});
|
|
assert.throws(() => calculateBreakEven('10', '20', '20'), /superior/);
|
|
});
|
|
|
|
void test('an invalid indicator does not hide independent financial calculations', () => {
|
|
const result = calculateFinancialTools({
|
|
loan: {
|
|
principal: '1200',
|
|
annualRatePercent: '0',
|
|
termMonths: 12,
|
|
periodMonths: 1,
|
|
rateType: 'nominal',
|
|
},
|
|
breakEven: {
|
|
fixedCosts: '100',
|
|
unitPrice: '20',
|
|
variableCostPerUnit: '20',
|
|
},
|
|
funding: { totalInvestment: '5000', securedFunding: '1000' },
|
|
incomes: ['500'],
|
|
expenses: ['600'],
|
|
});
|
|
|
|
assert.equal(result.loan.value?.schedule[0].totalPayment, '100.00');
|
|
assert.match(result.breakEven.error ?? '', /superior/);
|
|
assert.equal(result.funding.value?.gap, '4000.00');
|
|
assert.equal(result.budget.value?.operatingBalance, '-100.00');
|
|
});
|
|
|
|
void test('funding gap distinguishes zero from a negative surplus', () => {
|
|
assert.deepEqual(calculateFundingGap('100.00', '120.00'), {
|
|
gap: '-20.00',
|
|
reconciles: false,
|
|
status: 'estimated',
|
|
});
|
|
});
|
|
|
|
void test('invalid precision and negative inputs are rejected', () => {
|
|
assert.throws(() => calculateFundingGap('-1', '0'), /decimal/);
|
|
assert.throws(() => calculateLoanPayment('1.001', '0', 1), /casas/);
|
|
});
|
|
|
|
void test('loan schedule supports nominal rates, interest-only grace and separate fees', () => {
|
|
const result = buildLoanSchedule({
|
|
principal: '1200.00',
|
|
annualRatePercent: '12',
|
|
termMonths: 4,
|
|
periodMonths: 1,
|
|
rateType: 'nominal',
|
|
gracePeriods: 2,
|
|
upfrontFee: '10',
|
|
recurringFee: '2',
|
|
});
|
|
assert.equal(result.periodicRatePercent, '1.000000');
|
|
assert.equal(result.netDisbursed, '1190.00');
|
|
assert.equal(result.schedule[0].interest, '12.00');
|
|
assert.equal(result.schedule[0].principal, '0.00');
|
|
assert.equal(result.schedule[0].fee, '2.00');
|
|
assert.equal(result.schedule.at(-1).closingBalance, '0.00');
|
|
assert.equal(
|
|
result.schedule.reduce((sum, row) => sum + cents(row.principal), 0n),
|
|
120000n,
|
|
);
|
|
assert.equal(
|
|
result.schedule.reduce(
|
|
(sum, row) => sum + cents(row.totalPayment),
|
|
0n,
|
|
) + cents(result.upfrontFee),
|
|
cents(result.totalPaid),
|
|
);
|
|
for (const row of result.schedule) {
|
|
assert.equal(
|
|
cents(row.openingBalance) - cents(row.principal),
|
|
cents(row.closingBalance),
|
|
);
|
|
}
|
|
});
|
|
|
|
void test('loan schedule reconciles exact cents and adjusts the final installment', () => {
|
|
const result = buildLoanSchedule({
|
|
principal: '1000.01',
|
|
annualRatePercent: '7.123456',
|
|
termMonths: 7,
|
|
periodMonths: 1,
|
|
rateType: 'nominal',
|
|
});
|
|
assert.equal(result.periodicRatePercent, '0.593621');
|
|
assert.equal(result.totalInterest, '23.88');
|
|
assert.equal(result.totalPaid, '1023.89');
|
|
assert.equal(result.schedule.at(-1).closingBalance, '0.00');
|
|
assert.equal(
|
|
result.schedule.reduce((sum, row) => sum + cents(row.principal), 0n),
|
|
100001n,
|
|
);
|
|
assert.equal(
|
|
result.schedule.reduce((sum, row) => sum + cents(row.totalPayment), 0n),
|
|
cents(result.totalPaid),
|
|
);
|
|
});
|
|
|
|
void test('effective annual rate is converted to the selected repayment period', () => {
|
|
const result = buildLoanSchedule({
|
|
principal: '1000',
|
|
annualRatePercent: '12',
|
|
termMonths: 12,
|
|
periodMonths: 12,
|
|
rateType: 'effective',
|
|
});
|
|
assert.equal(result.periodicRatePercent, '12.000000');
|
|
assert.equal(result.schedule[0].interest, '120.00');
|
|
assert.equal(result.totalPaid, '1120.00');
|
|
const quarterly = buildLoanSchedule({
|
|
principal: '1000',
|
|
annualRatePercent: '12',
|
|
termMonths: 12,
|
|
periodMonths: 3,
|
|
rateType: 'effective',
|
|
});
|
|
assert.equal(quarterly.periodicRatePercent, '2.873734');
|
|
assert.equal(quarterly.schedule.at(-1).closingBalance, '0.00');
|
|
});
|
|
|
|
void test('loan schedules reconcile across rates, periodicities and grace periods', () => {
|
|
for (const periodMonths of [1, 2, 3, 4, 6, 12]) {
|
|
for (const rateType of ['nominal', 'effective']) {
|
|
for (const annualRatePercent of ['0', '8.500000', '24']) {
|
|
for (const gracePeriods of [0, 2]) {
|
|
const result = buildLoanSchedule({
|
|
principal: '12345.67',
|
|
annualRatePercent,
|
|
termMonths: periodMonths * 12,
|
|
periodMonths,
|
|
rateType,
|
|
gracePeriods,
|
|
upfrontFee: '15.25',
|
|
recurringFee: '2.37',
|
|
});
|
|
|
|
assert.equal(result.schedule.length, 12);
|
|
assert.equal(result.schedule.at(-1).closingBalance, '0.00');
|
|
assert.equal(
|
|
result.schedule.reduce(
|
|
(sum, row) => sum + cents(row.principal),
|
|
0n,
|
|
),
|
|
1_234_567n,
|
|
);
|
|
for (const row of result.schedule) {
|
|
assert.equal(
|
|
cents(row.openingBalance) - cents(row.principal),
|
|
cents(row.closingBalance),
|
|
);
|
|
assert.ok(cents(row.closingBalance) >= 0n);
|
|
}
|
|
assert.equal(
|
|
result.schedule.reduce(
|
|
(sum, row) => sum + cents(row.totalPayment),
|
|
0n,
|
|
) + cents(result.upfrontFee),
|
|
cents(result.totalPaid),
|
|
);
|
|
assert.equal(
|
|
cents(result.totalPaid),
|
|
1_234_567n +
|
|
cents(result.totalInterest) +
|
|
cents(result.totalFees),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
void test('budget preserves an operating deficit and rejects invalid values', () => {
|
|
assert.deepEqual(calculateBudget(['100.00', '25.50'], ['140.00']), {
|
|
incomeTotal: '125.50',
|
|
expenseTotal: '140.00',
|
|
operatingBalance: '-14.50',
|
|
status: 'estimated',
|
|
});
|
|
assert.throws(() => calculateBudget(['0'], ['-1']), /não negativo/);
|
|
});
|
|
|
|
void test('budget distinguishes absent income or expense rows from explicit zero', () => {
|
|
assert.throws(() => calculateBudget([], ['0']), /pelo menos uma linha/);
|
|
assert.throws(() => calculateBudget(['0'], []), /pelo menos uma linha/);
|
|
assert.deepEqual(calculateBudget(['0'], ['0']), {
|
|
incomeTotal: '0.00',
|
|
expenseTotal: '0.00',
|
|
operatingBalance: '0.00',
|
|
status: 'estimated',
|
|
});
|
|
});
|
|
|
|
void test('loan terms reject invalid periodicity, excessive grace and commission larger than principal', () => {
|
|
const base = {
|
|
principal: '1000',
|
|
annualRatePercent: '0',
|
|
termMonths: 12,
|
|
periodMonths: 1,
|
|
rateType: 'nominal',
|
|
};
|
|
assert.throws(
|
|
() => buildLoanSchedule({ ...base, periodMonths: 5 }),
|
|
/Periodicidade/,
|
|
);
|
|
assert.throws(
|
|
() => buildLoanSchedule({ ...base, gracePeriods: 12 }),
|
|
/carência/,
|
|
);
|
|
assert.throws(
|
|
() => buildLoanSchedule({ ...base, upfrontFee: '1001' }),
|
|
/exceder o capital/,
|
|
);
|
|
});
|
|
|
|
void test('financial tools CSV records reproducible assumptions, periods, formulas and matching results', () => {
|
|
const csv = exportFinancialToolsCsv({
|
|
currency: 'MZN',
|
|
generatedAt: '2026-10-02T10:00:00.000Z',
|
|
budgetPeriod: 'Mensal',
|
|
loan: {
|
|
principal: '1200',
|
|
annualRatePercent: '12',
|
|
termMonths: 12,
|
|
periodMonths: 1,
|
|
rateType: 'nominal',
|
|
gracePeriods: 0,
|
|
upfrontFee: '10',
|
|
recurringFee: '2',
|
|
},
|
|
breakEven: {
|
|
fixedCosts: '1000',
|
|
unitPrice: '30',
|
|
variableCostPerUnit: '20',
|
|
},
|
|
funding: { totalInvestment: '5000', securedFunding: '3000' },
|
|
incomes: [
|
|
{ label: '=HYPERLINK("https://example.invalid")', amount: '2500' },
|
|
],
|
|
expenses: [{ label: 'Operação, mensal', amount: '1800' }],
|
|
});
|
|
assert.match(csv, /^\uFEFF/);
|
|
assert.match(csv, /financial-tools-v1/);
|
|
assert.match(csv, /valores iniciais sao exemplos demonstrativos/);
|
|
assert.match(csv, /2026-10-02T10:00:00\.000Z/);
|
|
assert.match(csv, /Período do orçamento e dos custos fixos,Mensal/);
|
|
assert.match(csv, /saldo decrescente/i);
|
|
assert.match(csv, /Saldo operacional,,700\.00/);
|
|
assert.match(csv, /Unidades de equilíbrio,100/);
|
|
assert.match(csv, /Diferença por financiar,2000\.00/);
|
|
assert.match(csv, /"'=HYPERLINK/);
|
|
assert.match(csv, /"Operação, mensal"/);
|
|
assert.match(csv, /não inclui impostos/);
|
|
});
|