375 lines
14 KiB
TypeScript
375 lines
14 KiB
TypeScript
import { creditLimit, creditCompare } from './irpc-international-credit.ts';
|
||
import type { NormalDepreciationModel } from './irpc-normal-depreciation.ts';
|
||
import type { NormalCostsModel } from './irpc-normal-costs.ts';
|
||
import type { SchedulesModel } from './irpc-normal-schedules.ts';
|
||
import {
|
||
addFractions,
|
||
decimalFraction,
|
||
fraction,
|
||
type FiscalFraction,
|
||
} from './fiscal-rational.ts';
|
||
|
||
export const normalProfitVersion = 'IRPC-normal-adjustments/2';
|
||
export type ProfitEvidence = {
|
||
state: '' | 'known' | 'absent';
|
||
amount: string;
|
||
reference: string;
|
||
};
|
||
export const profitTopics = [
|
||
[
|
||
'positivePatrimonial',
|
||
'Variações patrimoniais positivas fora do resultado',
|
||
],
|
||
[
|
||
'negativePatrimonial',
|
||
'Variações patrimoniais negativas fora do resultado',
|
||
],
|
||
[
|
||
'incomeCorrections',
|
||
'Outras correcções aos proveitos, incluindo exclusões',
|
||
],
|
||
[
|
||
'expenseCorrections',
|
||
'Outras correcções aos custos, além dos acréscimos registados',
|
||
],
|
||
[
|
||
'timing',
|
||
'Diferenças de periodização e de critérios contabilísticos/fiscais',
|
||
],
|
||
['special', 'Operações especiais, benefícios e regimes fora deste recorte'],
|
||
] as const;
|
||
export type ProfitTopic = (typeof profitTopics)[number][0];
|
||
export type NormalProfitInput = {
|
||
version: 1;
|
||
completenessReference: string;
|
||
lines: Array<{
|
||
code: string;
|
||
state: '' | 'known' | 'absent';
|
||
reference: string;
|
||
}>;
|
||
topics: Record<ProfitTopic, ProfitEvidence>;
|
||
};
|
||
export const newNormalProfitInput = (): NormalProfitInput => ({
|
||
version: 1,
|
||
completenessReference: '',
|
||
lines: [],
|
||
topics: Object.fromEntries(
|
||
profitTopics.map(([k]) => [
|
||
k,
|
||
{ state: '', amount: '', reference: '' },
|
||
]),
|
||
) as NormalProfitInput['topics'],
|
||
});
|
||
export function validateNormalProfit(i: NormalProfitInput) {
|
||
if (
|
||
!i ||
|
||
i.version !== 1 ||
|
||
typeof i.completenessReference !== 'string' ||
|
||
!Array.isArray(i.lines) ||
|
||
i.lines.some(
|
||
(l) =>
|
||
!l ||
|
||
typeof l.code !== 'string' ||
|
||
typeof l.reference !== 'string' ||
|
||
!['', 'known', 'absent'].includes(l.state),
|
||
) ||
|
||
new Set(i.lines.map((l) => l.code)).size !== i.lines.length ||
|
||
!i.topics ||
|
||
typeof i.topics !== 'object' ||
|
||
Array.isArray(i.topics)
|
||
)
|
||
throw Error('Conferência do lucro tributável não recuperável.');
|
||
for (const [k] of profitTopics) {
|
||
const t = i.topics[k];
|
||
if (
|
||
!t ||
|
||
!['', 'known', 'absent'].includes(t.state) ||
|
||
typeof t.amount !== 'string' ||
|
||
typeof t.reference !== 'string'
|
||
)
|
||
throw Error(
|
||
'Estado documental do lucro tributável não recuperável.',
|
||
);
|
||
}
|
||
}
|
||
type Source = {
|
||
currency: string;
|
||
periods: number;
|
||
accountingLines: Array<{
|
||
code: string;
|
||
origin: string;
|
||
accountingEffect: 'income' | 'expense';
|
||
total: string;
|
||
}>;
|
||
};
|
||
export type NormalProfitModel = {
|
||
input: NormalProfitInput;
|
||
lines: Array<{
|
||
code: string;
|
||
label: string;
|
||
amount: string;
|
||
addedBack: FiscalFraction;
|
||
remainder: FiscalFraction | null;
|
||
state: ProfitEvidence['state'];
|
||
reference: string;
|
||
reason: string;
|
||
}>;
|
||
topics: Array<
|
||
ProfitEvidence & {
|
||
key: ProfitTopic;
|
||
label: string;
|
||
value: FiscalFraction | null;
|
||
reason: string;
|
||
}
|
||
>;
|
||
pending: string[];
|
||
taxableProfit: FiscalFraction | null;
|
||
source: string;
|
||
};
|
||
const zero = () => fraction(0n, 1n);
|
||
function money(s: string, exact = false) {
|
||
if (exact) {
|
||
const v = creditLimit(s.startsWith('-') ? s.slice(1) : s);
|
||
return s.startsWith('-')
|
||
? fraction(-BigInt(v.numerator), BigInt(v.denominator))
|
||
: v;
|
||
}
|
||
if (!/^-?\d+(?:\.\d{1,2})?$/.test(s)) throw Error('Montante inválido');
|
||
const v = decimalFraction(s.startsWith('-') ? s.slice(1) : s);
|
||
return s.startsWith('-')
|
||
? fraction(-BigInt(v.numerator), BigInt(v.denominator))
|
||
: v;
|
||
}
|
||
const units = (f: FiscalFraction) =>
|
||
(BigInt(f.numerator) * 100n) / BigInt(f.denominator);
|
||
const difference = (a: FiscalFraction, b: FiscalFraction) =>
|
||
fraction(
|
||
BigInt(a.numerator) * BigInt(b.denominator) -
|
||
BigInt(b.numerator) * BigInt(a.denominator),
|
||
BigInt(a.denominator) * BigInt(b.denominator),
|
||
);
|
||
export function closeNormalProfit(
|
||
input: NormalProfitInput,
|
||
source: Source | null,
|
||
rows: Array<{
|
||
sourceCode: string;
|
||
adjustment: FiscalFraction | null;
|
||
status: string;
|
||
}>,
|
||
accounting: FiscalFraction | null,
|
||
additions: FiscalFraction,
|
||
contextReady: boolean,
|
||
schedules?: SchedulesModel,
|
||
costs?: NormalCostsModel,
|
||
depreciation?: NormalDepreciationModel,
|
||
economic?: {
|
||
effects: {
|
||
id: string;
|
||
topic: string;
|
||
amount: FiscalFraction | null;
|
||
effect: FiscalFraction | null;
|
||
reason: string;
|
||
}[];
|
||
net: FiscalFraction;
|
||
pending: string[];
|
||
},
|
||
): NormalProfitModel {
|
||
validateNormalProfit(input);
|
||
const pending: string[] = [],
|
||
lines: NormalProfitModel['lines'] = [],
|
||
topics: NormalProfitModel['topics'] = [];
|
||
if (!contextReady)
|
||
pending.push(
|
||
'Âmbito,exercício,moeda ou correspondência anual ainda insuficientes.',
|
||
);
|
||
if (!input.completenessReference.trim())
|
||
pending.push(
|
||
'Documente que o plano e os ajustamentos abrangem o sujeito passivo/exercício completo;uma oportunidade isolada não equivale automaticamente ao conjunto da empresa.',
|
||
);
|
||
if (rows.some((r) => r.status !== 'calculated'))
|
||
pending.push(
|
||
'Existem ajustamentos registados por resolver;acréscimos independentes conservados.',
|
||
);
|
||
if (!source) pending.push('Falta resultado financeiro reconciliado.');
|
||
for (const l of source?.accountingLines ?? []) {
|
||
const review = input.lines.find((r) => r.code === l.code),
|
||
state = review?.state ?? '',
|
||
reference = review?.reference ?? '';
|
||
const addedBack = addFractions(
|
||
rows
|
||
.filter((r) => r.sourceCode === l.code && r.adjustment)
|
||
.map((r) => r.adjustment!),
|
||
);
|
||
let remainder: FiscalFraction | null = null;
|
||
const issues: string[] = [];
|
||
try {
|
||
const amount = money(l.total);
|
||
remainder = difference(amount, addedBack);
|
||
if (units(amount) < 0n || units(remainder) < 0n)
|
||
issues.push(
|
||
'Valor negativo ou acréscimos superiores à origem;reconcilie a linha sem compensação presumida.',
|
||
);
|
||
if (!state || !reference.trim())
|
||
issues.push('Conferência fiscal e suporte da linha em falta.');
|
||
if (
|
||
state === 'absent' &&
|
||
(units(amount) !== 0n || units(addedBack) !== 0n)
|
||
)
|
||
issues.push(
|
||
'Ausência declarada contradiz o montante conhecido da origem.',
|
||
);
|
||
if (
|
||
state === 'known' &&
|
||
l.code === 'depreciation' &&
|
||
units(amount) !== 0n &&
|
||
(depreciation
|
||
? depreciation.adjustment
|
||
: schedules?.depreciation) == null
|
||
)
|
||
issues.push(
|
||
'Depreciação contabilística conhecida;amortização fiscal por corresponder,não presumir igualdade.',
|
||
);
|
||
if (
|
||
state === 'known' &&
|
||
l.code === 'interest_expense' &&
|
||
units(remainder) !== 0n &&
|
||
schedules?.interest == null
|
||
)
|
||
issues.push(
|
||
'Juros financeiros remanescentes conhecidos;dedutibilidade e eventuais limites por corresponder. Só os juros compensatórios já acrescidos estão cobertos.',
|
||
);
|
||
if (
|
||
![
|
||
'revenue',
|
||
'cost_of_sales',
|
||
'operating_expenses',
|
||
'personnel',
|
||
'depreciation',
|
||
'interest_expense',
|
||
].includes(l.code)
|
||
)
|
||
issues.push(
|
||
'Linha financeira sem correspondência neste recorte.',
|
||
);
|
||
} catch {
|
||
issues.push('Montante da linha por reconciliar.');
|
||
}
|
||
const reason =
|
||
issues.join(' ') ||
|
||
(state === 'absent'
|
||
? 'Ausência expressamente declarada e compatível com a origem zero.'
|
||
: units(remainder ?? zero()) === 0n
|
||
? 'Valor conhecido,integralmente corrigido ou igual a zero;sem presumir ausência.'
|
||
: l.accountingEffect === 'income'
|
||
? 'Proveito ordinário reconhecido no exercício,qualificado e documentado nos termos dos artigos18/20.'
|
||
: 'Parcela remanescente qualificada como custo ordinário indispensável e documentado do artigo22,sem exclusões/limites adicionais declarados.');
|
||
if (issues.length) pending.push(l.origin + ': ' + reason);
|
||
lines.push({
|
||
code: l.code,
|
||
label: l.origin,
|
||
amount: l.total,
|
||
addedBack,
|
||
remainder,
|
||
state,
|
||
reference,
|
||
reason,
|
||
});
|
||
}
|
||
for (const l of input.lines)
|
||
if (!source?.accountingLines.some((s) => s.code === l.code))
|
||
pending.push('Conferência sem linha de origem actual: ' + l.code);
|
||
for (const [key, label] of profitTopics) {
|
||
const t = input.topics[key];
|
||
let value: FiscalFraction | null = null,
|
||
reason = '';
|
||
if (!t.state || !t.reference.trim())
|
||
reason = 'Informação em falta;sem zero presumido.';
|
||
else if (t.state === 'absent') {
|
||
if (t.amount.trim())
|
||
reason =
|
||
'Ausência declarada com montante preenchido;reconcilie os dois estados.';
|
||
else {
|
||
value = zero();
|
||
reason =
|
||
'Ausência expressamente declarada com suporte;parcela zero apenas por esta declaração.';
|
||
}
|
||
} else {
|
||
try {
|
||
value = money(
|
||
t.amount,
|
||
key === 'incomeCorrections' && !!economic,
|
||
);
|
||
if (BigInt(value.numerator) !== 0n)
|
||
reason =
|
||
'Montante conhecido,mas tratamento específico ainda por corresponder;conservado sem aplicação presumida.';
|
||
else
|
||
reason =
|
||
'Valor conhecido zero com suporte;distinto de ausência declarada.';
|
||
} catch {
|
||
reason = 'Montante conhecido por preencher ou inválido.';
|
||
}
|
||
}
|
||
const mapped = [
|
||
...(schedules?.effects ?? []),
|
||
...(costs?.effects ?? []),
|
||
...(economic?.effects ?? []),
|
||
].filter((e) => e.topic === key);
|
||
const mappedAmount = addFractions(
|
||
mapped.flatMap((e) => (e.amount ? [e.amount] : [])),
|
||
);
|
||
const covered =
|
||
(schedules || costs || economic) &&
|
||
mapped.length > 0 &&
|
||
mapped.every((e) => e.effect !== null) &&
|
||
value !== null &&
|
||
t.state === 'known' &&
|
||
(economic && key === 'incomeCorrections'
|
||
? creditCompare(mappedAmount, value) === 0n
|
||
: units(mappedAmount) === units(value));
|
||
if (covered)
|
||
reason =
|
||
'Montante reconciliado com as regras do mapa complementar; efeito fiscal discriminado por linha.';
|
||
if (
|
||
value === null ||
|
||
(BigInt(value.numerator) !== 0n && !covered) ||
|
||
(t.state === 'absent' && !!t.amount.trim()) ||
|
||
(mapped.length > 0 && !covered)
|
||
)
|
||
pending.push(
|
||
label +
|
||
': ' +
|
||
(mapped.length && !covered
|
||
? 'Mapa complementar não reconciliado com este estado/montante. '
|
||
: '') +
|
||
reason,
|
||
);
|
||
topics.push({ ...structuredClone(t), key, label, value, reason });
|
||
}
|
||
if (schedules) pending.push(...schedules.pending);
|
||
if (costs) pending.push(...costs.pending);
|
||
if (economic) pending.push(...economic.pending);
|
||
if (depreciation && !schedules) pending.push(...depreciation.pending);
|
||
return {
|
||
input: structuredClone(input),
|
||
lines,
|
||
topics,
|
||
pending,
|
||
taxableProfit:
|
||
pending.length === 0 && accounting
|
||
? addFractions([
|
||
accounting,
|
||
additions,
|
||
...(economic ? [economic.net] : []),
|
||
...(schedules ? [schedules.net] : []),
|
||
...(costs ? [costs.net] : []),
|
||
...(depreciation && !schedules && depreciation.adjustment
|
||
? [depreciation.adjustment]
|
||
: []),
|
||
])
|
||
: null,
|
||
source: schedules
|
||
? 'CIRPC17/18/20/21/22/24/36; Decreto72/2013 art3–7. Mapa complementar de efeitos com sinal reconciliado por tema, juros financeiros ordinários e quotas anuais constantes documentadas. Simulação emMZN/2026; benefícios e casos especiais separados emL7, saldo anual emL8.'
|
||
: 'CIRPC17(1)/18/20/22/36(1)c;recorte anual2026,residente geral,MZN. Resultado antes de imposto + acréscimos36(1)c;demais correcções/variações sem efeito apenas quando ausência ou zero conhecidos expressamente suportados. Receita/custos ordinários classificados pelo utilizador;depreciação fiscal e juros financeiros remanescentes fora deste percurso. Lucro tributável simulado,não matéria colectável,colecta ou declaraçãoM20/22.',
|
||
};
|
||
}
|