Files
Investidor-V2/resources/js/lib/irpc-interest.ts
T

305 lines
11 KiB
TypeScript

import type { IrpcBook } from './irpc-withholdings.ts';
import {
addFractions,
decimalFraction,
fraction,
subtractFractions,
type FiscalFraction,
} from './fiscal-rational.ts';
export type IrpcInterestRule = {
annualRate: string;
rateSource: string;
fiscalBasis: string;
validFrom: string;
validTo: string;
dayBasis: '' | '365' | '366' | 'actual_year';
dayBasisRef: string;
rounding: '' | 'total_cent_half_up' | 'total_cent_down';
roundingRef: string;
retention: '' | 'withheld' | 'not_withheld';
occurrenceRef: string;
};
export const newIrpcInterestRule = (): IrpcInterestRule => ({
annualRate: '',
rateSource: '',
fiscalBasis: '',
validFrom: '',
validTo: '',
dayBasis: '',
dayBasisRef: '',
rounding: '',
roundingRef: '',
retention: '',
occurrenceRef: '',
});
export const irpcInterestVersion = 'IRPC-interest/1';
export type IrpcInterestSegment = {
from: string;
to: string;
days: number;
denominator: number;
principal: FiscalFraction;
interest: FiscalFraction;
};
export type IrpcInterestCalculation = {
version: string;
rows: Array<{
id: string;
document: string;
segments: IrpcInterestSegment[];
exact: FiscalFraction;
}>;
pending: string[];
exact: FiscalFraction | null;
total: FiscalFraction | null;
rule: IrpcInterestRule;
dueDate: string;
liquidationDate: string;
source: string;
};
const validDate = (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 next = (s: string) =>
new Date(Date.parse(s) + 86400000).toISOString().slice(0, 10);
const less = (a: FiscalFraction, b: FiscalFraction) =>
BigInt(a.numerator) * BigInt(b.denominator) <
BigInt(b.numerator) * BigInt(a.denominator);
const zero = () => fraction(0n, 1n);
export function validateIrpcInterestRule(r: IrpcInterestRule) {
if (
!r ||
Object.keys(newIrpcInterestRule()).some(
(k) => typeof r[k as keyof IrpcInterestRule] !== 'string',
) ||
!['', '365', '366', 'actual_year'].includes(r.dayBasis) ||
!['', 'total_cent_half_up', 'total_cent_down'].includes(r.rounding) ||
!['', 'withheld', 'not_withheld'].includes(r.retention)
)
throw Error(
'Parâmetros de juros não recuperáveis;originais conservados.',
);
}
// No fiscal rate, annual day basis or rounding convention is supplied by default.
// Complete references describe the parameters of this case, not a global 2026 rate.
export function calculateIrpcInterest(
b: IrpcBook,
current: boolean,
period: string,
dueDate: string,
dueReference: string,
liquidationDate: string,
r: IrpcInterestRule,
): IrpcInterestCalculation {
validateIrpcInterestRule(r);
const pending: string[] = [],
rows: IrpcInterestCalculation['rows'] = [];
const result = (
exact: FiscalFraction | null = null,
total: FiscalFraction | null = null,
): IrpcInterestCalculation => ({
version: irpcInterestVersion,
rows,
pending,
exact,
total,
rule: structuredClone(r),
dueDate,
liquidationDate,
source: 'Decreto9/2008 art35(1/2):dia imediato ao prazo até pagamento/liquidação;25(1):parâmetro na data da liquidação. Taxa,base anual e arredondamento apenas conforme referências do caso. Sem parâmetro fiscal2026 pré-definido,sem MIMO/Prime presumidos. Mora e retenção por conta não efectuada conservam regra própria.',
});
if (!current || !b.result) pending.push('Retenções sem cálculo actual.');
if (
!/^2026-(0[1-9]|1[0-2])$/.test(period) ||
!validDate(dueDate) ||
!dueReference.trim() ||
!validDate(liquidationDate) ||
liquidationDate <= dueDate ||
dueDate < period + '-01'
)
pending.push(
'Identifique período,prazo documentado e data de liquidação posterior ao prazo;sem prazo/calendário presumidos.',
);
if (
!validDate(r.validFrom) ||
!validDate(r.validTo) ||
r.validFrom > r.validTo ||
liquidationDate < r.validFrom ||
liquidationDate > r.validTo
)
pending.push(
'Parâmetro fora da versão/período aplicável à data de liquidação.',
);
const labels: Record<string, string> = {
rateSource: 'fonte da taxa anual final',
fiscalBasis: 'fundamento fiscal aplicável',
dayBasisRef: 'fonte da base anual',
roundingRef: 'fonte do arredondamento',
occurrenceRef: 'documento da retenção/ocorrência',
};
for (const [k, label] of Object.entries(labels))
if (!r[k as keyof IrpcInterestRule].trim())
pending.push('Falta ' + label + '.');
if (!r.dayBasis || !r.rounding || !r.retention)
pending.push(
'Regra de base anual,arredondamento e situação da retenção por identificar.',
);
let rate: FiscalFraction;
try {
rate = decimalFraction(r.annualRate);
if (!less(zero(), rate)) throw Error();
} catch {
pending.push(
'Taxa anual final aplicável por identificar;sem zero ou taxa bancária de substituição.',
);
return result();
}
if (pending.length) return result();
const selected = b.result!.rows.filter(
(v) =>
v.direction === 'paid' &&
v.period === period &&
v.treatment !== 'dispensed',
);
if (!selected.length) {
pending.push(
'Sem retenções a entregar seleccionadas para este período.',
);
return result();
}
for (const v of selected) {
const input = b.result!.inputs.find((i) => i.id === v.id);
if (
!input ||
v.status !== 'calculated' ||
!v.tax ||
less(v.tax, zero()) ||
v.settled === null ||
!['none', 'complete'].includes(input.settlement) ||
!input.settlementRef.trim() ||
!v.nature
) {
pending.push(v.id + ': retenção/entregas não reconciliadas.');
continue;
}
if (v.nature === 'on_account' && r.retention !== 'withheld') {
pending.push(
input.documentRef +
': imposto por conta não retido exige o termo da declaração anual e responsabilidade correspondentes;fora deste cálculo.',
);
continue;
}
let payments: Array<{ date: string; amount: FiscalFraction }> = [];
try {
const seen = new Set<string>();
payments = input.allocations.map((a) => {
const p = b.result!.payments.find((p) => p.id === a.paymentId);
if (
!p ||
seen.has(p.id) ||
p.kind !== 'remittance' ||
p.currency !== 'MZN' ||
!validDate(p.date) ||
p.date < v.eventDate ||
!p.reference.trim() ||
p.date > liquidationDate
)
throw Error();
seen.add(p.id);
const amount = decimalFraction(a.amount);
if (!less(zero(), amount)) throw Error();
return { date: p.date, amount };
});
const allocated = addFractions(payments.map((p) => p.amount));
if (less(v.tax, allocated) || !isEqual(allocated, v.settled))
throw Error();
} catch {
pending.push(
input.documentRef +
': comprovativos incoerentes ou posteriores à data de liquidação;conserve e reconcilie.',
);
continue;
}
let principal = subtractFractions(
v.tax,
addFractions(
payments
.filter((p) => p.date <= dueDate)
.map((p) => p.amount),
),
),
cursor = next(dueDate);
const segments: IrpcInterestSegment[] = [];
const charge = (to: string) => {
if (cursor > to || !less(zero(), principal)) return;
while (cursor <= to) {
const year = Number(cursor.slice(0, 4)),
yearEnd = year + '-12-31',
end =
r.dayBasis === 'actual_year' && yearEnd < to
? yearEnd
: to;
const denominator =
r.dayBasis === 'actual_year'
? year % 4 === 0 &&
(year % 100 !== 0 || year % 400 === 0)
? 366
: 365
: Number(r.dayBasis);
const days =
(Date.parse(end) - Date.parse(cursor)) / 86400000 + 1;
const interest = fraction(
BigInt(principal.numerator) *
BigInt(rate.numerator) *
BigInt(days),
BigInt(principal.denominator) *
BigInt(rate.denominator) *
100n *
BigInt(denominator),
);
segments.push({
from: cursor,
to: end,
days,
denominator,
principal,
interest,
});
cursor = next(end);
}
};
const grouped = new Map<string, FiscalFraction[]>();
for (const p of payments.filter((p) => p.date > dueDate))
grouped.set(p.date, [...(grouped.get(p.date) ?? []), p.amount]);
for (const [day, parts] of [...grouped].sort(([a], [c]) =>
a.localeCompare(c),
)) {
charge(day);
principal = subtractFractions(principal, addFractions(parts));
cursor = next(day);
}
charge(liquidationDate);
rows.push({
id: v.id,
document: input.documentRef,
segments,
exact: addFractions(segments.map((s) => s.interest)),
});
}
if (pending.length) return result();
const exact = addFractions(rows.map((r) => r.exact)),
scaled = BigInt(exact.numerator) * 100n,
d = BigInt(exact.denominator);
let cents = scaled / d;
if (r.rounding === 'total_cent_half_up' && (scaled % d) * 2n >= d) cents++;
return result(exact, fraction(cents, 100n));
}
function isEqual(a: FiscalFraction, b: FiscalFraction) {
return (
BigInt(a.numerator) * BigInt(b.denominator) ===
BigInt(b.numerator) * BigInt(a.denominator)
);
}