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 = { 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(); 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(); 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) ); }