564 lines
19 KiB
TypeScript
564 lines
19 KiB
TypeScript
import {
|
||
addFractions,
|
||
decimalFraction,
|
||
fraction,
|
||
subtractFractions,
|
||
type FiscalFraction,
|
||
} from './fiscal-rational.ts';
|
||
export const normalSchedulesVersion = 'IRPC-normal-adjustments/5';
|
||
export const scheduleRules = {
|
||
tax_own: [
|
||
'IRPC e impostos sobre o lucro contabilizados',
|
||
'expenseCorrections',
|
||
'36(1)a',
|
||
1,
|
||
],
|
||
tax_other: [
|
||
'Impostos da responsabilidade de outrem',
|
||
'expenseCorrections',
|
||
'36(1)b',
|
||
1,
|
||
],
|
||
undocumented: [
|
||
'Despesa não documentada',
|
||
'expenseCorrections',
|
||
'36(1)g',
|
||
1,
|
||
],
|
||
capital_in: [
|
||
'Entrada de capital fora do resultado',
|
||
'positivePatrimonial',
|
||
'21(1)a',
|
||
0,
|
||
],
|
||
patrimonial_gain: [
|
||
'Variação positiva ordinária fora do resultado',
|
||
'positivePatrimonial',
|
||
'21',
|
||
1,
|
||
],
|
||
capital_out: [
|
||
'Distribuição aos titulares fora do resultado',
|
||
'negativePatrimonial',
|
||
'24(1)c',
|
||
0,
|
||
],
|
||
patrimonial_cost: [
|
||
'Variação negativa indispensável, sem exclusões do artigo24',
|
||
'negativePatrimonial',
|
||
'22/24(1)',
|
||
-1,
|
||
],
|
||
future_cost: [
|
||
'Custo contabilizado em2026 imputável ao exercício seguinte',
|
||
'timing',
|
||
'18(1)',
|
||
1,
|
||
],
|
||
future_income: [
|
||
'Proveito contabilizado em2026 imputável ao exercício seguinte',
|
||
'timing',
|
||
'18(1)',
|
||
-1,
|
||
],
|
||
current_cost: [
|
||
'Custo de2026 ainda não reflectido no resultado',
|
||
'timing',
|
||
'18(1)',
|
||
-1,
|
||
],
|
||
current_income: [
|
||
'Proveito de2026 ainda não reflectido no resultado',
|
||
'timing',
|
||
'18(1)',
|
||
1,
|
||
],
|
||
} as const;
|
||
export type ScheduleRule = keyof typeof scheduleRules;
|
||
export type ScheduleRow = {
|
||
id: string;
|
||
rule: '' | ScheduleRule;
|
||
amount: string;
|
||
sourceCode: string;
|
||
document: string;
|
||
line: string;
|
||
reference: string;
|
||
fiscalYear: string;
|
||
conditions: '' | 'yes';
|
||
};
|
||
export type NormalAsset = {
|
||
id: string;
|
||
category: '' | 'computer' | 'server' | 'other';
|
||
description: string;
|
||
cost: string;
|
||
residual: string;
|
||
rate: string;
|
||
accounting: string;
|
||
previousFiscal: string;
|
||
inUseFrom: string;
|
||
tableReference: string;
|
||
reference: string;
|
||
conditions: '' | 'yes';
|
||
};
|
||
export type NormalSchedules = {
|
||
version: 1;
|
||
period: {
|
||
kind: '' | 'calendar' | 'authorised_annual' | 'ep_annual';
|
||
from: string;
|
||
to: string;
|
||
reference: string;
|
||
};
|
||
rows: ScheduleRow[];
|
||
interest: { amount: string; reference: string; conditions: '' | 'yes' };
|
||
assets: NormalAsset[];
|
||
assetsReference: string;
|
||
currentLossReference: string;
|
||
};
|
||
export const newNormalSchedules = (): NormalSchedules => ({
|
||
version: 1,
|
||
period: { kind: '', from: '', to: '', reference: '' },
|
||
rows: [],
|
||
interest: { amount: '', reference: '', conditions: '' },
|
||
assets: [],
|
||
assetsReference: '',
|
||
currentLossReference: '',
|
||
});
|
||
export const newScheduleRow = (id: string): ScheduleRow => ({
|
||
id,
|
||
rule: '',
|
||
amount: '',
|
||
sourceCode: '',
|
||
document: '',
|
||
line: '',
|
||
reference: '',
|
||
fiscalYear: '',
|
||
conditions: '',
|
||
});
|
||
export const newNormalAsset = (id: string): NormalAsset => ({
|
||
id,
|
||
category: '',
|
||
description: '',
|
||
cost: '',
|
||
residual: '',
|
||
rate: '',
|
||
accounting: '',
|
||
previousFiscal: '',
|
||
inUseFrom: '',
|
||
tableReference: '',
|
||
reference: '',
|
||
conditions: '',
|
||
});
|
||
export function validateSchedules(s: NormalSchedules) {
|
||
if (
|
||
!s ||
|
||
s.version !== 1 ||
|
||
!Array.isArray(s.rows) ||
|
||
!Array.isArray(s.assets) ||
|
||
!s.interest ||
|
||
typeof s.assetsReference !== 'string' ||
|
||
typeof s.currentLossReference !== 'string' ||
|
||
['amount', 'reference', 'conditions'].some(
|
||
(k) => typeof s.interest[k as 'amount'] !== 'string',
|
||
) ||
|
||
!['', 'yes'].includes(s.interest.conditions)
|
||
)
|
||
throw Error('Mapas L6 não recuperáveis.');
|
||
for (const [rows, empty] of [
|
||
[s.rows, newScheduleRow('')],
|
||
[s.assets, newNormalAsset('')],
|
||
] as const) {
|
||
if (new Set(rows.map((r) => r.id)).size !== rows.length)
|
||
throw Error('Identificador duplicado no mapa L6.');
|
||
for (const r of rows)
|
||
if (
|
||
!r ||
|
||
Object.keys(empty).some(
|
||
(k) =>
|
||
typeof (r as unknown as Record<string, unknown>)[k] !==
|
||
'string',
|
||
) ||
|
||
!['', 'yes'].includes(r.conditions)
|
||
)
|
||
throw Error('Linha L6 não recuperável.');
|
||
}
|
||
if (
|
||
s.rows.some(
|
||
(r) => r.rule !== '' && !Object.hasOwn(scheduleRules, r.rule),
|
||
)
|
||
)
|
||
throw Error('Regra L6 desconhecida.');
|
||
if (
|
||
s.assets.some(
|
||
(a) => !['', 'computer', 'server', 'other'].includes(a.category),
|
||
)
|
||
)
|
||
throw Error('Categoria de activo desconhecida.');
|
||
if (
|
||
!s.period ||
|
||
!['', 'calendar', 'authorised_annual', 'ep_annual'].includes(
|
||
s.period.kind,
|
||
) ||
|
||
['from', 'to', 'reference'].some(
|
||
(k) => typeof s.period[k as 'from'] !== 'string',
|
||
)
|
||
)
|
||
throw Error('Período complementar não recuperável.');
|
||
}
|
||
export function normalSchedulePeriod(s: NormalSchedules, scope: string) {
|
||
const p = s.period,
|
||
pending: string[] = [];
|
||
const iso = (v: string) =>
|
||
/^20\d{2}-\d{2}-\d{2}$/.test(v) &&
|
||
Number.isFinite(Date.parse(v)) &&
|
||
new Date(v).toISOString().slice(0, 10) === v;
|
||
if (
|
||
!iso(p.from) ||
|
||
!iso(p.to) ||
|
||
!p.from.endsWith('-01') ||
|
||
!p.to.startsWith('2026-')
|
||
)
|
||
pending.push(
|
||
'Período anual por identificar: início no primeiro dia do mês e termo em2026, sem período curto ou alinhamento mensal presumido.',
|
||
);
|
||
else {
|
||
const end = new Date(p.from + 'T00:00:00Z');
|
||
end.setUTCFullYear(end.getUTCFullYear() + 1);
|
||
end.setUTCDate(end.getUTCDate() - 1);
|
||
if (end.toISOString().slice(0, 10) !== p.to)
|
||
pending.push(
|
||
'Os12 meses do plano não correspondem ao período anual indicado.',
|
||
);
|
||
}
|
||
if (!p.kind || !p.reference.trim())
|
||
pending.push(
|
||
'Identifique tipo de período e correspondência dos12 meses; ausência de informação não é ano civil presumido.',
|
||
);
|
||
if (
|
||
p.kind === 'calendar' &&
|
||
(p.from !== '2026-01-01' || p.to !== '2026-12-31')
|
||
)
|
||
pending.push('Datas incompatíveis com o ano civil2026.');
|
||
if (p.kind === 'ep_annual' && scope !== 'ep_general')
|
||
pending.push(
|
||
'Período do artigo7(3) exige EP e comunicação documentada; não aplicar a um residente.',
|
||
);
|
||
return { ...structuredClone(p), pending };
|
||
}
|
||
const z = () => fraction(0n);
|
||
export const fiscalMoney = (s: string) => {
|
||
if (!/^\d{1,18}(?:\.\d{1,2})?$/.test(s))
|
||
throw Error('Montante não negativo com até dois decimais necessário.');
|
||
return decimalFraction(s);
|
||
};
|
||
const cmp = (a: FiscalFraction, b: FiscalFraction) =>
|
||
BigInt(a.numerator) * BigInt(b.denominator) -
|
||
BigInt(b.numerator) * BigInt(a.denominator);
|
||
export type SchedulesModel = ReturnType<typeof calculateSchedules>;
|
||
export function calculateSchedules(
|
||
s: NormalSchedules,
|
||
source: {
|
||
accountingLines: Array<{
|
||
code: string;
|
||
total: string;
|
||
accountingEffect: string;
|
||
}>;
|
||
} | null,
|
||
oldRows: Array<{
|
||
sourceCode: string;
|
||
document: string;
|
||
line: string;
|
||
amount: string;
|
||
adjustment: FiscalFraction | null;
|
||
}>,
|
||
depreciationOverride?: { value: FiscalFraction | null; pending: string[] },
|
||
) {
|
||
validateSchedules(s);
|
||
const pending: string[] = [],
|
||
effects: Array<{
|
||
id: string;
|
||
topic: string;
|
||
amount: FiscalFraction | null;
|
||
effect: FiscalFraction | null;
|
||
reason: string;
|
||
}> = [];
|
||
for (const r of s.rows) {
|
||
const issues: string[] = [],
|
||
rule = r.rule ? scheduleRules[r.rule] : null;
|
||
let amount: FiscalFraction | null = null,
|
||
effect: FiscalFraction | null = null;
|
||
if (
|
||
!rule ||
|
||
!r.reference.trim() ||
|
||
!r.document.trim() ||
|
||
!r.line.trim() ||
|
||
r.conditions !== 'yes'
|
||
)
|
||
issues.push(
|
||
'Regra, documento/linha, suporte e condições por identificar.',
|
||
);
|
||
if (
|
||
s.rows.some(
|
||
(q) =>
|
||
q !== r &&
|
||
q.document.trim() === r.document.trim() &&
|
||
q.line.trim() === r.line.trim(),
|
||
) ||
|
||
oldRows.some(
|
||
(q) =>
|
||
q.document.trim() === r.document.trim() &&
|
||
q.line.trim() === r.line.trim(),
|
||
)
|
||
)
|
||
issues.push(
|
||
'Documento/linha duplicados entre mapas; sem dupla contagem.',
|
||
);
|
||
try {
|
||
amount = fiscalMoney(r.amount);
|
||
if (cmp(amount, z()) <= 0n) throw Error();
|
||
} catch {
|
||
issues.push('Parcela positiva por identificar.');
|
||
}
|
||
const topic = rule?.[1] ?? '';
|
||
const needsOrigin =
|
||
topic === 'expenseCorrections' ||
|
||
['future_cost', 'future_income'].includes(r.rule);
|
||
if (needsOrigin) {
|
||
const line = source?.accountingLines.find(
|
||
(l) => l.code === r.sourceCode,
|
||
);
|
||
const wanted = r.rule === 'future_income' ? 'income' : 'expense';
|
||
if (!line || line.accountingEffect !== wanted)
|
||
issues.push(
|
||
'Parcela deve estar incluída numa linha de origem compatível.',
|
||
);
|
||
else
|
||
try {
|
||
const allocated = addFractions([
|
||
...s.rows
|
||
.filter(
|
||
(q) =>
|
||
q.rule &&
|
||
(['expenseCorrections'].includes(
|
||
scheduleRules[q.rule][1],
|
||
) ||
|
||
[
|
||
'future_cost',
|
||
'future_income',
|
||
].includes(q.rule)) &&
|
||
q.sourceCode === r.sourceCode,
|
||
)
|
||
.map((q) => fiscalMoney(q.amount)),
|
||
...oldRows
|
||
.filter((q) => q.sourceCode === r.sourceCode)
|
||
.map((q) => fiscalMoney(q.amount)),
|
||
]);
|
||
if (cmp(allocated, fiscalMoney(line.total)) > 0n)
|
||
issues.push(
|
||
'Parcelas excedem a origem contabilística.',
|
||
);
|
||
} catch {
|
||
issues.push('Reconciliação da origem incompleta.');
|
||
}
|
||
} else if (r.sourceCode)
|
||
issues.push(
|
||
'Variação fora do resultado/custo ou proveito omitido não deve repetir linha já contabilizada.',
|
||
);
|
||
if (
|
||
topic === 'timing' &&
|
||
r.fiscalYear !== (r.rule.startsWith('future_') ? '2027' : '2026')
|
||
)
|
||
issues.push(
|
||
'Exercício de imputação incompatível; este recorte cobre2026/seguinte, sem resolver exercícios anteriores.',
|
||
);
|
||
if (!issues.length && amount && rule)
|
||
effect = fraction(
|
||
BigInt(amount.numerator) * BigInt(rule[3]),
|
||
BigInt(amount.denominator),
|
||
);
|
||
const reason =
|
||
issues.join(' ') ||
|
||
'CIRPC' +
|
||
rule?.[2] +
|
||
'; classificação e condições documentadas pelo utilizador; projecção, sem alteração da contabilidade de origem.';
|
||
if (issues.length) pending.push(r.document + ': ' + reason);
|
||
effects.push({ id: r.id, topic, amount, effect, reason });
|
||
}
|
||
let interest: FiscalFraction | null = null;
|
||
const interestOrigin = source?.accountingLines.find(
|
||
(l) => l.code === 'interest_expense',
|
||
);
|
||
try {
|
||
const corrected = s.rows.filter(
|
||
(r) =>
|
||
r.sourceCode === 'interest_expense' &&
|
||
r.rule &&
|
||
(scheduleRules[r.rule][1] === 'expenseCorrections' ||
|
||
r.rule === 'future_cost'),
|
||
);
|
||
const corrections = corrected.map((r) => {
|
||
const e = effects.find((e) => e.id === r.id);
|
||
if (!e?.effect) throw Error();
|
||
return e.effect;
|
||
});
|
||
const remaining = subtractFractions(
|
||
fiscalMoney(interestOrigin?.total ?? ''),
|
||
addFractions([
|
||
...oldRows
|
||
.filter((r) => r.sourceCode === 'interest_expense')
|
||
.flatMap((r) => (r.adjustment ? [r.adjustment] : [])),
|
||
...corrections,
|
||
]),
|
||
);
|
||
interest = fiscalMoney(s.interest.amount);
|
||
if (
|
||
cmp(interest, remaining) !== 0n ||
|
||
!s.interest.reference.trim() ||
|
||
s.interest.conditions !== 'yes'
|
||
)
|
||
throw Error();
|
||
} catch {
|
||
interest = null;
|
||
}
|
||
const assets = (depreciationOverride ? [] : s.assets).map((a) => {
|
||
const issues: string[] = [];
|
||
let accounting: FiscalFraction | null = null,
|
||
fiscal: FiscalFraction | null = null,
|
||
adjustment: FiscalFraction | null = null;
|
||
try {
|
||
const cost = fiscalMoney(a.cost),
|
||
residual = fiscalMoney(a.residual),
|
||
previous = fiscalMoney(a.previousFiscal),
|
||
rate = fiscalMoney(a.rate);
|
||
accounting = fiscalMoney(a.accounting);
|
||
const depreciable = subtractFractions(cost, residual);
|
||
if (
|
||
cmp(depreciable, z()) < 0n ||
|
||
cmp(previous, depreciable) > 0n ||
|
||
cmp(rate, z()) <= 0n ||
|
||
cmp(rate, fraction(100n)) > 0n
|
||
)
|
||
throw Error();
|
||
const annual = fraction(
|
||
BigInt(depreciable.numerator) * BigInt(rate.numerator),
|
||
BigInt(depreciable.denominator) *
|
||
BigInt(rate.denominator) *
|
||
100n,
|
||
);
|
||
const available = subtractFractions(depreciable, previous);
|
||
fiscal = cmp(annual, available) > 0n ? available : annual;
|
||
if (cmp(fiscal, accounting) > 0n)
|
||
issues.push(
|
||
'Quota fiscal superior à contabilizada neste exercício; dedução de quotas anteriormente tributadas exige mapa próprio, sem presunção.',
|
||
);
|
||
} catch {
|
||
issues.push(
|
||
'Custo, residual, taxa, acumulado fiscal e depreciação contabilizada por reconciliar.',
|
||
);
|
||
}
|
||
if (
|
||
!['computer', 'server'].includes(a.category) ||
|
||
a.rate !== (a.category === 'computer' ? '25' : '20')
|
||
)
|
||
issues.push(
|
||
'Categoria/taxa não correspondida: este recorte cobre tabelaII, grupo3, itens9 (25%) e9.1 (20%), sem regra específica da tabelaI prevalecente.',
|
||
);
|
||
if (
|
||
!/^20\d{2}-\d{2}-\d{2}$/.test(a.inUseFrom) ||
|
||
!Number.isFinite(Date.parse(a.inUseFrom)) ||
|
||
new Date(a.inUseFrom).toISOString().slice(0, 10) !== a.inUseFrom ||
|
||
a.inUseFrom > (s.period.from || '2026-01-01') ||
|
||
a.inUseFrom < '2014-01-01' ||
|
||
!a.description.trim() ||
|
||
!a.tableReference.trim() ||
|
||
!a.reference.trim() ||
|
||
a.conditions !== 'yes'
|
||
)
|
||
issues.push(
|
||
'Identifique activo próprio, em uso no início do período, tabela/taxa aplicável, vida útil e condições do método constante anual; casos especiais/duodécimos separados.',
|
||
);
|
||
if (
|
||
['computer', 'server'].includes(a.category) &&
|
||
/^20\d{2}-\d{2}-\d{2}$/.test(a.inUseFrom)
|
||
) {
|
||
const limit = new Date(a.inUseFrom + 'T00:00:00Z');
|
||
if (Number.isFinite(+limit)) {
|
||
limit.setUTCFullYear(
|
||
limit.getUTCFullYear() +
|
||
(a.category === 'computer' ? 8 : 10),
|
||
);
|
||
if (
|
||
(s.period.from || '2026-01-01') >=
|
||
limit.toISOString().slice(0, 10)
|
||
)
|
||
issues.push(
|
||
'Período máximo de vida útil excedido, artigo5; excepção específica não implementada.',
|
||
);
|
||
}
|
||
}
|
||
if (!issues.length && accounting && fiscal)
|
||
adjustment = subtractFractions(accounting, fiscal);
|
||
if (issues.length)
|
||
pending.push(a.description + ': ' + issues.join(' '));
|
||
return {
|
||
...structuredClone(a),
|
||
accountingValue: accounting,
|
||
fiscal,
|
||
adjustment,
|
||
reason:
|
||
issues.join(' ') ||
|
||
'Decreto72/2013 art3–7; quota constante anual documentada, limitada ao saldo fiscal depreciável; diferença para a depreciação contabilística.',
|
||
};
|
||
});
|
||
let depreciation: FiscalFraction | null =
|
||
depreciationOverride?.value ?? null;
|
||
if (!depreciationOverride)
|
||
try {
|
||
const total = addFractions(
|
||
assets.map((a) => a.accountingValue ?? z()),
|
||
);
|
||
if (
|
||
s.assetsReference.trim() &&
|
||
assets.every((a) => a.adjustment !== null) &&
|
||
cmp(
|
||
total,
|
||
fiscalMoney(
|
||
source?.accountingLines.find(
|
||
(l) => l.code === 'depreciation',
|
||
)?.total ?? '',
|
||
),
|
||
) === 0n
|
||
)
|
||
depreciation = addFractions(assets.map((a) => a.adjustment!));
|
||
} catch {
|
||
/* linha de origem desconhecida */
|
||
}
|
||
if (
|
||
assets.length &&
|
||
(oldRows.some((r) => r.sourceCode === 'depreciation') ||
|
||
s.rows.some((r) => r.sourceCode === 'depreciation'))
|
||
) {
|
||
depreciation = null;
|
||
pending.push(
|
||
'Depreciação repartida entre mapas de ajustamentos e activos: reconcilie as parcelas sem sobreposição antes de aplicar a quota fiscal.',
|
||
);
|
||
}
|
||
if (assets.length && depreciation === null)
|
||
pending.push(
|
||
'Mapa de activos sem reconciliação integral com a depreciação da origem; efeito fiscal não aplicado.',
|
||
);
|
||
if (depreciationOverride) pending.push(...depreciationOverride.pending);
|
||
return {
|
||
input: structuredClone(s),
|
||
effects,
|
||
interest,
|
||
depreciation,
|
||
assets,
|
||
pending,
|
||
net: addFractions([
|
||
...effects.flatMap((r) => (r.effect ? [r.effect] : [])),
|
||
...(depreciation ? [depreciation] : []),
|
||
]),
|
||
source: 'CIRPC18/21/22/24/36; Decreto72/2013 art3–7. Entradas documentadas, sem taxas de activos sugeridas nem casos especiais presumidos.',
|
||
};
|
||
}
|