import { addFractions, decimalFraction, fraction, subtractFractions, fractionPresentation, type FiscalFraction, } from './fiscal-rational.ts'; import { fiscalMoney, type NormalAsset } from './irpc-normal-schedules.ts'; export const normalDepreciationVersion = 'IRPC-normal-adjustments/7'; export const depreciationCatalog = { computer: { label: 'Computadores, impressoras, UPS e material informático', table: 'II', code: '3/9', rate: '25', kind: 'equipment', page: 17, }, server: { label: 'Servidores', table: 'II', code: '3/9.1', rate: '20', kind: 'equipment', page: 17, }, security: { label: 'Equipamento de vigilância e segurança', table: 'II', code: '3/23', rate: '20', kind: 'equipment', page: 18, }, machine: { label: 'Máquinas não especificadas', table: 'II', code: '3/22', rate: '10', kind: 'equipment', page: 18, }, furniture: { label: 'Mobiliário', table: 'II', code: '5/7', rate: '10', kind: 'furniture', page: 18, }, commercial_building: { label: 'Edifícios comerciais e administrativos', table: 'II', code: '1/2.2', rate: '2', kind: 'building', page: 16, }, passenger_vehicle: { label: 'Veículos automóveis ligeiros e mistos', table: 'II', code: '4/9.2', rate: '25', kind: 'vehicle', page: 18, }, software: { label: 'Programas de computadores', table: 'II', code: '5/8', rate: '25', kind: 'software', page: 18, }, hotel_decoration: { label: 'Decorações interiores — hotéis e actividades similares, classe84.2', table: 'I', code: '84.2/B', rate: '20', kind: 'furniture', page: 16, }, laundry_machine: { label: 'Maquinaria específica de lavandarias/tinturarias', table: 'I', code: '84.3/A', rate: '12.5', kind: 'equipment', page: 16, }, } as const; export type DepreciationCategory = keyof typeof depreciationCatalog; export type DepreciationAsset = { id: string; assetKey: string; description: string; document: string; line: string; category: '' | DepreciationCategory | 'other' | 'nondepreciable'; valuation: '' | 'historical_new' | 'used' | 'revalued' | 'other'; specificRate: '' | 'yes' | 'no'; method: '' | 'constant' | 'declining' | 'low_value'; rate: string; factor: string; timing: '' | 'annual' | 'months'; start: string; exitState: '' | 'absent' | 'known'; exitDate: string; cost: string; land: string; residual: string; accounting: string; previousAccounting: string; previousFiscal: string; priorAvailable: string; previousReference: string; newTangible: '' | 'yes' | 'no'; consistentMethod: '' | 'yes' | 'no'; switchToStraight: '' | 'yes' | 'no'; priorFullQuotas: '' | 'yes' | 'no'; vehicleException: '' | 'yes' | 'no'; limitedRights: '' | 'yes' | 'no'; individual: '' | 'yes' | 'no'; conditions: '' | 'yes'; reference: string; }; export type NormalDepreciationInput = { version: 1; completenessReference: string; assets: DepreciationAsset[]; }; export const newNormalDepreciation = (): NormalDepreciationInput => ({ version: 1, completenessReference: '', assets: [], }); export const newDepreciationAsset = (id: string): DepreciationAsset => ({ id, assetKey: '', description: '', document: '', line: '', category: '', valuation: '', specificRate: '', method: '', rate: '', factor: '', timing: '', start: '', exitState: '', exitDate: '', cost: '', land: '', residual: '', accounting: '', previousAccounting: '', previousFiscal: '', priorAvailable: '', previousReference: '', newTangible: '', consistentMethod: '', switchToStraight: '', priorFullQuotas: '', vehicleException: '', limitedRights: '', individual: '', conditions: '', reference: '', }); export function reuseNormalAssets( assets: NormalAsset[], ): NormalDepreciationInput { return { version: 1, completenessReference: '', assets: assets.map((a) => ({ ...newDepreciationAsset(a.id), assetKey: a.id, description: a.description, category: a.category, document: a.reference, line: a.id, method: 'constant', timing: 'annual', specificRate: 'no', consistentMethod: 'yes', conditions: a.conditions, rate: a.rate, cost: a.cost, residual: a.residual, accounting: a.accounting, previousFiscal: a.previousFiscal, start: a.inUseFrom, reference: a.reference, })), }; } export function validateNormalDepreciation(i: NormalDepreciationInput) { if ( !i || i.version !== 1 || typeof i.completenessReference !== 'string' || !Array.isArray(i.assets) || new Set(i.assets.map((a) => a.id)).size !== i.assets.length ) throw Error('Mapa de amortizações não recuperável.'); for (const a of i.assets) { if ( !a || Object.keys(newDepreciationAsset('')).some( (k) => typeof a[k as keyof DepreciationAsset] !== 'string', ) || ![ '', 'other', 'nondepreciable', ...Object.keys(depreciationCatalog), ].includes(a.category) || !['', 'historical_new', 'used', 'revalued', 'other'].includes( a.valuation, ) || !['', 'constant', 'declining', 'low_value'].includes(a.method) || !['', 'annual', 'months'].includes(a.timing) || !['', 'absent', 'known'].includes(a.exitState) || !['', 'yes'].includes(a.conditions) || [ 'specificRate', 'newTangible', 'consistentMethod', 'switchToStraight', 'priorFullQuotas', 'vehicleException', 'limitedRights', 'individual', ].some((k) => !['', 'yes', 'no'].includes(a[k as 'specificRate'])) ) throw Error( 'Activo ou parâmetros de amortização não recuperáveis.', ); } } const zero = () => fraction(0n), cmp = (a: FiscalFraction, b: FiscalFraction) => BigInt(a.numerator) * BigInt(b.denominator) - BigInt(b.numerator) * BigInt(a.denominator); const min = (...a: FiscalFraction[]) => a.reduce((v, w) => (cmp(v, w) < 0n ? v : w)), positive = (a: FiscalFraction) => (cmp(a, zero()) < 0n ? zero() : a); const multiply = (a: FiscalFraction, b: FiscalFraction) => fraction( BigInt(a.numerator) * BigInt(b.numerator), BigInt(a.denominator) * BigInt(b.denominator), ); const ratio = (a: FiscalFraction, b: FiscalFraction) => fraction( BigInt(a.numerator) * BigInt(b.denominator), BigInt(a.denominator) * BigInt(b.numerator), ); const date = (v: string) => /^20\d{2}-\d{2}-\d{2}$/.test(v) && Number.isFinite(Date.parse(v)) && new Date(v).toISOString().slice(0, 10) === v; const month = (v: string) => Number(v.slice(0, 4)) * 12 + Number(v.slice(5, 7)) - 1; const show = (v: FiscalFraction | null) => { if (!v) return 'Indeterminado'; const p = fractionPresentation(v); return ( (p.approximate ? '≈' : '') + p.decimal + ' MZN' + (p.approximate ? ' (exacto ' + v.numerator + '/' + v.denominator + ')' : '') ); }; export function calculateNormalDepreciation( input: NormalDepreciationInput, source: { currency: string; accountingLines: Array<{ code: string; total: string }>; } | null, period: { from: string; to: string }, claims: Array<{ sourceCode: string; document: string; line: string; amount: string; }>, short = false, acceleratedIds: ReadonlySet = new Set(), ) { validateNormalDepreciation(input); const pending: string[] = []; if (!input.completenessReference.trim()) pending.push( 'Reconciliação integral dos activos e quotas anteriores com a depreciação da origem em falta.', ); if ( source?.currency !== 'MZN' || !date(period.from) || !date(period.to) || !period.to.startsWith('2026-') || (!short && month(period.to) - month(period.from) !== 11) || (short && (month(period.to) < month(period.from) || month(period.to) - month(period.from) >= 11)) ) pending.push( 'Período anual emMZN com termo em2026 por reconciliar; períodos curtos permanecem separados.', ); const assets = input.assets.map((a) => { const issues: string[] = [], notes: string[] = []; let accounting: FiscalFraction | null = null, basis: FiscalFraction | null = null, taxBasis: FiscalFraction | null = null, quota: FiscalFraction | null = null, deduction: FiscalFraction | null = null, adjustment: FiscalFraction | null = null, priorUsed: FiscalFraction | null = null, priorRemaining: FiscalFraction | null = null, remainingFiscal: FiscalFraction | null = null, eligibleCurrent: FiscalFraction | null = null, months: number | null = null; const entry = Object.hasOwn(depreciationCatalog, a.category) ? depreciationCatalog[a.category as DepreciationCategory] : null; if ( short && a.category !== 'nondepreciable' && (a.timing !== 'months' || a.method === 'low_value') ) issues.push( 'Período curto exige duodécimos;opção integral art17 neste período por corresponder.', ); const money = (s: string, label: string) => { if (!s.trim()) throw Error(label + ' em falta; não presumir zero.'); return fiscalMoney(s); }; const yesno = (v: string, label: string) => { if (!['yes', 'no'].includes(v)) throw Error(label + ' por documentar.'); return v === 'yes'; }; if ( !a.assetKey.trim() || !a.description.trim() || !a.document.trim() || !a.line.trim() || !a.reference.trim() || a.conditions !== 'yes' ) issues.push( 'Identificação do activo, documento/linha, condições ordinárias e suporte em falta.', ); if ( input.assets.some( (b) => b !== a && (b.assetKey.trim() === a.assetKey.trim() || (b.document.trim() === a.document.trim() && b.line.trim() === a.line.trim())), ) || claims.some( (c) => c.document.trim() === a.document.trim() && c.line.trim() === a.line.trim(), ) ) issues.push( 'Activo ou documento/linha repetido entre mapas; reconcilie sem dedução duplicada.', ); try { accounting = money( a.accounting, 'Depreciação contabilística corrente', ); const cost = money(a.cost, 'Custo histórico'); if (a.category === 'nondepreciable') { basis = cost; taxBasis = zero(); quota = zero(); deduction = zero(); adjustment = accounting; notes.push( 'CIRPC26/27:elemento expressamente qualificado como não sujeito a deperecimento; depreciação contabilística integralmente acrescida.', ); } else { if (a.valuation !== 'historical_new') throw Error( 'Tipo de aquisição/base por identificar ou fora do recorte de bens novos a custo histórico; usados/abertura de escrita7(2) e reavaliados15 exigem regra própria.', ); if (!entry) throw Error( 'Categoria/tabela ainda não correspondida; sem taxa livre ou código provisório.', ); if (entry) { const specific = yesno( a.specificRate, 'Existência de taxa específica da tabelaI', ); if (entry.table === 'II' && specific) throw Error( 'Taxa específica da tabelaI prevalece; seleccione a categoria específica correspondente.', ); if (entry.table === 'I' && !specific) throw Error( 'Categoria específica da tabelaI sem enquadramento documentado.', ); } if (!date(a.start) || a.start < '2014-01-01') throw Error( 'Entrada em funcionamento/utilização desde2014 por identificar; activos anteriores exigem correspondência histórica própria.', ); if ( !yesno(a.consistentMethod, 'Método uniforme desde o início') ) throw Error( 'Mudança de método/razão excepcional requer correspondência própria do artigo10.', ); if ( (a.exitState === 'known' && (!date(a.exitDate) || a.exitDate < a.start)) || !a.exitState || (a.exitState === 'absent' && a.exitDate.trim()) ) throw Error( 'Estado/data de transmissão,inutilização ou termo de vida útil incoerente ou em falta.', ); if (!a.previousReference.trim()) throw Error( 'Suporte das quotas contabilísticas/fiscais anteriores e da parcela temporariamente tributada em falta.', ); const priorBook = money( a.previousAccounting, 'Quotas contabilísticas anteriores', ), priorFiscal = money( a.previousFiscal, 'Quotas fiscais anteriores', ), available = money( a.priorAvailable, 'Parcela anterior temporariamente tributada recuperável', ); if ( cmp(priorFiscal, priorBook) > 0n || cmp(available, subtractFractions(priorBook, priorFiscal)) > 0n ) throw Error( 'Quotas anteriores incoerentes; parcela recuperável não pode exceder a diferença contabilizada e não deduzida, excluindo encargos permanentemente não aceites.', ); const residual = money(a.residual, 'Residual'), land = entry?.kind === 'building' ? money(a.land, 'Terreno/parte não depreciável') : zero(); if ( entry?.kind !== 'building' && a.land.trim() && cmp(fiscalMoney(a.land), zero()) !== 0n ) throw Error( 'Terreno não corresponde a esta categoria de activo.', ); basis = subtractFractions( subtractFractions(cost, residual), land, ); if (cmp(basis, zero()) <= 0n) throw Error('Base depreciável positiva por reconciliar.'); if (entry?.kind !== 'building' && cmp(land, zero()) !== 0n) throw Error( 'Terreno só deve ser separado no imóvel correspondente.', ); taxBasis = basis; if ( entry?.kind === 'vehicle' && !yesno( a.vehicleException, 'Afectação a serviço público de transportes/locação na actividade normal', ) ) { taxBasis = min( basis, positive( subtractFractions(fraction(800000n), residual), ), ); notes.push( 'CIRPC27e:limite de aquisição800000MZN,sem afectar a depreciação contabilística de origem.', ); } if ( entry?.kind === 'software' && !yesno( a.limitedRights, 'Aquisição onerosa e direitos de utilização exclusiva por tempo limitado', ) ) throw Error( 'Direitos de software/intangível sem condições do artigo16 documentadas.', ); if (cmp(priorFiscal, taxBasis) > 0n) throw Error( 'Quotas fiscais anteriores excedem a base fiscal; reconcilie o saldo anterior.', ); const remaining = subtractFractions(taxBasis, priorFiscal), priorEligibleBook = min( taxBasis, multiply(priorBook, ratio(taxBasis, basis)), ); if ( cmp( available, positive( subtractFractions(priorEligibleBook, priorFiscal), ), ) > 0n ) throw Error( 'Parcela anterior recuperável inclui quota permanentemente não aceite ou excede a contabilização fiscalmente elegível.', ); eligibleCurrent = min( multiply(accounting, ratio(taxBasis, basis)), positive(subtractFractions(taxBasis, priorEligibleBook)), ); let maxRate: FiscalFraction | null = entry ? decimalFraction(entry.rate) : null, maxLifeMonths: FiscalFraction | null = maxRate ? ratio(fraction(2400n), maxRate) : null; if (a.method === 'low_value') { if ( cmp(cost, fraction(20000n)) > 0n || !yesno( a.individual, 'Activo individual fora de conjunto depreciado como um todo', ) || cmp(priorFiscal, zero()) !== 0n || a.start < period.from || a.start > period.to ) throw Error( 'Art17:valor unitário até20000MZN,activo individual e início no período,sem amortização fiscal anterior; condições em falta/incompatíveis.', ); quota = remaining; months = month(period.to) - month(a.start) + 1; notes.push( 'Art17:opção de depreciação integral em um período,limitada ao que está contabilizado e ao saldo fiscal.', ); } else { if (!entry || !maxRate || !maxLifeMonths) throw Error('Taxa aplicável não identificada.'); if (a.method === 'constant') { if (!/^\d+(?:\.\d{1,6})?$/.test(a.rate)) throw Error( 'Taxa constante escolhida por identificar.', ); const selected = decimalFraction(a.rate); if ( cmp(selected, multiply(maxRate, fraction(1n, 2n))) < 0n || cmp(selected, maxRate) > 0n ) throw Error( 'Taxa constante fora do recorte entre metade da máxima e a máxima;quotas inferiores exigem correspondência própria dos limites de vida útil, artigos5/7.', ); quota = multiply( taxBasis, multiply( selected, fraction( acceleratedIds.has(a.id) ? 3n : 1n, acceleratedIds.has(a.id) ? 200n : 100n, ), ), ); if (acceleratedIds.has(a.id)) notes.push( 'CBF16:L7 qualificado,incremento50% da taxa normal;mantidos contabilização e saldo fiscal.', ); notes.push( 'Quota constante pela taxa escolhida:' + a.rate + '%;tabela' + entry.table + '/' + entry.code + '.', ); } else if (a.method === 'declining') { if ( !yesno( a.newTangible, 'Activo tangível novo adquirido a terceiros/construído', ) || ['building', 'furniture', 'software'].includes( entry.kind, ) || (entry.kind === 'vehicle' && a.vehicleException !== 'yes') ) throw Error( 'Art6:quotas degressivas só nos activos tangíveis novos elegíveis;edifícios,mobiliário/equipamento social,intangíveis e veículos sem excepção ficam separados.', ); const life = ratio(fraction(100n), maxRate), maxFactor = cmp(life, fraction(5n)) < 0n ? fraction(3n, 2n) : cmp(life, fraction(6n)) <= 0n ? fraction(2n) : fraction(5n, 2n); if (!/^\d+(?:\.\d{1,6})?$/.test(a.factor)) throw Error( 'Coeficiente degressivo escolhido em falta.', ); const factor = decimalFraction(a.factor); if ( cmp(factor, zero()) <= 0n || cmp(factor, maxFactor) > 0n ) throw Error( 'Coeficiente degressivo excede o máximo do artigo8:1,5/2/2,5 conforme vida útil mínima.', ); quota = multiply( remaining, multiply( maxRate, multiply(factor, fraction(1n, 100n)), ), ); notes.push( 'Art8:saldo fiscal por amortizar ×taxa máxima' + entry.rate + '% ×coeficiente' + a.factor + ';máximo' + fractionPresentation(maxFactor).decimal + '.', ); if ( yesno( a.switchToStraight, 'Opção pela regra de saldo/vida útil restante8(2)', ) ) { if ( !yesno( a.priorFullQuotas, 'Condição das quotas anteriores8(2)', ) || !a.start.endsWith('-01-01') || period.from !== '2026-01-01' || a.timing !== 'annual' ) throw Error( 'Art8(2):condição das quotas anteriores por documentar;este recorte da opção exige início emJaneiro e período anual civil.', ); const remainingYears = subtractFractions( life, fraction( BigInt(2026 - Number(a.start.slice(0, 4))), ), ); if (cmp(remainingYears, zero()) <= 0n) throw Error( 'Art8(2):vida útil mínima esgotada;sem dividir por anos inexistentes.', ); const linear = ratio(remaining, remainingYears); if (cmp(quota, linear) < 0n) { quota = linear; notes.push( 'Art8(2):usado saldo/número de anos de vida útil mínima restantes,com condições anteriores documentadas.', ); } } } else throw Error('Método de amortização por identificar.'); if (!a.timing) throw Error('Opção anual/duodécimos por identificar.'); if (a.timing === 'annual') { if (a.exitState === 'known' && a.exitDate <= period.to) throw Error( 'Evento de saída em período anual:seleccione duodécimos e reconcilie a regra aplicável;sem quota anual presumida.', ); const lifeEnd = month(a.start) + Number( BigInt(maxLifeMonths.numerator) / BigInt(maxLifeMonths.denominator), ); if ( lifeEnd > month(period.from) && lifeEnd <= month(period.to) && cmp(remaining, zero()) > 0n ) throw Error( 'Vida útil máxima termina dentro do período;opção anual e quotas anteriores exigem correspondência própria. Duodécimos em vigor são calculados até ao limite temporal.', ); months = a.start > period.to ? 0 : 12; } else { const start = Math.max( month(period.from), month(a.start), ), end = Math.min( month(period.to) + 1, a.exitState === 'known' ? month(a.exitDate) : month(period.to) + 1, month(a.start) + Number( BigInt(maxLifeMonths.numerator) / BigInt(maxLifeMonths.denominator), ), ); months = Math.max(0, end - start); quota = multiply(quota, fraction(BigInt(months), 12n)); notes.push( 'Art9:entrada incluída desde o mês da utilização;saída exclui o mês do evento;' + months + 'duodécimos.', ); } const elapsed = Math.max( 0, month(period.from) - month(a.start), ); if (cmp(fraction(BigInt(elapsed)), maxLifeMonths) >= 0n) { quota = zero(); notes.push( 'Art5:vida útil máxima esgotada,sem excepção aplicada;nenhuma recuperação de quota fora desse limite.', ); } else if (a.start > period.to) { quota = zero(); notes.push( 'Art3:activo ainda não entrou emuso no período;sem amortização fiscal.', ); } } deduction = min( quota!, remaining, addFractions([eligibleCurrent, available]), ); priorUsed = positive( subtractFractions(deduction, eligibleCurrent), ); priorRemaining = subtractFractions(available, priorUsed); remainingFiscal = subtractFractions(remaining, deduction); adjustment = subtractFractions(accounting, deduction); notes.push( 'Art3(3):dedução limitada ao contabilizado no período ou antes desde o uso;quotas anteriores recuperáveis usadas:' + show(priorUsed) + ';saldo anterior aritmético:' + show(priorRemaining) + '. Sem consumo oficial nem recuperação de quota não contabilizada/permanentemente excluída.', ); } } catch (e) { issues.push(e instanceof Error ? e.message : String(e)); } if (issues.length) { deduction = null; adjustment = null; priorUsed = null; priorRemaining = null; remainingFiscal = null; pending.push((a.description || a.id) + ': ' + issues.join(' ')); } return { ...structuredClone(a), catalog: entry, accountingValue: accounting, basis, taxBasis, quota, deduction, adjustment, eligibleCurrent, priorUsed, priorRemaining, remainingFiscal, months, status: issues.length ? ('indeterminate' as const) : ('calculated' as const), reason: issues.join(' ') || notes.join(' '), }; }); let accountingTotal: FiscalFraction | null = null; try { accountingTotal = addFractions( assets.map((a) => { if (!a.accountingValue) throw Error(); return a.accountingValue; }), ); const origin = fiscalMoney( source?.accountingLines.find((l) => l.code === 'depreciation') ?.total ?? '', ); if (cmp(accountingTotal, origin) !== 0n) pending.push( 'Soma das quotas contabilísticas dos activos não corresponde à depreciação financeira declarada.', ); } catch { pending.push('Depreciação contabilística da origem por reconciliar.'); } if (claims.some((c) => c.sourceCode === 'depreciation')) pending.push( 'Depreciação partilhada com outros ajustamentos de origem;reconcilie as parcelas para não duplicar o mesmo encargo.', ); const knownAdjustment = addFractions( assets.flatMap((a) => (a.adjustment ? [a.adjustment] : [])), ), adjustment = pending.length === 0 ? knownAdjustment : null; return { input: structuredClone(input), assets, accountingTotal, knownAdjustment, adjustment, pending, source: 'Decreto72/2013,art3–10/16/17,tabelasI/II;CIRPC26/27. Recorte anualMZN com termo2026,custo histórico,sem reavaliação,turnos intensivos,concessões/fusões ou excepções presumidas. Fotografia7;mapa interno,não formulário oficial de amortizações.', }; } export type NormalDepreciationModel = ReturnType< typeof calculateNormalDepreciation >; export function normalDepreciationReport( m: NormalDepreciationModel, ): Array<[string, string, string, string]> { return [ [ 'IRPC normal — L6', 'Amortizações — tabelas, métodos, duodécimos e quotas anteriores', `Quotas contabilísticas:${show(m.accountingTotal)};ajustamento das parcelas conhecidas:${show(m.knownAdjustment)};ajustamento reconciliado:${show(m.adjustment)};${m.pending.join(' | ')}`, m.source, ], ...m.assets.map( (a) => [ 'IRPC normal — L6', a.description + ' — ' + a.document + '/' + a.line, `Estado:${a.status === 'calculated' ? 'Calculado no recorte' : 'Informação insuficiente'};contabilístico:${show(a.accountingValue)};parcela contabilística elegível:${show(a.eligibleCurrent)};base fiscal:${show(a.taxBasis)};quota calculada:${show(a.quota)};dedução fiscal:${show(a.deduction)};ajustamento com sinal:${show(a.adjustment)};duodécimos:${a.months ?? 'Não aplicável'};quota anterior utilizada:${show(a.priorUsed)};saldo anterior aritmético:${show(a.priorRemaining)};${a.reason}`, `${a.catalog ? 'Decreto72/2013,tabela' + a.catalog.table + ',código' + a.catalog.code + ',PDF' + a.catalog.page : 'Categoria sem código de tabela aplicado'};${a.reference};quotas anteriores:${a.previousReference};método:${a.method === 'constant' ? 'Constante' : a.method === 'declining' ? 'Degressivo' : a.method === 'low_value' ? 'Valor reduzido' : 'Por identificar'};sem alteração dos documentos/saldos oficiais ou históricos.`, ] as [string, string, string, string], ), ]; }