382 lines
13 KiB
TypeScript
382 lines
13 KiB
TypeScript
import { countryIsoCodes } from './country-iso-codes.ts';
|
||
import {
|
||
creditCompare,
|
||
creditMoney,
|
||
creditLimit,
|
||
} from './irpc-international-credit.ts';
|
||
import {
|
||
addFractions,
|
||
fraction,
|
||
subtractFractions,
|
||
type FiscalFraction,
|
||
} from './fiscal-rational.ts';
|
||
|
||
export const carryVersion = 'IRPC-credit-carry/1';
|
||
export const carrySource =
|
||
'CIRPC, Lei 34/2007, art.65(3): crédito internacional elegível não deduzido por insuficiência de colecta, até ao fim dos cinco exercícios seguintes; art.66(1–2): PEC até três exercícios seguintes, depois dos créditos precedentes, sem reembolso. Exemplar BR52, PDF18/p.768-(72). Recorte: fecho2026, mesmo sujeito, exercícios anuais ordinários, montantes comprovados em MZN. A origem e os usos efectivos são documentados; simulações não criam nem consomem saldos oficiais.';
|
||
type State = '' | 'known' | 'absent';
|
||
export type CarryUse = {
|
||
id: string;
|
||
year: string;
|
||
amount: string;
|
||
document: string;
|
||
reference: string;
|
||
};
|
||
export type CarryLot = {
|
||
id: string;
|
||
kind: '' | 'international' | 'pec';
|
||
country: string;
|
||
subjectNuit: string;
|
||
year: string;
|
||
currency: string;
|
||
ordinary: '' | 'yes' | 'no';
|
||
originDocument: string;
|
||
originReference: string;
|
||
ruleReference: string;
|
||
eligible: string;
|
||
originDeducted: string;
|
||
insufficient: '' | 'yes' | 'no';
|
||
usesState: State;
|
||
usesReference: string;
|
||
uses: CarryUse[];
|
||
selected: string;
|
||
};
|
||
export type CreditCarry = {
|
||
version: 1;
|
||
state: State;
|
||
reference: string;
|
||
lots: CarryLot[];
|
||
currentInternational: string;
|
||
currentPec: string;
|
||
allocationReference: string;
|
||
assessmentKey: string;
|
||
};
|
||
export const newCreditCarry = (): CreditCarry => ({
|
||
version: 1,
|
||
state: '',
|
||
reference: '',
|
||
lots: [],
|
||
currentInternational: '',
|
||
currentPec: '',
|
||
allocationReference: '',
|
||
assessmentKey: '',
|
||
});
|
||
export const newCarryLot = (id: string): CarryLot => ({
|
||
id,
|
||
kind: '',
|
||
country: '',
|
||
subjectNuit: '',
|
||
year: '',
|
||
currency: 'MZN',
|
||
ordinary: '',
|
||
originDocument: '',
|
||
originReference: '',
|
||
ruleReference: '',
|
||
eligible: '',
|
||
originDeducted: '',
|
||
insufficient: '',
|
||
usesState: '',
|
||
usesReference: '',
|
||
uses: [],
|
||
selected: '',
|
||
});
|
||
export const newCarryUse = (id: string): CarryUse => ({
|
||
id,
|
||
year: '',
|
||
amount: '',
|
||
document: '',
|
||
reference: '',
|
||
});
|
||
const states = ['', 'known', 'absent'];
|
||
const zero = () => fraction(0n);
|
||
const canonical = (s: string) =>
|
||
s.normalize('NFKC').trim().toLocaleLowerCase('pt').replace(/\s+/g, ' ');
|
||
// One consolidated balance per nature/country/origin exercise; changing the document does not create a second quota.
|
||
export const carryIdentity = (r: CarryLot) =>
|
||
JSON.stringify([r.subjectNuit, r.kind, r.country, r.year]);
|
||
export function validateCreditCarry(c: CreditCarry) {
|
||
if (
|
||
!c ||
|
||
c.version !== 1 ||
|
||
!states.includes(c.state) ||
|
||
[
|
||
'reference',
|
||
'currentInternational',
|
||
'currentPec',
|
||
'allocationReference',
|
||
'assessmentKey',
|
||
].some(
|
||
(k) =>
|
||
typeof (c as unknown as Record<string, unknown>)[k] !==
|
||
'string',
|
||
) ||
|
||
!Array.isArray(c.lots)
|
||
)
|
||
throw Error(
|
||
'Saldos anteriores não recuperáveis; originais conservados.',
|
||
);
|
||
const keys = Object.keys(newCarryLot('')).filter((k) => k !== 'uses');
|
||
const ids: string[] = [];
|
||
for (const r of c.lots) {
|
||
if (
|
||
!r ||
|
||
keys.some(
|
||
(k) =>
|
||
typeof (r as unknown as Record<string, unknown>)[k] !==
|
||
'string',
|
||
) ||
|
||
!['', 'international', 'pec'].includes(r.kind) ||
|
||
!['', 'yes', 'no'].includes(r.ordinary) ||
|
||
!['', 'yes', 'no'].includes(r.insufficient) ||
|
||
!states.includes(r.usesState) ||
|
||
!Array.isArray(r.uses)
|
||
)
|
||
throw Error('Origem do saldo anterior não recuperável.');
|
||
ids.push(r.id);
|
||
for (const u of r.uses) {
|
||
if (
|
||
!u ||
|
||
Object.keys(newCarryUse('')).some(
|
||
(k) =>
|
||
typeof (u as unknown as Record<string, unknown>)[k] !==
|
||
'string',
|
||
)
|
||
)
|
||
throw Error('Utilização anterior não recuperável.');
|
||
ids.push(u.id);
|
||
}
|
||
}
|
||
if (new Set(ids).size !== ids.length || ids.some((id) => !id.trim()))
|
||
throw Error('Identificador de saldo/utilização ausente ou repetido.');
|
||
}
|
||
export function calculateCarryLot(
|
||
r: CarryLot,
|
||
subjectNuit: string,
|
||
year: string,
|
||
) {
|
||
let opening: FiscalFraction | null = null,
|
||
used: FiscalFraction | null = null,
|
||
available: FiscalFraction | null = null,
|
||
selected: FiscalFraction | null = null,
|
||
remaining: FiscalFraction | null = null;
|
||
let lastYear: number | null = null,
|
||
expired = false;
|
||
const pending: string[] = [];
|
||
try {
|
||
if (
|
||
!r.kind ||
|
||
r.subjectNuit !== subjectNuit ||
|
||
!/^\d{9}$/.test(r.subjectNuit) ||
|
||
year !== '2026' ||
|
||
!/^20\d{2}$/.test(r.year) ||
|
||
Number(r.year) >= Number(year) ||
|
||
r.ordinary !== 'yes' ||
|
||
r.currency !== 'MZN'
|
||
)
|
||
throw Error(
|
||
'Origem exige mesmo titular, exercício anterior anual ordinário e montante documentado em MZN.',
|
||
);
|
||
if (
|
||
r.kind === 'international'
|
||
? !countryIsoCodes.includes(r.country) || r.country === 'MZ'
|
||
: r.country !== ''
|
||
)
|
||
throw Error(
|
||
'País estrangeiro obrigatório para crédito internacional; PEC não tem país estrangeiro.',
|
||
);
|
||
if (
|
||
!r.originDocument.trim() ||
|
||
!r.originReference.trim() ||
|
||
!r.ruleReference.trim()
|
||
)
|
||
throw Error(
|
||
'Faltam apuramento original e regra aplicável ao exercício de origem, com suporte.',
|
||
);
|
||
if (r.insufficient !== 'yes')
|
||
throw Error(
|
||
'Falta comprovar que o remanescente resulta de insuficiência de colecta na origem; imposto acima do limite não é saldo transportável.',
|
||
);
|
||
lastYear = Number(r.year) + (r.kind === 'international' ? 5 : 3);
|
||
expired = Number(year) > lastYear;
|
||
const amount = r.kind === 'international' ? creditLimit : creditMoney;
|
||
const eligible = amount(r.eligible),
|
||
originDeducted = amount(r.originDeducted);
|
||
if (creditCompare(originDeducted, eligible) > 0n)
|
||
throw Error(
|
||
'Dedução na origem ultrapassa o montante elegível comprovado.',
|
||
);
|
||
opening = subtractFractions(eligible, originDeducted);
|
||
if (
|
||
!r.usesState ||
|
||
!r.usesReference.trim() ||
|
||
(r.usesState === 'absent' && r.uses.length) ||
|
||
(r.usesState === 'known' && !r.uses.length)
|
||
)
|
||
throw Error(
|
||
'Utilizações efectivas posteriores à origem exigem inventário conhecido ou ausência expressa com suporte.',
|
||
);
|
||
const documents = new Set<string>();
|
||
const values = r.uses.map((u) => {
|
||
if (
|
||
!/^20\d{2}$/.test(u.year) ||
|
||
Number(u.year) <= Number(r.year) ||
|
||
Number(u.year) > Number(year) ||
|
||
Number(u.year) > lastYear! ||
|
||
!u.document.trim() ||
|
||
!u.reference.trim()
|
||
)
|
||
throw Error(
|
||
'Uso exige exercício elegível, posterior à origem, documento e suporte; pode incluir uso efectivo já ocorrido no exercício corrente.',
|
||
);
|
||
const identity = canonical(u.document);
|
||
if (documents.has(identity))
|
||
throw Error(
|
||
'Documento de utilização repetido; consolide a dedução efectiva.',
|
||
);
|
||
documents.add(identity);
|
||
return amount(u.amount);
|
||
});
|
||
used = addFractions(values);
|
||
if (creditCompare(used, opening) > 0n)
|
||
throw Error(
|
||
'Utilizações efectivas ultrapassam o remanescente da origem.',
|
||
);
|
||
const balance = subtractFractions(opening, used);
|
||
selected = amount(r.selected);
|
||
available = expired ? zero() : balance;
|
||
if (creditCompare(selected, available) > 0n)
|
||
throw Error(
|
||
expired
|
||
? 'Prazo ordinário terminado; saldo conservado no histórico, sem dedução neste exercício.'
|
||
: 'Parcela seleccionada ultrapassa o saldo documentado disponível.',
|
||
);
|
||
remaining = subtractFractions(balance, selected);
|
||
} catch (e) {
|
||
pending.push(String(e));
|
||
selected = null;
|
||
}
|
||
return {
|
||
id: r.id,
|
||
kind: r.kind,
|
||
country: r.country,
|
||
year: r.year,
|
||
opening,
|
||
used,
|
||
available,
|
||
selected,
|
||
remaining,
|
||
lastYear,
|
||
expired,
|
||
pending,
|
||
input: structuredClone(r),
|
||
};
|
||
}
|
||
export function carryPlan(
|
||
c: CreditCarry,
|
||
subjectNuit: string,
|
||
year: string,
|
||
assessmentKey: string,
|
||
currentInternational: FiscalFraction,
|
||
currentPec: FiscalFraction,
|
||
colecta: FiscalFraction | null,
|
||
duplicates: string[] = [],
|
||
) {
|
||
validateCreditCarry(c);
|
||
const pending = [...duplicates];
|
||
if (
|
||
!c.state ||
|
||
!c.reference.trim() ||
|
||
(c.state === 'absent' && c.lots.length) ||
|
||
(c.state === 'known' && !c.lots.length)
|
||
)
|
||
pending.push(
|
||
'Inventário de saldos anteriores em falta ou contraditório; desconhecido não significa ausente.',
|
||
);
|
||
const ids = c.lots.map(carryIdentity);
|
||
if (new Set(ids).size !== ids.length)
|
||
pending.push(
|
||
'Saldo de mesma natureza/país/exercício repetido; consolide a origem e os usos.',
|
||
);
|
||
const lots = c.lots.map((r) => calculateCarryLot(r, subjectNuit, year));
|
||
pending.push(
|
||
...lots.flatMap((r) =>
|
||
r.pending.map((p) => r.year + '/' + r.kind + ': ' + p),
|
||
),
|
||
);
|
||
let international: FiscalFraction | null = null,
|
||
pec: FiscalFraction | null = null;
|
||
try {
|
||
if (c.state !== 'known')
|
||
return {
|
||
version: carryVersion,
|
||
lots,
|
||
international,
|
||
pec,
|
||
priorInternational: zero(),
|
||
priorPec: zero(),
|
||
pending,
|
||
source: carrySource,
|
||
officialBalancesConsumed: false,
|
||
carryCreated: false,
|
||
};
|
||
if (
|
||
!c.allocationReference.trim() ||
|
||
!c.assessmentKey ||
|
||
c.assessmentKey !== assessmentKey
|
||
)
|
||
throw Error(
|
||
'Confirme a escolha das parcelas correntes e anteriores com suporte na colecta seleccionada; não se presume prioridade entre exercícios.',
|
||
);
|
||
international = creditLimit(c.currentInternational);
|
||
pec = creditMoney(c.currentPec);
|
||
if (
|
||
creditCompare(international, currentInternational) > 0n ||
|
||
creditCompare(pec, currentPec) > 0n
|
||
)
|
||
throw Error(
|
||
'Parcela corrente escolhida ultrapassa o respectivo crédito/PEC comprovado disponível.',
|
||
);
|
||
} catch (e) {
|
||
pending.push(String(e));
|
||
}
|
||
const priorInternational = addFractions(
|
||
lots
|
||
.filter(
|
||
(r) =>
|
||
r.kind === 'international' &&
|
||
!r.pending.length &&
|
||
r.selected,
|
||
)
|
||
.map((r) => r.selected!),
|
||
);
|
||
const priorPec = addFractions(
|
||
lots
|
||
.filter((r) => r.kind === 'pec' && !r.pending.length && r.selected)
|
||
.map((r) => r.selected!),
|
||
);
|
||
if (
|
||
international &&
|
||
pec &&
|
||
colecta &&
|
||
creditCompare(
|
||
addFractions([international, pec, priorInternational, priorPec]),
|
||
colecta,
|
||
) > 0n
|
||
)
|
||
pending.push(
|
||
'As parcelas escolhidas ultrapassam a colecta; ajuste a afectação entre exercícios antes de confirmar, sem reduzir valores automaticamente.',
|
||
);
|
||
return {
|
||
version: carryVersion,
|
||
lots,
|
||
international,
|
||
pec,
|
||
priorInternational,
|
||
priorPec,
|
||
pending,
|
||
source: carrySource,
|
||
officialBalancesConsumed: false,
|
||
carryCreated: false,
|
||
};
|
||
}
|