import { addFractions, decimalFraction, fraction, subtractFractions, type FiscalFraction, } from './fiscal-rational.ts'; export const normalSchedulesVersion = 'IRPC-normal-adjustments/5'; export const scheduleRules = { tax_own: [ 'IRPC e impostos sobre o lucro contabilizados', 'expenseCorrections', '36(1)a', 1, ], tax_other: [ 'Impostos da responsabilidade de outrem', 'expenseCorrections', '36(1)b', 1, ], undocumented: [ 'Despesa não documentada', 'expenseCorrections', '36(1)g', 1, ], capital_in: [ 'Entrada de capital fora do resultado', 'positivePatrimonial', '21(1)a', 0, ], patrimonial_gain: [ 'Variação positiva ordinária fora do resultado', 'positivePatrimonial', '21', 1, ], capital_out: [ 'Distribuição aos titulares fora do resultado', 'negativePatrimonial', '24(1)c', 0, ], patrimonial_cost: [ 'Variação negativa indispensável, sem exclusões do artigo24', 'negativePatrimonial', '22/24(1)', -1, ], future_cost: [ 'Custo contabilizado em2026 imputável ao exercício seguinte', 'timing', '18(1)', 1, ], future_income: [ 'Proveito contabilizado em2026 imputável ao exercício seguinte', 'timing', '18(1)', -1, ], current_cost: [ 'Custo de2026 ainda não reflectido no resultado', 'timing', '18(1)', -1, ], current_income: [ 'Proveito de2026 ainda não reflectido no resultado', 'timing', '18(1)', 1, ], } as const; export type ScheduleRule = keyof typeof scheduleRules; export type ScheduleRow = { id: string; rule: '' | ScheduleRule; amount: string; sourceCode: string; document: string; line: string; reference: string; fiscalYear: string; conditions: '' | 'yes'; }; export type NormalAsset = { id: string; category: '' | 'computer' | 'server' | 'other'; description: string; cost: string; residual: string; rate: string; accounting: string; previousFiscal: string; inUseFrom: string; tableReference: string; reference: string; conditions: '' | 'yes'; }; export type NormalSchedules = { version: 1; period: { kind: '' | 'calendar' | 'authorised_annual' | 'ep_annual'; from: string; to: string; reference: string; }; rows: ScheduleRow[]; interest: { amount: string; reference: string; conditions: '' | 'yes' }; assets: NormalAsset[]; assetsReference: string; currentLossReference: string; }; export const newNormalSchedules = (): NormalSchedules => ({ version: 1, period: { kind: '', from: '', to: '', reference: '' }, rows: [], interest: { amount: '', reference: '', conditions: '' }, assets: [], assetsReference: '', currentLossReference: '', }); export const newScheduleRow = (id: string): ScheduleRow => ({ id, rule: '', amount: '', sourceCode: '', document: '', line: '', reference: '', fiscalYear: '', conditions: '', }); export const newNormalAsset = (id: string): NormalAsset => ({ id, category: '', description: '', cost: '', residual: '', rate: '', accounting: '', previousFiscal: '', inUseFrom: '', tableReference: '', reference: '', conditions: '', }); export function validateSchedules(s: NormalSchedules) { if ( !s || s.version !== 1 || !Array.isArray(s.rows) || !Array.isArray(s.assets) || !s.interest || typeof s.assetsReference !== 'string' || typeof s.currentLossReference !== 'string' || ['amount', 'reference', 'conditions'].some( (k) => typeof s.interest[k as 'amount'] !== 'string', ) || !['', 'yes'].includes(s.interest.conditions) ) throw Error('Mapas L6 não recuperáveis.'); for (const [rows, empty] of [ [s.rows, newScheduleRow('')], [s.assets, newNormalAsset('')], ] as const) { if (new Set(rows.map((r) => r.id)).size !== rows.length) throw Error('Identificador duplicado no mapa L6.'); for (const r of rows) if ( !r || Object.keys(empty).some( (k) => typeof (r as unknown as Record)[k] !== 'string', ) || !['', 'yes'].includes(r.conditions) ) throw Error('Linha L6 não recuperável.'); } if ( s.rows.some( (r) => r.rule !== '' && !Object.hasOwn(scheduleRules, r.rule), ) ) throw Error('Regra L6 desconhecida.'); if ( s.assets.some( (a) => !['', 'computer', 'server', 'other'].includes(a.category), ) ) throw Error('Categoria de activo desconhecida.'); if ( !s.period || !['', 'calendar', 'authorised_annual', 'ep_annual'].includes( s.period.kind, ) || ['from', 'to', 'reference'].some( (k) => typeof s.period[k as 'from'] !== 'string', ) ) throw Error('Período complementar não recuperável.'); } export function normalSchedulePeriod(s: NormalSchedules, scope: string) { const p = s.period, pending: string[] = []; const iso = (v: string) => /^20\d{2}-\d{2}-\d{2}$/.test(v) && Number.isFinite(Date.parse(v)) && new Date(v).toISOString().slice(0, 10) === v; if ( !iso(p.from) || !iso(p.to) || !p.from.endsWith('-01') || !p.to.startsWith('2026-') ) pending.push( 'Período anual por identificar: início no primeiro dia do mês e termo em2026, sem período curto ou alinhamento mensal presumido.', ); else { const end = new Date(p.from + 'T00:00:00Z'); end.setUTCFullYear(end.getUTCFullYear() + 1); end.setUTCDate(end.getUTCDate() - 1); if (end.toISOString().slice(0, 10) !== p.to) pending.push( 'Os12 meses do plano não correspondem ao período anual indicado.', ); } if (!p.kind || !p.reference.trim()) pending.push( 'Identifique tipo de período e correspondência dos12 meses; ausência de informação não é ano civil presumido.', ); if ( p.kind === 'calendar' && (p.from !== '2026-01-01' || p.to !== '2026-12-31') ) pending.push('Datas incompatíveis com o ano civil2026.'); if (p.kind === 'ep_annual' && scope !== 'ep_general') pending.push( 'Período do artigo7(3) exige EP e comunicação documentada; não aplicar a um residente.', ); return { ...structuredClone(p), pending }; } const z = () => fraction(0n); export const fiscalMoney = (s: string) => { if (!/^\d{1,18}(?:\.\d{1,2})?$/.test(s)) throw Error('Montante não negativo com até dois decimais necessário.'); return decimalFraction(s); }; const cmp = (a: FiscalFraction, b: FiscalFraction) => BigInt(a.numerator) * BigInt(b.denominator) - BigInt(b.numerator) * BigInt(a.denominator); export type SchedulesModel = ReturnType; export function calculateSchedules( s: NormalSchedules, source: { accountingLines: Array<{ code: string; total: string; accountingEffect: string; }>; } | null, oldRows: Array<{ sourceCode: string; document: string; line: string; amount: string; adjustment: FiscalFraction | null; }>, depreciationOverride?: { value: FiscalFraction | null; pending: string[] }, ) { validateSchedules(s); const pending: string[] = [], effects: Array<{ id: string; topic: string; amount: FiscalFraction | null; effect: FiscalFraction | null; reason: string; }> = []; for (const r of s.rows) { const issues: string[] = [], rule = r.rule ? scheduleRules[r.rule] : null; let amount: FiscalFraction | null = null, effect: FiscalFraction | null = null; if ( !rule || !r.reference.trim() || !r.document.trim() || !r.line.trim() || r.conditions !== 'yes' ) issues.push( 'Regra, documento/linha, suporte e condições por identificar.', ); if ( s.rows.some( (q) => q !== r && q.document.trim() === r.document.trim() && q.line.trim() === r.line.trim(), ) || oldRows.some( (q) => q.document.trim() === r.document.trim() && q.line.trim() === r.line.trim(), ) ) issues.push( 'Documento/linha duplicados entre mapas; sem dupla contagem.', ); try { amount = fiscalMoney(r.amount); if (cmp(amount, z()) <= 0n) throw Error(); } catch { issues.push('Parcela positiva por identificar.'); } const topic = rule?.[1] ?? ''; const needsOrigin = topic === 'expenseCorrections' || ['future_cost', 'future_income'].includes(r.rule); if (needsOrigin) { const line = source?.accountingLines.find( (l) => l.code === r.sourceCode, ); const wanted = r.rule === 'future_income' ? 'income' : 'expense'; if (!line || line.accountingEffect !== wanted) issues.push( 'Parcela deve estar incluída numa linha de origem compatível.', ); else try { const allocated = addFractions([ ...s.rows .filter( (q) => q.rule && (['expenseCorrections'].includes( scheduleRules[q.rule][1], ) || [ 'future_cost', 'future_income', ].includes(q.rule)) && q.sourceCode === r.sourceCode, ) .map((q) => fiscalMoney(q.amount)), ...oldRows .filter((q) => q.sourceCode === r.sourceCode) .map((q) => fiscalMoney(q.amount)), ]); if (cmp(allocated, fiscalMoney(line.total)) > 0n) issues.push( 'Parcelas excedem a origem contabilística.', ); } catch { issues.push('Reconciliação da origem incompleta.'); } } else if (r.sourceCode) issues.push( 'Variação fora do resultado/custo ou proveito omitido não deve repetir linha já contabilizada.', ); if ( topic === 'timing' && r.fiscalYear !== (r.rule.startsWith('future_') ? '2027' : '2026') ) issues.push( 'Exercício de imputação incompatível; este recorte cobre2026/seguinte, sem resolver exercícios anteriores.', ); if (!issues.length && amount && rule) effect = fraction( BigInt(amount.numerator) * BigInt(rule[3]), BigInt(amount.denominator), ); const reason = issues.join(' ') || 'CIRPC' + rule?.[2] + '; classificação e condições documentadas pelo utilizador; projecção, sem alteração da contabilidade de origem.'; if (issues.length) pending.push(r.document + ': ' + reason); effects.push({ id: r.id, topic, amount, effect, reason }); } let interest: FiscalFraction | null = null; const interestOrigin = source?.accountingLines.find( (l) => l.code === 'interest_expense', ); try { const corrected = s.rows.filter( (r) => r.sourceCode === 'interest_expense' && r.rule && (scheduleRules[r.rule][1] === 'expenseCorrections' || r.rule === 'future_cost'), ); const corrections = corrected.map((r) => { const e = effects.find((e) => e.id === r.id); if (!e?.effect) throw Error(); return e.effect; }); const remaining = subtractFractions( fiscalMoney(interestOrigin?.total ?? ''), addFractions([ ...oldRows .filter((r) => r.sourceCode === 'interest_expense') .flatMap((r) => (r.adjustment ? [r.adjustment] : [])), ...corrections, ]), ); interest = fiscalMoney(s.interest.amount); if ( cmp(interest, remaining) !== 0n || !s.interest.reference.trim() || s.interest.conditions !== 'yes' ) throw Error(); } catch { interest = null; } const assets = (depreciationOverride ? [] : s.assets).map((a) => { const issues: string[] = []; let accounting: FiscalFraction | null = null, fiscal: FiscalFraction | null = null, adjustment: FiscalFraction | null = null; try { const cost = fiscalMoney(a.cost), residual = fiscalMoney(a.residual), previous = fiscalMoney(a.previousFiscal), rate = fiscalMoney(a.rate); accounting = fiscalMoney(a.accounting); const depreciable = subtractFractions(cost, residual); if ( cmp(depreciable, z()) < 0n || cmp(previous, depreciable) > 0n || cmp(rate, z()) <= 0n || cmp(rate, fraction(100n)) > 0n ) throw Error(); const annual = fraction( BigInt(depreciable.numerator) * BigInt(rate.numerator), BigInt(depreciable.denominator) * BigInt(rate.denominator) * 100n, ); const available = subtractFractions(depreciable, previous); fiscal = cmp(annual, available) > 0n ? available : annual; if (cmp(fiscal, accounting) > 0n) issues.push( 'Quota fiscal superior à contabilizada neste exercício; dedução de quotas anteriormente tributadas exige mapa próprio, sem presunção.', ); } catch { issues.push( 'Custo, residual, taxa, acumulado fiscal e depreciação contabilizada por reconciliar.', ); } if ( !['computer', 'server'].includes(a.category) || a.rate !== (a.category === 'computer' ? '25' : '20') ) issues.push( 'Categoria/taxa não correspondida: este recorte cobre tabelaII, grupo3, itens9 (25%) e9.1 (20%), sem regra específica da tabelaI prevalecente.', ); if ( !/^20\d{2}-\d{2}-\d{2}$/.test(a.inUseFrom) || !Number.isFinite(Date.parse(a.inUseFrom)) || new Date(a.inUseFrom).toISOString().slice(0, 10) !== a.inUseFrom || a.inUseFrom > (s.period.from || '2026-01-01') || a.inUseFrom < '2014-01-01' || !a.description.trim() || !a.tableReference.trim() || !a.reference.trim() || a.conditions !== 'yes' ) issues.push( 'Identifique activo próprio, em uso no início do período, tabela/taxa aplicável, vida útil e condições do método constante anual; casos especiais/duodécimos separados.', ); if ( ['computer', 'server'].includes(a.category) && /^20\d{2}-\d{2}-\d{2}$/.test(a.inUseFrom) ) { const limit = new Date(a.inUseFrom + 'T00:00:00Z'); if (Number.isFinite(+limit)) { limit.setUTCFullYear( limit.getUTCFullYear() + (a.category === 'computer' ? 8 : 10), ); if ( (s.period.from || '2026-01-01') >= limit.toISOString().slice(0, 10) ) issues.push( 'Período máximo de vida útil excedido, artigo5; excepção específica não implementada.', ); } } if (!issues.length && accounting && fiscal) adjustment = subtractFractions(accounting, fiscal); if (issues.length) pending.push(a.description + ': ' + issues.join(' ')); return { ...structuredClone(a), accountingValue: accounting, fiscal, adjustment, reason: issues.join(' ') || 'Decreto72/2013 art3–7; quota constante anual documentada, limitada ao saldo fiscal depreciável; diferença para a depreciação contabilística.', }; }); let depreciation: FiscalFraction | null = depreciationOverride?.value ?? null; if (!depreciationOverride) try { const total = addFractions( assets.map((a) => a.accountingValue ?? z()), ); if ( s.assetsReference.trim() && assets.every((a) => a.adjustment !== null) && cmp( total, fiscalMoney( source?.accountingLines.find( (l) => l.code === 'depreciation', )?.total ?? '', ), ) === 0n ) depreciation = addFractions(assets.map((a) => a.adjustment!)); } catch { /* linha de origem desconhecida */ } if ( assets.length && (oldRows.some((r) => r.sourceCode === 'depreciation') || s.rows.some((r) => r.sourceCode === 'depreciation')) ) { depreciation = null; pending.push( 'Depreciação repartida entre mapas de ajustamentos e activos: reconcilie as parcelas sem sobreposição antes de aplicar a quota fiscal.', ); } if (assets.length && depreciation === null) pending.push( 'Mapa de activos sem reconciliação integral com a depreciação da origem; efeito fiscal não aplicado.', ); if (depreciationOverride) pending.push(...depreciationOverride.pending); return { input: structuredClone(s), effects, interest, depreciation, assets, pending, net: addFractions([ ...effects.flatMap((r) => (r.effect ? [r.effect] : [])), ...(depreciation ? [depreciation] : []), ]), source: 'CIRPC18/21/22/24/36; Decreto72/2013 art3–7. Entradas documentadas, sem taxas de activos sugeridas nem casos especiais presumidos.', }; }