73 lines
13 KiB
TypeScript
73 lines
13 KiB
TypeScript
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<string,unknown>)[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<typeof calculateSchedules>;
|
||
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.'};
|
||
}
|