284 lines
10 KiB
TypeScript
284 lines
10 KiB
TypeScript
import type { forecastBusinessPlan } from '@/lib/business-financial-plan';
|
|
|
|
type Forecast = ReturnType<typeof forecastBusinessPlan>;
|
|
|
|
export type FinancialFiscalLine = {
|
|
code:
|
|
| 'revenue'
|
|
| 'cost_of_sales'
|
|
| 'operating_expenses'
|
|
| 'personnel'
|
|
| 'depreciation'
|
|
| 'interest_expense';
|
|
origin: string;
|
|
accountingEffect: 'income' | 'expense';
|
|
taxTreatment: 'unclassified';
|
|
total: string;
|
|
monthlyAmounts: Array<{ period: number; amount: string }>;
|
|
};
|
|
|
|
function cents(value: string): bigint {
|
|
const match = /^(-?)(\d+)(?:\.(\d{1,2}))?$/.exec(value);
|
|
if (!match)
|
|
throw new Error('O resultado financeiro contém um montante inválido.');
|
|
const amount =
|
|
BigInt(match[2]) * 100n +
|
|
BigInt((match[3] ?? '').padEnd(2, '0') || '0');
|
|
return match[1] ? -amount : amount;
|
|
}
|
|
|
|
function formatCents(value: bigint): string {
|
|
const absolute = value < 0n ? -value : value;
|
|
return `${value < 0n ? '-' : ''}${absolute / 100n}.${String(absolute % 100n).padStart(2, '0')}`;
|
|
}
|
|
|
|
function assertForecastReconciles(forecast: Forecast): void {
|
|
if (forecast.rows.length === 0)
|
|
throw new Error(
|
|
'A previsão financeira não contém períodos para mapear.',
|
|
);
|
|
|
|
let accountingProfit = 0n;
|
|
let totalRevenue = 0n;
|
|
let totalCollections = 0n;
|
|
let cumulativeNetCashFlow = 0n;
|
|
let previousClosingCash = cents(forecast.openingCashAfterInitialUses);
|
|
let previousOpeningReceivables = cents(forecast.openingReceivables);
|
|
let totalOpeningReceivablesCollections = 0n;
|
|
let previousPeriod = 0;
|
|
|
|
for (const row of forecast.rows) {
|
|
if (!Number.isSafeInteger(row.month) || row.month <= previousPeriod)
|
|
throw new Error(
|
|
'Os períodos da previsão financeira não estão em ordem.',
|
|
);
|
|
previousPeriod = row.month;
|
|
|
|
const revenue = cents(String(row.revenue));
|
|
totalRevenue += revenue;
|
|
accountingProfit += revenue;
|
|
for (const field of [
|
|
'variableCost',
|
|
'operatingExpenses',
|
|
'payrollExpense',
|
|
'depreciation',
|
|
'interestExpense',
|
|
] as const) {
|
|
accountingProfit -= cents(String(row[field]));
|
|
}
|
|
|
|
const salesCollections =
|
|
row.salesCollections === null
|
|
? 0n
|
|
: cents(String(row.salesCollections));
|
|
const openingReceivablesCollections = cents(
|
|
String(row.openingReceivablesCollections),
|
|
);
|
|
const openingReceivablesBalance = cents(
|
|
String(row.openingReceivablesBalance),
|
|
);
|
|
if (
|
|
openingReceivablesBalance !==
|
|
previousOpeningReceivables - openingReceivablesCollections
|
|
)
|
|
throw new Error(
|
|
'O saldo de recebíveis de abertura não reconcilia com as cobranças do período.',
|
|
);
|
|
previousOpeningReceivables = openingReceivablesBalance;
|
|
totalOpeningReceivablesCollections += openingReceivablesCollections;
|
|
const otherCollections =
|
|
row.otherCollections === null
|
|
? cents(String(row.cashCollections)) -
|
|
salesCollections -
|
|
openingReceivablesCollections
|
|
: cents(String(row.otherCollections));
|
|
if (otherCollections < 0n)
|
|
throw new Error(
|
|
'A previsão financeira contém cobranças reconciliadas inválidas.',
|
|
);
|
|
|
|
const collections = cents(String(row.cashCollections));
|
|
if (
|
|
collections !==
|
|
salesCollections + openingReceivablesCollections + otherCollections
|
|
)
|
|
throw new Error(
|
|
'As cobranças mensais não reconciliam com as suas origens.',
|
|
);
|
|
totalCollections += collections;
|
|
|
|
const netCashFlow =
|
|
collections -
|
|
cents(String(row.variableCostPayments)) -
|
|
cents(String(row.operatingPayments)) -
|
|
cents(String(row.payrollPayments)) -
|
|
cents(String(row.interestPayment)) -
|
|
cents(String(row.principalRepayment));
|
|
if (netCashFlow !== cents(String(row.netCashFlow)))
|
|
throw new Error(
|
|
'O fluxo líquido mensal não reconcilia com recebimentos e pagamentos.',
|
|
);
|
|
const closingCash = cents(String(row.closingCash));
|
|
if (closingCash !== previousClosingCash + netCashFlow)
|
|
throw new Error(
|
|
'O saldo de caixa mensal não reconcilia com o período anterior.',
|
|
);
|
|
previousClosingCash = closingCash;
|
|
cumulativeNetCashFlow += netCashFlow;
|
|
}
|
|
|
|
if (accountingProfit !== cents(forecast.totalProfitBeforeTax))
|
|
throw new Error(
|
|
'O resultado contabilístico não reconcilia com as linhas mapeadas.',
|
|
);
|
|
if (totalRevenue !== cents(forecast.totalRevenue))
|
|
throw new Error(
|
|
'A facturação total não reconcilia com os períodos mapeados.',
|
|
);
|
|
if (totalCollections !== cents(forecast.totalCashCollections))
|
|
throw new Error(
|
|
'As cobranças totais não reconciliam com o calendário de caixa.',
|
|
);
|
|
if (
|
|
totalOpeningReceivablesCollections !==
|
|
cents(forecast.totalOpeningReceivablesCollections) ||
|
|
previousOpeningReceivables !== cents(forecast.openingReceivablesAtEnd)
|
|
)
|
|
throw new Error(
|
|
'Os recebíveis de abertura não reconciliam com a previsão final.',
|
|
);
|
|
if (
|
|
previousClosingCash !== cents(forecast.endingCash) ||
|
|
cumulativeNetCashFlow !==
|
|
cents(forecast.endingCash) -
|
|
cents(forecast.openingCashAfterInitialUses)
|
|
)
|
|
throw new Error(
|
|
'O saldo final de caixa não reconcilia com o calendário mensal.',
|
|
);
|
|
}
|
|
|
|
/** Maps accounting figures into a reviewable tax-input draft without deciding tax treatment. */
|
|
export function buildFinancialFiscalBridge(forecast: Forecast) {
|
|
assertForecastReconciles(forecast);
|
|
const definitions: Array<
|
|
Pick<
|
|
FinancialFiscalLine,
|
|
'code' | 'origin' | 'accountingEffect' | 'taxTreatment'
|
|
> & { field: keyof Forecast['rows'][number] }
|
|
> = [
|
|
{
|
|
code: 'revenue',
|
|
origin: 'Produtos/serviços facturados',
|
|
accountingEffect: 'income',
|
|
taxTreatment: 'unclassified',
|
|
field: 'revenue',
|
|
},
|
|
{
|
|
code: 'cost_of_sales',
|
|
origin: 'Custo variável dos produtos',
|
|
accountingEffect: 'expense',
|
|
taxTreatment: 'unclassified',
|
|
field: 'variableCost',
|
|
},
|
|
{
|
|
code: 'operating_expenses',
|
|
origin: 'Despesas operacionais reconhecidas',
|
|
accountingEffect: 'expense',
|
|
taxTreatment: 'unclassified',
|
|
field: 'operatingExpenses',
|
|
},
|
|
{
|
|
code: 'personnel',
|
|
origin: 'Despesas com pessoal reconhecidas',
|
|
accountingEffect: 'expense',
|
|
taxTreatment: 'unclassified',
|
|
field: 'payrollExpense',
|
|
},
|
|
{
|
|
code: 'depreciation',
|
|
origin: 'Depreciação contabilística',
|
|
accountingEffect: 'expense',
|
|
taxTreatment: 'unclassified',
|
|
field: 'depreciation',
|
|
},
|
|
{
|
|
code: 'interest_expense',
|
|
origin: 'Juros reconhecidos',
|
|
accountingEffect: 'expense',
|
|
taxTreatment: 'unclassified',
|
|
field: 'interestExpense',
|
|
},
|
|
];
|
|
const accountingLines: FinancialFiscalLine[] = definitions.map(
|
|
({ field, ...definition }) => {
|
|
const monthlyAmounts = forecast.rows.map((row) => ({
|
|
period: row.month,
|
|
amount: String(row[field]),
|
|
}));
|
|
return {
|
|
...definition,
|
|
total: formatCents(
|
|
monthlyAmounts.reduce(
|
|
(sum, row) => sum + cents(row.amount),
|
|
0n,
|
|
),
|
|
),
|
|
monthlyAmounts,
|
|
};
|
|
},
|
|
);
|
|
|
|
return {
|
|
status: 'incomplete' as const,
|
|
currency: forecast.currency,
|
|
accountingProfitBeforeTax: forecast.totalProfitBeforeTax,
|
|
taxableBase: {
|
|
status: 'indeterminate' as const,
|
|
amount: null,
|
|
missingInputs: [
|
|
'expense_deductibility_classification',
|
|
'approved_tax_adjustments',
|
|
'applicable_tax_period',
|
|
'approved_effective_rule',
|
|
],
|
|
},
|
|
accountingLines,
|
|
cashSchedule: forecast.rows.map((row) => ({
|
|
period: row.month,
|
|
collectionMode:
|
|
row.salesCollections === null
|
|
? ('manual' as const)
|
|
: ('scheduled' as const),
|
|
salesCollections: row.salesCollections,
|
|
openingReceivablesCollections: row.openingReceivablesCollections,
|
|
openingReceivablesBalance: row.openingReceivablesBalance,
|
|
otherCollections:
|
|
row.otherCollections ??
|
|
formatCents(
|
|
cents(row.cashCollections) -
|
|
(row.salesCollections === null
|
|
? 0n
|
|
: cents(row.salesCollections)) -
|
|
cents(row.openingReceivablesCollections),
|
|
),
|
|
collections: row.cashCollections,
|
|
collectionsTotal: row.cashCollections,
|
|
salesReceivablesBalance: row.salesReceivablesClosing,
|
|
supplierPayments: row.variableCostPayments,
|
|
operatingPayments: row.operatingPayments,
|
|
personnelPayments: row.payrollPayments,
|
|
interestPayments: row.interestPayment,
|
|
principalRepayments: row.principalRepayment,
|
|
netCashFlow: row.netCashFlow,
|
|
closingCash: row.closingCash,
|
|
})),
|
|
notes: [
|
|
'A classificação fiscal das linhas contabilísticas está pendente; este mapeamento não afirma dedutibilidade.',
|
|
'Cobranças e pagamentos são fluxos de caixa separados das receitas e despesas reconhecidas.',
|
|
'Reembolsos de capital aparecem apenas no calendário de caixa e não como despesa contabilística.',
|
|
'Nenhum imposto, contribuição, retenção ou pagamento fiscal é somado como zero ou estimado nesta ponte.',
|
|
],
|
|
};
|
|
}
|