305 lines
11 KiB
TypeScript
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)
|
|
);
|
|
}
|