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

3176 lines
131 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { csvCell } from './csv.js';
type ProductLine = {
label: string;
monthlyUnits: string;
/** Optional monthly unit overrides; omitted keeps monthlyUnits constant. */
monthlyUnitsByPeriod?: string[];
unitPrice: string;
unitPriceByPeriod?: string[];
unitVariableCost: string;
unitVariableCostByPeriod?: string[];
productInventoryOpening?: string;
productInventoryPurchasesByPeriod?: InventoryPurchasePeriod[] | null;
};
export const MAX_BUSINESS_PLAN_SNAPSHOT_LENGTH = 60_000;
export type ScenarioAdjustment = {
unitVolumePercent: string;
unitPricePercent: string;
unitCostPercent: string;
operatingCostsPercent: string;
cashCollectionsPercent: string;
};
export type OpeningReceivableInstallment = { month: number; percent: string };
export type OpeningPayableInstallment = { month: number; percent: string };
export type SalesReceivableRecovery = { month: number; percent: string };
export type InventoryPurchasePeriod = {
amount: string;
leadMonths: number;
paymentLagMonths?: number;
paymentInstallments?: SupplierPaymentInstallment[];
};
export type SupplierPaymentInstallment = {
lagMonths: number;
percent: string;
};
function validCurrency(value: string): boolean {
const intl = Intl as unknown as {
supportedValuesOf?: (key: 'currency') => string[];
};
const options = new Set([
'MZN',
'USD',
'EUR',
'ZAR',
...(intl.supportedValuesOf?.('currency') ?? []),
]);
try {
return (
options.has(value) &&
new Intl.NumberFormat('en', {
style: 'currency',
currency: value,
}).resolvedOptions().maximumFractionDigits === 2
);
} catch {
return false;
}
}
export type BusinessFinancialPlanInput = {
currency: string;
presentationCurrency?: string;
fxRate?: string;
fxDate?: string;
fxSource?: string;
openingCash: string;
products: ProductLine[];
monthlyInventoryPurchases?: string;
inventoryPurchaseLeadMonths?: number;
/** Optional monthly order/lead-time schedule; inactive values remain saved for later editing. */
inventoryPurchaseSchedule?: InventoryPurchasePeriod[] | null;
useInventoryPurchaseSchedule?: boolean;
useProductInventorySchedule?: boolean;
/** Pays product purchase orders by declared supplier installments instead of the manual variable-payment schedule. */
useSupplierPaymentTerms?: boolean;
monthlyCollections: string;
/** Null keeps the legacy user-entered cash collection schedule. 0–12 schedules invoiced sales. */
salesCollectionLagMonths?: number | null;
/** Fraction of each invoice collected at its scheduled due month; 0–100. */
salesCollectionPercent?: string;
/** Recovery as a percentage of the past-due balance remaining at each declared month. */
salesRecoverySchedule?: SalesReceivableRecovery[];
/** One declared month for collecting opening receivables; null means unscheduled. */
openingReceivablesCollectionMonth?: number | null;
openingReceivablesCollectionPercent?: string;
/** Additional declared installments, each as a share of the opening receivable balance. */
openingReceivablesInstallments?: OpeningReceivableInstallment[];
/** Declared cash payments for opening payables; omitted means no payment schedule is assumed. */
openingPayablesInstallments?: OpeningPayableInstallment[];
monthlyVariableCostPayments: string;
/** Optional monthly payment schedule; independent from inventory order/receipt dates. */
monthlyVariableCostPaymentsByPeriod?: string[] | null;
monthlyOperatingExpenses: string;
monthlyOperatingExpensesByPeriod?: string[];
monthlyOperatingPayments: string;
monthlyOperatingPaymentsByPeriod?: string[];
monthlyPayrollExpense: string;
monthlyPayrollExpenseByPeriod?: string[];
monthlyPayrollPayments: string;
monthlyPayrollPaymentsByPeriod?: string[];
assetInvestment: string;
inventory: string;
receivables: string;
payables: string;
equity: string;
shareholderLoans: string;
bankLoanProceeds: string;
grants: string;
assetDepreciation: string;
depreciationLifeMonths: number;
monthlyInterestExpense: string;
monthlyInterestPayment: string;
monthlyPrincipalRepayment: string;
periods?: number;
scenarioAdjustments?: {
favorable: ScenarioAdjustment;
unfavorable: ScenarioAdjustment;
};
};
export function serializeBusinessPlanSnapshot(
plan: BusinessFinancialPlanInput,
): string {
const snapshot = JSON.stringify({
version: 17,
plan: {
...plan,
...(plan.useSupplierPaymentTerms === true
? {
products: plan.products.map((product) => ({
...product,
productInventoryPurchasesByPeriod:
product.productInventoryPurchasesByPeriod?.map(
(entry) => ({
...entry,
paymentInstallments:
entry.paymentInstallments ?? [
{
lagMonths:
entry.paymentLagMonths ??
0,
percent: '100',
},
],
}),
) ?? null,
})),
}
: {}),
monthlyInventoryPurchases: plan.monthlyInventoryPurchases ?? '',
inventoryPurchaseLeadMonths: plan.inventoryPurchaseLeadMonths ?? 0,
inventoryPurchaseSchedule: plan.inventoryPurchaseSchedule ?? null,
useInventoryPurchaseSchedule:
plan.useInventoryPurchaseSchedule ?? false,
useProductInventorySchedule:
plan.useProductInventorySchedule ?? false,
useSupplierPaymentTerms: plan.useSupplierPaymentTerms ?? false,
monthlyVariableCostPaymentsByPeriod:
plan.monthlyVariableCostPaymentsByPeriod ?? null,
salesCollectionLagMonths: plan.salesCollectionLagMonths ?? null,
salesCollectionPercent: plan.salesCollectionPercent ?? '100',
openingReceivablesCollectionMonth:
plan.openingReceivablesCollectionMonth ?? null,
openingReceivablesCollectionPercent:
plan.openingReceivablesCollectionPercent ?? '100',
openingReceivablesInstallments:
plan.openingReceivablesInstallments ?? [],
openingPayablesInstallments: plan.openingPayablesInstallments ?? [],
salesRecoverySchedule: plan.salesRecoverySchedule ?? [],
},
});
if (snapshot.length > MAX_BUSINESS_PLAN_SNAPSHOT_LENGTH)
throw new Error(
'O plano financeiro excede o limite de armazenamento de 60 KB. Reduza produtos ou agendas mensais antes de guardar.',
);
return snapshot;
}
export function restoreBusinessPlanSnapshot(
snapshot: string | undefined,
): BusinessFinancialPlanInput | null {
if (!snapshot || snapshot.length > MAX_BUSINESS_PLAN_SNAPSHOT_LENGTH)
return null;
try {
const decoded = JSON.parse(snapshot) as {
version?: unknown;
plan?: unknown;
};
if (
(decoded.version !== 1 &&
decoded.version !== 2 &&
decoded.version !== 3 &&
decoded.version !== 4 &&
decoded.version !== 5 &&
decoded.version !== 6 &&
decoded.version !== 7 &&
decoded.version !== 8 &&
decoded.version !== 9 &&
decoded.version !== 10 &&
decoded.version !== 11 &&
decoded.version !== 12 &&
decoded.version !== 13 &&
decoded.version !== 14 &&
decoded.version !== 15 &&
decoded.version !== 16 &&
decoded.version !== 17) ||
!decoded.plan ||
typeof decoded.plan !== 'object'
)
return null;
const plan = decoded.plan as Record<string, unknown>;
const textFields = [
'currency',
'openingCash',
'monthlyCollections',
'monthlyVariableCostPayments',
'monthlyOperatingExpenses',
'monthlyOperatingPayments',
'monthlyPayrollExpense',
'monthlyPayrollPayments',
'assetInvestment',
'inventory',
'receivables',
'payables',
'equity',
'shareholderLoans',
'bankLoanProceeds',
'grants',
'assetDepreciation',
'monthlyInterestExpense',
'monthlyInterestPayment',
'monthlyPrincipalRepayment',
];
if (
textFields.some((field) => typeof plan[field] !== 'string') ||
!Number.isSafeInteger(plan.periods) ||
!Number.isSafeInteger(plan.depreciationLifeMonths)
)
return null;
if (
plan.monthlyInventoryPurchases !== undefined &&
typeof plan.monthlyInventoryPurchases !== 'string'
)
return null;
if (
decoded.version !== 1 &&
typeof plan.monthlyInventoryPurchases !== 'string'
)
return null;
if (
plan.inventoryPurchaseLeadMonths !== undefined &&
(!Number.isSafeInteger(plan.inventoryPurchaseLeadMonths) ||
Number(plan.inventoryPurchaseLeadMonths) < 0 ||
Number(plan.inventoryPurchaseLeadMonths) > 12)
)
return null;
if (
decoded.version === 3 &&
!Number.isSafeInteger(plan.inventoryPurchaseLeadMonths)
)
return null;
if (
[12, 13, 14, 15, 16, 17].includes(Number(decoded.version)) &&
(!Object.hasOwn(plan, 'inventoryPurchaseSchedule') ||
!Object.hasOwn(plan, 'useInventoryPurchaseSchedule') ||
typeof plan.useInventoryPurchaseSchedule !== 'boolean')
)
return null;
if (
plan.inventoryPurchaseSchedule !== undefined &&
plan.inventoryPurchaseSchedule !== null
) {
if (
![12, 13, 14, 15, 16, 17].includes(Number(decoded.version)) ||
!Array.isArray(plan.inventoryPurchaseSchedule) ||
plan.inventoryPurchaseSchedule.length < Number(plan.periods) ||
plan.inventoryPurchaseSchedule.length > 60 ||
plan.inventoryPurchaseSchedule.some(
(entry) =>
!entry ||
typeof entry !== 'object' ||
typeof entry.amount !== 'string' ||
!Number.isSafeInteger(entry.leadMonths) ||
entry.leadMonths < 0 ||
entry.leadMonths > 12 ||
entry.paymentLagMonths !== undefined,
)
)
return null;
}
if (
plan.useInventoryPurchaseSchedule === true &&
!Array.isArray(plan.inventoryPurchaseSchedule)
)
return null;
if (
[13, 14, 15, 16, 17].includes(Number(decoded.version)) &&
!Object.hasOwn(plan, 'monthlyVariableCostPaymentsByPeriod')
)
return null;
if (
plan.monthlyVariableCostPaymentsByPeriod !== undefined &&
plan.monthlyVariableCostPaymentsByPeriod !== null &&
(![13, 14, 15, 16, 17].includes(Number(decoded.version)) ||
!Array.isArray(plan.monthlyVariableCostPaymentsByPeriod) ||
plan.monthlyVariableCostPaymentsByPeriod.length <
Number(plan.periods) ||
plan.monthlyVariableCostPaymentsByPeriod.length > 60 ||
plan.monthlyVariableCostPaymentsByPeriod.some(
(value) => typeof value !== 'string',
))
)
return null;
if (
plan.salesCollectionLagMonths !== undefined &&
plan.salesCollectionLagMonths !== null &&
(!Number.isSafeInteger(plan.salesCollectionLagMonths) ||
Number(plan.salesCollectionLagMonths) < 0 ||
Number(plan.salesCollectionLagMonths) > 12)
)
return null;
if (
[4, 5, 6].includes(Number(decoded.version)) &&
!Object.hasOwn(plan, 'salesCollectionLagMonths')
)
return null;
if (
plan.salesCollectionPercent !== undefined &&
typeof plan.salesCollectionPercent !== 'string'
)
return null;
if (
[5, 6].includes(Number(decoded.version)) &&
!Object.hasOwn(plan, 'salesCollectionPercent')
)
return null;
const collectionPercent = String(plan.salesCollectionPercent ?? '100');
const collectionRate = percentMicro(
collectionPercent,
'Percentagem de cobrança',
);
if (collectionRate < 0n || collectionRate > 100_000_000n) return null;
if (
plan.openingReceivablesCollectionMonth !== undefined &&
plan.openingReceivablesCollectionMonth !== null &&
(!Number.isSafeInteger(plan.openingReceivablesCollectionMonth) ||
Number(plan.openingReceivablesCollectionMonth) < 1 ||
Number(plan.openingReceivablesCollectionMonth) > 60)
)
return null;
if (
plan.openingReceivablesCollectionPercent !== undefined &&
typeof plan.openingReceivablesCollectionPercent !== 'string'
)
return null;
if (
decoded.version === 6 &&
(!Object.hasOwn(plan, 'openingReceivablesCollectionMonth') ||
!Object.hasOwn(plan, 'openingReceivablesCollectionPercent'))
)
return null;
const openingCollectionRate = percentMicro(
String(plan.openingReceivablesCollectionPercent ?? '100'),
'Percentagem de cobrança dos valores a receber de abertura',
);
if (openingCollectionRate < 0n || openingCollectionRate > 100_000_000n)
return null;
if (
[7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17].includes(
Number(decoded.version),
)
) {
if (
!Array.isArray(plan.openingReceivablesInstallments) ||
plan.openingReceivablesInstallments.length > 60
)
return null;
let scheduledRate =
plan.openingReceivablesCollectionMonth === null ||
plan.openingReceivablesCollectionMonth === undefined
? 0n
: openingCollectionRate;
for (const entry of plan.openingReceivablesInstallments) {
if (
!entry ||
typeof entry !== 'object' ||
!Number.isSafeInteger(entry.month) ||
entry.month < 1 ||
entry.month > 60 ||
typeof entry.percent !== 'string'
)
return null;
const rate = percentMicro(
entry.percent,
'Percentagem da parcela de recebíveis',
);
if (rate < 0n || rate > 100_000_000n) return null;
scheduledRate += rate;
}
if (scheduledRate > 100_000_000n) return null;
}
if (
decoded.version === 17 &&
!Array.isArray(plan.openingPayablesInstallments)
)
return null;
if (
plan.openingPayablesInstallments !== undefined &&
(!Array.isArray(plan.openingPayablesInstallments) ||
plan.openingPayablesInstallments.length > 60 ||
(decoded.version !== 17 &&
plan.openingPayablesInstallments.length > 0) ||
plan.openingPayablesInstallments.some(
(entry) =>
!entry ||
typeof entry !== 'object' ||
!Number.isSafeInteger(entry.month) ||
entry.month < 1 ||
entry.month > 60 ||
typeof entry.percent !== 'string' ||
percentMicro(
entry.percent,
'Percentagem da liquidação de valores a pagar',
) < 0n ||
percentMicro(
entry.percent,
'Percentagem da liquidação de valores a pagar',
) > 100_000_000n,
) ||
plan.openingPayablesInstallments.reduce(
(sum, entry) =>
sum +
percentMicro(
entry.percent,
'Percentagem da liquidação de valores a pagar',
),
0n,
) > 100_000_000n)
)
return null;
if (
[8, 9, 10, 11, 12, 13, 14, 15, 16, 17].includes(
Number(decoded.version),
)
) {
if (
!Array.isArray(plan.salesRecoverySchedule) ||
plan.salesRecoverySchedule.length > 60
)
return null;
const months = new Set<number>();
for (const entry of plan.salesRecoverySchedule) {
if (
!entry ||
typeof entry !== 'object' ||
!Number.isSafeInteger(entry.month) ||
entry.month < 1 ||
entry.month > 60 ||
typeof entry.percent !== 'string' ||
months.has(entry.month)
)
return null;
months.add(entry.month);
const rate = percentMicro(
entry.percent,
'Percentagem de recuperação de vendas',
);
if (rate < 0n || rate > 100_000_000n) return null;
}
}
const scheduleFields = [
'monthlyOperatingExpensesByPeriod',
'monthlyOperatingPaymentsByPeriod',
'monthlyPayrollExpenseByPeriod',
'monthlyPayrollPaymentsByPeriod',
];
for (const field of scheduleFields) {
const schedule = plan[field];
if (
schedule !== undefined &&
(![11, 12, 13, 14, 15, 16, 17].includes(
Number(decoded.version),
) ||
!Array.isArray(schedule) ||
schedule.length < Number(plan.periods) ||
schedule.length > 60 ||
schedule.some((value) => typeof value !== 'string'))
)
return null;
}
for (const field of [
'presentationCurrency',
'fxRate',
'fxDate',
'fxSource',
])
if (plan[field] !== undefined && typeof plan[field] !== 'string')
return null;
if (
decoded.version >= 14 &&
typeof plan.useProductInventorySchedule !== 'boolean'
)
return null;
if (decoded.version < 14 && plan.useProductInventorySchedule === true)
return null;
if (
decoded.version >= 15 &&
typeof plan.useSupplierPaymentTerms !== 'boolean'
)
return null;
if (decoded.version < 15 && plan.useSupplierPaymentTerms === true)
return null;
if (
plan.useSupplierPaymentTerms === true &&
plan.useProductInventorySchedule !== true
)
return null;
if (plan.scenarioAdjustments !== undefined) {
const scenarios = plan.scenarioAdjustments as Record<
string,
unknown
>;
for (const name of ['favorable', 'unfavorable']) {
const adjustment = scenarios[name];
if (
!adjustment ||
typeof adjustment !== 'object' ||
[
'unitVolumePercent',
'unitPricePercent',
'unitCostPercent',
'operatingCostsPercent',
'cashCollectionsPercent',
].some(
(field) =>
typeof (adjustment as Record<string, unknown>)[
field
] !== 'string',
)
)
return null;
}
}
if (
!Array.isArray(plan.products) ||
plan.products.length === 0 ||
plan.products.some((product) => {
if (
!product ||
typeof product !== 'object' ||
[
'label',
'monthlyUnits',
'unitPrice',
'unitVariableCost',
].some(
(field) =>
typeof (product as Record<string, unknown>)[
field
] !== 'string',
)
)
return true;
const productData = product as Record<string, unknown>;
const schedule = productData.monthlyUnitsByPeriod;
const priceSchedule = productData.unitPriceByPeriod;
const costSchedule = productData.unitVariableCostByPeriod;
const validMoneySchedule = (values: unknown) =>
values === undefined ||
([10, 11, 12, 13, 14, 15, 16, 17].includes(
Number(decoded.version),
) &&
Array.isArray(values) &&
values.length >= Number(plan.periods) &&
values.length <= 60 &&
values.every((value) => typeof value === 'string'));
return (
(schedule !== undefined &&
(![9, 10, 11, 12, 13, 14, 15, 16, 17].includes(
Number(decoded.version),
) ||
!Array.isArray(schedule) ||
schedule.length < Number(plan.periods) ||
schedule.length > 60 ||
schedule.some(
(units) => typeof units !== 'string',
))) ||
!validMoneySchedule(priceSchedule) ||
!validMoneySchedule(costSchedule) ||
(productData.productInventoryOpening !== undefined &&
(Number(decoded.version) < 14 ||
typeof productData.productInventoryOpening !==
'string')) ||
(productData.productInventoryPurchasesByPeriod !==
undefined &&
(Number(decoded.version) < 14 ||
!Array.isArray(
productData.productInventoryPurchasesByPeriod,
) ||
productData.productInventoryPurchasesByPeriod
.length < Number(plan.periods) ||
productData.productInventoryPurchasesByPeriod
.length > 60 ||
productData.productInventoryPurchasesByPeriod.some(
(entry) =>
!entry ||
typeof entry.amount !== 'string' ||
!Number.isSafeInteger(entry.leadMonths) ||
entry.leadMonths < 0 ||
entry.leadMonths > 12 ||
(entry.paymentLagMonths !== undefined &&
(!Number.isSafeInteger(
entry.paymentLagMonths,
) ||
entry.paymentLagMonths < 0 ||
entry.paymentLagMonths > 12)) ||
(entry.paymentInstallments !== undefined &&
(![16, 17].includes(
Number(decoded.version),
) ||
!Array.isArray(
entry.paymentInstallments,
) ||
entry.paymentInstallments.length >
12 ||
entry.paymentInstallments.some(
(part: any) =>
!part ||
!Number.isSafeInteger(
part.lagMonths,
) ||
part.lagMonths < 0 ||
part.lagMonths > 12 ||
typeof part.percent !==
'string' ||
percentMicro(
part.percent,
'Percentagem da parcela',
) < 0n ||
percentMicro(
part.percent,
'Percentagem da parcela',
) > 100_000_000n,
) ||
entry.paymentInstallments.reduce(
(sum: bigint, part: any) =>
sum +
percentMicro(
part.percent,
'Percentagem da parcela',
),
0n,
) > 100_000_000n)),
))) ||
(plan.useProductInventorySchedule === true &&
(typeof productData.productInventoryOpening !==
'string' ||
!Array.isArray(
productData.productInventoryPurchasesByPeriod,
))) ||
(plan.useSupplierPaymentTerms === true &&
productData.productInventoryPurchasesByPeriod!.some(
(entry) =>
decoded.version === 15
? !Number.isSafeInteger(
entry.paymentLagMonths,
) ||
entry.paymentLagMonths < 0 ||
entry.paymentLagMonths > 12
: !Array.isArray(entry.paymentInstallments),
))
);
})
)
return null;
return {
...plan,
inventoryPurchaseLeadMonths: Number(
plan.inventoryPurchaseLeadMonths ?? 0,
),
salesCollectionLagMonths:
plan.salesCollectionLagMonths === null ||
plan.salesCollectionLagMonths === undefined
? null
: Number(plan.salesCollectionLagMonths),
salesCollectionPercent: String(
plan.salesCollectionPercent ?? '100',
),
openingReceivablesCollectionMonth:
plan.openingReceivablesCollectionMonth === null ||
plan.openingReceivablesCollectionMonth === undefined
? null
: Number(plan.openingReceivablesCollectionMonth),
openingReceivablesCollectionPercent: String(
plan.openingReceivablesCollectionPercent ?? '100',
),
openingReceivablesInstallments: Array.isArray(
plan.openingReceivablesInstallments,
)
? plan.openingReceivablesInstallments.map((entry) => ({
month: Number(entry.month),
percent: String(entry.percent),
}))
: [],
openingPayablesInstallments: Array.isArray(
plan.openingPayablesInstallments,
)
? plan.openingPayablesInstallments.map((entry) => ({
month: Number(entry.month),
percent: String(entry.percent),
}))
: [],
salesRecoverySchedule: Array.isArray(plan.salesRecoverySchedule)
? plan.salesRecoverySchedule.map((entry) => ({
month: Number(entry.month),
percent: String(entry.percent),
}))
: [],
inventoryPurchaseSchedule: Array.isArray(
plan.inventoryPurchaseSchedule,
)
? plan.inventoryPurchaseSchedule.map((entry) => ({
amount: String(entry.amount),
leadMonths: Number(entry.leadMonths),
}))
: null,
useInventoryPurchaseSchedule:
plan.useInventoryPurchaseSchedule === true,
useProductInventorySchedule:
plan.useProductInventorySchedule === true,
useSupplierPaymentTerms: plan.useSupplierPaymentTerms === true,
monthlyVariableCostPaymentsByPeriod: Array.isArray(
plan.monthlyVariableCostPaymentsByPeriod,
)
? plan.monthlyVariableCostPaymentsByPeriod.map(String)
: null,
} as unknown as BusinessFinancialPlanInput;
} catch {
return null;
}
}
function cents(value: string, label: string, signed = false): bigint {
const match = (
signed ? /^(-?\d+)(?:\.(\d{1,2}))?$/ : /^(\d+)(?:\.(\d{1,2}))?$/
).exec(value.trim());
if (!match || value.length > 40)
throw new Error(
`${label}: use um montante${signed ? '' : ' não negativo'} com até duas casas decimais.`,
);
const sign = match[1].startsWith('-') ? -1n : 1n;
return (
sign *
(BigInt(match[1].replace('-', '')) * 100n +
BigInt((match[2] ?? '').padEnd(2, '0') || '0'))
);
}
function quantity(value: string): bigint {
const match = /^(\d+)(?:\.(\d{1,3}))?$/.exec(value.trim());
if (!match || value.length > 40)
throw new Error(
'Quantidade: use um valor não negativo com até três casas decimais.',
);
return (
BigInt(match[1]) * 1000n +
BigInt((match[2] ?? '').padEnd(3, '0') || '0')
);
}
function multiplyQuantity(
quantityMilli: bigint,
unitPriceCents: bigint,
): bigint {
return (quantityMilli * unitPriceCents + 500n) / 1000n;
}
/** Reuses the sales model, without requiring unrelated cost/cash assumptions. Blank is never zero. */
export function monthlyProjectedSales(
input: BusinessFinancialPlanInput,
): Array<string | null> {
const periods = input.periods ?? 12;
if (!Number.isInteger(periods) || periods < 1 || periods > 60)
throw new Error('Horizonte de vendas inválido.');
return Array.from({ length: periods }, (_, month) => {
if (!input.products.length) return null;
let total = 0n;
for (const product of input.products) {
const units =
product.monthlyUnitsByPeriod === undefined
? product.monthlyUnits
: product.monthlyUnitsByPeriod[month];
const price =
product.unitPriceByPeriod === undefined
? product.unitPrice
: product.unitPriceByPeriod[month];
if (
typeof units !== 'string' ||
typeof price !== 'string' ||
units.trim() === '' ||
price.trim() === ''
)
return null;
total += multiplyQuantity(
quantity(units),
cents(price, `Preço de ${product.label}`),
);
}
return format(total);
});
}
/** Existing purchase orders are planning references, never a proxy for deductible VAT or invoice dates. */
export function monthlyProjectedPurchaseOrders(
input: BusinessFinancialPlanInput,
): Array<string | null> {
const periods = input.periods ?? 12;
if (!Number.isInteger(periods) || periods < 1 || periods > 60)
throw new Error('Horizonte de compras inválido.');
return Array.from({ length: periods }, (_, month) => {
const amounts = input.useProductInventorySchedule
? input.products.map(
(product) =>
product.productInventoryPurchasesByPeriod?.[month]
?.amount,
)
: [
input.useInventoryPurchaseSchedule
? input.inventoryPurchaseSchedule?.[month]?.amount
: input.monthlyInventoryPurchases,
];
if (
!amounts.length ||
amounts.some(
(amount) => typeof amount !== 'string' || amount.trim() === '',
)
)
return null;
return format(
amounts.reduce(
(total, amount) =>
total + cents(amount!, 'Compras projectadas'),
0n,
),
);
});
}
function format(centsValue: bigint): string {
const absolute = centsValue < 0n ? -centsValue : centsValue;
return `${centsValue < 0n ? '-' : ''}${absolute / 100n}.${String(absolute % 100n).padStart(2, '0')}`;
}
function scaledDecimal(value: string, scale: number, label: string): bigint {
const match = /^(\d+)(?:\.(\d+))?$/.exec(value.trim());
if (!match || (match[2]?.length ?? 0) > scale || value.length > 40)
throw new Error(
`${label}: use um decimal positivo com até ${scale} casas.`,
);
return (
BigInt(match[1]) * 10n ** BigInt(scale) +
BigInt((match[2] ?? '').padEnd(scale, '0') || '0')
);
}
export function convertCents(
value: bigint,
rateScaled: bigint,
rateScale: bigint,
): bigint {
const sign = value < 0n ? -1n : 1n;
const absolute = value < 0n ? -value : value;
return sign * ((absolute * rateScaled + rateScale / 2n) / rateScale);
}
function percentMicro(value: string, label: string): bigint {
const match = /^(-?\d+)(?:\.(\d{1,6}))?$/.exec(value.trim());
if (!match || value.length > 40)
throw new Error(
`${label}: indique uma percentagem com até seis casas.`,
);
const sign = match[1].startsWith('-') ? -1n : 1n;
return (
sign *
(BigInt(match[1].replace('-', '')) * 1_000_000n +
BigInt((match[2] ?? '').padEnd(6, '0') || '0'))
);
}
function formatPercentMicro(rate: bigint): string {
return `${rate / 1_000_000n}.${String(rate % 1_000_000n)
.padStart(6, '0')
.replace(/0+$/, '')}`.replace(/\.$/, '');
}
function adjustPercent(
value: bigint,
percentage: string,
label: string,
): bigint {
const factor = 100_000_000n + percentMicro(percentage, label);
if (factor < 0n)
throw new Error(`${label}: a redução não pode exceder 100%.`);
const absolute = value < 0n ? -value : value;
const adjusted = (absolute * factor + 50_000_000n) / 100_000_000n;
return value < 0n ? -adjusted : adjusted;
}
function formatQuantity(quantityMilli: bigint): string {
return `${quantityMilli / 1000n}.${String(quantityMilli % 1000n).padStart(3, '0')}`;
}
function applyScenario(
base: BusinessFinancialPlanInput,
adjustment: ScenarioAdjustment,
): BusinessFinancialPlanInput {
const volume = (value: string, label: string) =>
formatQuantity(
adjustPercent(
quantity(value),
adjustment.unitVolumePercent,
`${label}: volume`,
),
);
const moneyChange = (value: string, percentage: string, label: string) =>
format(adjustPercent(cents(value, label), percentage, label));
const baseCollectionRate = percentMicro(
base.salesCollectionPercent ?? '100',
'Percentagem de cobrança',
);
const scenarioCollectionFactor =
100_000_000n +
percentMicro(
adjustment.cashCollectionsPercent,
'Variação das cobranças',
);
const rawAdjustedCollectionRate =
(baseCollectionRate * scenarioCollectionFactor + 50_000_000n) /
100_000_000n;
const adjustedCollectionRate =
rawAdjustedCollectionRate < 0n
? 0n
: rawAdjustedCollectionRate > 100_000_000n
? 100_000_000n
: rawAdjustedCollectionRate;
const adjustedCollectionPercent = formatPercentMicro(
adjustedCollectionRate,
);
const baseOpeningReceivableRate = percentMicro(
base.openingReceivablesCollectionPercent ?? '100',
'Percentagem de cobrança dos valores a receber de abertura',
);
const rawAdjustedOpeningReceivableRate =
(baseOpeningReceivableRate * scenarioCollectionFactor + 50_000_000n) /
100_000_000n;
const adjustedOpeningReceivableRate =
rawAdjustedOpeningReceivableRate < 0n
? 0n
: rawAdjustedOpeningReceivableRate > 100_000_000n
? 100_000_000n
: rawAdjustedOpeningReceivableRate;
const openingReceivablePercent =
base.openingReceivablesCollectionMonth === null ||
base.openingReceivablesCollectionMonth === undefined
? (base.openingReceivablesCollectionPercent ?? '100')
: formatPercentMicro(adjustedOpeningReceivableRate);
let remainingOpeningCollectionRate =
100_000_000n -
(base.openingReceivablesCollectionMonth === null ||
base.openingReceivablesCollectionMonth === undefined
? 0n
: adjustedOpeningReceivableRate);
const openingReceivablesInstallments = (
base.openingReceivablesInstallments ?? []
).map((installment) => {
const declaredRate = percentMicro(
installment.percent,
'Percentagem da parcela de recebíveis',
);
const adjustedRate =
(declaredRate * scenarioCollectionFactor + 50_000_000n) /
100_000_000n;
const payableRate =
adjustedRate < 0n
? 0n
: adjustedRate > remainingOpeningCollectionRate
? remainingOpeningCollectionRate
: adjustedRate;
remainingOpeningCollectionRate -= payableRate;
return { ...installment, percent: formatPercentMicro(payableRate) };
});
const salesRecoverySchedule = (base.salesRecoverySchedule ?? []).map(
(entry) => {
const declared = percentMicro(
entry.percent,
'Percentagem de recuperação de vendas',
);
const adjusted =
(declared * scenarioCollectionFactor + 50_000_000n) /
100_000_000n;
return {
...entry,
percent: formatPercentMicro(
adjusted < 0n
? 0n
: adjusted > 100_000_000n
? 100_000_000n
: adjusted,
),
};
},
);
return {
...base,
products: base.products.map((product) => ({
...product,
monthlyUnits: volume(product.monthlyUnits, product.label),
monthlyUnitsByPeriod: product.monthlyUnitsByPeriod?.map(
(units, index) =>
volume(units, `${product.label}: mês ${index + 1}`),
),
unitPrice: moneyChange(
product.unitPrice,
adjustment.unitPricePercent,
`${product.label}: preço`,
),
unitPriceByPeriod: product.unitPriceByPeriod?.map((price, index) =>
moneyChange(
price,
adjustment.unitPricePercent,
`${product.label}: preço mês ${index + 1}`,
),
),
unitVariableCost: moneyChange(
product.unitVariableCost,
adjustment.unitCostPercent,
`${product.label}: custo unitário`,
),
unitVariableCostByPeriod: product.unitVariableCostByPeriod?.map(
(cost, index) =>
moneyChange(
cost,
adjustment.unitCostPercent,
`${product.label}: custo unitário mês ${index + 1}`,
),
),
productInventoryPurchasesByPeriod:
base.useProductInventorySchedule === true
? product.productInventoryPurchasesByPeriod?.map(
(entry, month) => ({
...entry,
amount:
month >= (base.periods ?? 12)
? entry.amount
: moneyChange(
moneyChange(
entry.amount,
adjustment.unitVolumePercent,
`${product.label}: stock volume mes ${month + 1}`,
),
adjustment.unitCostPercent,
`${product.label}: compra mes ${month + 1}`,
),
}),
)
: product.productInventoryPurchasesByPeriod,
})),
monthlyVariableCostPayments: moneyChange(
moneyChange(
base.monthlyVariableCostPayments,
adjustment.unitVolumePercent,
'Volume de pagamentos variáveis',
),
adjustment.unitCostPercent,
'Custo dos pagamentos variáveis',
),
monthlyVariableCostPaymentsByPeriod:
base.monthlyVariableCostPaymentsByPeriod?.map((amount, month) =>
moneyChange(
moneyChange(
amount,
adjustment.unitVolumePercent,
`Volume dos pagamentos de compras mês ${month + 1}`,
),
adjustment.unitCostPercent,
`Pagamento de compras mês ${month + 1}`,
),
) ?? null,
monthlyInventoryPurchases:
base.monthlyInventoryPurchases === undefined ||
base.monthlyInventoryPurchases.trim() === ''
? undefined
: moneyChange(
moneyChange(
base.monthlyInventoryPurchases,
adjustment.unitVolumePercent,
'Volume de compras para existências',
),
adjustment.unitCostPercent,
'Compras para existências',
),
inventoryPurchaseSchedule:
base.useInventoryPurchaseSchedule === true
? base.inventoryPurchaseSchedule?.map((entry, index) => ({
...entry,
amount:
index >= (base.periods ?? 12)
? entry.amount
: moneyChange(
moneyChange(
entry.amount,
adjustment.unitVolumePercent,
`Compras para existências, volume mês ${index + 1}`,
),
adjustment.unitCostPercent,
`Compras para existências mês ${index + 1}`,
),
}))
: base.inventoryPurchaseSchedule,
monthlyOperatingExpenses: moneyChange(
base.monthlyOperatingExpenses,
adjustment.operatingCostsPercent,
'Despesas operacionais',
),
monthlyOperatingExpensesByPeriod:
base.monthlyOperatingExpensesByPeriod?.map((amount, month) =>
moneyChange(
amount,
adjustment.operatingCostsPercent,
`Despesas operacionais mes ${month + 1}`,
),
),
monthlyOperatingPayments: moneyChange(
base.monthlyOperatingPayments,
adjustment.operatingCostsPercent,
'Pagamentos operacionais',
),
monthlyOperatingPaymentsByPeriod:
base.monthlyOperatingPaymentsByPeriod?.map((amount, month) =>
moneyChange(
amount,
adjustment.operatingCostsPercent,
`Pagamentos operacionais mes ${month + 1}`,
),
),
monthlyPayrollExpense: moneyChange(
base.monthlyPayrollExpense,
adjustment.operatingCostsPercent,
'Pessoal',
),
monthlyPayrollExpenseByPeriod: base.monthlyPayrollExpenseByPeriod?.map(
(amount, month) =>
moneyChange(
amount,
adjustment.operatingCostsPercent,
`Despesa com pessoal mes ${month + 1}`,
),
),
monthlyPayrollPayments: moneyChange(
base.monthlyPayrollPayments,
adjustment.operatingCostsPercent,
'Pagamentos de pessoal',
),
monthlyPayrollPaymentsByPeriod:
base.monthlyPayrollPaymentsByPeriod?.map((amount, month) =>
moneyChange(
amount,
adjustment.operatingCostsPercent,
`Pagamentos de pessoal mes ${month + 1}`,
),
),
monthlyCollections: moneyChange(
base.monthlyCollections,
adjustment.cashCollectionsPercent,
'Cobranças',
),
salesCollectionPercent: adjustedCollectionPercent,
openingReceivablesCollectionPercent: openingReceivablePercent,
openingReceivablesInstallments,
salesRecoverySchedule,
};
}
export function compareBusinessScenarios(input: BusinessFinancialPlanInput) {
if (!input.scenarioAdjustments)
throw new Error(
'Indique os pressupostos para os cenários favorável e desfavorável.',
);
const base = forecastBusinessPlan(input);
const favorableInputs = applyScenario(
input,
input.scenarioAdjustments.favorable,
);
const unfavorableInputs = applyScenario(
input,
input.scenarioAdjustments.unfavorable,
);
return {
base,
favorable: {
adjustments: input.scenarioAdjustments.favorable,
result: forecastBusinessPlan(favorableInputs),
},
unfavorable: {
adjustments: input.scenarioAdjustments.unfavorable,
result: forecastBusinessPlan(unfavorableInputs),
},
changedAssumptions: [
'unidades vendidas',
'preço unitário',
'custo variável unitário',
'compras para existências',
'despesas e pessoal',
'cobranças',
],
};
}
/** Forecast separates invoiced sales, collections, accounting expenses and cash payments. */
export function forecastBusinessPlan(input: BusinessFinancialPlanInput) {
if (!validCurrency(input.currency))
throw new Error('Seleccione um código de moeda ISO suportado.');
const periods = input.periods ?? 12;
if (!Number.isSafeInteger(periods) || periods < 1 || periods > 60)
throw new Error('O horizonte deve estar entre 1 e 60 meses.');
if (
!Number.isSafeInteger(input.depreciationLifeMonths) ||
input.depreciationLifeMonths < 1 ||
input.depreciationLifeMonths > 600
)
throw new Error(
'A vida útil contabilística deve estar entre 1 e 600 meses.',
);
const inventoryPurchaseLeadMonths = input.inventoryPurchaseLeadMonths ?? 0;
if (
!Number.isSafeInteger(inventoryPurchaseLeadMonths) ||
inventoryPurchaseLeadMonths < 0 ||
inventoryPurchaseLeadMonths > 12
)
throw new Error(
'O prazo de entrega das compras deve estar entre 0 e 12 meses.',
);
const inventoryPurchaseSchedule = input.inventoryPurchaseSchedule ?? null;
if (
inventoryPurchaseSchedule !== null &&
(!Array.isArray(inventoryPurchaseSchedule) ||
inventoryPurchaseSchedule.length < periods ||
inventoryPurchaseSchedule.length > 60 ||
inventoryPurchaseSchedule.some(
(entry) =>
!entry ||
typeof entry.amount !== 'string' ||
!Number.isSafeInteger(entry.leadMonths) ||
entry.leadMonths < 0 ||
entry.leadMonths > 12,
))
)
throw new Error(
`A agenda de compras deve cobrir os ${periods} meses e usar prazos de entrega entre 0 e 12 meses.`,
);
const useInventoryPurchaseSchedule =
input.useInventoryPurchaseSchedule === true;
const useProductInventorySchedule =
input.useProductInventorySchedule === true;
const useSupplierPaymentTerms = input.useSupplierPaymentTerms === true;
if (useSupplierPaymentTerms && !useProductInventorySchedule)
throw new Error(
'Os prazos de pagamento a fornecedores exigem compras planeadas por produto.',
);
if (useInventoryPurchaseSchedule && useProductInventorySchedule)
throw new Error(
'Seleccione compras agregadas ou stock por produto, nao os dois modos.',
);
if (useInventoryPurchaseSchedule && inventoryPurchaseSchedule === null)
throw new Error('Indique a agenda mensal de compras para a utilizar.');
const salesCollectionLagMonths = input.salesCollectionLagMonths ?? null;
if (
salesCollectionLagMonths !== null &&
(!Number.isSafeInteger(salesCollectionLagMonths) ||
salesCollectionLagMonths < 0 ||
salesCollectionLagMonths > 12)
)
throw new Error(
'O prazo de cobrança das vendas deve estar entre 0 e 12 meses.',
);
const salesCollectionPercent = input.salesCollectionPercent ?? '100';
const salesCollectionRate = percentMicro(
salesCollectionPercent,
'Percentagem de cobrança',
);
if (salesCollectionRate < 0n || salesCollectionRate > 100_000_000n)
throw new Error('A percentagem de cobrança deve estar entre 0 e 100.');
const openingReceivablesCollectionMonth =
input.openingReceivablesCollectionMonth ?? null;
if (
openingReceivablesCollectionMonth !== null &&
(!Number.isSafeInteger(openingReceivablesCollectionMonth) ||
openingReceivablesCollectionMonth < 1 ||
openingReceivablesCollectionMonth > periods)
)
throw new Error(
'O mês de cobrança dos valores a receber deve estar dentro do horizonte da simulação.',
);
const openingReceivablesCollectionPercent =
input.openingReceivablesCollectionPercent ?? '100';
const openingReceivablesCollectionRate = percentMicro(
openingReceivablesCollectionPercent,
'Percentagem de cobrança dos valores a receber de abertura',
);
if (
openingReceivablesCollectionRate < 0n ||
openingReceivablesCollectionRate > 100_000_000n
)
throw new Error(
'A percentagem de cobrança dos valores a receber deve estar entre 0 e 100.',
);
const openingReceivablesInstallments =
input.openingReceivablesInstallments ?? [];
if (
!Array.isArray(openingReceivablesInstallments) ||
openingReceivablesInstallments.length > 60
)
throw new Error('A agenda de recebíveis deve ter até 60 parcelas.');
let openingReceivablesScheduledRate =
openingReceivablesCollectionMonth === null
? 0n
: openingReceivablesCollectionRate;
const openingReceivablesInstallmentRates =
openingReceivablesInstallments.map((installment) => {
if (
!Number.isSafeInteger(installment.month) ||
installment.month < 1 ||
installment.month > periods
)
throw new Error(
'Cada parcela de recebíveis deve estar dentro do horizonte da simulação.',
);
const rate = percentMicro(
installment.percent,
'Percentagem da parcela de recebíveis',
);
if (rate < 0n || rate > 100_000_000n)
throw new Error(
'A percentagem de cada parcela deve estar entre 0 e 100.',
);
openingReceivablesScheduledRate += rate;
return rate;
});
if (openingReceivablesScheduledRate > 100_000_000n)
throw new Error(
'A soma das cobranças declaradas de recebíveis de abertura não pode exceder 100%.',
);
const openingPayablesInstallments = input.openingPayablesInstallments ?? [];
if (
!Array.isArray(openingPayablesInstallments) ||
openingPayablesInstallments.length > 60
)
throw new Error(
'A agenda de valores a pagar deve ter até 60 parcelas.',
);
let openingPayablesScheduledRate = 0n;
const openingPayablesInstallmentRates = openingPayablesInstallments.map(
(installment) => {
if (
!Number.isSafeInteger(installment.month) ||
installment.month < 1 ||
installment.month > periods
)
throw new Error(
'Cada parcela de valores a pagar deve estar dentro do horizonte da simulação.',
);
const rate = percentMicro(
installment.percent,
'Percentagem da liquidação de valores a pagar',
);
if (rate < 0n || rate > 100_000_000n)
throw new Error(
'A percentagem de cada liquidação deve estar entre 0 e 100.',
);
openingPayablesScheduledRate += rate;
return rate;
},
);
if (openingPayablesScheduledRate > 100_000_000n)
throw new Error(
'A soma das liquidações declaradas de valores a pagar não pode exceder 100%.',
);
const salesRecoverySchedule = input.salesRecoverySchedule ?? [];
if (
!Array.isArray(salesRecoverySchedule) ||
salesRecoverySchedule.length > 60
)
throw new Error('A agenda de recuperação deve ter até 60 parcelas.');
const recoveryMonths = new Set<number>();
const salesRecoveryRates = new Map<number, bigint>();
salesRecoverySchedule.forEach((entry) => {
if (
!Number.isSafeInteger(entry.month) ||
entry.month < 1 ||
entry.month > periods ||
recoveryMonths.has(entry.month)
)
throw new Error(
'Cada recuperação deve ter um mês único dentro do horizonte.',
);
recoveryMonths.add(entry.month);
const rate = percentMicro(
entry.percent,
'Percentagem de recuperação de vendas',
);
if (rate < 0n || rate > 100_000_000n)
throw new Error(
'A percentagem de recuperação deve estar entre 0 e 100.',
);
salesRecoveryRates.set(entry.month, rate);
});
if (!input.products.length)
throw new Error('Adicione pelo menos um produto ou serviço.');
const presentationCurrency = input.presentationCurrency || input.currency;
if (!validCurrency(presentationCurrency))
throw new Error('Seleccione uma moeda de apresentação ISO suportada.');
let fxRate: bigint | null = null;
if (presentationCurrency !== input.currency) {
if (!input.fxRate || !input.fxDate || !input.fxSource?.trim())
throw new Error(
'Para converter moeda, indique taxa, data efectiva e fonte da taxa de câmbio.',
);
fxRate = scaledDecimal(input.fxRate, 8, 'Taxa de câmbio');
if (fxRate === 0n)
throw new Error('A taxa de câmbio deve ser superior a zero.');
const effectiveDate = /^\d{4}-\d{2}-\d{2}$/.test(input.fxDate)
? new Date(`${input.fxDate}T00:00:00Z`)
: null;
if (
!effectiveDate ||
Number.isNaN(effectiveDate.getTime()) ||
effectiveDate.toISOString().slice(0, 10) !== input.fxDate
)
throw new Error(
'Indique uma data de câmbio válida no formato AAAA-MM-DD.',
);
if (input.fxSource.trim().length > 2000)
throw new Error(
'A fonte da taxa de câmbio excede o limite de caracteres.',
);
}
const productInputs = input.products.map((product, index) => {
const unitsByPeriod = product.monthlyUnitsByPeriod;
const priceSchedule = product.unitPriceByPeriod;
const costSchedule = product.unitVariableCostByPeriod;
if (
unitsByPeriod !== undefined &&
(!Array.isArray(unitsByPeriod) ||
unitsByPeriod.length < periods ||
unitsByPeriod.length > 60)
)
throw new Error(
`O plano de unidades do produto ${index + 1} deve cobrir os ${periods} meses do horizonte.`,
);
const parseMoneySchedule = (
schedule: string[] | undefined,
baseValue: bigint,
label: string,
) => {
if (schedule === undefined) return Array(periods).fill(baseValue);
if (
!Array.isArray(schedule) ||
schedule.length < periods ||
schedule.length > 60
)
throw new Error(
`${label}: a agenda deve cobrir os ${periods} meses do horizonte e ter no máximo 60 meses.`,
);
return schedule.map((amount, month) =>
cents(amount, `${label}, mês ${month + 1}`),
);
};
const basePrice = cents(
product.unitPrice,
`Preço do produto ${index + 1}`,
);
const baseVariableCost = cents(
product.unitVariableCost,
`Custo variável do produto ${index + 1}`,
);
const _productInventoryOpening = useProductInventorySchedule
? cents(
product.productInventoryOpening ?? '',
`Opening inventory for ${product.label}`,
)
: null;
const productPurchaseSchedule =
product.productInventoryPurchasesByPeriod ?? null;
if (
useProductInventorySchedule &&
(!Array.isArray(productPurchaseSchedule) ||
productPurchaseSchedule.length < periods ||
productPurchaseSchedule.length > 60 ||
productPurchaseSchedule.some(
(entry) =>
!entry ||
typeof entry.amount !== 'string' ||
!Number.isSafeInteger(entry.leadMonths) ||
entry.leadMonths < 0 ||
entry.leadMonths > 12,
))
)
throw new Error(
`${product.label}: declare purchases and lead times for all ${periods} months.`,
);
return {
units:
unitsByPeriod?.map(quantity) ??
Array(periods).fill(quantity(product.monthlyUnits)),
prices: parseMoneySchedule(
priceSchedule,
basePrice,
`Preço do produto ${index + 1}`,
),
costs: parseMoneySchedule(
costSchedule,
baseVariableCost,
`Custo variável do produto ${index + 1}`,
),
};
});
const productStockInputs = input.products.map((product, _index) => {
if (!useProductInventorySchedule)
return { opening: null, orders: null };
const opening = cents(
product.productInventoryOpening ?? '',
`Existencias iniciais de ${product.label}`,
);
const schedule = product.productInventoryPurchasesByPeriod;
if (
!Array.isArray(schedule) ||
schedule.length < periods ||
schedule.length > 60 ||
schedule.some(
(entry) =>
!entry ||
typeof entry.amount !== 'string' ||
!Number.isSafeInteger(entry.leadMonths) ||
entry.leadMonths < 0 ||
entry.leadMonths > 12 ||
(useSupplierPaymentTerms &&
(entry.paymentInstallments !== undefined
? !Array.isArray(entry.paymentInstallments) ||
entry.paymentInstallments.length > 12 ||
entry.paymentInstallments.some(
(part) =>
!Number.isSafeInteger(part.lagMonths) ||
part.lagMonths < 0 ||
part.lagMonths > 12 ||
typeof part.percent !== 'string' ||
percentMicro(
part.percent,
'Percentagem da parcela',
) < 0n ||
percentMicro(
part.percent,
'Percentagem da parcela',
) > 100_000_000n,
) ||
entry.paymentInstallments.reduce(
(sum, part) =>
sum +
percentMicro(
part.percent,
'Percentagem da parcela',
),
0n,
) > 100_000_000n
: !Number.isSafeInteger(entry.paymentLagMonths) ||
entry.paymentLagMonths! < 0 ||
entry.paymentLagMonths! > 12)),
)
)
throw new Error(
`${product.label}: declare encomendas e prazos de entrega para todos os ${periods} meses.`,
);
return {
opening,
orders: schedule.slice(0, periods).map((entry, month) => ({
amount: cents(
entry.amount,
`${product.label}: compra para existencias, mes ${month + 1}`,
),
leadMonths: entry.leadMonths,
paymentLagMonths: entry.paymentLagMonths ?? 0,
paymentInstallments: entry.paymentInstallments ?? [
{
lagMonths: entry.paymentLagMonths ?? 0,
percent: '100',
},
],
})),
};
});
const revenueByPeriod = Array<bigint>(periods).fill(0n);
const costOfSalesByPeriod = Array<bigint>(periods).fill(0n);
for (const product of productInputs) {
for (let period = 0; period < periods; period++) {
revenueByPeriod[period] += multiplyQuantity(
product.units[period],
product.prices[period],
);
costOfSalesByPeriod[period] += multiplyQuantity(
product.units[period],
product.costs[period],
);
}
}
const totalRevenue = revenueByPeriod.reduce(
(total, value) => total + value,
0n,
);
const totalCostOfSales = costOfSalesByPeriod.reduce(
(total, value) => total + value,
0n,
);
const monthlyCollections = cents(
input.monthlyCollections,
'Cobranças mensais',
);
const variablePayments = cents(
input.monthlyVariableCostPayments,
'Pagamentos de compras',
);
const monthlyInventoryPurchases = input.monthlyInventoryPurchases?.trim()
? cents(
input.monthlyInventoryPurchases,
'Compras mensais para existências',
)
: null;
const inventoryPurchasesByPeriod = useProductInventorySchedule
? Array.from({ length: periods }, (_, month) =>
productStockInputs.reduce(
(sum, product) => sum + product.orders![month].amount,
0n,
),
)
: useInventoryPurchaseSchedule
? inventoryPurchaseSchedule!
.slice(0, periods)
.map((entry, month) =>
cents(
entry.amount,
`Compras para existências, mês ${month + 1}`,
),
)
: monthlyInventoryPurchases === null
? null
: Array<bigint>(periods).fill(monthlyInventoryPurchases);
const inventoryLeadMonthsByPeriod = useProductInventorySchedule
? Array<number>(periods).fill(0)
: useInventoryPurchaseSchedule
? inventoryPurchaseSchedule!
.slice(0, periods)
.map((entry) => entry.leadMonths)
: Array<number>(periods).fill(inventoryPurchaseLeadMonths);
const inventoryProjectionEnabled = inventoryPurchasesByPeriod !== null;
const operatingExpenses = cents(
input.monthlyOperatingExpenses,
'Despesas operacionais',
);
const moneySchedule = (
schedule: string[] | undefined,
base: bigint,
label: string,
) => {
if (schedule === undefined) return Array(periods).fill(base);
if (
!Array.isArray(schedule) ||
schedule.length < periods ||
schedule.length > 60
)
throw new Error(
`${label}: a agenda deve cobrir os ${periods} meses e ter no maximo 60 meses.`,
);
return schedule
.slice(0, periods)
.map((amount, month) =>
cents(amount, `${label}, mes ${month + 1}`),
);
};
const supplierPaymentAmountsByPeriod = Array<bigint>(periods).fill(0n);
let supplierPaymentsOutstanding = 0n;
const supplierPaymentSchedules = useSupplierPaymentTerms
? input.products.map((product, index) =>
productStockInputs[index].orders!.flatMap((order, orderMonth) => {
let remaining = order.amount;
const events: {
orderMonth: number;
paymentMonth: number | null;
percent: string;
amount: string;
paidWithinHorizon: boolean;
}[] = order.paymentInstallments.map((part) => {
const rate = percentMicro(
part.percent,
'Percentagem da parcela',
);
const rounded =
(order.amount * rate + 50_000_000n) / 100_000_000n;
const amount = rounded > remaining ? remaining : rounded;
remaining -= amount;
const paymentMonth = orderMonth + 1 + part.lagMonths;
const paidWithinHorizon = paymentMonth <= periods;
if (paidWithinHorizon)
supplierPaymentAmountsByPeriod[paymentMonth - 1] +=
amount;
else supplierPaymentsOutstanding += amount;
return {
orderMonth: orderMonth + 1,
paymentMonth,
percent: part.percent,
amount: format(amount),
paidWithinHorizon,
};
});
if (remaining > 0n) {
supplierPaymentsOutstanding += remaining;
events.push({
orderMonth: orderMonth + 1,
paymentMonth: null,
percent: 'remaining',
amount: format(remaining),
paidWithinHorizon: false,
});
}
return events;
}),
)
: null;
const variableCostPaymentsByPeriod = useSupplierPaymentTerms
? supplierPaymentAmountsByPeriod
: moneySchedule(
input.monthlyVariableCostPaymentsByPeriod ?? undefined,
variablePayments,
'Pagamentos de compras',
);
const operatingExpenseByPeriod = moneySchedule(
input.monthlyOperatingExpensesByPeriod,
operatingExpenses,
'Despesas operacionais',
);
const operatingPayments = cents(
input.monthlyOperatingPayments,
'Pagamentos operacionais',
);
const operatingPaymentsByPeriod = moneySchedule(
input.monthlyOperatingPaymentsByPeriod,
operatingPayments,
'Pagamentos operacionais',
);
const payrollExpense = cents(
input.monthlyPayrollExpense,
'Despesa com pessoal',
);
const payrollExpenseByPeriod = moneySchedule(
input.monthlyPayrollExpenseByPeriod,
payrollExpense,
'Despesa com pessoal',
);
const payrollPayments = cents(
input.monthlyPayrollPayments,
'Pagamentos de pessoal',
);
const payrollPaymentsByPeriod = moneySchedule(
input.monthlyPayrollPaymentsByPeriod,
payrollPayments,
'Pagamentos de pessoal',
);
const assetInvestment = cents(
input.assetInvestment,
'Investimento em activos',
);
const inventory = cents(input.inventory, 'Existências iniciais');
const productOpeningInventory = productStockInputs.reduce(
(sum, product) => sum + (product.opening ?? 0n),
0n,
);
if (useProductInventorySchedule && productOpeningInventory !== inventory)
throw new Error(
'A soma das existencias iniciais por produto deve coincidir com as existencias iniciais do plano.',
);
const receivables = cents(input.receivables, 'Valores a receber');
const payables = cents(input.payables, 'Valores a pagar');
const equity = cents(input.equity, 'Capital próprio');
const shareholderLoans = cents(input.shareholderLoans, 'Suprimentos');
const bankLoanProceeds = cents(
input.bankLoanProceeds,
'Empréstimos recebidos',
);
const grants = cents(input.grants, 'Subvenções');
const assetDepreciation = cents(
input.assetDepreciation,
'Base contabilística dos activos',
);
const interestExpense = cents(
input.monthlyInterestExpense,
'Juros reconhecidos',
);
const interestPayment = cents(
input.monthlyInterestPayment,
'Pagamento de juros',
);
const principalRepayment = cents(
input.monthlyPrincipalRepayment,
'Reembolso de capital',
);
const workingCapital = inventory + receivables - payables;
const initialUses = assetInvestment + workingCapital;
const financing = equity + shareholderLoans + bankLoanProceeds + grants;
const fundingGap = initialUses - financing;
const monthlyDepreciation =
(assetDepreciation + BigInt(input.depreciationLifeMonths) / 2n) /
BigInt(input.depreciationLifeMonths);
const openingCashInput = cents(input.openingCash, 'Saldo inicial', true);
let cash = openingCashInput + financing - initialUses;
let inventoryBalance = inventory;
let inventoryInTransit = 0n;
let totalInventoryReceipts = 0n;
let totalInventoryShortfall = 0n;
let totalProfitBeforeTax = 0n;
let totalCashCollections = 0n;
let totalSalesCollections = 0n;
let totalScheduledSalesCollections = 0n;
let totalSalesRecoveryCollections = 0n;
let totalOpeningReceivablesCollections = 0n;
let salesReceivables = 0n;
let totalOperatingPayments = 0n;
const inventoryArrivals = new Map<number, bigint>();
const productInventoryStates = useProductInventorySchedule
? productStockInputs.map((product) => ({
balance: product.opening!,
inTransit: 0n,
arrivals: new Map<number, bigint>(),
totalReceipts: 0n,
totalShortfall: 0n,
monthlyRows: [] as {
month: number;
opening: string;
purchases: string;
leadMonths: number;
paymentLagMonths: number;
receipts: string;
inTransit: string;
consumption: string;
shortfall: string;
closing: string;
}[],
}))
: [];
const salesInvoiceCohorts: { dueMonth: number; balance: bigint }[] = [];
const openingReceivablesCollectionAmount =
openingReceivablesCollectionMonth === null
? 0n
: (receivables * openingReceivablesCollectionRate + 50_000_000n) /
100_000_000n;
const openingReceivablesInstallmentSchedule = new Map<number, bigint>();
const openingReceivablesInstallmentAmounts: {
month: number;
percent: string;
amount: bigint;
}[] = [];
let remainingForInstallments =
receivables - openingReceivablesCollectionAmount;
openingReceivablesInstallments.forEach((installment, index) => {
const roundedAmount =
(receivables * openingReceivablesInstallmentRates[index] +
50_000_000n) /
100_000_000n;
const amount =
roundedAmount > remainingForInstallments
? remainingForInstallments
: roundedAmount;
remainingForInstallments -= amount;
openingReceivablesInstallmentSchedule.set(
installment.month,
(openingReceivablesInstallmentSchedule.get(installment.month) ??
0n) + amount,
);
openingReceivablesInstallmentAmounts.push({
month: installment.month,
percent: installment.percent,
amount,
});
});
const openingPayablesInstallmentSchedule = new Map<number, bigint>();
const openingPayablesInstallmentAmounts: {
month: number;
percent: string;
amount: bigint;
}[] = [];
let remainingOpeningPayables = payables;
openingPayablesInstallments.forEach((installment, index) => {
const roundedAmount =
(payables * openingPayablesInstallmentRates[index] + 50_000_000n) /
100_000_000n;
const amount =
roundedAmount > remainingOpeningPayables
? remainingOpeningPayables
: roundedAmount;
remainingOpeningPayables -= amount;
openingPayablesInstallmentSchedule.set(
installment.month,
(openingPayablesInstallmentSchedule.get(installment.month) ?? 0n) +
amount,
);
openingPayablesInstallmentAmounts.push({
month: installment.month,
percent: installment.percent,
amount,
});
});
let openingPayablesBalance = payables;
let openingReceivablesBalance = receivables;
const rows = [];
for (let month = 1; month <= periods; month++) {
const periodVariableCostPayment =
variableCostPaymentsByPeriod[month - 1];
const periodOperatingExpense = operatingExpenseByPeriod[month - 1];
const periodOperatingPayment = operatingPaymentsByPeriod[month - 1];
const periodPayrollExpense = payrollExpenseByPeriod[month - 1];
const periodPayrollPayment = payrollPaymentsByPeriod[month - 1];
const revenue = revenueByPeriod[month - 1];
const costOfSales = costOfSalesByPeriod[month - 1];
const _opening = cash;
const openingInventory = inventoryBalance;
let inventoryShortfall: bigint | null = null;
let closingInventory: bigint | null = null;
let inventoryReceipts: bigint | null = null;
let inventoryInTransitAtClose: bigint | null = null;
let inventoryProductRows: {
label: string;
opening: string;
purchases: string;
leadMonths: number;
paymentLagMonths: number;
receipts: string;
inTransit: string;
consumption: string;
shortfall: string;
closing: string;
}[] = [];
if (inventoryProjectionEnabled) {
const ordered = inventoryPurchasesByPeriod![month - 1];
const leadMonths = inventoryLeadMonthsByPeriod[month - 1];
if (useProductInventorySchedule) {
inventoryReceipts = 0n;
inventoryShortfall = 0n;
closingInventory = 0n;
inventoryInTransit = 0n;
inventoryProductRows = productInputs.map((product, index) => {
const stock = productInventoryStates[index];
const order = productStockInputs[index].orders![month - 1];
const arrivalMonth = month + order.leadMonths;
stock.arrivals.set(
arrivalMonth,
(stock.arrivals.get(arrivalMonth) ?? 0n) + order.amount,
);
const receipts = stock.arrivals.get(month) ?? 0n;
stock.arrivals.delete(month);
stock.inTransit += order.amount - receipts;
const consumption = multiplyQuantity(
product.units[month - 1],
product.costs[month - 1],
);
const available = stock.balance + receipts;
const shortfall =
consumption > available ? consumption - available : 0n;
stock.balance =
available > consumption ? available - consumption : 0n;
stock.totalReceipts += receipts;
stock.totalShortfall += shortfall;
inventoryReceipts! += receipts;
inventoryShortfall! += shortfall;
closingInventory! += stock.balance;
inventoryInTransit += stock.inTransit;
return {
label: input.products[index].label,
opening: format(available - receipts),
purchases: format(order.amount),
leadMonths: order.leadMonths,
paymentLagMonths: order.paymentLagMonths,
receipts: format(receipts),
inTransit: format(stock.inTransit),
consumption: format(consumption),
shortfall: format(shortfall),
closing: format(stock.balance),
};
});
productInventoryStates.forEach((state, index) => {
state.monthlyRows.push({
month,
...inventoryProductRows[index],
});
});
totalInventoryReceipts += inventoryReceipts;
totalInventoryShortfall += inventoryShortfall;
inventoryBalance = closingInventory;
inventoryInTransitAtClose = inventoryInTransit;
} else {
const arrivalMonth = month + leadMonths;
inventoryArrivals.set(
arrivalMonth,
(inventoryArrivals.get(arrivalMonth) ?? 0n) + ordered,
);
inventoryReceipts = inventoryArrivals.get(month) ?? 0n;
inventoryArrivals.delete(month);
inventoryInTransit += ordered - inventoryReceipts;
totalInventoryReceipts += inventoryReceipts;
const availableInventory = openingInventory + inventoryReceipts;
inventoryShortfall =
costOfSales > availableInventory
? costOfSales - availableInventory
: 0n;
closingInventory =
availableInventory > costOfSales
? availableInventory - costOfSales
: 0n;
inventoryBalance = closingInventory;
inventoryInTransitAtClose = inventoryInTransit;
totalInventoryShortfall += inventoryShortfall;
}
}
const depreciation =
month < input.depreciationLifeMonths
? monthlyDepreciation
: month === input.depreciationLifeMonths
? assetDepreciation -
monthlyDepreciation *
BigInt(input.depreciationLifeMonths - 1)
: 0n;
const operatingResult =
revenue -
costOfSales -
periodOperatingExpense -
periodPayrollExpense -
depreciation;
const profitBeforeTax = operatingResult - interestExpense;
let salesCollections: bigint | null = null;
let salesScheduledCollections: bigint | null = null;
let salesRecoveryCollections: bigint | null = null;
if (salesCollectionLagMonths !== null) {
const collectionMonth = month + salesCollectionLagMonths;
const scheduledCollection =
(revenue * salesCollectionRate + 50_000_000n) / 100_000_000n;
salesInvoiceCohorts.push({
dueMonth: collectionMonth,
balance: revenue,
});
salesReceivables += revenue;
salesScheduledCollections = 0n;
for (const invoice of salesInvoiceCohorts) {
if (invoice.dueMonth !== month || invoice.balance === 0n)
continue;
const collected =
invoice.balance < scheduledCollection
? invoice.balance
: scheduledCollection;
invoice.balance -= collected;
salesReceivables -= collected;
salesScheduledCollections += collected;
}
const recoveryRate = salesRecoveryRates.get(month) ?? 0n;
let recoveryTarget =
(salesInvoiceCohorts
.filter((invoice) => invoice.dueMonth < month)
.reduce((sum, invoice) => sum + invoice.balance, 0n) *
recoveryRate +
50_000_000n) /
100_000_000n;
salesRecoveryCollections = recoveryTarget;
for (const invoice of salesInvoiceCohorts) {
if (invoice.dueMonth >= month || recoveryTarget <= 0n) continue;
const recovered =
invoice.balance < recoveryTarget
? invoice.balance
: recoveryTarget;
invoice.balance -= recovered;
recoveryTarget -= recovered;
}
salesRecoveryCollections -= recoveryTarget;
salesReceivables -= salesRecoveryCollections;
salesCollections =
salesScheduledCollections + salesRecoveryCollections;
totalScheduledSalesCollections += salesScheduledCollections;
totalSalesRecoveryCollections += salesRecoveryCollections;
totalSalesCollections += salesCollections;
}
const totalCollections = monthlyCollections + (salesCollections ?? 0n);
const legacyOpeningReceivablesCollections =
month === openingReceivablesCollectionMonth
? openingReceivablesCollectionAmount
: 0n;
const openingReceivablesCollections =
legacyOpeningReceivablesCollections +
(openingReceivablesInstallmentSchedule.get(month) ?? 0n);
openingReceivablesBalance -= openingReceivablesCollections;
const openingPayablesPayments =
openingPayablesInstallmentSchedule.get(month) ?? 0n;
openingPayablesBalance -= openingPayablesPayments;
const allCollections = totalCollections + openingReceivablesCollections;
const totalPayments =
periodVariableCostPayment +
periodOperatingPayment +
periodPayrollPayment +
interestPayment +
principalRepayment +
openingPayablesPayments;
const netCashFlow = allCollections - totalPayments;
cash += netCashFlow;
totalProfitBeforeTax += profitBeforeTax;
totalCashCollections += allCollections;
totalOpeningReceivablesCollections += openingReceivablesCollections;
totalOperatingPayments += totalPayments;
rows.push({
month,
revenue: format(revenue),
cashCollections: format(allCollections),
openingReceivablesBalance: format(openingReceivablesBalance),
openingReceivablesCollections: format(
openingReceivablesCollections,
),
openingPayablesBalance: format(openingPayablesBalance),
openingPayablesPayments: format(openingPayablesPayments),
salesCollections:
salesCollections === null ? null : format(salesCollections),
salesScheduledCollections:
salesScheduledCollections === null
? null
: format(salesScheduledCollections),
salesRecoveryCollections:
salesRecoveryCollections === null
? null
: format(salesRecoveryCollections),
otherCollections:
salesCollections === null ? null : format(monthlyCollections),
salesReceivablesClosing:
salesCollections === null ? null : format(salesReceivables),
variableCost: format(costOfSales),
inventoryOpening: !inventoryProjectionEnabled
? null
: format(openingInventory),
inventoryPurchases: !inventoryProjectionEnabled
? null
: format(inventoryPurchasesByPeriod![month - 1]),
inventoryLeadMonths:
inventoryProjectionEnabled && !useProductInventorySchedule
? inventoryLeadMonthsByPeriod[month - 1]
: null,
inventoryReceipts:
inventoryReceipts === null ? null : format(inventoryReceipts),
inventoryInTransit:
inventoryInTransitAtClose === null
? null
: format(inventoryInTransitAtClose),
inventoryConsumption: format(costOfSales),
inventoryClosing:
closingInventory === null ? null : format(closingInventory),
inventoryShortfall:
inventoryShortfall === null ? null : format(inventoryShortfall),
operatingExpenses: format(periodOperatingExpense),
payrollExpense: format(periodPayrollExpense),
depreciation: format(depreciation),
interestExpense: format(interestExpense),
operatingResult: format(operatingResult),
profitBeforeTax: format(profitBeforeTax),
variableCostPayments: format(periodVariableCostPayment),
operatingPayments: format(periodOperatingPayment),
payrollPayments: format(periodPayrollPayment),
interestPayment: format(interestPayment),
principalRepayment: format(principalRepayment),
netCashFlow: format(netCashFlow),
closingCash: format(cash),
});
}
const inventoryProjection = !inventoryProjectionEnabled
? {
status: 'indeterminate' as const,
leadMonths:
useInventoryPurchaseSchedule || useProductInventorySchedule
? null
: inventoryPurchaseLeadMonths,
openingInventory: format(inventory),
monthlyPurchases: null,
purchaseSchedule: null,
totalPurchases: null,
totalReceipts: null,
totalConsumption: format(totalCostOfSales),
endingInventory: null,
inTransitAtEnd: null,
totalShortfall: null,
productSchedules: null,
}
: {
status:
totalInventoryShortfall > 0n
? ('estimated_with_shortfall' as const)
: ('estimated' as const),
leadMonths:
useInventoryPurchaseSchedule || useProductInventorySchedule
? null
: inventoryPurchaseLeadMonths,
openingInventory: format(inventory),
monthlyPurchases:
useInventoryPurchaseSchedule || useProductInventorySchedule
? null
: format(monthlyInventoryPurchases!),
purchaseSchedule:
useInventoryPurchaseSchedule || useProductInventorySchedule
? inventoryPurchasesByPeriod!.map((amount, index) => ({
month: index + 1,
amount: format(amount),
leadMonths: useProductInventorySchedule
? null
: inventoryLeadMonthsByPeriod[index],
}))
: null,
totalPurchases: format(
inventoryPurchasesByPeriod!.reduce(
(total, amount) => total + amount,
0n,
),
),
totalReceipts: format(totalInventoryReceipts),
totalConsumption: format(totalCostOfSales),
endingInventory: format(inventoryBalance),
inTransitAtEnd: format(inventoryInTransit),
totalShortfall: format(totalInventoryShortfall),
productSchedules: useProductInventorySchedule
? productInputs.map((product, index) => ({
label: input.products[index].label,
openingInventory: format(
productStockInputs[index].opening!,
),
purchaseSchedule: productStockInputs[index].orders!.map(
(order, month) => ({
month: month + 1,
amount: format(order.amount),
leadMonths: order.leadMonths,
paymentLagMonths: order.paymentLagMonths,
}),
),
paymentSchedule:
supplierPaymentSchedules?.[index] ?? null,
totalPurchases: format(
productStockInputs[index].orders!.reduce(
(sum, order) => sum + order.amount,
0n,
),
),
totalReceipts: format(
productInventoryStates[index].totalReceipts,
),
totalConsumption: format(
product.units.reduce(
(sum, units, month) =>
sum +
multiplyQuantity(
units,
product.costs[month],
),
0n,
),
),
endingInventory: format(
productInventoryStates[index].balance,
),
inTransitAtEnd: format(
productInventoryStates[index].inTransit,
),
totalShortfall: format(
productInventoryStates[index].totalShortfall,
),
monthlyRows: productInventoryStates[index].monthlyRows,
}))
: null,
};
const output = {
currency: input.currency,
workingCapital: format(workingCapital),
initialUses: format(initialUses),
financing: format(financing),
fundingGap: format(fundingGap),
fundingStatus:
fundingGap > 0n
? ('shortfall' as const)
: fundingGap < 0n
? ('surplus' as const)
: ('balanced' as const),
openingReceivables: format(receivables),
openingCashAfterInitialUses: format(
openingCashInput + financing - initialUses,
),
totalRevenue: format(totalRevenue),
totalCashCollections: format(totalCashCollections),
totalOpeningReceivablesCollections: format(
totalOpeningReceivablesCollections,
),
openingReceivablesAtEnd: format(openingReceivablesBalance),
totalOpeningPayablesPayments: format(payables - openingPayablesBalance),
openingPayablesAtEnd: format(openingPayablesBalance),
openingPayablesInstallments: openingPayablesInstallmentAmounts.map(
(installment) => ({
month: installment.month,
percent: installment.percent,
amount: format(installment.amount),
}),
),
openingReceivablesCollectionMonth,
openingReceivablesCollectionPercent:
openingReceivablesCollectionMonth === null
? null
: openingReceivablesCollectionPercent,
openingReceivablesInstallments:
openingReceivablesInstallmentAmounts.map((installment) => ({
month: installment.month,
percent: installment.percent,
amount: format(installment.amount),
})),
totalSalesCollections:
salesCollectionLagMonths === null
? null
: format(totalSalesCollections),
totalScheduledSalesCollections:
salesCollectionLagMonths === null
? null
: format(totalScheduledSalesCollections),
totalSalesRecoveryCollections:
salesCollectionLagMonths === null
? null
: format(totalSalesRecoveryCollections),
salesReceivablesAtEnd:
salesCollectionLagMonths === null ? null : format(salesReceivables),
salesCollectionLagMonths,
salesCollectionPercent:
salesCollectionLagMonths === null ? null : salesCollectionPercent,
totalPayments: format(totalOperatingPayments),
supplierPaymentsOutstandingAtEnd: useSupplierPaymentTerms
? format(supplierPaymentsOutstanding)
: null,
totalProfitBeforeTax: format(totalProfitBeforeTax),
endingCash: format(cash),
inventoryProjection,
rows,
status: 'estimated' as const,
};
const conversion =
fxRate === null
? null
: {
currency: presentationCurrency,
rate: input.fxRate!,
date: input.fxDate!,
source: input.fxSource!.trim(),
workingCapital: format(
convertCents(workingCapital, fxRate, 100_000_000n),
),
initialUses: format(
convertCents(initialUses, fxRate, 100_000_000n),
),
financing: format(
convertCents(financing, fxRate, 100_000_000n),
),
fundingGap: format(
convertCents(fundingGap, fxRate, 100_000_000n),
),
fundingStatus: output.fundingStatus,
totalRevenue: format(
convertCents(totalRevenue, fxRate, 100_000_000n),
),
totalCashCollections: format(
convertCents(totalCashCollections, fxRate, 100_000_000n),
),
totalOpeningReceivablesCollections: format(
convertCents(
totalOpeningReceivablesCollections,
fxRate,
100_000_000n,
),
),
openingReceivablesAtEnd: format(
convertCents(
receivables - totalOpeningReceivablesCollections,
fxRate,
100_000_000n,
),
),
openingReceivablesInstallments:
output.openingReceivablesInstallments?.map(
(installment) => ({
...installment,
amount: format(
convertCents(
cents(
installment.amount,
'Parcela de recebíveis',
),
fxRate,
100_000_000n,
),
),
}),
) ?? [],
totalOpeningPayablesPayments: format(
convertCents(
payables - openingPayablesBalance,
fxRate,
100_000_000n,
),
),
openingPayablesAtEnd: format(
convertCents(
openingPayablesBalance,
fxRate,
100_000_000n,
),
),
openingPayablesInstallments:
output.openingPayablesInstallments.map((installment) => ({
...installment,
amount: format(
convertCents(
cents(
installment.amount,
'Parcela de valores a pagar',
),
fxRate,
100_000_000n,
),
),
})),
totalSalesCollections:
output.totalSalesCollections === null
? null
: format(
convertCents(
totalSalesCollections,
fxRate,
100_000_000n,
),
),
totalScheduledSalesCollections:
output.totalScheduledSalesCollections === null
? null
: format(
convertCents(
totalScheduledSalesCollections,
fxRate,
100_000_000n,
),
),
totalSalesRecoveryCollections:
output.totalSalesRecoveryCollections === null
? null
: format(
convertCents(
totalSalesRecoveryCollections,
fxRate,
100_000_000n,
),
),
salesReceivablesAtEnd:
output.salesReceivablesAtEnd === null
? null
: format(
convertCents(
salesReceivables,
fxRate,
100_000_000n,
),
),
totalPayments: format(
convertCents(
totalOperatingPayments,
fxRate,
100_000_000n,
),
),
totalProfitBeforeTax: format(
convertCents(totalProfitBeforeTax, fxRate, 100_000_000n),
),
endingCash: format(convertCents(cash, fxRate, 100_000_000n)),
inventoryProjection: {
status: inventoryProjection.status,
leadMonths: inventoryProjection.leadMonths,
openingInventory: format(
convertCents(
cents(
inventoryProjection.openingInventory,
'Existências iniciais',
true,
),
fxRate,
100_000_000n,
),
),
monthlyPurchases:
inventoryProjection.monthlyPurchases === null
? null
: format(
convertCents(
cents(
inventoryProjection.monthlyPurchases,
'Compras para existências',
true,
),
fxRate,
100_000_000n,
),
),
totalPurchases:
inventoryProjection.totalPurchases === null
? null
: format(
convertCents(
cents(
inventoryProjection.totalPurchases,
'Compras totais',
true,
),
fxRate,
100_000_000n,
),
),
totalReceipts:
inventoryProjection.totalReceipts === null
? null
: format(
convertCents(
cents(
inventoryProjection.totalReceipts,
'Recepções totais',
true,
),
fxRate,
100_000_000n,
),
),
totalConsumption: format(
convertCents(
cents(
inventoryProjection.totalConsumption,
'Consumo total',
true,
),
fxRate,
100_000_000n,
),
),
endingInventory:
inventoryProjection.endingInventory === null
? null
: format(
convertCents(
cents(
inventoryProjection.endingInventory,
'Existências finais',
true,
),
fxRate,
100_000_000n,
),
),
inTransitAtEnd:
inventoryProjection.inTransitAtEnd === null
? null
: format(
convertCents(
cents(
inventoryProjection.inTransitAtEnd,
'Existências em trânsito',
true,
),
fxRate,
100_000_000n,
),
),
totalShortfall:
inventoryProjection.totalShortfall === null
? null
: format(
convertCents(
cents(
inventoryProjection.totalShortfall,
'Falta de stock',
true,
),
fxRate,
100_000_000n,
),
),
},
rows: rows.map((row) =>
Object.fromEntries(
Object.entries(row).map(([key, value]) => [
key,
key === 'month' || value === null
? value
: format(
convertCents(
cents(String(value), key, true),
fxRate!,
100_000_000n,
),
),
]),
),
),
};
return { ...output, presentationCurrency, conversion };
}
/** Reproducible local report: original assumptions, declared FX basis, and the same forecast totals. */
export function exportBusinessPlanCsv(
input: BusinessFinancialPlanInput,
generatedAt = new Date().toISOString(),
): string {
const result = forecastBusinessPlan(input);
const fiscalLines = [
['Rendimentos facturados', 'income', 'revenue'],
['Custo variável dos produtos', 'expense', 'variableCost'],
['Despesas operacionais reconhecidas', 'expense', 'operatingExpenses'],
['Despesas com pessoal reconhecidas', 'expense', 'payrollExpense'],
['Depreciação contabilística', 'expense', 'depreciation'],
['Juros reconhecidos', 'expense', 'interestExpense'],
] as const;
const fiscalRows = fiscalLines.map(([origin, effect, field]) => {
const amounts = result.rows.map((row) => ({
period: row.month,
amount: row[field],
}));
const total = amounts.reduce(
(sum, row) => sum + cents(row.amount, origin, true),
0n,
);
return [
origin,
effect,
format(total),
'unclassified',
JSON.stringify(amounts),
];
});
let scenarioComparison: ReturnType<typeof compareBusinessScenarios> | null =
null;
if (input.scenarioAdjustments) {
const adjustmentValues = [
...Object.values(input.scenarioAdjustments.favorable),
...Object.values(input.scenarioAdjustments.unfavorable),
];
const hasScenarioChanges = adjustmentValues.some(
(value) => value !== '',
);
if (
hasScenarioChanges &&
adjustmentValues.every((value) => value !== '')
)
scenarioComparison = compareBusinessScenarios(input);
else if (hasScenarioChanges)
throw new Error(
'Complete ou limpe todas as variações antes de exportar os cenários.',
);
}
const rows: (string | number)[][] = [
['Relatório', 'Plano financeiro empresarial indicativo'],
['Versão do modelo', 'business-plan-v17'],
[
'Prazo de cobrança das vendas',
input.salesCollectionLagMonths === null ||
input.salesCollectionLagMonths === undefined
? 'agenda manual'
: `${input.salesCollectionLagMonths} meses`,
],
[
'Percentagem cobrada no prazo declarado',
input.salesCollectionLagMonths === null ||
input.salesCollectionLagMonths === undefined
? 'não aplicável; modo manual'
: `${input.salesCollectionPercent ?? '100'}%`,
],
[
'Agenda de recuperação de vendas vencidas',
JSON.stringify(input.salesRecoverySchedule ?? []),
],
[
'Mês de cobrança dos valores a receber de abertura',
result.openingReceivablesCollectionMonth ??
'sem previsão declarada',
],
[
'Percentagem de cobrança dos valores de abertura',
result.openingReceivablesCollectionPercent === null
? 'não aplicável'
: `${result.openingReceivablesCollectionPercent}%`,
],
[
'Parcelas adicionais de recebíveis de abertura',
JSON.stringify(result.openingReceivablesInstallments),
],
[
'Liquidação de valores a pagar de abertura',
JSON.stringify(result.openingPayablesInstallments),
],
['Data de exportação', generatedAt],
['Horizonte', `${input.periods ?? 12} meses`],
['Moeda de cálculo', result.currency],
['Moeda de apresentação', result.presentationCurrency],
[
'Fonte dos pressupostos',
'Valores do formulário; os valores iniciais podem ser exemplos demonstrativos',
],
['Facturação', 'unidades mensais × preço unitário'],
['Custo variável', 'unidades mensais × custo variável unitário'],
[
'Compras mensais para existências',
input.monthlyInventoryPurchases?.trim() ||
'não fornecidas; projeção de existências indeterminada',
],
[
'Prazo de entrega das compras',
`${input.inventoryPurchaseLeadMonths ?? 0} meses`,
],
[
'Movimento simplificado de existências',
'saldo inicial + recepções de compras − consumo mensal; encomendas chegam após o prazo de entrega; não reduz as vendas planeadas perante falta de stock',
],
[
'Resultado antes de impostos',
'facturação − custo variável − despesas operacionais − pessoal − depreciação contabilística − juros reconhecidos',
],
[
'Fluxo de caixa',
'cobranças − pagamentos declarados de valores a pagar de abertura − pagamentos de compras − pagamentos operacionais − pessoal − juros pagos − capital reembolsado',
],
[
'Depreciação',
'base contabilística / vida útil em meses; residual reconciliado no último mês',
],
['Impostos', 'não calculados nem incluídos'],
[
'Uso inicial',
'activos + existências + valores a receber − valores a pagar',
],
[
'Fontes iniciais',
'capital próprio + suprimentos + empréstimos + subvenções',
],
[
'Taxa de câmbio',
result.conversion ? result.conversion.rate : 'não aplicada',
],
['Data da taxa de câmbio', result.conversion?.date ?? 'não aplicada'],
[
'Fonte da taxa de câmbio',
result.conversion?.source ?? 'não aplicada',
],
[
'Estado da conversao cambial',
result.conversion
? 'pressuposto de planeamento declarado pelo utilizador; nao verificado pelo portal'
: 'nao aplicada',
],
[],
['Pressupostos por produto/serviço'],
[
'Produto/serviço',
'Unidades por mes (JSON; extras preservados)',
'Precos unitarios por mes (JSON; vazio = constante)',
'Custos unitarios por mes (JSON; vazio = constante)',
],
...input.products.map((product) => [
product.label,
product.monthlyUnitsByPeriod
? JSON.stringify(product.monthlyUnitsByPeriod)
: product.monthlyUnits,
product.unitPriceByPeriod
? JSON.stringify(product.unitPriceByPeriod)
: product.unitPrice,
product.unitVariableCostByPeriod
? JSON.stringify(product.unitVariableCostByPeriod)
: product.unitVariableCost,
]),
[],
['Pressupostos mensais de despesas e pessoal'],
[
'Despesas operacionais reconhecidas por mes (JSON; vazio = constante)',
JSON.stringify(input.monthlyOperatingExpensesByPeriod ?? []),
],
[
'Pagamentos operacionais por mes (JSON; vazio = constante)',
JSON.stringify(input.monthlyOperatingPaymentsByPeriod ?? []),
],
[
'Despesas de pessoal reconhecidas por mes (JSON; vazio = constante)',
JSON.stringify(input.monthlyPayrollExpenseByPeriod ?? []),
],
[
'Pagamentos de pessoal por mes (JSON; vazio = constante)',
JSON.stringify(input.monthlyPayrollPaymentsByPeriod ?? []),
],
[],
['Resumo', result.currency],
['Fundo de maneio líquido', result.workingCapital],
['Usos iniciais', result.initialUses],
['Fontes de financiamento', result.financing],
['Estado da reconciliação do financiamento', result.fundingStatus],
['Diferença entre usos e fontes', result.fundingGap],
['Facturação no horizonte', result.totalRevenue],
['Cobranças no horizonte', result.totalCashCollections],
[
'Cobrança de valores a receber de abertura',
result.totalOpeningReceivablesCollections,
],
[
'Valores de abertura por receber no fim',
result.openingReceivablesAtEnd,
],
[
'Pagamento de valores a pagar de abertura',
result.totalOpeningPayablesPayments,
],
[
'Valores a pagar de abertura pendentes no fim',
result.openingPayablesAtEnd,
],
[
'Cobranças da facturação modelada',
result.totalSalesCollections ?? 'agenda manual',
],
[
'Facturação por cobrar no fim',
result.salesReceivablesAtEnd ?? 'agenda manual',
],
['Resultado antes de impostos', result.totalProfitBeforeTax],
[
'Pagamentos a fornecedores fora do horizonte',
result.supplierPaymentsOutstandingAtEnd ?? 'nao aplicavel',
],
['Ponte contabilístico-fiscal', 'incomplete'],
['Matéria colectável', 'indeterminate'],
[
'Entradas fiscais em falta',
'expense_deductibility_classification; approved_tax_adjustments; applicable_tax_period; approved_effective_rule',
],
[
'Resultado contabilístico antes de impostos (ponte)',
result.totalProfitBeforeTax,
],
[],
[
'Linhas contabilísticas para classificação fiscal',
'Efeito',
'Total',
'Tratamento fiscal',
'Valores mensais',
],
...fiscalRows,
[
'Capital reembolsado',
'fluxo de caixa; excluído das despesas contabilísticas',
'',
'não aplicável à ponte de resultado',
'',
],
['Caixa final projectada', result.endingCash],
['Projecção de existências', result.inventoryProjection.status],
[
'Existências finais',
result.inventoryProjection.endingInventory ?? 'indeterminadas',
],
[
'Existências em trânsito no fim do horizonte',
result.inventoryProjection.inTransitAtEnd ?? 'indeterminadas',
],
[
'Recepções de compras no horizonte',
result.inventoryProjection.totalReceipts ?? 'indeterminadas',
],
[
'Falta total de stock estimada',
result.inventoryProjection.totalShortfall ?? 'indeterminada',
],
...(result.inventoryProjection.purchaseSchedule
? [
[
'Agenda mensal de compras e recepção',
JSON.stringify(
result.inventoryProjection.purchaseSchedule,
),
],
]
: []),
...(result.inventoryProjection.productSchedules
? [
[],
[
'Existencias por produto',
'Mes',
'Inicial',
'Encomendas',
'Prazo de entrega',
'Recepcoes',
'Em transito',
'Consumo',
'Falta',
'Final',
],
...result.inventoryProjection.productSchedules.flatMap(
(product) =>
product.monthlyRows.map((row) => [
product.label,
row.month,
row.opening,
row.purchases,
row.leadMonths,
row.receipts,
row.inTransit,
row.consumption,
row.shortfall,
row.closing,
]),
),
]
: []),
...(result.inventoryProjection.productSchedules?.some(
(product) => product.paymentSchedule !== null,
)
? [
[],
[
'Pagamentos a fornecedores por prazo declarado',
'Produto',
'Mes da encomenda',
'Mes de pagamento',
'Percentagem',
'Montante',
'Situacao no horizonte',
],
...result.inventoryProjection.productSchedules.flatMap(
(product) =>
product.paymentSchedule?.map((payment) => [
product.label,
payment.orderMonth,
payment.paymentMonth ?? 'sem prazo declarado',
payment.percent === 'remaining'
? 'saldo nao alocado'
: payment.percent,
payment.amount,
payment.paidWithinHorizon
? 'incluido no horizonte'
: 'por pagar',
]) ?? [],
),
]
: []),
...(scenarioComparison
? [
[],
['Comparação de cenários', 'Valores na moeda de cálculo'],
[
'Cenário',
'Facturação',
'Resultado antes impostos',
'Caixa final',
'Existências finais',
'Falta de stock',
],
[
'Base',
scenarioComparison.base.totalRevenue,
scenarioComparison.base.totalProfitBeforeTax,
scenarioComparison.base.endingCash,
scenarioComparison.base.inventoryProjection
.endingInventory ?? 'indeterminadas',
scenarioComparison.base.inventoryProjection
.totalShortfall ?? 'indeterminada',
],
[
'Favorável',
'Variações',
JSON.stringify(scenarioComparison.favorable.adjustments),
scenarioComparison.favorable.result.totalRevenue,
scenarioComparison.favorable.result.totalProfitBeforeTax,
scenarioComparison.favorable.result.endingCash,
scenarioComparison.favorable.result.inventoryProjection
.endingInventory ?? 'indeterminadas',
scenarioComparison.favorable.result.inventoryProjection
.totalShortfall ?? 'indeterminada',
],
[
'Desfavorável',
'Variações',
JSON.stringify(
scenarioComparison.unfavorable.adjustments,
),
scenarioComparison.unfavorable.result.totalRevenue,
scenarioComparison.unfavorable.result
.totalProfitBeforeTax,
scenarioComparison.unfavorable.result.endingCash,
scenarioComparison.unfavorable.result.inventoryProjection
.endingInventory ?? 'indeterminadas',
scenarioComparison.unfavorable.result.inventoryProjection
.totalShortfall ?? 'indeterminada',
],
]
: []),
...(result.conversion
? [
[],
['Resumo convertido', result.conversion.currency],
['Fundo de maneio líquido', result.conversion.workingCapital],
['Usos iniciais', result.conversion.initialUses],
['Fontes de financiamento', result.conversion.financing],
[
'Estado da reconciliação do financiamento',
result.conversion.fundingStatus,
],
[
'Diferença entre usos e fontes',
result.conversion.fundingGap,
],
['Facturação no horizonte', result.conversion.totalRevenue],
[
'Cobranças no horizonte',
result.conversion.totalCashCollections,
],
[
'Cobrança de valores a receber de abertura',
result.conversion.totalOpeningReceivablesCollections,
],
[
'Valores de abertura por receber no fim',
result.conversion.openingReceivablesAtEnd,
],
[
'Pagamento de valores a pagar de abertura',
result.conversion.totalOpeningPayablesPayments,
],
[
'Valores a pagar de abertura pendentes no fim',
result.conversion.openingPayablesAtEnd,
],
[
'Resultado antes de impostos',
result.conversion.totalProfitBeforeTax,
],
['Caixa final projectada', result.conversion.endingCash],
]
: []),
[],
[
'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',
'Custo variável/consumo',
'Existências iniciais',
'Encomendas para existências',
'Prazo da encomenda (meses)',
'Recepções de compras',
'Existências em trânsito',
'Existências finais',
'Falta de stock',
'Despesas operacionais',
'Pagamentos operacionais',
'Pessoal',
'Pagamentos de pessoal',
'Depreciação',
'Juros',
'Pagamento de juros',
'Pagamento de compras',
'Resultado antes impostos',
'Pagamento capital',
'Fluxo líquido',
'Caixa final',
],
...result.rows.map((row) => [
row.month,
row.revenue,
row.cashCollections,
row.salesCollections ?? 'agenda manual',
row.salesScheduledCollections ?? 'agenda manual',
row.salesRecoveryCollections ?? 'agenda manual',
row.openingReceivablesCollections,
row.openingReceivablesBalance,
row.openingPayablesPayments,
row.openingPayablesBalance,
row.otherCollections ?? 'incluídas nas cobranças manuais',
row.variableCost,
row.inventoryOpening ?? 'indeterminado',
row.inventoryPurchases ?? 'indeterminadas',
row.inventoryLeadMonths ?? 'indeterminado',
row.inventoryReceipts ?? 'indeterminadas',
row.inventoryInTransit ?? 'indeterminadas',
row.inventoryClosing ?? 'indeterminadas',
row.inventoryShortfall ?? 'indeterminada',
row.operatingExpenses,
row.operatingPayments,
row.payrollExpense,
row.payrollPayments,
row.depreciation,
row.interestExpense,
row.interestPayment,
row.variableCostPayments,
row.profitBeforeTax,
row.principalRepayment,
row.netCashFlow,
row.closingCash,
]),
...(result.conversion
? [
[],
[
'Mês convertido',
'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',
'Resultado antes impostos',
'Fluxo líquido',
'Caixa final',
],
...result.conversion.rows.map((row) => [
row.month ?? '',
row.revenue ?? '',
row.cashCollections ?? '',
row.salesCollections ?? 'agenda manual',
row.salesScheduledCollections ?? '',
row.salesRecoveryCollections ?? '',
row.openingReceivablesCollections ?? '',
row.openingReceivablesBalance ?? '',
row.openingPayablesPayments ?? '',
row.openingPayablesBalance ?? '',
row.otherCollections ?? 'incluídas nas cobranças manuais',
row.profitBeforeTax ?? '',
row.netCashFlow ?? '',
row.closingCash ?? '',
]),
]
: []),
[],
[
'Limitações',
'Estimativa sem aconselhamento fiscal, parâmetros legais, sazonalidade, inflação ou validação contabilística. Moeda original preservada.',
],
];
return `\uFEFF${rows
.map((row) => row.map(csvCell).join(','))
.join('\r\n')}`;
}