330 lines
13 KiB
TypeScript
330 lines
13 KiB
TypeScript
import {
|
||
addFractions,
|
||
fraction,
|
||
type FiscalFraction,
|
||
} from './fiscal-rational.ts';
|
||
import {
|
||
convertIrpcAmount,
|
||
irpcFxDependency,
|
||
type IrpcFxAmount,
|
||
type IrpcFxEvidence,
|
||
irpcFxEvidenceLabel,
|
||
} from './irpc-fx.ts';
|
||
import type { rawNormalFinancialSource } from './irpc-normal.ts';
|
||
export const normalPeriodFxVersion = 'IRPC-normal-adjustments/8';
|
||
export type NormalPeriodFxInput = {
|
||
version: 1;
|
||
kind: '' | 'annual' | 'start' | 'cessation' | 'same_year' | 'transition';
|
||
from: string;
|
||
to: string;
|
||
planStart: string;
|
||
reference: string;
|
||
conversionReference: string;
|
||
mznMapsReference: string;
|
||
months: Array<IrpcFxAmount & { period: number }>;
|
||
};
|
||
export const newNormalPeriodFx = (): NormalPeriodFxInput => ({
|
||
version: 1,
|
||
kind: '',
|
||
from: '',
|
||
to: '',
|
||
planStart: '',
|
||
reference: '',
|
||
conversionReference: '',
|
||
mznMapsReference: '',
|
||
months: [],
|
||
});
|
||
export function validateNormalPeriodFx(i: NormalPeriodFxInput) {
|
||
if (
|
||
!i ||
|
||
i.version !== 1 ||
|
||
![
|
||
'',
|
||
'annual',
|
||
'start',
|
||
'cessation',
|
||
'same_year',
|
||
'transition',
|
||
].includes(i.kind) ||
|
||
[
|
||
'from',
|
||
'to',
|
||
'planStart',
|
||
'reference',
|
||
'conversionReference',
|
||
'mznMapsReference',
|
||
].some((k) => typeof i[k as 'from'] !== 'string') ||
|
||
!Array.isArray(i.months) ||
|
||
new Set(i.months.map((m) => m.period)).size !== i.months.length ||
|
||
i.months.some(
|
||
(m) =>
|
||
!Number.isSafeInteger(m.period) ||
|
||
m.period < 1 ||
|
||
typeof m.currency !== 'string' ||
|
||
(m.fxSelection !== undefined &&
|
||
(!m.fxSelection || typeof m.fxSelection !== 'object')) ||
|
||
(m.fxLink !== undefined &&
|
||
(!m.fxLink ||
|
||
typeof m.fxLink.id !== 'string' ||
|
||
typeof m.fxLink.fingerprint !== 'string')),
|
||
)
|
||
)
|
||
throw Error(
|
||
'Correspondência do período/moeda não recuperável;originais preservados.',
|
||
);
|
||
}
|
||
const iso = (s: string) =>
|
||
/^20\d{2}-\d{2}-\d{2}$/.test(s) &&
|
||
Number.isFinite(Date.parse(s)) &&
|
||
new Date(s).toISOString().slice(0, 10) === s;
|
||
const month = (s: string) =>
|
||
Number(s.slice(0, 4)) * 12 + Number(s.slice(5, 7)) - 1;
|
||
const monthEnd = (s: string) => {
|
||
const d = new Date(s + 'T00:00:00Z');
|
||
d.setUTCMonth(d.getUTCMonth() + 1, 0);
|
||
return d.toISOString().slice(0, 10);
|
||
};
|
||
const amount = (v: FiscalFraction) => {
|
||
const n = BigInt(v.numerator) * 100n,
|
||
d = BigInt(v.denominator);
|
||
if (n % d !== 0n)
|
||
throw Error(
|
||
'Conversão com fracção de cêntimo: representação fiscal por corresponder,sem arredondamento novo.',
|
||
);
|
||
const c = n / d,
|
||
a = c < 0n ? -c : c;
|
||
return (
|
||
(c < 0n ? '-' : '') + a / 100n + '.' + String(a % 100n).padStart(2, '0')
|
||
);
|
||
};
|
||
type Source = NonNullable<ReturnType<typeof rawNormalFinancialSource>>;
|
||
export function mapNormalPeriodFx(
|
||
d: Record<string, unknown>,
|
||
input: NormalPeriodFxInput,
|
||
raw: Source | null,
|
||
) {
|
||
validateNormalPeriodFx(input);
|
||
const pending: string[] = [],
|
||
short = input.kind !== '' && input.kind !== 'annual';
|
||
let count = 0;
|
||
if (!input.kind || !input.reference.trim())
|
||
pending.push(
|
||
'Tipo,evento e suporte da correspondência do período por identificar.',
|
||
);
|
||
if (
|
||
!iso(input.from) ||
|
||
!iso(input.to) ||
|
||
input.from > input.to ||
|
||
!input.from.endsWith('-01') ||
|
||
monthEnd(input.to.slice(0, 7) + '-01') !== input.to ||
|
||
!input.to.startsWith('2026-')
|
||
)
|
||
pending.push(
|
||
'Recorte:meses completos,termo em2026;datas parciais ou inválidas permanecem por corresponder.',
|
||
);
|
||
else {
|
||
count = month(input.to) - month(input.from) + 1;
|
||
if (
|
||
count < 1 ||
|
||
count > 12 ||
|
||
(short && count >= 12) ||
|
||
(!short && count !== 12)
|
||
)
|
||
pending.push('Número de meses incompatível com o tipo de período.');
|
||
}
|
||
if (
|
||
short &&
|
||
(!input.from.startsWith('2026-') ||
|
||
(input.kind === 'start' && input.to !== '2026-12-31') ||
|
||
(['cessation', 'transition'].includes(input.kind) &&
|
||
input.from !== '2026-01-01'))
|
||
)
|
||
pending.push(
|
||
'Datas incompatíveis com início,cessação ou transição no ano civil2026.',
|
||
);
|
||
if (!iso(input.planStart) || !input.planStart.endsWith('-01'))
|
||
pending.push(
|
||
'Identifique o primeiro mês civil do plano;meses orçamentais não têm data fiscal presumida.',
|
||
);
|
||
if (!input.mznMapsReference.trim())
|
||
pending.push(
|
||
'Confirme que parcelas,activos,quotas,saldos e mapas fiscais existentes estão reconciliados emMZN com este período;sem converter novamente esses dados.',
|
||
);
|
||
if (!raw) pending.push('Plano financeiro reconciliado em falta.');
|
||
const selected =
|
||
raw?.accountingLines[0]?.monthlyAmounts.filter(
|
||
(m) =>
|
||
iso(input.planStart) &&
|
||
month(input.planStart) + m.period - 1 >= month(input.from) &&
|
||
month(input.planStart) + m.period - 1 <= month(input.to),
|
||
) ?? [];
|
||
if (selected.length !== count || !count)
|
||
pending.push(
|
||
'Meses do plano não cobrem integralmente o período indicado;sem anualização ou preenchimento de meses em falta.',
|
||
);
|
||
if (raw?.currency !== 'MZN' && !input.conversionReference.trim())
|
||
pending.push(
|
||
'Identifique pressuposto documentado da conversão mensal para a simulação;selecção cambial não confirma,por si,a regra fiscal de conversão anual.',
|
||
);
|
||
for (const m of input.months)
|
||
if (!selected.some((x) => x.period === m.period))
|
||
pending.push(
|
||
'Selecção cambial sem mês seleccionado no período: ' + m.period,
|
||
);
|
||
const rows: Array<{
|
||
code: string;
|
||
period: number;
|
||
date: string;
|
||
originalAmount: string;
|
||
originalCurrency: string;
|
||
value: FiscalFraction | null;
|
||
evidence: IrpcFxEvidence | null;
|
||
reason: string;
|
||
}> = [];
|
||
const lines =
|
||
raw?.accountingLines.map((l) => {
|
||
const monthlyAmounts = l.monthlyAmounts
|
||
.filter((m) => selected.some((x) => x.period === m.period))
|
||
.map((m) => {
|
||
let value: FiscalFraction | null = null,
|
||
evidence: IrpcFxEvidence | null = null,
|
||
reason = '';
|
||
const day = iso(input.planStart)
|
||
? new Date(
|
||
Date.UTC(
|
||
Number(input.planStart.slice(0, 4)),
|
||
Number(input.planStart.slice(5, 7)) -
|
||
1 +
|
||
m.period -
|
||
1,
|
||
1,
|
||
),
|
||
)
|
||
.toISOString()
|
||
.slice(0, 10)
|
||
: '';
|
||
try {
|
||
const sign = m.amount.startsWith('-') ? -1n : 1n,
|
||
original = sign < 0n ? m.amount.slice(1) : m.amount;
|
||
const selection = input.months.find(
|
||
(x) => x.period === m.period,
|
||
);
|
||
if (
|
||
raw.currency !== 'MZN' &&
|
||
(!selection || selection.currency !== raw.currency)
|
||
)
|
||
throw Error(
|
||
'Selecção cambial deste mês/moeda em falta;sem usar taxa de outro mês por defeito.',
|
||
);
|
||
const converted = convertIrpcAmount(
|
||
d,
|
||
original,
|
||
selection ?? { currency: 'MZN' },
|
||
);
|
||
value = fraction(
|
||
BigInt(converted.value.numerator) * sign,
|
||
BigInt(converted.value.denominator),
|
||
);
|
||
evidence = converted.evidence;
|
||
amount(value);
|
||
} catch (e) {
|
||
value = null;
|
||
reason = String(e);
|
||
pending.push(
|
||
l.code + '/mês' + m.period + ': ' + reason,
|
||
);
|
||
}
|
||
rows.push({
|
||
code: l.code,
|
||
period: m.period,
|
||
date: day,
|
||
originalAmount: m.amount,
|
||
originalCurrency: raw.currency,
|
||
value,
|
||
evidence,
|
||
reason,
|
||
});
|
||
return {
|
||
period: m.period,
|
||
amount: value ? amount(value) : '0.00',
|
||
};
|
||
});
|
||
const total = addFractions(
|
||
rows
|
||
.filter((r) => r.code === l.code && r.value)
|
||
.map((r) => r.value!),
|
||
);
|
||
return { ...l, monthlyAmounts, total: amount(total) };
|
||
}) ?? [];
|
||
const ready = pending.length === 0;
|
||
const profit = addFractions(
|
||
lines.map((l) => {
|
||
const f = rows
|
||
.filter((r) => r.code === l.code && r.value)
|
||
.map((r) => r.value!);
|
||
const sum = addFractions(f);
|
||
return l.accountingEffect === 'income'
|
||
? sum
|
||
: fraction(-BigInt(sum.numerator), BigInt(sum.denominator));
|
||
}),
|
||
);
|
||
return {
|
||
input: structuredClone(input),
|
||
short,
|
||
months: count,
|
||
originalCurrency: raw?.currency ?? '',
|
||
rows,
|
||
pending,
|
||
source:
|
||
ready && raw
|
||
? {
|
||
...raw,
|
||
currency: 'MZN',
|
||
cashSchedule:
|
||
raw.currency === 'MZN'
|
||
? raw.cashSchedule.filter((r) =>
|
||
selected.some((m) => m.period === r.period),
|
||
)
|
||
: [],
|
||
periods: count,
|
||
accountingLines: lines,
|
||
accountingProfitBeforeTax: amount(profit),
|
||
}
|
||
: null,
|
||
rule: 'CIRPC7(4)a–d;Decreto72/2013 art9(3)a–c para os casos correspondidos. Meses completos do plano,sem anualização. Conversão para simulação reutiliza mecanismo cambial entregue e pressupostos explícitos;sem confirmação fiscal anual presumida.',
|
||
};
|
||
}
|
||
export type NormalPeriodFxModel = ReturnType<typeof mapNormalPeriodFx>;
|
||
export function normalPeriodFxDependency(
|
||
d: Record<string, unknown>,
|
||
i: NormalPeriodFxInput,
|
||
) {
|
||
return i.months
|
||
.map((m) => irpcFxDependency(d, m))
|
||
.filter(Boolean)
|
||
.join(' | ');
|
||
}
|
||
export function normalPeriodFxReport(
|
||
m: NormalPeriodFxModel,
|
||
): Array<[string, string, string, string]> {
|
||
return [
|
||
[
|
||
'IRPC normal — L6',
|
||
'Período e moeda do plano',
|
||
`${m.input.from}–${m.input.to};${m.months}meses;origem:${m.originalCurrency};resultado emMZN:${m.source?.accountingProfitBeforeTax ?? 'Indeterminado'};${m.originalCurrency !== 'MZN' ? 'Conversão para simulação com pressupostos documentados;regra fiscal anual não confirmada automaticamente.' : ''}${m.pending.join(' | ')}`,
|
||
`${m.input.reference};${m.input.conversionReference};${m.input.mznMapsReference};${m.rule}`,
|
||
],
|
||
...m.rows.map(
|
||
(r) =>
|
||
[
|
||
'IRPC normal — L6',
|
||
`${({ revenue: 'Receitas', cost_of_sales: 'Custo das vendas', operating_expenses: 'Despesas operacionais', personnel: 'Pessoal', depreciation: 'Depreciação contabilística', interest_expense: 'Juros reconhecidos' } as Record<string, string>)[r.code] ?? r.code}/mês${r.period} (${r.date})`,
|
||
`Original:${r.originalAmount} ${r.originalCurrency};MZN:${r.value ? amount(r.value) : 'Indeterminado'};${r.reason}`,
|
||
r.evidence
|
||
? irpcFxEvidenceLabel(r.evidence)
|
||
: 'OrigemMZN;sem conversão',
|
||
] as [string, string, string, string],
|
||
),
|
||
];
|
||
}
|