432 lines
16 KiB
TypeScript
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')}`;
|
|
}
|