2056 lines
69 KiB
JavaScript
2056 lines
69 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
import {
|
|
compareBusinessScenarios,
|
|
exportBusinessPlanCsv,
|
|
forecastBusinessPlan,
|
|
restoreBusinessPlanSnapshot,
|
|
serializeBusinessPlanSnapshot,
|
|
} from '../../resources/js/lib/business-financial-plan.ts';
|
|
import {
|
|
partitionSimulationDraft,
|
|
restoreSimulationDraft,
|
|
} from '../../resources/js/lib/simulation-form.ts';
|
|
|
|
const assumptions = {
|
|
currency: 'MZN',
|
|
openingCash: '0',
|
|
products: [
|
|
{
|
|
label: 'Serviço',
|
|
monthlyUnits: '100',
|
|
unitPrice: '1000',
|
|
unitVariableCost: '400',
|
|
},
|
|
],
|
|
monthlyCollections: '90000',
|
|
salesCollectionLagMonths: null,
|
|
salesCollectionPercent: '100',
|
|
salesRecoverySchedule: [],
|
|
monthlyVariableCostPayments: '42000',
|
|
monthlyVariableCostPaymentsByPeriod: null,
|
|
monthlyInventoryPurchases: '40000',
|
|
inventoryPurchaseLeadMonths: 0,
|
|
inventoryPurchaseSchedule: null,
|
|
useInventoryPurchaseSchedule: false,
|
|
useProductInventorySchedule: false,
|
|
useSupplierPaymentTerms: false,
|
|
monthlyOperatingExpenses: '20000',
|
|
monthlyOperatingPayments: '18000',
|
|
monthlyPayrollExpense: '15000',
|
|
monthlyPayrollPayments: '15000',
|
|
assetInvestment: '100000',
|
|
inventory: '20000',
|
|
receivables: '10000',
|
|
openingReceivablesCollectionMonth: null,
|
|
openingReceivablesCollectionPercent: '100',
|
|
openingReceivablesInstallments: [],
|
|
openingPayablesInstallments: [],
|
|
payables: '5000',
|
|
equity: '80000',
|
|
shareholderLoans: '0',
|
|
bankLoanProceeds: '50000',
|
|
grants: '0',
|
|
assetDepreciation: '100000',
|
|
depreciationLifeMonths: 60,
|
|
monthlyInterestExpense: '1200',
|
|
monthlyInterestPayment: '1000',
|
|
monthlyPrincipalRepayment: '5000',
|
|
periods: 12,
|
|
};
|
|
|
|
void test('financial forecast reconciles revenue, finance sources, working capital and monthly cash', () => {
|
|
const result = forecastBusinessPlan(assumptions);
|
|
assert.equal(result.workingCapital, '25000.00');
|
|
assert.equal(result.initialUses, '125000.00');
|
|
assert.equal(result.financing, '130000.00');
|
|
assert.equal(result.fundingGap, '-5000.00');
|
|
assert.equal(result.fundingStatus, 'surplus');
|
|
assert.equal(
|
|
forecastBusinessPlan({ ...assumptions, equity: '75000' }).fundingStatus,
|
|
'balanced',
|
|
);
|
|
assert.equal(
|
|
forecastBusinessPlan({ ...assumptions, equity: '0' }).fundingStatus,
|
|
'shortfall',
|
|
);
|
|
assert.equal(result.openingCashAfterInitialUses, '5000.00');
|
|
assert.equal(result.totalRevenue, '1200000.00');
|
|
assert.equal(result.totalCashCollections, '1080000.00');
|
|
assert.equal(result.inventoryProjection.status, 'estimated');
|
|
assert.equal(result.inventoryProjection.endingInventory, '20000.00');
|
|
assert.equal(result.rows[0].inventoryConsumption, '40000.00');
|
|
assert.equal(result.rows[0].revenue, '100000.00');
|
|
assert.equal(result.rows[0].cashCollections, '90000.00');
|
|
assert.equal(result.rows[0].salesCollections, null);
|
|
assert.equal(result.totalSalesCollections, null);
|
|
assert.equal(result.rows[0].principalRepayment, '5000.00');
|
|
assert.equal(result.rows[0].interestExpense, '1200.00');
|
|
assert.equal(result.rows[0].depreciation, '1666.67');
|
|
assert.equal(result.endingCash, '113000.00');
|
|
});
|
|
|
|
void test('opening payable installments reduce cash without changing profit and retain unpaid balances', () => {
|
|
const opening = {
|
|
...assumptions,
|
|
periods: 3,
|
|
products: [
|
|
{
|
|
...assumptions.products[0],
|
|
monthlyUnits: '0',
|
|
unitPrice: '0',
|
|
unitVariableCost: '0',
|
|
},
|
|
],
|
|
monthlyCollections: '0',
|
|
monthlyVariableCostPayments: '0',
|
|
monthlyInventoryPurchases: '0',
|
|
monthlyOperatingExpenses: '0',
|
|
monthlyOperatingPayments: '0',
|
|
monthlyPayrollExpense: '0',
|
|
monthlyPayrollPayments: '0',
|
|
assetInvestment: '1000',
|
|
inventory: '0',
|
|
receivables: '0',
|
|
payables: '1000',
|
|
equity: '0',
|
|
shareholderLoans: '0',
|
|
bankLoanProceeds: '0',
|
|
grants: '0',
|
|
assetDepreciation: '0',
|
|
monthlyInterestExpense: '0',
|
|
monthlyInterestPayment: '0',
|
|
monthlyPrincipalRepayment: '0',
|
|
};
|
|
const unscheduled = forecastBusinessPlan(opening);
|
|
const scheduled = forecastBusinessPlan({
|
|
...opening,
|
|
openingPayablesInstallments: [
|
|
{ month: 1, percent: '50' },
|
|
{ month: 3, percent: '25' },
|
|
],
|
|
});
|
|
|
|
assert.equal(unscheduled.initialUses, '0.00');
|
|
assert.equal(unscheduled.endingCash, '0.00');
|
|
assert.equal(unscheduled.openingPayablesAtEnd, '1000.00');
|
|
assert.deepEqual(
|
|
scheduled.rows.map((row) => row.openingPayablesPayments),
|
|
['500.00', '0.00', '250.00'],
|
|
);
|
|
assert.deepEqual(
|
|
scheduled.rows.map((row) => row.openingPayablesBalance),
|
|
['500.00', '500.00', '250.00'],
|
|
);
|
|
assert.equal(scheduled.totalOpeningPayablesPayments, '750.00');
|
|
assert.equal(scheduled.openingPayablesAtEnd, '250.00');
|
|
assert.equal(scheduled.totalPayments, '750.00');
|
|
assert.equal(scheduled.endingCash, '-750.00');
|
|
assert.equal(
|
|
scheduled.totalProfitBeforeTax,
|
|
unscheduled.totalProfitBeforeTax,
|
|
);
|
|
assert.throws(
|
|
() =>
|
|
forecastBusinessPlan({
|
|
...opening,
|
|
openingPayablesInstallments: [
|
|
{ month: 1, percent: '60' },
|
|
{ month: 2, percent: '40.01' },
|
|
],
|
|
}),
|
|
/100%/,
|
|
);
|
|
assert.throws(
|
|
() =>
|
|
forecastBusinessPlan({
|
|
...opening,
|
|
openingPayablesInstallments: [{ month: 4, percent: '50' }],
|
|
}),
|
|
/dentro do horizonte/,
|
|
);
|
|
});
|
|
|
|
void test('opening payable schedule caps cent rounding at the remaining balance', () => {
|
|
const rounded = forecastBusinessPlan({
|
|
...assumptions,
|
|
periods: 2,
|
|
products: [
|
|
{
|
|
...assumptions.products[0],
|
|
monthlyUnits: '0',
|
|
unitPrice: '0',
|
|
unitVariableCost: '0',
|
|
},
|
|
],
|
|
monthlyCollections: '0',
|
|
monthlyVariableCostPayments: '0',
|
|
monthlyOperatingExpenses: '0',
|
|
monthlyOperatingPayments: '0',
|
|
monthlyPayrollExpense: '0',
|
|
monthlyPayrollPayments: '0',
|
|
assetInvestment: '0.01',
|
|
inventory: '0',
|
|
receivables: '0',
|
|
payables: '0.01',
|
|
equity: '0',
|
|
shareholderLoans: '0',
|
|
bankLoanProceeds: '0',
|
|
grants: '0',
|
|
assetDepreciation: '0',
|
|
monthlyInterestExpense: '0',
|
|
monthlyInterestPayment: '0',
|
|
monthlyPrincipalRepayment: '0',
|
|
openingPayablesInstallments: [
|
|
{ month: 1, percent: '50' },
|
|
{ month: 2, percent: '50' },
|
|
],
|
|
});
|
|
assert.deepEqual(
|
|
rounded.rows.map((row) => row.openingPayablesPayments),
|
|
['0.01', '0.00'],
|
|
);
|
|
assert.equal(rounded.openingPayablesAtEnd, '0.00');
|
|
});
|
|
|
|
void test('monthly unit schedules drive revenue, costs, inventory and scenario volume', () => {
|
|
const seasonal = {
|
|
...assumptions,
|
|
periods: 3,
|
|
products: [
|
|
{
|
|
...assumptions.products[0],
|
|
monthlyUnitsByPeriod: ['100', '50', '0', '20'],
|
|
},
|
|
],
|
|
monthlyCollections: '0',
|
|
monthlyVariableCostPayments: '0',
|
|
monthlyInventoryPurchases: '0',
|
|
monthlyOperatingExpenses: '0',
|
|
monthlyOperatingPayments: '0',
|
|
monthlyPayrollExpense: '0',
|
|
monthlyPayrollPayments: '0',
|
|
assetInvestment: '0',
|
|
inventory: '0',
|
|
receivables: '0',
|
|
payables: '0',
|
|
equity: '0',
|
|
bankLoanProceeds: '0',
|
|
assetDepreciation: '0',
|
|
monthlyInterestExpense: '0',
|
|
monthlyInterestPayment: '0',
|
|
monthlyPrincipalRepayment: '0',
|
|
};
|
|
const result = forecastBusinessPlan(seasonal);
|
|
assert.deepEqual(
|
|
result.rows.map((row) => row.revenue),
|
|
['100000.00', '50000.00', '0.00'],
|
|
);
|
|
assert.deepEqual(
|
|
result.rows.map((row) => row.variableCost),
|
|
['40000.00', '20000.00', '0.00'],
|
|
);
|
|
assert.equal(result.totalRevenue, '150000.00');
|
|
assert.equal(result.inventoryProjection.totalConsumption, '60000.00');
|
|
assert.equal(
|
|
forecastBusinessPlan({ ...seasonal, periods: 2 }).totalRevenue,
|
|
'150000.00',
|
|
);
|
|
|
|
const scenarios = compareBusinessScenarios({
|
|
...seasonal,
|
|
scenarioAdjustments: {
|
|
favorable: {
|
|
unitVolumePercent: '20',
|
|
unitPricePercent: '0',
|
|
unitCostPercent: '0',
|
|
operatingCostsPercent: '0',
|
|
cashCollectionsPercent: '0',
|
|
},
|
|
unfavorable: {
|
|
unitVolumePercent: '-20',
|
|
unitPricePercent: '0',
|
|
unitCostPercent: '0',
|
|
operatingCostsPercent: '0',
|
|
cashCollectionsPercent: '0',
|
|
},
|
|
},
|
|
});
|
|
assert.deepEqual(
|
|
scenarios.favorable.result.rows.map((row) => row.revenue),
|
|
['120000.00', '60000.00', '0.00'],
|
|
);
|
|
assert.deepEqual(
|
|
scenarios.unfavorable.result.rows.map((row) => row.revenue),
|
|
['80000.00', '40000.00', '0.00'],
|
|
);
|
|
});
|
|
|
|
void test('monthly unit schedules must match the horizon and contain valid quantities', () => {
|
|
assert.throws(
|
|
() =>
|
|
forecastBusinessPlan({
|
|
...assumptions,
|
|
periods: 2,
|
|
products: [
|
|
{
|
|
...assumptions.products[0],
|
|
monthlyUnitsByPeriod: ['10'],
|
|
},
|
|
],
|
|
}),
|
|
/cobrir os 2 meses/,
|
|
);
|
|
assert.throws(
|
|
() =>
|
|
forecastBusinessPlan({
|
|
...assumptions,
|
|
periods: 1,
|
|
products: [
|
|
{
|
|
...assumptions.products[0],
|
|
monthlyUnitsByPeriod: ['-1'],
|
|
},
|
|
],
|
|
}),
|
|
/Quantidade/,
|
|
);
|
|
assert.throws(
|
|
() =>
|
|
forecastBusinessPlan({
|
|
...assumptions,
|
|
periods: 1,
|
|
products: [
|
|
{
|
|
...assumptions.products[0],
|
|
monthlyUnitsByPeriod: ['1.0001'],
|
|
},
|
|
],
|
|
}),
|
|
/Quantidade/,
|
|
);
|
|
});
|
|
|
|
void test('monthly prices and unit costs affect revenue, consumption and scenarios', () => {
|
|
const plan = {
|
|
...assumptions,
|
|
periods: 3,
|
|
products: [
|
|
{
|
|
...assumptions.products[0],
|
|
monthlyUnitsByPeriod: ['100', '50', '0', '20'],
|
|
unitPriceByPeriod: ['1000', '1200', '900', '500'],
|
|
unitVariableCostByPeriod: ['400', '550', '300', '200'],
|
|
},
|
|
],
|
|
};
|
|
const result = forecastBusinessPlan(plan);
|
|
assert.deepEqual(
|
|
result.rows.map((row) => row.revenue),
|
|
['100000.00', '60000.00', '0.00'],
|
|
);
|
|
assert.deepEqual(
|
|
result.rows.map((row) => row.variableCost),
|
|
['40000.00', '27500.00', '0.00'],
|
|
);
|
|
assert.equal(result.totalRevenue, '160000.00');
|
|
assert.equal(result.inventoryProjection.totalConsumption, '67500.00');
|
|
|
|
const scenarios = compareBusinessScenarios({
|
|
...plan,
|
|
scenarioAdjustments: {
|
|
favorable: {
|
|
unitVolumePercent: '0',
|
|
unitPricePercent: '10',
|
|
unitCostPercent: '-20',
|
|
operatingCostsPercent: '0',
|
|
cashCollectionsPercent: '0',
|
|
},
|
|
unfavorable: {
|
|
unitVolumePercent: '0',
|
|
unitPricePercent: '-10',
|
|
unitCostPercent: '20',
|
|
operatingCostsPercent: '0',
|
|
cashCollectionsPercent: '0',
|
|
},
|
|
},
|
|
});
|
|
assert.deepEqual(
|
|
scenarios.favorable.result.rows.map((row) => row.revenue),
|
|
['110000.00', '66000.00', '0.00'],
|
|
);
|
|
assert.deepEqual(
|
|
scenarios.favorable.result.rows.map((row) => row.variableCost),
|
|
['32000.00', '22000.00', '0.00'],
|
|
);
|
|
});
|
|
|
|
void test('monthly money schedules cover the horizon and reject invalid amounts', () => {
|
|
assert.throws(
|
|
() =>
|
|
forecastBusinessPlan({
|
|
...assumptions,
|
|
periods: 2,
|
|
products: [
|
|
{
|
|
...assumptions.products[0],
|
|
unitPriceByPeriod: ['1000'],
|
|
},
|
|
],
|
|
}),
|
|
/agenda deve cobrir os 2 meses/,
|
|
);
|
|
assert.throws(
|
|
() =>
|
|
forecastBusinessPlan({
|
|
...assumptions,
|
|
periods: 1,
|
|
products: [
|
|
{
|
|
...assumptions.products[0],
|
|
unitVariableCostByPeriod: ['-1'],
|
|
},
|
|
],
|
|
}),
|
|
/não negativo/,
|
|
);
|
|
assert.throws(
|
|
() =>
|
|
forecastBusinessPlan({
|
|
...assumptions,
|
|
periods: 1,
|
|
products: [
|
|
{
|
|
...assumptions.products[0],
|
|
unitPriceByPeriod: ['1.001'],
|
|
},
|
|
],
|
|
}),
|
|
/até duas casas decimais/,
|
|
);
|
|
});
|
|
|
|
void test('monthly operating and payroll expense schedules remain separate from payments', () => {
|
|
const result = forecastBusinessPlan({
|
|
...assumptions,
|
|
periods: 3,
|
|
monthlyOperatingExpensesByPeriod: ['100', '200', '300'],
|
|
monthlyOperatingPaymentsByPeriod: ['0', '150', '450'],
|
|
monthlyPayrollExpenseByPeriod: ['50', '75', '100'],
|
|
monthlyPayrollPaymentsByPeriod: ['25', '100', '100'],
|
|
});
|
|
assert.deepEqual(
|
|
result.rows.map((row) => row.operatingExpenses),
|
|
['100.00', '200.00', '300.00'],
|
|
);
|
|
assert.deepEqual(
|
|
result.rows.map((row) => row.operatingPayments),
|
|
['0.00', '150.00', '450.00'],
|
|
);
|
|
assert.deepEqual(
|
|
result.rows.map((row) => row.payrollExpense),
|
|
['50.00', '75.00', '100.00'],
|
|
);
|
|
assert.deepEqual(
|
|
result.rows.map((row) => row.payrollPayments),
|
|
['25.00', '100.00', '100.00'],
|
|
);
|
|
assert.equal(result.totalProfitBeforeTax, '170574.99');
|
|
|
|
const scenarios = compareBusinessScenarios({
|
|
...assumptions,
|
|
periods: 1,
|
|
monthlyOperatingExpensesByPeriod: ['1000'],
|
|
monthlyPayrollExpenseByPeriod: ['500'],
|
|
scenarioAdjustments: {
|
|
favorable: {
|
|
unitVolumePercent: '0',
|
|
unitPricePercent: '0',
|
|
unitCostPercent: '0',
|
|
operatingCostsPercent: '-10',
|
|
cashCollectionsPercent: '0',
|
|
},
|
|
unfavorable: {
|
|
unitVolumePercent: '0',
|
|
unitPricePercent: '0',
|
|
unitCostPercent: '0',
|
|
operatingCostsPercent: '10',
|
|
cashCollectionsPercent: '0',
|
|
},
|
|
},
|
|
});
|
|
assert.equal(
|
|
scenarios.favorable.result.rows[0].operatingExpenses,
|
|
'900.00',
|
|
);
|
|
assert.equal(scenarios.favorable.result.rows[0].payrollExpense, '450.00');
|
|
});
|
|
|
|
void test('monthly operating schedules require full horizon, exact cents and current snapshot version', () => {
|
|
assert.throws(
|
|
() =>
|
|
forecastBusinessPlan({
|
|
...assumptions,
|
|
periods: 2,
|
|
monthlyOperatingPaymentsByPeriod: ['10'],
|
|
}),
|
|
/deve cobrir os 2 meses/,
|
|
);
|
|
assert.throws(
|
|
() =>
|
|
forecastBusinessPlan({
|
|
...assumptions,
|
|
periods: 1,
|
|
monthlyPayrollExpenseByPeriod: ['-1'],
|
|
}),
|
|
/não negativo/,
|
|
);
|
|
const restored = restoreBusinessPlanSnapshot(
|
|
serializeBusinessPlanSnapshot({
|
|
...assumptions,
|
|
periods: 2,
|
|
monthlyOperatingExpensesByPeriod: ['100', '200'],
|
|
monthlyOperatingPaymentsByPeriod: ['25', '75'],
|
|
}),
|
|
);
|
|
assert.deepEqual(restored?.monthlyOperatingExpensesByPeriod, [
|
|
'100',
|
|
'200',
|
|
]);
|
|
assert.deepEqual(restored?.monthlyOperatingPaymentsByPeriod, ['25', '75']);
|
|
assert.equal(
|
|
restoreBusinessPlanSnapshot(
|
|
JSON.stringify({
|
|
version: 10,
|
|
plan: {
|
|
...assumptions,
|
|
monthlyOperatingExpensesByPeriod: ['100'],
|
|
},
|
|
}),
|
|
),
|
|
null,
|
|
);
|
|
const csv = exportBusinessPlanCsv({
|
|
...assumptions,
|
|
periods: 2,
|
|
monthlyOperatingExpensesByPeriod: ['100', '200'],
|
|
monthlyOperatingPaymentsByPeriod: ['25', '75'],
|
|
monthlyPayrollExpenseByPeriod: ['40', '50'],
|
|
monthlyPayrollPaymentsByPeriod: ['30', '60'],
|
|
});
|
|
assert.ok(csv.includes('"[""100"",""200""]"'));
|
|
assert.ok(csv.includes('"[""25"",""75""]"'));
|
|
assert.ok(csv.includes('"[""40"",""50""]"'));
|
|
assert.ok(csv.includes('"[""30"",""60""]"'));
|
|
assert.match(csv, /Pagamentos operacionais/);
|
|
assert.match(csv, /Pagamentos de pessoal/);
|
|
});
|
|
|
|
void test('losses and negative closing cash remain visible instead of being clamped to zero', () => {
|
|
const result = forecastBusinessPlan({
|
|
...assumptions,
|
|
openingCash: '-100',
|
|
products: [{ ...assumptions.products[0], monthlyUnits: '0' }],
|
|
monthlyCollections: '0',
|
|
monthlyVariableCostPayments: '0',
|
|
monthlyOperatingPayments: '50000',
|
|
monthlyPayrollPayments: '0',
|
|
assetInvestment: '0',
|
|
inventory: '0',
|
|
receivables: '0',
|
|
payables: '0',
|
|
equity: '0',
|
|
bankLoanProceeds: '0',
|
|
monthlyInterestPayment: '0',
|
|
monthlyPrincipalRepayment: '0',
|
|
periods: 1,
|
|
});
|
|
assert.ok(Number(result.rows[0].profitBeforeTax) < 0);
|
|
assert.ok(Number(result.endingCash) < 0);
|
|
});
|
|
|
|
void test('principal repayments affect cash but are excluded from profit expenses', () => {
|
|
const withPrincipal = forecastBusinessPlan(assumptions);
|
|
const withoutPrincipal = forecastBusinessPlan({
|
|
...assumptions,
|
|
monthlyPrincipalRepayment: '0',
|
|
});
|
|
assert.equal(
|
|
withPrincipal.rows[0].profitBeforeTax,
|
|
withoutPrincipal.rows[0].profitBeforeTax,
|
|
);
|
|
assert.equal(
|
|
Number(withoutPrincipal.rows[0].closingCash) -
|
|
Number(withPrincipal.rows[0].closingCash),
|
|
5000,
|
|
);
|
|
});
|
|
|
|
void test('depreciation allocation reconciles exactly over useful life', () => {
|
|
const result = forecastBusinessPlan({
|
|
...assumptions,
|
|
assetDepreciation: '100.00',
|
|
depreciationLifeMonths: 3,
|
|
periods: 3,
|
|
});
|
|
assert.deepEqual(
|
|
result.rows.map((row) => row.depreciation),
|
|
['33.33', '33.33', '33.34'],
|
|
);
|
|
});
|
|
|
|
void test('missing currency, products, invalid numbers and invalid horizons are explicit errors', () => {
|
|
assert.throws(
|
|
() => forecastBusinessPlan({ ...assumptions, currency: '' }),
|
|
/moeda ISO/,
|
|
);
|
|
assert.throws(
|
|
() => forecastBusinessPlan({ ...assumptions, products: [] }),
|
|
/produto/,
|
|
);
|
|
assert.throws(
|
|
() =>
|
|
forecastBusinessPlan({ ...assumptions, monthlyCollections: '-10' }),
|
|
/não negativo/,
|
|
);
|
|
assert.throws(
|
|
() => forecastBusinessPlan({ ...assumptions, periods: 61 }),
|
|
/1 e 60/,
|
|
);
|
|
assert.throws(
|
|
() =>
|
|
forecastBusinessPlan({
|
|
...assumptions,
|
|
inventoryPurchaseLeadMonths: 13,
|
|
}),
|
|
/entre 0 e 12 meses/,
|
|
);
|
|
assert.throws(
|
|
() =>
|
|
forecastBusinessPlan({
|
|
...assumptions,
|
|
inventoryPurchaseLeadMonths: 1.5,
|
|
}),
|
|
/entre 0 e 12 meses/,
|
|
);
|
|
assert.throws(
|
|
() =>
|
|
forecastBusinessPlan({
|
|
...assumptions,
|
|
salesCollectionLagMonths: 13,
|
|
}),
|
|
/prazo de cobrança.*0 e 12 meses/,
|
|
);
|
|
});
|
|
|
|
void test('versioned plan snapshots restore assumptions and reject unsupported or oversized data', () => {
|
|
const saved = serializeBusinessPlanSnapshot(assumptions);
|
|
assert.deepEqual(restoreBusinessPlanSnapshot(saved), assumptions);
|
|
assert.equal(
|
|
restoreBusinessPlanSnapshot('{"version":4,"plan":{"products":[]}}'),
|
|
null,
|
|
);
|
|
assert.equal(
|
|
restoreBusinessPlanSnapshot('{"version":"3","plan":{"products":[]}}'),
|
|
null,
|
|
);
|
|
assert.equal(
|
|
restoreBusinessPlanSnapshot('{"version":3,"plan":{"products":[]}}'),
|
|
null,
|
|
);
|
|
assert.equal(
|
|
restoreBusinessPlanSnapshot('{"version":1,"plan":{"products":[null]}}'),
|
|
null,
|
|
);
|
|
assert.equal(restoreBusinessPlanSnapshot('x'.repeat(60001)), null);
|
|
const legacyPlan = { ...assumptions };
|
|
delete legacyPlan.monthlyInventoryPurchases;
|
|
const restoredLegacy = restoreBusinessPlanSnapshot(
|
|
JSON.stringify({ version: 1, plan: legacyPlan }),
|
|
);
|
|
assert.equal(restoredLegacy?.monthlyInventoryPurchases, undefined);
|
|
assert.equal(restoredLegacy?.inventoryPurchaseLeadMonths, 0);
|
|
assert.equal(
|
|
forecastBusinessPlan(restoredLegacy).inventoryProjection.status,
|
|
'indeterminate',
|
|
);
|
|
const v2Plan = { ...assumptions };
|
|
delete v2Plan.inventoryPurchaseLeadMonths;
|
|
delete v2Plan.salesCollectionLagMonths;
|
|
assert.equal(
|
|
restoreBusinessPlanSnapshot(
|
|
JSON.stringify({ version: 2, plan: v2Plan }),
|
|
)?.inventoryPurchaseLeadMonths,
|
|
0,
|
|
);
|
|
assert.equal(
|
|
restoreBusinessPlanSnapshot(
|
|
JSON.stringify({
|
|
version: 3,
|
|
plan: { ...assumptions, salesCollectionLagMonths: undefined },
|
|
}),
|
|
)?.salesCollectionLagMonths,
|
|
null,
|
|
);
|
|
const legacyV4 = restoreBusinessPlanSnapshot(
|
|
JSON.stringify({ version: 4, plan: assumptions }),
|
|
);
|
|
assert.equal(legacyV4?.salesCollectionPercent, '100');
|
|
const legacyV5Plan = { ...assumptions };
|
|
delete legacyV5Plan.openingReceivablesCollectionMonth;
|
|
delete legacyV5Plan.openingReceivablesCollectionPercent;
|
|
delete legacyV5Plan.openingReceivablesInstallments;
|
|
const legacyV5 = restoreBusinessPlanSnapshot(
|
|
JSON.stringify({ version: 5, plan: legacyV5Plan }),
|
|
);
|
|
assert.equal(legacyV5?.openingReceivablesCollectionMonth, null);
|
|
assert.equal(legacyV5?.openingReceivablesCollectionPercent, '100');
|
|
const current = JSON.parse(serializeBusinessPlanSnapshot(assumptions));
|
|
assert.equal(current.version, 17);
|
|
assert.equal(current.plan.salesCollectionPercent, '100');
|
|
assert.equal(current.plan.openingReceivablesCollectionMonth, null);
|
|
assert.deepEqual(current.plan.openingReceivablesInstallments, []);
|
|
assert.deepEqual(current.plan.openingPayablesInstallments, []);
|
|
assert.deepEqual(current.plan.salesRecoverySchedule, []);
|
|
assert.equal(current.plan.useInventoryPurchaseSchedule, false);
|
|
assert.equal(current.plan.inventoryPurchaseSchedule, null);
|
|
const legacyV16Snapshot = JSON.parse(
|
|
serializeBusinessPlanSnapshot(assumptions),
|
|
);
|
|
legacyV16Snapshot.version = 16;
|
|
delete legacyV16Snapshot.plan.openingPayablesInstallments;
|
|
assert.deepEqual(
|
|
restoreBusinessPlanSnapshot(JSON.stringify(legacyV16Snapshot))
|
|
?.openingPayablesInstallments,
|
|
[],
|
|
);
|
|
const paymentSnapshot = serializeBusinessPlanSnapshot({
|
|
...assumptions,
|
|
periods: 2,
|
|
monthlyVariableCostPaymentsByPeriod: ['0.01', '2500.50'],
|
|
});
|
|
assert.deepEqual(restoreBusinessPlanSnapshot(paymentSnapshot), {
|
|
...assumptions,
|
|
periods: 2,
|
|
monthlyVariableCostPaymentsByPeriod: ['0.01', '2500.50'],
|
|
});
|
|
const invalidPaymentSnapshot = JSON.parse(paymentSnapshot);
|
|
invalidPaymentSnapshot.plan.monthlyVariableCostPaymentsByPeriod = ['10'];
|
|
assert.equal(
|
|
restoreBusinessPlanSnapshot(JSON.stringify(invalidPaymentSnapshot)),
|
|
null,
|
|
);
|
|
const productInventoryPlan = {
|
|
...assumptions,
|
|
periods: 2,
|
|
inventory: '1500',
|
|
useProductInventorySchedule: true,
|
|
products: [
|
|
{
|
|
...assumptions.products[0],
|
|
productInventoryOpening: '1500',
|
|
productInventoryPurchasesByPeriod: [
|
|
{ amount: '20.50', leadMonths: 1 },
|
|
{ amount: '0', leadMonths: 0 },
|
|
],
|
|
},
|
|
],
|
|
};
|
|
const productInventorySnapshot =
|
|
serializeBusinessPlanSnapshot(productInventoryPlan);
|
|
assert.deepEqual(
|
|
restoreBusinessPlanSnapshot(productInventorySnapshot),
|
|
productInventoryPlan,
|
|
);
|
|
const legacyProductInventorySnapshot = JSON.parse(productInventorySnapshot);
|
|
legacyProductInventorySnapshot.version = 14;
|
|
delete legacyProductInventorySnapshot.plan.useSupplierPaymentTerms;
|
|
assert.deepEqual(
|
|
restoreBusinessPlanSnapshot(
|
|
JSON.stringify(legacyProductInventorySnapshot),
|
|
),
|
|
productInventoryPlan,
|
|
);
|
|
const malformedProductInventorySnapshot = JSON.parse(
|
|
productInventorySnapshot,
|
|
);
|
|
malformedProductInventorySnapshot.plan.products[0].productInventoryPurchasesByPeriod[0].leadMonths = 13;
|
|
assert.equal(
|
|
restoreBusinessPlanSnapshot(
|
|
JSON.stringify(malformedProductInventorySnapshot),
|
|
),
|
|
null,
|
|
);
|
|
const supplierTermsPlan = {
|
|
...productInventoryPlan,
|
|
useSupplierPaymentTerms: true,
|
|
products: productInventoryPlan.products.map((product) => ({
|
|
...product,
|
|
productInventoryPurchasesByPeriod:
|
|
product.productInventoryPurchasesByPeriod?.map((entry) => ({
|
|
...entry,
|
|
paymentLagMonths: 2,
|
|
})),
|
|
})),
|
|
};
|
|
const supplierTermsSnapshot =
|
|
serializeBusinessPlanSnapshot(supplierTermsPlan);
|
|
assert.deepEqual(restoreBusinessPlanSnapshot(supplierTermsSnapshot), {
|
|
...supplierTermsPlan,
|
|
products: supplierTermsPlan.products.map((product) => ({
|
|
...product,
|
|
productInventoryPurchasesByPeriod:
|
|
product.productInventoryPurchasesByPeriod?.map((entry) => ({
|
|
...entry,
|
|
paymentInstallments: [{ lagMonths: 2, percent: '100' }],
|
|
})),
|
|
})),
|
|
});
|
|
const legacySupplierSnapshot = JSON.parse(supplierTermsSnapshot);
|
|
legacySupplierSnapshot.version = 15;
|
|
for (const product of legacySupplierSnapshot.plan.products) {
|
|
for (const entry of product.productInventoryPurchasesByPeriod) {
|
|
delete entry.paymentInstallments;
|
|
}
|
|
}
|
|
assert.deepEqual(
|
|
restoreBusinessPlanSnapshot(JSON.stringify(legacySupplierSnapshot)),
|
|
supplierTermsPlan,
|
|
);
|
|
const missingSupplierTermSnapshot = JSON.parse(supplierTermsSnapshot);
|
|
delete missingSupplierTermSnapshot.plan.products[0]
|
|
.productInventoryPurchasesByPeriod[0].paymentInstallments;
|
|
assert.equal(
|
|
restoreBusinessPlanSnapshot(
|
|
JSON.stringify(missingSupplierTermSnapshot),
|
|
),
|
|
null,
|
|
);
|
|
const scheduledInventory = {
|
|
...assumptions,
|
|
periods: 2,
|
|
inventoryPurchaseSchedule: [
|
|
{ amount: '125.50', leadMonths: 2 },
|
|
{ amount: '25.00', leadMonths: 0 },
|
|
],
|
|
useInventoryPurchaseSchedule: true,
|
|
};
|
|
const inventorySnapshot = serializeBusinessPlanSnapshot(scheduledInventory);
|
|
assert.deepEqual(
|
|
restoreBusinessPlanSnapshot(inventorySnapshot),
|
|
scheduledInventory,
|
|
);
|
|
const invalidInventorySchedule = JSON.parse(inventorySnapshot);
|
|
invalidInventorySchedule.plan.inventoryPurchaseSchedule[1].leadMonths = 13;
|
|
assert.equal(
|
|
restoreBusinessPlanSnapshot(JSON.stringify(invalidInventorySchedule)),
|
|
null,
|
|
);
|
|
assert.equal(
|
|
restoreBusinessPlanSnapshot(
|
|
JSON.stringify({
|
|
version: 9,
|
|
plan: {
|
|
...assumptions,
|
|
products: [
|
|
{
|
|
...assumptions.products[0],
|
|
monthlyUnitsByPeriod: ['100', '50'],
|
|
},
|
|
],
|
|
},
|
|
}),
|
|
)?.products[0].unitPriceByPeriod,
|
|
undefined,
|
|
);
|
|
assert.equal(
|
|
restoreBusinessPlanSnapshot(
|
|
JSON.stringify({
|
|
version: 8,
|
|
plan: { ...assumptions, salesRecoverySchedule: [] },
|
|
}),
|
|
)?.products[0].monthlyUnitsByPeriod,
|
|
undefined,
|
|
);
|
|
const scheduledSnapshot = serializeBusinessPlanSnapshot({
|
|
...assumptions,
|
|
periods: 2,
|
|
products: [
|
|
{
|
|
...assumptions.products[0],
|
|
monthlyUnitsByPeriod: ['100', '50', '0', '20'],
|
|
},
|
|
],
|
|
});
|
|
assert.deepEqual(
|
|
restoreBusinessPlanSnapshot(scheduledSnapshot)?.products[0]
|
|
.monthlyUnitsByPeriod,
|
|
['100', '50', '0', '20'],
|
|
);
|
|
assert.equal(
|
|
restoreBusinessPlanSnapshot(
|
|
serializeBusinessPlanSnapshot({
|
|
...assumptions,
|
|
products: [
|
|
{
|
|
...assumptions.products[0],
|
|
monthlyUnitsByPeriod: ['100'],
|
|
},
|
|
],
|
|
}),
|
|
),
|
|
null,
|
|
);
|
|
const legacyV7Plan = { ...assumptions, openingReceivablesInstallments: [] };
|
|
delete legacyV7Plan.salesRecoverySchedule;
|
|
assert.deepEqual(
|
|
restoreBusinessPlanSnapshot(
|
|
JSON.stringify({ version: 7, plan: legacyV7Plan }),
|
|
)?.salesRecoverySchedule,
|
|
[],
|
|
);
|
|
assert.equal(
|
|
restoreBusinessPlanSnapshot(
|
|
JSON.stringify({
|
|
version: 8,
|
|
plan: {
|
|
...assumptions,
|
|
salesRecoverySchedule: [{ month: 1, percent: '100.01' }],
|
|
},
|
|
}),
|
|
),
|
|
null,
|
|
);
|
|
assert.equal(
|
|
restoreBusinessPlanSnapshot(
|
|
JSON.stringify({
|
|
version: 8,
|
|
plan: {
|
|
...assumptions,
|
|
salesRecoverySchedule: [
|
|
{ month: 1, percent: '50' },
|
|
{ month: 1, percent: '25' },
|
|
],
|
|
},
|
|
}),
|
|
),
|
|
null,
|
|
);
|
|
const legacyV6Plan = {
|
|
...assumptions,
|
|
openingReceivablesCollectionMonth: 2,
|
|
openingReceivablesCollectionPercent: '40',
|
|
};
|
|
delete legacyV6Plan.openingReceivablesInstallments;
|
|
const legacyV6 = restoreBusinessPlanSnapshot(
|
|
JSON.stringify({
|
|
version: 6,
|
|
plan: legacyV6Plan,
|
|
}),
|
|
);
|
|
assert.equal(legacyV6?.openingReceivablesCollectionMonth, 2);
|
|
assert.equal(legacyV6?.openingReceivablesCollectionPercent, '40');
|
|
assert.deepEqual(legacyV6?.openingReceivablesInstallments, []);
|
|
const missingV7Schedule = { ...assumptions };
|
|
delete missingV7Schedule.openingReceivablesInstallments;
|
|
assert.equal(
|
|
restoreBusinessPlanSnapshot(
|
|
JSON.stringify({ version: 7, plan: missingV7Schedule }),
|
|
),
|
|
null,
|
|
);
|
|
assert.equal(
|
|
restoreBusinessPlanSnapshot(
|
|
JSON.stringify({
|
|
version: 7,
|
|
plan: {
|
|
...assumptions,
|
|
openingReceivablesInstallments: [
|
|
{ month: 1, percent: '70' },
|
|
{ month: 2, percent: '31' },
|
|
],
|
|
},
|
|
}),
|
|
),
|
|
null,
|
|
);
|
|
});
|
|
|
|
void test('business plan serializer refuses snapshots above the server storage limit', () => {
|
|
assert.throws(
|
|
() =>
|
|
serializeBusinessPlanSnapshot({
|
|
...assumptions,
|
|
products: [
|
|
{
|
|
...assumptions.products[0],
|
|
label: 'x'.repeat(60_000),
|
|
},
|
|
],
|
|
}),
|
|
/excede o limite de armazenamento de 60 KB/,
|
|
);
|
|
});
|
|
|
|
void test('sales invoicing and receipts follow an optional declared collection lag', () => {
|
|
const delayed = forecastBusinessPlan({
|
|
...assumptions,
|
|
monthlyCollections: '0',
|
|
salesCollectionLagMonths: 1,
|
|
periods: 2,
|
|
});
|
|
assert.deepEqual(
|
|
delayed.rows.map((row) => row.salesCollections),
|
|
['0.00', '100000.00'],
|
|
);
|
|
assert.deepEqual(
|
|
delayed.rows.map((row) => row.salesReceivablesClosing),
|
|
['100000.00', '100000.00'],
|
|
);
|
|
assert.equal(delayed.totalRevenue, '200000.00');
|
|
assert.equal(delayed.totalSalesCollections, '100000.00');
|
|
assert.equal(delayed.salesReceivablesAtEnd, '100000.00');
|
|
assert.equal(delayed.totalCashCollections, '100000.00');
|
|
assert.equal(delayed.totalProfitBeforeTax, '44266.66');
|
|
|
|
const sameMonth = forecastBusinessPlan({
|
|
...assumptions,
|
|
monthlyCollections: '0',
|
|
salesCollectionLagMonths: 0,
|
|
periods: 2,
|
|
});
|
|
assert.equal(sameMonth.totalSalesCollections, '200000.00');
|
|
assert.equal(sameMonth.salesReceivablesAtEnd, '0.00');
|
|
assert.equal(sameMonth.totalProfitBeforeTax, delayed.totalProfitBeforeTax);
|
|
});
|
|
|
|
void test('scheduled collection percentage keeps unpaid invoices in receivables with exact rounding', () => {
|
|
const partial = forecastBusinessPlan({
|
|
...assumptions,
|
|
monthlyCollections: '0',
|
|
salesCollectionLagMonths: 0,
|
|
salesCollectionPercent: '60',
|
|
periods: 2,
|
|
});
|
|
assert.deepEqual(
|
|
partial.rows.map((row) => row.salesCollections),
|
|
['60000.00', '60000.00'],
|
|
);
|
|
assert.deepEqual(
|
|
partial.rows.map((row) => row.salesReceivablesClosing),
|
|
['40000.00', '80000.00'],
|
|
);
|
|
assert.equal(partial.totalSalesCollections, '120000.00');
|
|
assert.equal(partial.salesReceivablesAtEnd, '80000.00');
|
|
|
|
const halfCent = forecastBusinessPlan({
|
|
...assumptions,
|
|
products: [
|
|
{
|
|
label: 'Unitário',
|
|
monthlyUnits: '1',
|
|
unitPrice: '0.01',
|
|
unitVariableCost: '0',
|
|
},
|
|
],
|
|
monthlyCollections: '0',
|
|
salesCollectionLagMonths: 0,
|
|
salesCollectionPercent: '50',
|
|
periods: 1,
|
|
});
|
|
assert.equal(halfCent.rows[0].salesCollections, '0.01');
|
|
assert.equal(halfCent.salesReceivablesAtEnd, '0.00');
|
|
assert.throws(
|
|
() =>
|
|
forecastBusinessPlan({
|
|
...assumptions,
|
|
salesCollectionLagMonths: 0,
|
|
salesCollectionPercent: '100.01',
|
|
}),
|
|
/entre 0 e 100/,
|
|
);
|
|
});
|
|
|
|
void test('sales recovery applies only to overdue invoices and never exceeds open balances', () => {
|
|
const recovered = forecastBusinessPlan({
|
|
...assumptions,
|
|
monthlyCollections: '0',
|
|
salesCollectionLagMonths: 0,
|
|
salesCollectionPercent: '60',
|
|
salesRecoverySchedule: [
|
|
{ month: 2, percent: '50' },
|
|
{ month: 3, percent: '50' },
|
|
],
|
|
periods: 3,
|
|
});
|
|
assert.deepEqual(
|
|
recovered.rows.map((row) => row.salesScheduledCollections),
|
|
['60000.00', '60000.00', '60000.00'],
|
|
);
|
|
assert.deepEqual(
|
|
recovered.rows.map((row) => row.salesRecoveryCollections),
|
|
['0.00', '20000.00', '30000.00'],
|
|
);
|
|
assert.deepEqual(
|
|
recovered.rows.map((row) => row.salesReceivablesClosing),
|
|
['40000.00', '60000.00', '70000.00'],
|
|
);
|
|
assert.equal(recovered.totalScheduledSalesCollections, '180000.00');
|
|
assert.equal(recovered.totalSalesRecoveryCollections, '50000.00');
|
|
assert.equal(recovered.totalSalesCollections, '230000.00');
|
|
assert.equal(recovered.salesReceivablesAtEnd, '70000.00');
|
|
|
|
const noRecovery = forecastBusinessPlan({
|
|
...assumptions,
|
|
monthlyCollections: '0',
|
|
salesCollectionLagMonths: 0,
|
|
salesCollectionPercent: '60',
|
|
periods: 2,
|
|
});
|
|
assert.equal(noRecovery.totalSalesRecoveryCollections, '0.00');
|
|
assert.equal(noRecovery.salesReceivablesAtEnd, '80000.00');
|
|
assert.throws(
|
|
() =>
|
|
forecastBusinessPlan({
|
|
...assumptions,
|
|
salesCollectionLagMonths: 0,
|
|
periods: 2,
|
|
salesRecoverySchedule: [{ month: 3, percent: '50' }],
|
|
}),
|
|
/dentro do horizonte/,
|
|
);
|
|
assert.throws(
|
|
() =>
|
|
forecastBusinessPlan({
|
|
...assumptions,
|
|
salesCollectionLagMonths: 0,
|
|
salesRecoverySchedule: [{ month: 1, percent: '100.01' }],
|
|
}),
|
|
/entre 0 e 100/,
|
|
);
|
|
assert.throws(
|
|
() =>
|
|
forecastBusinessPlan({
|
|
...assumptions,
|
|
salesCollectionLagMonths: 0,
|
|
salesRecoverySchedule: [
|
|
{ month: 1, percent: '50' },
|
|
{ month: 1, percent: '50' },
|
|
],
|
|
}),
|
|
/único dentro do horizonte/,
|
|
);
|
|
});
|
|
|
|
void test('opening receivables affect cash only when a month and collection percentage are declared', () => {
|
|
const unscheduled = forecastBusinessPlan({
|
|
...assumptions,
|
|
monthlyCollections: '0',
|
|
periods: 2,
|
|
});
|
|
const scheduled = forecastBusinessPlan({
|
|
...assumptions,
|
|
monthlyCollections: '0',
|
|
openingReceivablesCollectionMonth: 2,
|
|
openingReceivablesCollectionPercent: '50',
|
|
periods: 2,
|
|
});
|
|
assert.deepEqual(
|
|
scheduled.rows.map((row) => row.openingReceivablesCollections),
|
|
['0.00', '5000.00'],
|
|
);
|
|
assert.equal(scheduled.totalOpeningReceivablesCollections, '5000.00');
|
|
assert.equal(scheduled.openingReceivablesAtEnd, '5000.00');
|
|
assert.equal(scheduled.initialUses, unscheduled.initialUses);
|
|
assert.equal(
|
|
Number(scheduled.endingCash) - Number(unscheduled.endingCash),
|
|
5000,
|
|
);
|
|
assert.equal(
|
|
scheduled.totalProfitBeforeTax,
|
|
unscheduled.totalProfitBeforeTax,
|
|
);
|
|
const halfCent = forecastBusinessPlan({
|
|
...assumptions,
|
|
receivables: '0.01',
|
|
openingReceivablesCollectionMonth: 1,
|
|
openingReceivablesCollectionPercent: '50',
|
|
periods: 1,
|
|
});
|
|
assert.equal(halfCent.rows[0].openingReceivablesCollections, '0.01');
|
|
assert.equal(halfCent.openingReceivablesAtEnd, '0.00');
|
|
assert.throws(
|
|
() =>
|
|
forecastBusinessPlan({
|
|
...assumptions,
|
|
openingReceivablesCollectionMonth: 3,
|
|
periods: 2,
|
|
}),
|
|
/dentro do horizonte/,
|
|
);
|
|
});
|
|
|
|
void test('opening receivable installments reconcile to the initial balance across months', () => {
|
|
const scheduled = forecastBusinessPlan({
|
|
...assumptions,
|
|
monthlyCollections: '0',
|
|
receivables: '10000',
|
|
openingReceivablesInstallments: [
|
|
{ month: 1, percent: '25' },
|
|
{ month: 3, percent: '35' },
|
|
],
|
|
periods: 3,
|
|
});
|
|
assert.deepEqual(
|
|
scheduled.rows.map((row) => row.openingReceivablesCollections),
|
|
['2500.00', '0.00', '3500.00'],
|
|
);
|
|
assert.deepEqual(
|
|
scheduled.rows.map((row) => row.openingReceivablesBalance),
|
|
['7500.00', '7500.00', '4000.00'],
|
|
);
|
|
assert.equal(scheduled.totalOpeningReceivablesCollections, '6000.00');
|
|
assert.equal(scheduled.openingReceivablesAtEnd, '4000.00');
|
|
assert.deepEqual(scheduled.openingReceivablesInstallments, [
|
|
{ month: 1, percent: '25', amount: '2500.00' },
|
|
{ month: 3, percent: '35', amount: '3500.00' },
|
|
]);
|
|
assert.match(
|
|
exportBusinessPlanCsv({
|
|
...assumptions,
|
|
receivables: '10000',
|
|
openingReceivablesInstallments: [
|
|
{ month: 1, percent: '25' },
|
|
{ month: 3, percent: '35' },
|
|
],
|
|
periods: 3,
|
|
}),
|
|
/Parcelas adicionais de recebíveis de abertura,"\[.*2500\.00.*3500\.00.*\]"/,
|
|
);
|
|
|
|
const legacyAndInstallments = forecastBusinessPlan({
|
|
...assumptions,
|
|
receivables: '10000',
|
|
openingReceivablesCollectionMonth: 1,
|
|
openingReceivablesCollectionPercent: '20',
|
|
openingReceivablesInstallments: [{ month: 2, percent: '80' }],
|
|
periods: 2,
|
|
});
|
|
assert.equal(
|
|
legacyAndInstallments.totalOpeningReceivablesCollections,
|
|
'10000.00',
|
|
);
|
|
assert.equal(legacyAndInstallments.openingReceivablesAtEnd, '0.00');
|
|
|
|
assert.throws(
|
|
() =>
|
|
forecastBusinessPlan({
|
|
...assumptions,
|
|
openingReceivablesCollectionMonth: 1,
|
|
openingReceivablesCollectionPercent: '20',
|
|
openingReceivablesInstallments: [{ month: 2, percent: '81' }],
|
|
periods: 2,
|
|
}),
|
|
/não pode exceder 100%/,
|
|
);
|
|
assert.throws(
|
|
() =>
|
|
forecastBusinessPlan({
|
|
...assumptions,
|
|
openingReceivablesInstallments: [{ month: 13, percent: '10' }],
|
|
}),
|
|
/dentro do horizonte/,
|
|
);
|
|
});
|
|
|
|
void test('opening receivable installments cap independently rounded cents at the available balance', () => {
|
|
const rounded = forecastBusinessPlan({
|
|
...assumptions,
|
|
receivables: '0.01',
|
|
openingReceivablesInstallments: [
|
|
{ month: 1, percent: '50' },
|
|
{ month: 2, percent: '50' },
|
|
],
|
|
periods: 2,
|
|
});
|
|
assert.deepEqual(
|
|
rounded.rows.map((row) => row.openingReceivablesCollections),
|
|
['0.01', '0.00'],
|
|
);
|
|
assert.equal(rounded.openingReceivablesAtEnd, '0.00');
|
|
});
|
|
|
|
void test('inventory purchases, consumption, closing stock and stock shortfall reconcile independently of payments', () => {
|
|
const supplied = forecastBusinessPlan({
|
|
...assumptions,
|
|
inventory: '0',
|
|
monthlyInventoryPurchases: '10000',
|
|
monthlyVariableCostPayments: '0',
|
|
periods: 2,
|
|
});
|
|
assert.equal(supplied.rows[0].inventoryOpening, '0.00');
|
|
assert.equal(supplied.rows[0].inventoryPurchases, '10000.00');
|
|
assert.equal(supplied.rows[0].inventoryConsumption, '40000.00');
|
|
assert.equal(supplied.rows[0].inventoryShortfall, '30000.00');
|
|
assert.equal(supplied.rows[0].inventoryClosing, '0.00');
|
|
assert.equal(
|
|
supplied.inventoryProjection.status,
|
|
'estimated_with_shortfall',
|
|
);
|
|
assert.equal(supplied.inventoryProjection.totalShortfall, '60000.00');
|
|
assert.equal(supplied.totalPayments, '78000.00');
|
|
assert.equal(supplied.rows[0].variableCostPayments, '0.00');
|
|
});
|
|
|
|
void test('inventory lead time separates monthly orders, receipts, shortages and goods still in transit', () => {
|
|
const result = forecastBusinessPlan({
|
|
...assumptions,
|
|
inventory: '0',
|
|
monthlyInventoryPurchases: '10000',
|
|
inventoryPurchaseLeadMonths: 1,
|
|
monthlyVariableCostPayments: '0',
|
|
periods: 2,
|
|
});
|
|
const noDelay = forecastBusinessPlan({
|
|
...assumptions,
|
|
inventory: '0',
|
|
monthlyInventoryPurchases: '10000',
|
|
inventoryPurchaseLeadMonths: 0,
|
|
monthlyVariableCostPayments: '0',
|
|
periods: 2,
|
|
});
|
|
assert.equal(result.rows[0].inventoryPurchases, '10000.00');
|
|
assert.equal(result.rows[0].inventoryReceipts, '0.00');
|
|
assert.equal(result.rows[0].inventoryShortfall, '40000.00');
|
|
assert.equal(result.rows[0].inventoryInTransit, '10000.00');
|
|
assert.equal(result.rows[1].inventoryReceipts, '10000.00');
|
|
assert.equal(result.rows[1].inventoryInTransit, '10000.00');
|
|
assert.equal(result.inventoryProjection.totalPurchases, '20000.00');
|
|
assert.equal(result.inventoryProjection.totalReceipts, '10000.00');
|
|
assert.equal(result.inventoryProjection.inTransitAtEnd, '10000.00');
|
|
assert.equal(result.inventoryProjection.totalShortfall, '70000.00');
|
|
assert.equal(result.totalProfitBeforeTax, noDelay.totalProfitBeforeTax);
|
|
assert.equal(result.endingCash, noDelay.endingCash);
|
|
});
|
|
|
|
void test('monthly inventory purchase schedule reconciles variable orders, lead times, receipts and transit', () => {
|
|
const result = forecastBusinessPlan({
|
|
...assumptions,
|
|
periods: 4,
|
|
products: [
|
|
{
|
|
label: 'Stock',
|
|
monthlyUnits: '0',
|
|
unitPrice: '0',
|
|
unitVariableCost: '0',
|
|
},
|
|
],
|
|
inventoryPurchaseSchedule: [
|
|
{ amount: '10000', leadMonths: 2 },
|
|
{ amount: '20000', leadMonths: 0 },
|
|
{ amount: '5000', leadMonths: 1 },
|
|
{ amount: '0', leadMonths: 0 },
|
|
],
|
|
useInventoryPurchaseSchedule: true,
|
|
});
|
|
|
|
assert.deepEqual(
|
|
result.rows.map((row) => [
|
|
row.inventoryPurchases,
|
|
row.inventoryLeadMonths,
|
|
row.inventoryReceipts,
|
|
row.inventoryInTransit,
|
|
row.inventoryClosing,
|
|
]),
|
|
[
|
|
['10000.00', 2, '0.00', '10000.00', '20000.00'],
|
|
['20000.00', 0, '20000.00', '10000.00', '40000.00'],
|
|
['5000.00', 1, '10000.00', '5000.00', '50000.00'],
|
|
['0.00', 0, '5000.00', '0.00', '55000.00'],
|
|
],
|
|
);
|
|
assert.equal(result.inventoryProjection.leadMonths, null);
|
|
assert.equal(result.inventoryProjection.totalPurchases, '35000.00');
|
|
assert.deepEqual(
|
|
result.inventoryProjection.purchaseSchedule.map((entry) => [
|
|
entry.month,
|
|
entry.amount,
|
|
entry.leadMonths,
|
|
]),
|
|
[
|
|
[1, '10000.00', 2],
|
|
[2, '20000.00', 0],
|
|
[3, '5000.00', 1],
|
|
[4, '0.00', 0],
|
|
],
|
|
);
|
|
assert.deepEqual(
|
|
result.rows.map((row) => row.variableCostPayments),
|
|
['42000.00', '42000.00', '42000.00', '42000.00'],
|
|
);
|
|
assert.throws(
|
|
() =>
|
|
forecastBusinessPlan({
|
|
...assumptions,
|
|
periods: 2,
|
|
useInventoryPurchaseSchedule: true,
|
|
inventoryPurchaseSchedule: [{ amount: '10', leadMonths: 0 }],
|
|
}),
|
|
/agenda de compras deve cobrir os 2 meses/,
|
|
);
|
|
assert.throws(
|
|
() =>
|
|
forecastBusinessPlan({
|
|
...assumptions,
|
|
useInventoryPurchaseSchedule: true,
|
|
inventoryPurchaseSchedule: Array.from(
|
|
{ length: assumptions.periods },
|
|
() => ({ amount: '10', leadMonths: 13 }),
|
|
),
|
|
}),
|
|
/prazos de entrega entre 0 e 12 meses/,
|
|
);
|
|
const csv = exportBusinessPlanCsv(
|
|
{
|
|
...assumptions,
|
|
periods: 4,
|
|
products: [
|
|
{
|
|
label: 'Stock',
|
|
monthlyUnits: '0',
|
|
unitPrice: '0',
|
|
unitVariableCost: '0',
|
|
},
|
|
],
|
|
inventoryPurchaseSchedule: [
|
|
{ amount: '10000', leadMonths: 2 },
|
|
{ amount: '20000', leadMonths: 0 },
|
|
{ amount: '5000', leadMonths: 1 },
|
|
{ amount: '0', leadMonths: 0 },
|
|
],
|
|
useInventoryPurchaseSchedule: true,
|
|
},
|
|
'2026-10-03T10:00:00.000Z',
|
|
);
|
|
assert.match(csv, /Agenda mensal de compras e recepção/);
|
|
assert.match(csv, /Prazo da encomenda \(meses\)/);
|
|
});
|
|
|
|
void test('monthly variable purchase payments affect cash independently from inventory orders and profit', () => {
|
|
const base = {
|
|
...assumptions,
|
|
periods: 3,
|
|
monthlyVariableCostPayments: '1000',
|
|
monthlyInventoryPurchases: '2000',
|
|
};
|
|
const scheduled = forecastBusinessPlan({
|
|
...base,
|
|
monthlyVariableCostPaymentsByPeriod: ['0', '1000', '3000'],
|
|
});
|
|
const constant = forecastBusinessPlan(base);
|
|
|
|
assert.deepEqual(
|
|
scheduled.rows.map((row) => row.variableCostPayments),
|
|
['0.00', '1000.00', '3000.00'],
|
|
);
|
|
assert.equal(
|
|
Number(scheduled.endingCash) - Number(constant.endingCash),
|
|
-1000,
|
|
);
|
|
assert.equal(scheduled.totalProfitBeforeTax, constant.totalProfitBeforeTax);
|
|
assert.equal(
|
|
scheduled.inventoryProjection.totalPurchases,
|
|
constant.inventoryProjection.totalPurchases,
|
|
);
|
|
});
|
|
|
|
void test('product inventory schedules reconcile independently and do not transfer stock between products', () => {
|
|
const plan = {
|
|
...assumptions,
|
|
periods: 2,
|
|
inventory: '1500',
|
|
useInventoryPurchaseSchedule: false,
|
|
useProductInventorySchedule: true,
|
|
products: [
|
|
{
|
|
label: 'Produto A',
|
|
monthlyUnits: '10',
|
|
unitPrice: '20',
|
|
unitVariableCost: '10',
|
|
productInventoryOpening: '0',
|
|
productInventoryPurchasesByPeriod: [
|
|
{ amount: '50', leadMonths: 1 },
|
|
{ amount: '0', leadMonths: 0 },
|
|
],
|
|
},
|
|
{
|
|
label: 'Produto B',
|
|
monthlyUnits: '5',
|
|
unitPrice: '40',
|
|
unitVariableCost: '20',
|
|
productInventoryOpening: '1500',
|
|
productInventoryPurchasesByPeriod: [
|
|
{ amount: '0', leadMonths: 0 },
|
|
{ amount: '0', leadMonths: 0 },
|
|
],
|
|
},
|
|
],
|
|
};
|
|
const result = forecastBusinessPlan(plan);
|
|
assert.deepEqual(
|
|
result.inventoryProjection.productSchedules.map((product) => [
|
|
product.label,
|
|
product.totalConsumption,
|
|
product.endingInventory,
|
|
product.totalShortfall,
|
|
]),
|
|
[
|
|
['Produto A', '200.00', '0.00', '150.00'],
|
|
['Produto B', '200.00', '1300.00', '0.00'],
|
|
],
|
|
);
|
|
assert.deepEqual(
|
|
result.inventoryProjection.productSchedules[0].monthlyRows.map(
|
|
(row) => [row.receipts, row.inTransit, row.shortfall],
|
|
),
|
|
[
|
|
['0.00', '50.00', '100.00'],
|
|
['50.00', '0.00', '50.00'],
|
|
],
|
|
);
|
|
assert.equal(result.inventoryProjection.totalShortfall, '150.00');
|
|
assert.equal(result.inventoryProjection.totalPurchases, '50.00');
|
|
assert.equal(result.rows[0].inventoryLeadMonths, null);
|
|
const report = exportBusinessPlanCsv(plan);
|
|
assert.match(report, /Existencias por produto/);
|
|
assert.match(report, /Produto A,1,0\.00,50\.00,1,0\.00,50\.00/);
|
|
assert.throws(
|
|
() => forecastBusinessPlan({ ...plan, inventory: '1499' }),
|
|
/soma das existencias iniciais por produto/,
|
|
);
|
|
assert.throws(
|
|
() =>
|
|
forecastBusinessPlan({
|
|
...plan,
|
|
useInventoryPurchaseSchedule: true,
|
|
inventoryPurchaseSchedule: [
|
|
{ amount: '0', leadMonths: 0 },
|
|
{ amount: '0', leadMonths: 0 },
|
|
],
|
|
}),
|
|
/compras agregadas ou stock por produto/,
|
|
);
|
|
});
|
|
|
|
void test('supplier payment terms drive purchase cash flow and expose amounts due beyond the horizon', () => {
|
|
const plan = {
|
|
...assumptions,
|
|
periods: 2,
|
|
inventory: '1000',
|
|
monthlyVariableCostPayments: '999',
|
|
monthlyVariableCostPaymentsByPeriod: ['999', '999'],
|
|
useProductInventorySchedule: true,
|
|
useSupplierPaymentTerms: true,
|
|
products: [
|
|
{
|
|
...assumptions.products[0],
|
|
monthlyUnits: '0',
|
|
productInventoryOpening: '1000',
|
|
productInventoryPurchasesByPeriod: [
|
|
{
|
|
amount: '100',
|
|
leadMonths: 0,
|
|
paymentLagMonths: 1,
|
|
},
|
|
{
|
|
amount: '200',
|
|
leadMonths: 0,
|
|
paymentLagMonths: 2,
|
|
},
|
|
],
|
|
},
|
|
],
|
|
};
|
|
const result = forecastBusinessPlan(plan);
|
|
assert.deepEqual(
|
|
result.rows.map((row) => row.variableCostPayments),
|
|
['0.00', '100.00'],
|
|
);
|
|
assert.equal(result.supplierPaymentsOutstandingAtEnd, '200.00');
|
|
assert.deepEqual(
|
|
result.inventoryProjection.productSchedules[0].paymentSchedule.map(
|
|
(payment) => [
|
|
payment.orderMonth,
|
|
payment.paymentMonth,
|
|
payment.amount,
|
|
payment.paidWithinHorizon,
|
|
],
|
|
),
|
|
[
|
|
[1, 2, '100.00', true],
|
|
[2, 4, '200.00', false],
|
|
],
|
|
);
|
|
const report = exportBusinessPlanCsv(plan);
|
|
assert.match(report, /Pagamentos a fornecedores por prazo declarado/);
|
|
assert.match(report, /,2,4,100,200\.00,por pagar/);
|
|
assert.throws(
|
|
() =>
|
|
forecastBusinessPlan({
|
|
...plan,
|
|
useProductInventorySchedule: false,
|
|
}),
|
|
/exigem compras planeadas por produto/,
|
|
);
|
|
assert.throws(
|
|
() =>
|
|
forecastBusinessPlan({
|
|
...plan,
|
|
products: [
|
|
{
|
|
...plan.products[0],
|
|
productInventoryPurchasesByPeriod: [
|
|
{
|
|
amount: '0',
|
|
leadMonths: 0,
|
|
paymentLagMonths: 13,
|
|
},
|
|
{ amount: '0', leadMonths: 0, paymentLagMonths: 0 },
|
|
],
|
|
},
|
|
],
|
|
}),
|
|
/declare encomendas e prazos de entrega/,
|
|
);
|
|
});
|
|
|
|
void test('supplier order installments reconcile exact cents, cash dates and unallocated balances', () => {
|
|
const plan = {
|
|
...assumptions,
|
|
periods: 2,
|
|
inventory: '1010.01',
|
|
useProductInventorySchedule: true,
|
|
useSupplierPaymentTerms: true,
|
|
products: [
|
|
{
|
|
...assumptions.products[0],
|
|
monthlyUnits: '0',
|
|
productInventoryOpening: '1010.01',
|
|
productInventoryPurchasesByPeriod: [
|
|
{
|
|
amount: '10.01',
|
|
leadMonths: 0,
|
|
paymentInstallments: [
|
|
{ lagMonths: 0, percent: '33.333333' },
|
|
{ lagMonths: 2, percent: '33.333333' },
|
|
],
|
|
},
|
|
{
|
|
amount: '200',
|
|
leadMonths: 0,
|
|
paymentInstallments: [{ lagMonths: 2, percent: '100' }],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
};
|
|
const result = forecastBusinessPlan(plan);
|
|
assert.deepEqual(
|
|
result.rows.map((row) => row.variableCostPayments),
|
|
['3.34', '0.00'],
|
|
);
|
|
assert.equal(result.supplierPaymentsOutstandingAtEnd, '206.67');
|
|
assert.deepEqual(
|
|
result.inventoryProjection.productSchedules[0].paymentSchedule.map(
|
|
(payment) => [
|
|
payment.orderMonth,
|
|
payment.paymentMonth,
|
|
payment.amount,
|
|
payment.paidWithinHorizon,
|
|
],
|
|
),
|
|
[
|
|
[1, 1, '3.34', true],
|
|
[1, 3, '3.34', false],
|
|
[1, null, '3.33', false],
|
|
[2, 4, '200.00', false],
|
|
],
|
|
);
|
|
const malformedPlan = structuredClone(plan);
|
|
malformedPlan.products[0].productInventoryPurchasesByPeriod[0].paymentInstallments =
|
|
[
|
|
{ lagMonths: 0, percent: '60' },
|
|
{ lagMonths: 1, percent: '50' },
|
|
];
|
|
assert.throws(
|
|
() => forecastBusinessPlan(malformedPlan),
|
|
/declare encomendas/,
|
|
);
|
|
const snapshot = JSON.parse(serializeBusinessPlanSnapshot(plan));
|
|
snapshot.plan.products[0].productInventoryPurchasesByPeriod[0].paymentInstallments[1].lagMonths = 13;
|
|
assert.equal(restoreBusinessPlanSnapshot(JSON.stringify(snapshot)), null);
|
|
});
|
|
|
|
void test('business assumptions survive the existing encrypted-draft snapshot split and restore', () => {
|
|
const encoded = serializeBusinessPlanSnapshot(assumptions);
|
|
const snapshots = partitionSimulationDraft({
|
|
purpose: 'own_investment',
|
|
business_plan_snapshot: encoded,
|
|
});
|
|
const restoredDraft = restoreSimulationDraft(
|
|
snapshots.economic_snapshot,
|
|
snapshots.personal_snapshot,
|
|
);
|
|
assert.deepEqual(
|
|
restoreBusinessPlanSnapshot(
|
|
String(restoredDraft.business_plan_snapshot),
|
|
),
|
|
assumptions,
|
|
);
|
|
});
|
|
|
|
void test('presentation currency conversion requires and reports its rate, date, and declared source', () => {
|
|
const result = forecastBusinessPlan({
|
|
...assumptions,
|
|
presentationCurrency: 'USD',
|
|
fxRate: '0.01',
|
|
fxDate: '2026-10-02',
|
|
fxSource: 'Taxa introduzida pelo utilizador',
|
|
openingReceivablesInstallments: [{ month: 1, percent: '40' }],
|
|
});
|
|
assert.equal(result.currency, 'MZN');
|
|
assert.equal(result.presentationCurrency, 'USD');
|
|
assert.equal(result.conversion.totalRevenue, '12000.00');
|
|
assert.equal(result.conversion.endingCash, '1170.00');
|
|
assert.equal(result.conversion.fundingStatus, 'surplus');
|
|
assert.equal(result.conversion.rows.at(-1).closingCash, '1170.00');
|
|
assert.equal(
|
|
result.conversion.openingReceivablesInstallments[0].amount,
|
|
'40.00',
|
|
);
|
|
assert.throws(
|
|
() =>
|
|
forecastBusinessPlan({
|
|
...assumptions,
|
|
presentationCurrency: 'USD',
|
|
}),
|
|
/taxa, data efectiva e fonte/,
|
|
);
|
|
assert.throws(
|
|
() =>
|
|
forecastBusinessPlan({
|
|
...assumptions,
|
|
presentationCurrency: 'USD',
|
|
fxRate: '0',
|
|
fxDate: '2026-10-02',
|
|
fxSource: 'Fonte',
|
|
}),
|
|
/superior a zero/,
|
|
);
|
|
assert.throws(
|
|
() =>
|
|
forecastBusinessPlan({
|
|
...assumptions,
|
|
presentationCurrency: 'USD',
|
|
fxRate: '1',
|
|
fxDate: '2026-02-30',
|
|
fxSource: 'Fonte',
|
|
}),
|
|
/data de câmbio válida/,
|
|
);
|
|
});
|
|
|
|
void test('CSV marks presentation currency conversion as an unverified user planning assumption', () => {
|
|
const csv = exportBusinessPlanCsv(
|
|
{
|
|
...assumptions,
|
|
presentationCurrency: 'USD',
|
|
fxRate: '0.01',
|
|
fxDate: '2026-10-02',
|
|
fxSource: 'Cotacao informada pelo investidor',
|
|
},
|
|
'2026-10-04T00:00:00.000Z',
|
|
);
|
|
|
|
assert.match(csv, /Estado da conversao cambial/);
|
|
assert.match(
|
|
csv,
|
|
/pressuposto de planeamento declarado pelo utilizador; nao verificado pelo portal/,
|
|
);
|
|
});
|
|
|
|
void test('business plan export carries its assumptions, formulas, period, currency, and matching result', () => {
|
|
const csv = exportBusinessPlanCsv(
|
|
{ ...assumptions, periods: 1 },
|
|
'2026-10-02T10:00:00.000Z',
|
|
);
|
|
assert.match(csv, /^\uFEFF/);
|
|
assert.match(csv, /business-plan-v17/);
|
|
assert.match(
|
|
csv,
|
|
/Valores do formulário; os valores iniciais podem ser exemplos demonstrativos/,
|
|
);
|
|
assert.match(csv, /Ponte contabilístico-fiscal,incomplete/);
|
|
assert.match(csv, /Matéria colectável,indeterminate/);
|
|
assert.match(csv, /expense_deductibility_classification/);
|
|
assert.match(csv, /2026-10-02T10:00:00\.000Z/);
|
|
assert.match(csv, /Moeda de cálculo,MZN/);
|
|
assert.match(csv, /Resultado antes de impostos/);
|
|
assert.match(csv, /não calculados nem incluídos/);
|
|
assert.match(csv, /Caixa final projectada,14000\.00/);
|
|
assert.match(csv, /Projecção de existências,estimated/);
|
|
assert.match(csv, /Compras mensais para existências,40000/);
|
|
assert.match(csv, /Prazo de entrega das compras,0 meses/);
|
|
assert.match(csv, /Recepções de compras no horizonte,40000\.00/);
|
|
assert.match(csv, /Existências em trânsito no fim do horizonte,0\.00/);
|
|
});
|
|
|
|
void test('business plan CSV preserves declared monthly product schedules', () => {
|
|
const csv = exportBusinessPlanCsv(
|
|
{
|
|
...assumptions,
|
|
periods: 3,
|
|
products: [
|
|
{
|
|
...assumptions.products[0],
|
|
monthlyUnitsByPeriod: ['10', '5.5', '0', '9'],
|
|
unitPriceByPeriod: ['100', '125', '90', '80'],
|
|
unitVariableCostByPeriod: ['40', '55', '30', '20'],
|
|
},
|
|
],
|
|
},
|
|
'2026-10-03T10:00:00.000Z',
|
|
);
|
|
assert.ok(csv.includes('"[""10"",""5.5"",""0"",""9""]"'));
|
|
assert.ok(csv.includes('"[""100"",""125"",""90"",""80""]"'));
|
|
assert.ok(csv.includes('"[""40"",""55"",""30"",""20""]"'));
|
|
});
|
|
|
|
void test('CSV export exposes the sales collection assumption and outstanding receivable', () => {
|
|
const csv = exportBusinessPlanCsv(
|
|
{
|
|
...assumptions,
|
|
monthlyCollections: '0',
|
|
salesCollectionLagMonths: 1,
|
|
openingReceivablesCollectionMonth: 2,
|
|
openingReceivablesCollectionPercent: '25',
|
|
periods: 2,
|
|
},
|
|
'2026-10-02T10:00:00.000Z',
|
|
);
|
|
assert.match(csv, /Prazo de cobrança das vendas,1 meses/);
|
|
assert.match(csv, /Cobranças da facturação modelada,100000\.00/);
|
|
assert.match(csv, /Facturação por cobrar no fim,100000\.00/);
|
|
assert.match(csv, /Cobrança de valores a receber de abertura,2500\.00/);
|
|
assert.match(csv, /Valores de abertura por receber no fim,7500\.00/);
|
|
assert.match(
|
|
csv,
|
|
/Mês,Facturação,Cobranças,Cobranças de vendas,Cobranças iniciais das vendas,Recuperações de vendas vencidas,Cobrança de valores de abertura,Valores de abertura por receber,Pagamento de valores a pagar de abertura,Saldo de valores a pagar de abertura,Outras cobranças/,
|
|
);
|
|
});
|
|
|
|
void test('business plan CSV neutralizes formula-like labels and preserves negative financial values', () => {
|
|
const csv = exportBusinessPlanCsv({
|
|
...assumptions,
|
|
products: [
|
|
{
|
|
...assumptions.products[0],
|
|
label: '=HYPERLINK("https://example.invalid")',
|
|
},
|
|
],
|
|
periods: 1,
|
|
});
|
|
assert.match(csv, /"'=HYPERLINK\(""https:\/\/example\.invalid""\)"/);
|
|
assert.match(csv, /Estado da reconciliação do financiamento,surplus/);
|
|
assert.match(csv, /Diferença entre usos e fontes,-5000\.00/);
|
|
});
|
|
|
|
void test('financial scenarios expose user-declared changes without creating tax values', () => {
|
|
const scenarioPlan = {
|
|
...assumptions,
|
|
periods: 1,
|
|
monthlyCollections: '0',
|
|
salesCollectionLagMonths: 0,
|
|
salesCollectionPercent: '80',
|
|
scenarioAdjustments: {
|
|
favorable: {
|
|
unitVolumePercent: '20',
|
|
unitPricePercent: '10',
|
|
unitCostPercent: '-5',
|
|
operatingCostsPercent: '-10',
|
|
cashCollectionsPercent: '10',
|
|
},
|
|
unfavorable: {
|
|
unitVolumePercent: '-20',
|
|
unitPricePercent: '-10',
|
|
unitCostPercent: '15',
|
|
operatingCostsPercent: '20',
|
|
cashCollectionsPercent: '-20',
|
|
},
|
|
},
|
|
};
|
|
const result = compareBusinessScenarios(scenarioPlan);
|
|
assert.equal(result.base.totalRevenue, '100000.00');
|
|
assert.equal(result.favorable.result.totalRevenue, '132000.00');
|
|
assert.equal(result.favorable.result.rows[0].variableCost, '45600.00');
|
|
const scheduledPaymentScenarios = compareBusinessScenarios({
|
|
...scenarioPlan,
|
|
monthlyVariableCostPaymentsByPeriod: ['10000'],
|
|
});
|
|
assert.equal(
|
|
scheduledPaymentScenarios.favorable.result.rows[0].variableCostPayments,
|
|
'11400.00',
|
|
);
|
|
assert.equal(
|
|
scheduledPaymentScenarios.unfavorable.result.rows[0]
|
|
.variableCostPayments,
|
|
'9200.00',
|
|
);
|
|
assert.equal(result.unfavorable.result.totalRevenue, '72000.00');
|
|
assert.equal(result.favorable.result.totalSalesCollections, '116160.00');
|
|
assert.equal(result.unfavorable.result.totalSalesCollections, '46080.00');
|
|
assert.notEqual(
|
|
result.favorable.result.endingCash,
|
|
result.unfavorable.result.endingCash,
|
|
);
|
|
assert.deepEqual(result.changedAssumptions, [
|
|
'unidades vendidas',
|
|
'preço unitário',
|
|
'custo variável unitário',
|
|
'compras para existências',
|
|
'despesas e pessoal',
|
|
'cobranças',
|
|
]);
|
|
const receivableScenarios = compareBusinessScenarios({
|
|
...scenarioPlan,
|
|
receivables: '10000',
|
|
openingReceivablesCollectionMonth: 1,
|
|
openingReceivablesCollectionPercent: '40',
|
|
openingReceivablesInstallments: [{ month: 1, percent: '20' }],
|
|
});
|
|
assert.equal(
|
|
receivableScenarios.favorable.result.totalOpeningReceivablesCollections,
|
|
'6600.00',
|
|
);
|
|
assert.equal(
|
|
receivableScenarios.unfavorable.result
|
|
.totalOpeningReceivablesCollections,
|
|
'4800.00',
|
|
);
|
|
const inventoryScenarios = compareBusinessScenarios({
|
|
...scenarioPlan,
|
|
periods: 2,
|
|
inventoryPurchaseSchedule: [
|
|
{ amount: '10000', leadMonths: 1 },
|
|
{ amount: '5000', leadMonths: 2 },
|
|
],
|
|
useInventoryPurchaseSchedule: true,
|
|
});
|
|
assert.equal(
|
|
inventoryScenarios.base.inventoryProjection.totalPurchases,
|
|
'15000.00',
|
|
);
|
|
assert.equal(
|
|
inventoryScenarios.favorable.result.inventoryProjection.totalPurchases,
|
|
'17100.00',
|
|
);
|
|
assert.equal(
|
|
inventoryScenarios.unfavorable.result.inventoryProjection
|
|
.totalPurchases,
|
|
'13800.00',
|
|
);
|
|
const productInventoryScenarios = compareBusinessScenarios({
|
|
...scenarioPlan,
|
|
inventory: '20000',
|
|
useProductInventorySchedule: true,
|
|
products: [
|
|
{
|
|
...scenarioPlan.products[0],
|
|
productInventoryOpening: '20000',
|
|
productInventoryPurchasesByPeriod: [
|
|
{ amount: '10000', leadMonths: 1 },
|
|
],
|
|
},
|
|
],
|
|
});
|
|
assert.equal(
|
|
productInventoryScenarios.favorable.result.inventoryProjection
|
|
.productSchedules[0].purchaseSchedule[0].amount,
|
|
'11400.00',
|
|
);
|
|
assert.equal(
|
|
productInventoryScenarios.unfavorable.result.inventoryProjection
|
|
.productSchedules[0].purchaseSchedule[0].amount,
|
|
'9200.00',
|
|
);
|
|
const inactiveInventorySchedule = compareBusinessScenarios({
|
|
...scenarioPlan,
|
|
inventoryPurchaseSchedule: [{ amount: '', leadMonths: 0 }],
|
|
useInventoryPurchaseSchedule: false,
|
|
});
|
|
assert.equal(
|
|
inactiveInventorySchedule.base.inventoryProjection.totalPurchases,
|
|
'40000.00',
|
|
);
|
|
const report = exportBusinessPlanCsv(
|
|
scenarioPlan,
|
|
'2026-10-02T10:00:00.000Z',
|
|
);
|
|
assert.match(report, /Comparação de cenários/);
|
|
assert.match(report, /Existências finais/);
|
|
assert.match(report, /Falta de stock/);
|
|
assert.match(report, /business-plan-v17/);
|
|
assert.match(report, /Favorável/);
|
|
assert.match(report, /Desfavorável/);
|
|
assert.throws(
|
|
() =>
|
|
exportBusinessPlanCsv({
|
|
...assumptions,
|
|
scenarioAdjustments: {
|
|
favorable: {
|
|
unitVolumePercent: '10',
|
|
unitPricePercent: '',
|
|
unitCostPercent: '',
|
|
operatingCostsPercent: '',
|
|
cashCollectionsPercent: '',
|
|
},
|
|
unfavorable: {
|
|
unitVolumePercent: '',
|
|
unitPricePercent: '',
|
|
unitCostPercent: '',
|
|
operatingCostsPercent: '',
|
|
cashCollectionsPercent: '',
|
|
},
|
|
},
|
|
}),
|
|
/Complete ou limpe/,
|
|
);
|
|
assert.throws(
|
|
() => compareBusinessScenarios(assumptions),
|
|
/Indique os pressupostos/,
|
|
);
|
|
assert.throws(
|
|
() =>
|
|
compareBusinessScenarios({
|
|
...assumptions,
|
|
scenarioAdjustments: {
|
|
favorable: {
|
|
unitVolumePercent: '-101',
|
|
unitPricePercent: '0',
|
|
unitCostPercent: '0',
|
|
operatingCostsPercent: '0',
|
|
cashCollectionsPercent: '0',
|
|
},
|
|
unfavorable: {
|
|
unitVolumePercent: '0',
|
|
unitPricePercent: '0',
|
|
unitCostPercent: '0',
|
|
operatingCostsPercent: '0',
|
|
cashCollectionsPercent: '0',
|
|
},
|
|
},
|
|
}),
|
|
/redução não pode exceder/,
|
|
);
|
|
});
|