Files
Investidor-V2/resources/js/lib/monthly-cashflow.ts
T

432 lines
16 KiB
TypeScript

import { csvCell } from './csv.js';
export function defaultCashflowDraftInputs() {
return {
openingCash: '0',
monthlyRevenue: '200000',
monthlyOperatingCosts: '150000',
monthlyDebtService: '0',
monthlyTaxAssessed: '',
monthlyTaxSchedule: Array.from({ length: 12 }, () => ''),
vatCreditEstimated: '0',
vatRefundRequested: '0',
vatRefundRecognized: '0',
vatRefundExpectedReceipt: '0',
vatRefundExpectedMonth: '',
};
}
export type CashflowDraftSnapshot = {
version: 1 | 2;
/** Missing in legacy v1: never infer it from the current project. */
currency?: string | null;
taxMode: 'fixed' | 'schedule';
inputs: ReturnType<typeof defaultCashflowDraftInputs>;
};
/** Preserve editable strings, including unanswered and invalid amounts, without inventing zero. */
export function restoreCashflowDraftSnapshot(
snapshot: string | undefined,
): CashflowDraftSnapshot | null {
if (!snapshot || snapshot.length > 20000) return null;
try {
const parsed = JSON.parse(snapshot);
if (
!parsed ||
![1, 2].includes(parsed.version) ||
(parsed.version === 2 &&
parsed.currency !== null &&
(typeof parsed.currency !== 'string' ||
!/^[A-Z]{3}$/.test(parsed.currency))) ||
!['fixed', 'schedule'].includes(parsed.taxMode) ||
!parsed.inputs ||
typeof parsed.inputs !== 'object' ||
Array.isArray(parsed.inputs)
)
return null;
const keys = Object.keys(defaultCashflowDraftInputs());
if (Object.keys(parsed.inputs).some((key) => !keys.includes(key)))
return null;
for (const key of keys) {
const value = parsed.inputs[key];
if (key === 'monthlyTaxSchedule') {
if (
!Array.isArray(value) ||
value.length !== 12 ||
value.some(
(item) => typeof item !== 'string' || item.length > 160,
)
)
return null;
} else if (typeof value !== 'string' || value.length > 160)
return null;
}
return {
version: parsed.version,
...(parsed.version === 2 ? { currency: parsed.currency } : {}),
taxMode: parsed.taxMode,
inputs: parsed.inputs,
};
} catch {
return null;
}
}
export function serializeCashflowDraftSnapshot(
taxMode: CashflowDraftSnapshot['taxMode'],
inputs: CashflowDraftSnapshot['inputs'],
currency?: string | null,
): string {
return JSON.stringify({
version: currency === undefined ? 1 : 2,
...(currency !== undefined ? { currency } : {}),
taxMode,
inputs,
});
}
export function cashflowCurrency(
saved: CashflowDraftSnapshot,
projectCurrency: string,
): { currency: string; matchesProject: boolean; notice: string } {
const currency = saved.version === 2 ? saved.currency : null;
if (!currency)
throw new Error(
'A moeda de origem da tesouraria guardada está por confirmar. Os montantes foram preservados; confirme a moeda na ferramenta de tesouraria antes de calcular ou reutilizar as vendas.',
);
const matchesProject = currency === projectCurrency;
return {
currency,
matchesProject,
notice: matchesProject
? ''
: `A tesouraria foi guardada em ${currency}, mas o projecto está em ${projectCurrency || 'moeda por indicar'}. Os montantes continuam em ${currency}; não foram convertidos nem reinterpretados. Para os reutilizar no projecto, restaure explicitamente a moeda de origem. Uma projecção noutra moeda exige pressupostos próprios.`,
};
}
export type MonthlyCashflowInput = {
openingCash: string;
monthlyRevenue: string;
monthlyOperatingCosts: string;
monthlyDebtService: string;
/** Empty/omitted means unknown; enter "0" to explicitly declare zero tax. */
monthlyTaxAssessed?: string | null;
/** Optional per-period user assumptions; null/blank means unknown for that month. */
monthlyTaxSchedule?: (string | null)[];
vatCreditEstimated?: string;
vatRefundRequested?: string;
vatRefundRecognized?: string;
vatRefundExpectedReceipt?: string;
vatRefundExpectedMonth?: string;
currency?: string;
periods?: number;
};
function cents(value: string, label: string): bigint {
const match = /^(\d+)(?:\.(\d{1,2}))?$/.exec(value.trim());
if (!match)
throw new Error(
`${label}: introduza um montante não negativo com até duas casas decimais.`,
);
return (
BigInt(match[1]) * 100n + BigInt((match[2] ?? '').padEnd(2, '0') || '0')
);
}
function signedCents(value: string, label: string): bigint {
const match = /^(-?\d+)(?:\.(\d{1,2}))?$/.exec(value.trim());
if (!match)
throw new Error(
`${label}: introduza um montante 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 money(value: bigint): string {
const absolute = value < 0n ? -value : value;
return `${value < 0n ? '-' : ''}${absolute / 100n}.${String(absolute % 100n).padStart(2, '0')}`;
}
/** 12-period cashflow. VAT stages are informational except an explicitly assumed receipt month. */
export function projectMonthlyCashflow(input: MonthlyCashflowInput) {
if (input.currency) {
try {
if (
new Intl.NumberFormat('en', {
style: 'currency',
currency: input.currency,
}).resolvedOptions().maximumFractionDigits !== 2
)
throw new Error(
'Esta ferramenta requer moeda com duas casas decimais.',
);
} catch (cause) {
if (cause instanceof Error && cause.message.includes('duas casas'))
throw cause;
throw new Error(
'Seleccione uma moeda suportada com duas casas decimais.',
);
}
}
const periods = input.periods ?? 12;
if (!Number.isSafeInteger(periods) || periods < 1 || periods > 60)
throw new Error('O período deve estar entre 1 e 60 meses.');
let closingBeforeUnassessedTax = signedCents(
input.openingCash,
'Saldo inicial',
);
let closingWithAssessedTax: bigint | null = closingBeforeUnassessedTax;
const revenue = cents(input.monthlyRevenue, 'Receita mensal');
const costs = cents(input.monthlyOperatingCosts, 'Custos operacionais');
const debt = cents(input.monthlyDebtService, 'Serviço da dívida');
const scalarTaxKnown =
typeof input.monthlyTaxAssessed === 'string' &&
input.monthlyTaxAssessed.trim() !== '';
if (input.monthlyTaxSchedule !== undefined && scalarTaxKnown)
throw new Error(
'Use o imposto mensal fixo ou o calendário por mês, não ambos.',
);
let taxSchedule: (bigint | null)[];
if (input.monthlyTaxSchedule !== undefined) {
if (
!Array.isArray(input.monthlyTaxSchedule) ||
input.monthlyTaxSchedule.length !== periods ||
input.monthlyTaxSchedule.some(
(value) => value !== null && typeof value !== 'string',
)
) {
throw new Error(
'O calendário do imposto deve conter um valor ou campo vazio para cada mês projectado.',
);
}
taxSchedule = input.monthlyTaxSchedule.map((value, index) =>
typeof value === 'string' && value.trim() !== ''
? cents(value, `Imposto apurado no mês ${index + 1}`)
: null,
);
} else {
const tax = scalarTaxKnown
? cents(input.monthlyTaxAssessed as string, 'Imposto apurado')
: null;
taxSchedule = Array.from({ length: periods }, () => tax);
}
const hasUnassessedTax = taxSchedule.some((tax) => tax === null);
const vatEstimate = cents(
input.vatCreditEstimated ?? '0',
'Crédito de IVA estimado',
);
const vatRequested = cents(
input.vatRefundRequested ?? '0',
'Reembolso de IVA pedido',
);
const vatRecognized = cents(
input.vatRefundRecognized ?? '0',
'Reembolso de IVA reconhecido',
);
const vatReceipt = cents(
input.vatRefundExpectedReceipt ?? '0',
'Recebimento previsto de IVA',
);
if (vatReceipt > vatRecognized) {
throw new Error(
'O recebimento previsto de IVA não pode exceder o montante reconhecido.',
);
}
const receiptMonth = input.vatRefundExpectedMonth
? Number(input.vatRefundExpectedMonth)
: 0;
if (
vatReceipt > 0n &&
(!Number.isSafeInteger(receiptMonth) ||
receiptMonth < 1 ||
receiptMonth > periods)
) {
throw new Error(
'Indique o mês previsto do recebimento entre 1 e o horizonte projectado.',
);
}
void vatEstimate;
void vatRequested;
void vatRecognized;
const rows = [];
for (let month = 1; month <= periods; month++) {
const openingBeforeUnassessedTax = closingBeforeUnassessedTax;
const openingWithAssessedTax = closingWithAssessedTax;
const refundReceived =
vatReceipt > 0n && receiptMonth === month ? vatReceipt : 0n;
const tax = taxSchedule[month - 1];
const netFlowBeforeUnassessedTax =
revenue - costs - debt + refundReceived - (tax ?? 0n);
const netFlowWithAssessedTax =
tax === null ? null : revenue - costs - debt + refundReceived - tax;
closingBeforeUnassessedTax += netFlowBeforeUnassessedTax;
if (closingWithAssessedTax !== null) {
closingWithAssessedTax =
netFlowWithAssessedTax === null
? null
: closingWithAssessedTax + netFlowWithAssessedTax;
}
rows.push({
month,
openingCashBeforeUnassessedTax: money(openingBeforeUnassessedTax),
openingCash:
openingWithAssessedTax === null
? null
: money(openingWithAssessedTax),
revenue: money(revenue),
operatingCosts: money(costs),
debtService: money(debt),
taxAssessed: tax === null ? null : money(tax),
vatRefundExpectedReceipt: money(refundReceived),
netFlowBeforeUnassessedTax: money(netFlowBeforeUnassessedTax),
closingCashBeforeUnassessedTax: money(closingBeforeUnassessedTax),
netFlow:
netFlowWithAssessedTax === null
? null
: money(netFlowWithAssessedTax),
closingCash:
closingWithAssessedTax === null
? null
: money(closingWithAssessedTax),
});
}
return {
rows,
endingCash:
closingWithAssessedTax === null
? null
: money(closingWithAssessedTax),
endingCashBeforeUnassessedTax: money(closingBeforeUnassessedTax),
firstDeficitMonth:
rows.find(
(row) =>
row.closingCash !== null &&
BigInt(row.closingCash.replace('.', '')) < 0n,
)?.month ?? null,
firstDeficitMonthBeforeUnassessedTax:
rows.find(
(row) =>
BigInt(
row.closingCashBeforeUnassessedTax.replace('.', ''),
) < 0n,
)?.month ?? null,
vatCreditEstimated: money(vatEstimate),
vatRefundRequested: money(vatRequested),
vatRefundRecognized: money(vatRecognized),
vatRefundExpectedReceipt: money(vatReceipt),
status: hasUnassessedTax
? ('incomplete' as const)
: ('estimated' as const),
};
}
/** CSV contains only assumptions and derived financial rows supplied/calculated in this browser. */
export function exportMonthlyCashflowCsv(
input: MonthlyCashflowInput,
exportedAt = new Date().toISOString(),
): string {
if (!input.currency)
throw new Error('Seleccione a moeda antes de exportar.');
const projection = projectMonthlyCashflow(input);
const rows = [
['Relatório', 'Projecção educativa de tesouraria'],
['Versão do modelo', 'monthly-cashflow-v1'],
['Data de exportação', exportedAt],
['Horizonte', `${projection.rows.length} meses`],
['Estado', projection.status],
['Moeda', input.currency ?? 'Não seleccionada'],
[
'Fonte dos pressupostos',
'Valores do formulário; os valores pré-preenchidos podem ser exemplos demonstrativos',
],
['Pressupostos', 'Saldo inicial', input.openingCash],
['Pressupostos', 'Receita mensal', input.monthlyRevenue],
[
'Pressupostos',
'Custos operacionais mensais',
input.monthlyOperatingCosts,
],
['Pressupostos', 'Serviço mensal da dívida', input.monthlyDebtService],
[
'Pressupostos',
'Imposto assumido na projecção (saída de caixa)',
input.monthlyTaxSchedule === undefined
? input.monthlyTaxAssessed?.trim() ||
'indeterminado; não informado'
: 'Calendário variável; consultar valores mensais abaixo',
],
...(input.monthlyTaxSchedule === undefined
? []
: input.monthlyTaxSchedule.map((value, index) => [
'Pressuposto mensal de imposto',
`Mês ${index + 1}`,
value?.trim() || 'indeterminado; não informado',
])),
[
'Informativo; não caixa',
'Crédito de IVA estimado',
input.vatCreditEstimated ?? '0',
],
[
'Informativo; não caixa',
'Pedido de reembolso de IVA',
input.vatRefundRequested ?? '0',
],
[
'Informativo; não caixa',
'Reembolso de IVA reconhecido',
input.vatRefundRecognized ?? '0',
],
[
'Pressuposto condicional de caixa',
'Recebimento previsto de reembolso IVA',
input.vatRefundExpectedReceipt ?? '0',
],
[
'Pressuposto condicional de caixa',
'Mês previsto do recebimento IVA',
input.vatRefundExpectedMonth ?? 'não indicado',
],
[],
[
'Mês',
'Saldo inicial',
'Receitas',
'Custos operacionais',
'Serviço da dívida',
'Imposto assumido',
'Recebimento IVA previsto',
'Fluxo líquido',
'Saldo final',
'Fluxo conhecido antes de imposto não informado',
'Caixa conhecida antes de imposto não informado',
],
...projection.rows.map((row) => [
row.month,
row.openingCash ?? 'indeterminado',
row.revenue,
row.operatingCosts,
row.debtService,
row.taxAssessed ?? 'indeterminado',
row.vatRefundExpectedReceipt,
row.netFlow ?? 'indeterminado',
row.closingCash ?? 'indeterminado',
row.netFlowBeforeUnassessedTax,
row.closingCashBeforeUnassessedTax,
]),
[],
[
'Nota',
'O imposto indicado é pressuposto do utilizador, não cálculo ou confirmação de obrigação fiscal. Meses sem pressuposto deixam o saldo total indeterminado; a caixa conhecida inclui impostos declarados e omite apenas os meses sem valor. Recebimento previsto é pressuposto não garantido. Crédito estimado, pedido e montante reconhecido não entram no caixa. Projecção exclui sazonalidade e movimentos pontuais.',
],
];
return `\uFEFF${rows
.map((row) => row.map(csvCell).join(','))
.join('\r\n')}`;
}