363 lines
14 KiB
TypeScript
363 lines
14 KiB
TypeScript
import {
|
||
addFractions,
|
||
decimalFraction,
|
||
fraction,
|
||
subtractFractions,
|
||
type FiscalFraction,
|
||
} from './fiscal-rational.ts';
|
||
import type { ProfitEvidence } from './irpc-normal-profit.ts';
|
||
|
||
export const normalBaseVersion = 'IRPC-normal-adjustments/3';
|
||
type Choice = '' | 'yes' | 'no';
|
||
export type NormalLoss = {
|
||
id: string;
|
||
year: string;
|
||
subjectNuit: string;
|
||
assessed: string;
|
||
previouslyUsed: string;
|
||
requested: string;
|
||
reference: string;
|
||
kind: '' | 'ordinary' | 'transfer' | 'other';
|
||
annualPeriods: Choice;
|
||
generalActivity: Choice;
|
||
balanceConfirmed: Choice;
|
||
};
|
||
export type NormalBaseInput = {
|
||
version: 1;
|
||
subjectNuit: string;
|
||
identityReference: string;
|
||
method: '' | 'direct' | 'indirect';
|
||
methodReference: string;
|
||
lossesState: '' | 'known' | 'absent';
|
||
lossesReference: string;
|
||
unchangedObject: Choice;
|
||
objectReference: string;
|
||
losses: NormalLoss[];
|
||
benefits: ProfitEvidence;
|
||
};
|
||
export const newNormalBaseInput = (): NormalBaseInput => ({
|
||
version: 1,
|
||
subjectNuit: '',
|
||
identityReference: '',
|
||
method: '',
|
||
methodReference: '',
|
||
lossesState: '',
|
||
lossesReference: '',
|
||
unchangedObject: '',
|
||
objectReference: '',
|
||
losses: [],
|
||
benefits: { state: '', amount: '', reference: '' },
|
||
});
|
||
export const newNormalLoss = (id: string): NormalLoss => ({
|
||
id,
|
||
year: '',
|
||
subjectNuit: '',
|
||
assessed: '',
|
||
previouslyUsed: '',
|
||
requested: '',
|
||
reference: '',
|
||
kind: '',
|
||
annualPeriods: '',
|
||
generalActivity: '',
|
||
balanceConfirmed: '',
|
||
});
|
||
export function validateNormalBase(i: NormalBaseInput) {
|
||
if (
|
||
!i ||
|
||
i.version !== 1 ||
|
||
[
|
||
'subjectNuit',
|
||
'identityReference',
|
||
'methodReference',
|
||
'lossesReference',
|
||
'objectReference',
|
||
].some((k) => typeof i[k as 'subjectNuit'] !== 'string') ||
|
||
!['', 'direct', 'indirect'].includes(i.method) ||
|
||
!['', 'known', 'absent'].includes(i.lossesState) ||
|
||
!['', 'yes', 'no'].includes(i.unchangedObject) ||
|
||
!Array.isArray(i.losses) ||
|
||
new Set(i.losses.map((l) => l.id)).size !== i.losses.length ||
|
||
!i.benefits ||
|
||
!['', 'known', 'absent'].includes(i.benefits.state) ||
|
||
typeof i.benefits.amount !== 'string' ||
|
||
typeof i.benefits.reference !== 'string'
|
||
)
|
||
throw Error('Preparação da matéria colectável não recuperável.');
|
||
for (const l of i.losses)
|
||
if (
|
||
!l ||
|
||
Object.keys(newNormalLoss('')).some(
|
||
(k) => typeof l[k as keyof NormalLoss] !== 'string',
|
||
) ||
|
||
!['', 'ordinary', 'transfer', 'other'].includes(l.kind) ||
|
||
['annualPeriods', 'generalActivity', 'balanceConfirmed'].some(
|
||
(k) => !['', 'yes', 'no'].includes(l[k as 'annualPeriods']),
|
||
)
|
||
)
|
||
throw Error('Perda fiscal não recuperável;originais conservados.');
|
||
}
|
||
export type NormalBaseModel = {
|
||
input: NormalBaseInput;
|
||
taxableProfit: FiscalFraction | null;
|
||
requestedLosses: FiscalFraction;
|
||
deductedLosses: FiscalFraction | null;
|
||
benefitDeduction: FiscalFraction | null;
|
||
taxableBase: FiscalFraction | null;
|
||
losses: Array<
|
||
NormalLoss & {
|
||
available: FiscalFraction | null;
|
||
requestedAmount: FiscalFraction | null;
|
||
deducted: FiscalFraction | null;
|
||
remaining: FiscalFraction | null;
|
||
lastOrdinaryYear: number | null;
|
||
status: 'eligible' | 'excluded' | 'indeterminate';
|
||
reason: string;
|
||
}
|
||
>;
|
||
pending: string[];
|
||
source: string;
|
||
};
|
||
const zero = () => fraction(0n);
|
||
function amount(s: string) {
|
||
if (!/^\d{1,18}(?:\.\d{1,2})?$/.test(s)) throw Error();
|
||
return decimalFraction(s);
|
||
}
|
||
const units = (f: FiscalFraction) =>
|
||
(BigInt(f.numerator) * 100n) / BigInt(f.denominator);
|
||
export function calculateNormalBase(
|
||
input: NormalBaseInput,
|
||
profit: FiscalFraction | null,
|
||
year: string,
|
||
currentLossReference?: string,
|
||
short = false,
|
||
): NormalBaseModel {
|
||
validateNormalBase(input);
|
||
const i = structuredClone(input),
|
||
pending: string[] = [],
|
||
losses: NormalBaseModel['losses'] = [];
|
||
if (year !== '2026')
|
||
pending.push('Matéria colectável delimitada ao exercício2026.');
|
||
if (!/^\d{9}$/.test(i.subjectNuit) || !i.identityReference.trim())
|
||
pending.push(
|
||
'Identifique NUIT e suporte do sujeito passivo do lucro e das perdas.',
|
||
);
|
||
if (i.method !== 'direct' || !i.methodReference.trim())
|
||
pending.push(
|
||
'Método declarativo/directo e suporte por identificar;apuramento indirecto fora deste recorte (15(2),41(2)).',
|
||
);
|
||
if (!profit)
|
||
pending.push(
|
||
'Lucro tributável actual indeterminado;nenhuma dedução transforma informação em falta em zero.',
|
||
);
|
||
else if (BigInt(profit.numerator) < 0n && !currentLossReference?.trim())
|
||
pending.push(
|
||
'Resultado fiscal negativo conhecido;tratamento da perda do próprio exercício fora deste recorte,sem matéria colectável zero ou reporte automático.',
|
||
);
|
||
if (!i.lossesState || !i.lossesReference.trim())
|
||
pending.push(
|
||
'Situação e completude das perdas anteriores em falta;sem ausência presumida.',
|
||
);
|
||
if (i.lossesState === 'absent' && i.losses.length)
|
||
pending.push(
|
||
'Ausência declarada de perdas contradiz os registos existentes;reconcilie sem apagar histórico.',
|
||
);
|
||
if (i.lossesState === 'known' && !i.losses.length)
|
||
pending.push(
|
||
'Perdas declaradas conhecidas sem discriminação por exercício.',
|
||
);
|
||
if (
|
||
i.losses.some(
|
||
(l) =>
|
||
!/^20\d{2}$/.test(l.year) || Number(l.year) + 5 >= Number(year),
|
||
) &&
|
||
(!i.unchangedObject || !i.objectReference.trim())
|
||
)
|
||
pending.push(
|
||
'Documente manutenção ou alteração do objecto/natureza da actividade no termo do exercício (41(7)).',
|
||
);
|
||
for (const l of i.losses) {
|
||
const r: NormalBaseModel['losses'][number] = {
|
||
...l,
|
||
available: null,
|
||
requestedAmount: null,
|
||
deducted: null,
|
||
remaining: null,
|
||
lastOrdinaryYear: null,
|
||
status: 'indeterminate',
|
||
reason: '',
|
||
};
|
||
const issues: string[] = [];
|
||
if (!l.reference.trim())
|
||
issues.push(
|
||
'Documento de apuramento/saldo fiscal por identificar.',
|
||
);
|
||
if (l.subjectNuit !== i.subjectNuit || !/^\d{9}$/.test(l.subjectNuit))
|
||
issues.push('Perda não reconciliada com o mesmo sujeito passivo.');
|
||
if (!/^20\d{2}$/.test(l.year) || Number(l.year) >= Number(year))
|
||
issues.push(
|
||
'Exercício da perda deve ser anterior ao exercício de utilização;perda actual não é dedução a si própria.',
|
||
);
|
||
if (
|
||
i.losses.some(
|
||
(q) =>
|
||
q.id !== l.id &&
|
||
q.year === l.year &&
|
||
q.subjectNuit === l.subjectNuit,
|
||
)
|
||
)
|
||
issues.push(
|
||
'Saldo anual duplicado;reconcilie um saldo por sujeito/exercício.',
|
||
);
|
||
if (l.kind !== 'ordinary')
|
||
issues.push(
|
||
'Transmissão/fusão ou outra origem exige correspondência própria;sem aplicação automática de41-A.',
|
||
);
|
||
if (l.annualPeriods !== 'yes')
|
||
issues.push(
|
||
'Contagem por exercícios anuais ordinários não demonstrada;períodos especiais/curtos (41(5)) fora deste recorte.',
|
||
);
|
||
if (l.generalActivity !== 'yes')
|
||
issues.push(
|
||
'Origem no regime geral,fora de transparência/isenção parcial ou actividade com redução,por demonstrar (41(4/6)).',
|
||
);
|
||
if (l.balanceConfirmed !== 'yes')
|
||
issues.push(
|
||
'Saldo fiscal corrigido e utilizações anteriores por reconciliar (41(3));não usar perda contabilística como fiscal.',
|
||
);
|
||
try {
|
||
const assessed = amount(l.assessed),
|
||
used = amount(l.previouslyUsed);
|
||
if (units(used) > units(assessed)) throw Error();
|
||
r.available = subtractFractions(assessed, used);
|
||
} catch {
|
||
issues.push(
|
||
'Apuramento fiscal e utilização anterior devem ser montantes conhecidos não negativos e coerentes.',
|
||
);
|
||
}
|
||
try {
|
||
r.requestedAmount = amount(l.requested);
|
||
if (r.available && units(r.requestedAmount) > units(r.available))
|
||
issues.push(
|
||
'Utilização proposta excede saldo fiscal disponível.',
|
||
);
|
||
} catch {
|
||
issues.push(
|
||
'Identifique utilização proposta,não negativa,mesmo quando zero.',
|
||
);
|
||
}
|
||
if (l.annualPeriods === 'yes' && /^20\d{2}$/.test(l.year))
|
||
r.lastOrdinaryYear = Number(l.year) + 5;
|
||
if (!issues.length) {
|
||
if (
|
||
r.lastOrdinaryYear! < Number(year) ||
|
||
i.unchangedObject === 'no'
|
||
) {
|
||
r.status = 'excluded';
|
||
r.reason =
|
||
r.lastOrdinaryYear! < Number(year)
|
||
? 'Prazo dos cinco exercícios ordinários posteriores expirado (41(1));saldo não utilizável neste exercício.'
|
||
: 'Objecto/natureza alterados conforme declaração;dedução41(1)não aplicável (41(7)).';
|
||
if (units(r.requestedAmount!) !== 0n)
|
||
issues.push(
|
||
'Perda excluída com utilização positiva proposta;corrija a proposta sem dedução presumida.',
|
||
);
|
||
} else {
|
||
r.status = 'eligible';
|
||
r.reason =
|
||
'Saldo fiscal ordinário documentado,mesmo sujeito,cinco exercícios posteriores;utilização escolhida explicitamente,sem ordemFIFO ou percentagem presumida.';
|
||
}
|
||
}
|
||
if (issues.length) {
|
||
r.status = 'indeterminate';
|
||
r.reason = issues.join(' ');
|
||
pending.push(l.year + ': ' + r.reason);
|
||
}
|
||
losses.push(r);
|
||
}
|
||
if (
|
||
short &&
|
||
losses.some(
|
||
(l) =>
|
||
l.requestedAmount && BigInt(l.requestedAmount.numerator) > 0n,
|
||
)
|
||
)
|
||
pending.push(
|
||
'Período curto:sequência dos períodos posteriores por reconciliar;41(5) exclui da contagem o período de transição7(4)d inferior a6meses,sem agregar todos os períodos curtos ou presumir prazo por ano civil.',
|
||
);
|
||
const requestedLosses = addFractions(
|
||
losses
|
||
.filter((l) => l.status === 'eligible')
|
||
.map((l) => l.requestedAmount!),
|
||
);
|
||
if (
|
||
profit &&
|
||
BigInt(requestedLosses.numerator) * BigInt(profit.denominator) >
|
||
(BigInt(profit.numerator) > 0n
|
||
? BigInt(profit.numerator) * BigInt(requestedLosses.denominator)
|
||
: 0n)
|
||
)
|
||
pending.push(
|
||
'Utilizações propostas excedem o lucro tributável disponível;reparta explicitamente sem criar base negativa ou escolher uma ordem de perdas.',
|
||
);
|
||
let benefitDeduction: FiscalFraction | null = null;
|
||
const benefit = i.benefits;
|
||
if (!benefit.state || !benefit.reference.trim())
|
||
pending.push(
|
||
'Benefícios/deduções à matéria colectável por identificar (15(4));créditos à colecta e retenções não são esta dedução.',
|
||
);
|
||
else if (benefit.state === 'absent') {
|
||
if (benefit.amount.trim())
|
||
pending.push(
|
||
'Ausência de benefícios contradiz montante preenchido.',
|
||
);
|
||
else benefitDeduction = zero();
|
||
} else {
|
||
try {
|
||
const value = amount(benefit.amount);
|
||
if (units(value) !== 0n)
|
||
pending.push(
|
||
'Benefício de montante conhecido:regra,condições,limite e ordem próprios por implementar;sem subtracção livre do valor.',
|
||
);
|
||
else benefitDeduction = value;
|
||
} catch {
|
||
pending.push(
|
||
'Montante conhecido das deduções por preencher ou inválido.',
|
||
);
|
||
}
|
||
}
|
||
const ready = pending.length === 0 && profit !== null,
|
||
deductedLosses = ready ? requestedLosses : null,
|
||
taxableBase = ready
|
||
? subtractFractions(
|
||
subtractFractions(
|
||
BigInt(profit!.numerator) < 0n ? zero() : profit!,
|
||
requestedLosses,
|
||
),
|
||
benefitDeduction!,
|
||
)
|
||
: null;
|
||
for (const l of losses) {
|
||
if (l.available) {
|
||
l.deducted = ready
|
||
? l.status === 'eligible'
|
||
? l.requestedAmount!
|
||
: zero()
|
||
: null;
|
||
l.remaining = l.deducted
|
||
? subtractFractions(l.available, l.deducted)
|
||
: null;
|
||
}
|
||
}
|
||
return {
|
||
input: i,
|
||
taxableProfit: profit,
|
||
requestedLosses,
|
||
deductedLosses,
|
||
benefitDeduction,
|
||
taxableBase,
|
||
losses,
|
||
pending,
|
||
source: 'CIRPC15(1)a/15(2–4),41(1–7),Lei34/2007 PDF5/p768(59),PDF12/p768(66). Recorte2026,residente geral,método declarativo,perdas próprias em exercícios anuais ordinários;cinco exercícios posteriores,sem transferência41-A,regimes especiais ou benefícios positivos implementados. Pedido de utilização expresso e limitado ao saldo/lucro;semFIFO obrigatório ou limite percentual inventado. Matéria colectável simulada,não colecta/imposto a pagar,semM20/M22/submissão ou consumo persistente do saldo oficial.',
|
||
};
|
||
}
|