274 lines
10 KiB
TypeScript
274 lines
10 KiB
TypeScript
import { canonicalParcel } from './fiscal-proof-parcel.ts';
|
||
import type { NormalBook } from './irpc-normal.ts';
|
||
import {
|
||
economicTaxProof,
|
||
economicDistributionIdentity,
|
||
economicIncome,
|
||
validateEconomic,
|
||
type EconomicPosition,
|
||
} from './irpc-economic-credit.ts';
|
||
import {
|
||
creditMoney,
|
||
creditLimit,
|
||
creditCompare,
|
||
} from './irpc-international-credit.ts';
|
||
import {
|
||
addFractions,
|
||
fractionPresentation,
|
||
subtractFractions,
|
||
fraction,
|
||
type FiscalFraction,
|
||
} from './fiscal-rational.ts';
|
||
|
||
export type EconomicInclusion = {
|
||
source: EconomicPosition;
|
||
profitAlreadyIncluded: string;
|
||
creditAlreadyIncluded: string;
|
||
reference: string;
|
||
};
|
||
export type EconomicInclusions = { version: 1; rows: EconomicInclusion[] };
|
||
export const economicInclusionVersion = 'IRPC-economic-inclusion/1';
|
||
export function validateEconomicInclusions(input: EconomicInclusions) {
|
||
if (
|
||
input?.version !== 1 ||
|
||
!Array.isArray(input.rows) ||
|
||
input.rows.some(
|
||
(r) =>
|
||
!r ||
|
||
typeof r.profitAlreadyIncluded !== 'string' ||
|
||
typeof r.creditAlreadyIncluded !== 'string' ||
|
||
typeof r.reference !== 'string',
|
||
)
|
||
)
|
||
throw Error('Inclusões económicas não recuperáveis.');
|
||
validateEconomic({
|
||
version: 1,
|
||
state: 'known',
|
||
reference: economicInclusionVersion,
|
||
rows: input.rows.map((r) => r.source),
|
||
});
|
||
}
|
||
export function economicInclusionMoney(value: FiscalFraction): string {
|
||
const presentation = fractionPresentation(value);
|
||
return presentation.approximate
|
||
? value.numerator + '/' + value.denominator
|
||
: presentation.decimal;
|
||
}
|
||
export function calculateEconomicInclusions(
|
||
input: EconomicInclusions,
|
||
subject: string,
|
||
year: string,
|
||
) {
|
||
validateEconomicInclusions(input);
|
||
const pending: string[] = [];
|
||
const effects: {
|
||
id: string;
|
||
topic: string;
|
||
amount: FiscalFraction | null;
|
||
effect: FiscalFraction | null;
|
||
reason: string;
|
||
}[] = [];
|
||
const seen = new Set<string>();
|
||
for (const row of input.rows) {
|
||
const r = row.source;
|
||
try {
|
||
if (
|
||
!r ||
|
||
!['ordinary_profit', 'liquidation', 'association'].includes(
|
||
r.kind,
|
||
) ||
|
||
!r.id.trim() ||
|
||
!/^\d{9}$/.test(r.payerNuit) ||
|
||
r.subjectNuit !== subject ||
|
||
r.year !== year ||
|
||
year !== '2026' ||
|
||
r.recipientResident !== 'yes' ||
|
||
r.payerResident !== 'yes' ||
|
||
r.payerIrpc !== 'yes' ||
|
||
r.payerExempt !== 'no' ||
|
||
r.article40 !== 'no' ||
|
||
!r.qualificationReference?.trim() ||
|
||
!r.article40Reference?.trim() ||
|
||
!row.reference?.trim() ||
|
||
!r.distributionDocument?.trim() ||
|
||
!r.distributionParcel?.trim() ||
|
||
!/^2026-\d{2}-\d{2}$/.test(r.distributionDate) ||
|
||
new Date(r.distributionDate).toISOString().slice(0, 10) !==
|
||
r.distributionDate
|
||
)
|
||
throw Error(
|
||
'Falta qualificação, origem, titular/exercício ou correspondência da inclusão; art40 e outros âmbitos separados.',
|
||
);
|
||
const identity =
|
||
r.distributionDocument.trim().toUpperCase() +
|
||
'|' +
|
||
r.distributionParcel.trim().toUpperCase();
|
||
const taxIdentity = JSON.stringify([
|
||
canonicalParcel(r.taxIssuer),
|
||
canonicalParcel(r.taxDocument),
|
||
canonicalParcel(r.taxParcel),
|
||
]);
|
||
const distributionIdentity = economicDistributionIdentity(r);
|
||
if (
|
||
seen.has(distributionIdentity) ||
|
||
seen.has('tax|' + taxIdentity)
|
||
)
|
||
throw Error(
|
||
'Origem/parcela económica repetida; consolide antes de corrigir a base.',
|
||
);
|
||
seen.add(distributionIdentity);
|
||
seen.add('tax|' + taxIdentity);
|
||
const qualification = economicIncome(r);
|
||
const tax = economicTaxProof(r).value;
|
||
const nominal = fraction(
|
||
BigInt(tax.numerator) * 3n,
|
||
BigInt(tax.denominator) * 5n,
|
||
);
|
||
if (
|
||
creditCompare(qualification.income, fraction(0n)) === 0n &&
|
||
creditCompare(nominal, fraction(0n)) > 0n
|
||
)
|
||
throw Error(
|
||
'Imposto subjacente positivo sem rendimento elegível para crédito económico.',
|
||
);
|
||
const specialReference = qualification.liquidation
|
||
? `${qualification.liquidation.version}; rendimento elegível ${economicInclusionMoney(qualification.liquidation.eligibleIncome)}; excesso com natureza de mais-valia ${economicInclusionMoney(qualification.liquidation.excessCapitalGain)} incluído no rendimento total a reconciliar. ${qualification.liquidation.reference}`
|
||
: qualification.association
|
||
? `${qualification.association.version}; ${qualification.association.kind}; ${qualification.association.reference}`
|
||
: '';
|
||
const targets = [creditMoney(r.income), nominal];
|
||
const included = [
|
||
creditMoney(row.profitAlreadyIncluded),
|
||
creditLimit(row.creditAlreadyIncluded),
|
||
];
|
||
if (
|
||
r.incomeIncluded === 'yes' &&
|
||
creditCompare(included[0], targets[0]) < 0n
|
||
)
|
||
throw Error(
|
||
'A origem L8 declara os lucros já incluídos; o montante anterior indicado contradiz essa declaração. Compare e corrija a origem antes de acrescentar novamente.',
|
||
);
|
||
if (
|
||
r.grossUpIncluded.trim() &&
|
||
creditCompare(creditLimit(r.grossUpIncluded), included[1]) !==
|
||
0n
|
||
)
|
||
throw Error(
|
||
'O crédito já acrescido indicado não corresponde à origem L8; reconcilie a diferença antes de alterar a base.',
|
||
);
|
||
targets.forEach((target, index) => {
|
||
const effect = subtractFractions(target, included[index]);
|
||
economicInclusionMoney(effect);
|
||
effects.push({
|
||
id: identity + ':' + index,
|
||
topic: 'incomeCorrections',
|
||
amount: fraction(
|
||
BigInt(effect.numerator) < 0n
|
||
? -BigInt(effect.numerator)
|
||
: BigInt(effect.numerator),
|
||
BigInt(effect.denominator),
|
||
),
|
||
effect,
|
||
reason: `${economicInclusionVersion}${[target, included[index], effect].some((v) => (BigInt(v.numerator) * 100n) % BigInt(v.denominator) !== 0n) ? '; IRPC-economic-inclusion-exact/1: fracções conservadas, sem arredondamento fiscal' : ''}; CIRPC64(1–2)/53(1)a; ${index ? 'acréscimo nominal' : 'lucros'}: alvo ${economicInclusionMoney(target)}; já considerado ${economicInclusionMoney(included[index])}; diferença ${economicInclusionMoney(effect)}. ${row.reference}${specialReference ? ' ' + specialReference : ''}`,
|
||
});
|
||
});
|
||
} catch (e) {
|
||
pending.push(String(e));
|
||
}
|
||
}
|
||
return {
|
||
version: economicInclusionVersion,
|
||
input: structuredClone(input),
|
||
effects,
|
||
pending,
|
||
net: addFractions(effects.flatMap((e) => (e.effect ? [e.effect] : []))),
|
||
};
|
||
}
|
||
export function prepareEconomicInclusion(
|
||
book: NormalBook,
|
||
row: EconomicInclusion,
|
||
): NormalBook {
|
||
if (
|
||
book.special ||
|
||
book.periodsFx ||
|
||
(book.schedules && book.schedules.period.kind !== 'calendar') ||
|
||
book.scope !== 'resident_general' ||
|
||
book.year !== '2026' ||
|
||
!book.base ||
|
||
!book.profit
|
||
)
|
||
throw Error(
|
||
'Prepare primeiro o lucro e a base L6 ordinária do sujeito/ano civil2026/MZN; períodos especiais e conversões têm correspondência própria.',
|
||
);
|
||
const old = book.economicInclusions ?? { version: 1 as const, rows: [] };
|
||
const rows = old.rows.filter(
|
||
(p) =>
|
||
economicDistributionIdentity(p.source) !==
|
||
economicDistributionIdentity(row.source),
|
||
);
|
||
const next: EconomicInclusions = {
|
||
version: 1,
|
||
rows: [...rows, structuredClone(row)],
|
||
};
|
||
const previous = calculateEconomicInclusions(
|
||
old,
|
||
book.base.subjectNuit,
|
||
book.year,
|
||
),
|
||
prepared = calculateEconomicInclusions(
|
||
next,
|
||
book.base.subjectNuit,
|
||
book.year,
|
||
);
|
||
if (previous.pending.length || prepared.pending.length)
|
||
throw Error([...previous.pending, ...prepared.pending].join(' '));
|
||
const topic = book.profit.topics.incomeCorrections;
|
||
if (
|
||
!topic.reference.trim() ||
|
||
!['known', 'absent'].includes(topic.state) ||
|
||
(topic.state === 'absent' && topic.amount.trim())
|
||
)
|
||
throw Error(
|
||
'Reconcilie as outras correcções aos proveitos antes de substituir parcelas.',
|
||
);
|
||
const current =
|
||
topic.state === 'absent'
|
||
? fraction(0n)
|
||
: old.rows.length
|
||
? creditLimit(topic.amount)
|
||
: creditMoney(topic.amount);
|
||
const remaining = subtractFractions(
|
||
current,
|
||
addFractions(
|
||
previous.effects.flatMap((e) => (e.amount ? [e.amount] : [])),
|
||
),
|
||
);
|
||
if (BigInt(remaining.numerator) < 0n)
|
||
throw Error(
|
||
'Montante de correcções inferior às parcelas anteriores; compare antes de substituir.',
|
||
);
|
||
const amount = addFractions([
|
||
remaining,
|
||
addFractions(
|
||
prepared.effects.flatMap((e) => (e.amount ? [e.amount] : [])),
|
||
),
|
||
]);
|
||
return {
|
||
...book,
|
||
economicInclusions: next,
|
||
profit: {
|
||
...book.profit,
|
||
topics: {
|
||
...book.profit.topics,
|
||
incomeCorrections: {
|
||
state: 'known',
|
||
amount: economicInclusionMoney(amount),
|
||
reference: topic.reference.includes(row.reference)
|
||
? topic.reference
|
||
: topic.reference + '\n' + row.reference,
|
||
},
|
||
},
|
||
},
|
||
};
|
||
}
|