feat(irpc): simulate annual settlement with actual withholding and PPC proofs
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import {SimulationIrpcAnnual} from '@/components/simulation-irpc-annual';
|
||||
import {SimulationIrpcNormal} from '@/components/simulation-irpc-normal';
|
||||
import {SimulationIrpcWithholdings} from '@/components/simulation-irpc-withholdings';
|
||||
import {SimulationIspcResiduals} from '@/components/simulation-ispc-residuals';
|
||||
@@ -3513,6 +3514,7 @@ export function SimulationForm() {
|
||||
{(current.id === 'financial' || current.id === 'tax') && <SimulationIspcResiduals draft={draft} onChange={change} />}
|
||||
{(current.id === 'financial' || current.id === 'tax') && <SimulationIrpcWithholdings draft={draft} onChange={change} />}
|
||||
{(current.id === 'financial' || current.id === 'tax') && <SimulationIrpcNormal draft={draft} onChange={change} />}
|
||||
{(current.id === 'financial' || current.id === 'tax') && <SimulationIrpcAnnual draft={draft} onChange={change} />}
|
||||
{(current.id === 'financial' || current.id === 'tax') && <SimulationMonthlyIva draft={draft} busy={isCalculating} onCalculate={runSimulation} onChange={change} />}
|
||||
{current.id === 'financial' &&
|
||||
(draft.detail_level === 'advanced' ||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import {useState} from 'react';
|
||||
import {annualBook,newAnnualBook,saveAnnualBook,reuseAnnualAssessment,annualAssessment,annualWithholdingOptions,reuseAnnualWithholding,newAnnualPpc,recordAnnual,annualReportRows,annualHtml,annualSettlementSource,type AnnualBook,type AnnualPpc} from '@/lib/irpc-annual-settlement';
|
||||
|
||||
type Props={draft:Record<string,unknown>;onChange:(field:string,value:string)=>void};
|
||||
export function SimulationIrpcAnnual({draft,onChange}:Props){
|
||||
const[error,setError]=useState('');if(draft.fiscal_component!=='irpc')return null;
|
||||
try{
|
||||
const b=annualBook(draft),save=(next:AnnualBook)=>{try{onChange('modelo_a_snapshot',saveAnnualBook(draft,next));setError('');}catch(e){setError(String(e));}};
|
||||
if(!b)return<section aria-label="IRPC — L8 fecho anual" className="rounded border p-4"><h4>L8 — da colecta ao saldo anual simulado</h4><p>Reutiliza a colecta válida, retenções efectivamente sofridas por conta e PPC comprovados do próprio sujeito/exercício. PEC e créditos com regras próprias permanecem separados.</p><button type="button" onClick={()=>{try{save(reuseAnnualAssessment(draft,newAnnualBook()));}catch(e){setError(String(e));}}}>Preparar saldo anual L8</button>{error&&<p role="alert">{error}</p>}</section>;
|
||||
const patch=(p:Partial<AnnualBook>)=>save({...b,...p}),a=annualAssessment(draft),options=annualWithholdingOptions(draft);
|
||||
const field=(key:'subjectNuit'|'year'|'reference'|'assessmentReference',label:string)=><label className="block">{label}<input aria-label={label} value={b[key]} onChange={e=>patch({[key]:e.target.value})}/></label>;
|
||||
const group=(key:'withholdings'|'ppc',label:string)=><><label className="block">Estado — {label}<select aria-label={'Estado L8 — '+label} value={b[key].state} onChange={e=>patch({[key]:{...b[key],state:e.target.value as ''|'known'|'absent'}})}><option value="">Informação em falta</option><option value="known">Valores conhecidos — seleccionar comprovativos</option><option value="absent">Ausência declarada com suporte</option></select></label><label className="block">Suporte — {label}<input aria-label={'Suporte L8 — '+label} value={b[key].reference} onChange={e=>patch({[key]:{...b[key],reference:e.target.value}})}/></label></>;
|
||||
return<section aria-label="IRPC — L8 fecho anual" className="space-y-3 rounded border p-4"><h4>L8 — colecta, abatimentos e saldo anual simulado</h4><p>A colecta acima é anterior aos abatimentos. Este saldo tem cálculo e histórico próprios; retenção entregue pela empresa por pagamento a terceiros não é retenção sofrida. O resultado continua simulado, sem submissão, pagamento ou reembolso automático.</p><p>{annualSettlementSource}</p>{error&&<p role="alert">{error}</p>}
|
||||
{field('subjectNuit','NUIT do sujeito do fecho L8')}{field('year','Exercício do fecho L8')}{field('reference','Suporte do fecho da entidade e exercício L8')}
|
||||
<p>Colecta disponível:{a.colecta?a.colecta.numerator+'/'+a.colecta.denominator+' MZN':'Indeterminada'};origem:{a.kind==='special'?'L7 qualificado':'L6 normal'};NUIT:{a.subjectNuit||'Por identificar'};exercício:{a.year||'Por identificar'}.</p><button type="button" onClick={()=>{try{save(reuseAnnualAssessment(draft,b));}catch(e){setError(String(e));}}}>Reutilizar colecta corrente L6/L7</button>{field('assessmentReference','Suporte da colecta reutilizada L8')}
|
||||
<fieldset className="space-y-2 rounded border p-3"><legend>Retenções efectivamente sofridas por conta</legend>{group('withholdings','Retenções sofridas')}
|
||||
{options.length===0?<p>Não há afectações de comprovativosL5 neste contexto. Reutilize e reconcilie a retenção recebida emL5 ou declare ausência com suporte.</p>:options.map(o=><div key={o.rowId+'|'+o.paymentId} className="rounded border p-2"><p>{o.document||'Prova por identificar'};emitente:{o.issuer};data:{o.date};parcelaL5:{o.amount}MZN;{o.reason||'Retenção recebida/efectiva por conta reconciliada emL5.'}</p><button type="button" disabled={!o.ready||b.withholdings.rows.some(r=>r.rowId===o.rowId&&r.paymentId===o.paymentId)} onClick={()=>{try{save(reuseAnnualWithholding(draft,b,o.rowId,o.paymentId,crypto.randomUUID()));}catch(e){setError(String(e));}}}>Seleccionar prova L5 {o.document||o.paymentId}</button></div>)}
|
||||
{b.withholdings.rows.map((r,index)=>{const change=(p:Partial<typeof r>)=>patch({withholdings:{...b.withholdings,rows:b.withholdings.rows.map(x=>x.id===r.id?{...x,...p}:x)}});return<fieldset key={r.id} className="rounded border p-2"><legend>Retenção seleccionada {index+1}</legend><p>Rendimento:{r.rowId};prova:{r.paymentId};NUIT:{r.subjectNuit};exercício:{r.year}. Fonte conservada. Se mudou, retire esta selecção e seleccione a prova actual. O histórico mantém-se.</p><label className="block">Parcela a abater<input aria-label={'Parcela da retenção L8 '+(index+1)} value={r.amount} onChange={e=>change({amount:e.target.value})}/></label><label className="block">O próprio sujeito é beneficiário e o rendimento está incluído na base deste exercício<select aria-label={'Rendimento incluído L8 '+(index+1)} value={r.incomeIncluded} onChange={e=>change({incomeIncluded:e.target.value as ''|'yes'|'no'})}><option value="">Informação em falta</option><option value="yes">Sim — documentado</option><option value="no">Não — crédito não aplicável neste fecho</option></select></label><label className="block">Suporte da titularidade e inclusão<input aria-label={'Suporte da retenção L8 '+(index+1)} value={r.reference} onChange={e=>change({reference:e.target.value})}/></label><button type="button" onClick={()=>patch({withholdings:{...b.withholdings,rows:b.withholdings.rows.filter(x=>x.id!==r.id)}})}>Retirar selecção L8 da retenção {index+1}</button></fieldset>;})}</fieldset>
|
||||
<fieldset className="space-y-2 rounded border p-3"><legend>PPC efectivamente pagos</legend>{group('ppc','PPC efectuados')}<p>Regista comprovativos de pagamentos por conta; não calcula automaticamente a obrigação PPC nem usa pagamentos previstos no plano.</p><button type="button" onClick={()=>patch({ppc:{...b.ppc,rows:[...b.ppc.rows,{...newAnnualPpc(crypto.randomUUID()),subjectNuit:b.subjectNuit,year:b.year}]}})}>Registar comprovativo PPC</button>
|
||||
{b.ppc.rows.map((r,index)=>{const change=(p:Partial<AnnualPpc>)=>patch({ppc:{...b.ppc,rows:b.ppc.rows.map(x=>x.id===r.id?{...x,...p}:x)}});return<fieldset key={r.id} className="rounded border p-2"><legend>PPC {index+1}</legend>{([['document','Comprovativo'],['issuer','Emitente'],['date','Data (AAAA-MM-DD)'],['subjectNuit','NUIT beneficiário'],['year','Exercício do comprovativo'],['currency','Moeda do comprovativo'],['proofAmount','Montante comprovado'],['amount','Parcela a abater'],['reference','Suporte do pagamento e afectação']]as const).map(([key,label])=><label key={key} className="block">{label}<input aria-label={'PPC L8 '+(index+1)+' — '+label} value={r[key]} onChange={e=>change({[key]:e.target.value})}/></label>)}<label className="block">Pagamento efectivamente comprovado<select aria-label={'PPC L8 '+(index+1)+' — Efectivo'} value={r.effective} onChange={e=>change({effective:e.target.value as ''|'yes'|'no'})}><option value="">Informação em falta</option><option value="yes">Sim — comprovativo reconciliado</option><option value="no">Não — apenas previsão/outro estado</option></select></label><button type="button" onClick={()=>patch({ppc:{...b.ppc,rows:b.ppc.rows.filter(x=>x.id!==r.id)}})}>Retirar PPC {index+1}</button></fieldset>;})}</fieldset>
|
||||
{([['pec','PEC'],['credits','Créditos com regras próprias']]as const).map(([key,label])=><fieldset key={key} className="rounded border p-3"><legend>{label} — tratamento separado</legend><label className="block">Estado — {label}<select aria-label={'Estado L8 — '+label} value={b[key].state} onChange={e=>patch({[key]:{...b[key],state:e.target.value as ''|'known'|'absent'}})}><option value="">Informação em falta</option><option value="absent">Ausência declarada com suporte</option><option value="known">Valor conhecido — positivo exige regra própria</option></select></label>{(b[key].state==='known'||b[key].amount)&&<label className="block">Montante declarado<input aria-label={'Montante L8 — '+label} value={b[key].amount} onChange={e=>patch({[key]:{...b[key],amount:e.target.value}})}/></label>}<label className="block">Suporte<input aria-label={'Suporte L8 — '+label} value={b[key].reference} onChange={e=>patch({[key]:{...b[key],reference:e.target.value}})}/></label></fieldset>)}
|
||||
<button type="button" onClick={()=>{try{save(recordAnnual(draft,b));}catch(e){setError(String(e));}}}>Calcular e guardar saldo anual L8</button><button type="button" onClick={()=>{try{const html=annualHtml(draft),url=URL.createObjectURL(new Blob([html],{type:'text/html;charset=utf-8'})),link=document.createElement('a');link.href=url;link.download='IRPC-L8-saldo-simulado.html';link.click();URL.revokeObjectURL(url);setError('');}catch(e){setError(String(e));}}}>Exportar saldo anual L8</button>
|
||||
{annualReportRows(draft).map((r,n)=><p key={n}>{r[1]} — {r[2]} — {r[3]}</p>)}</section>;
|
||||
}catch(e){return<p role="alert">PreparaçãoL8 não recuperável:{String(e)}. Originais e históricos conservados.</p>;}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import {normalBook,normalCurrent} from './irpc-normal.ts';
|
||||
import {irpcBook,irpcCurrent} from './irpc-withholdings.ts';
|
||||
import {acquisitionScope} from './iva-acquisitions.ts';
|
||||
import {closureFingerprint} from './iva-closure.ts';
|
||||
import {addFractions,subtractFractions,decimalFraction,fraction,fractionPresentation,type FiscalFraction} from './fiscal-rational.ts';
|
||||
|
||||
export const annualSettlementVersion='IRPC-annual-settlement/1';
|
||||
export const annualSettlementSource='CIRPC, arts. 63 e 67 (Lei 34/2007 e natureza da retenção na redacção aplicável da Lei 12/2025); Regulamento, Decreto 9/2008, arts. 21(2–5) e 27(1–2/6). Recorte: 2026, residente no regime geral, ano civil e MZN. Colecta corrente válida, retenções efectivamente sofridas por conta e PPC comprovados. PEC/créditos positivos e tributação autónoma ficam fora deste fecho inicial. Art. 27(6): montante inferior a 100 MZN sem cobrança/reembolso; saldo aritmético exacto conservado. Saldo simulado por colecta projectada; não é declaração submetida, pagamento ou reembolso recebido.';
|
||||
type State=''|'known'|'absent';
|
||||
type Evidence={state:State;amount:string;reference:string};
|
||||
type Group={state:State;reference:string};
|
||||
export type AnnualWithholding={id:string;rowId:string;paymentId:string;sourceKey:string;subjectNuit:string;year:string;amount:string;incomeIncluded:''|'yes'|'no';reference:string};
|
||||
export type AnnualPpc={id:string;document:string;issuer:string;date:string;subjectNuit:string;year:string;currency:string;proofAmount:string;amount:string;effective:''|'yes'|'no';reference:string};
|
||||
type AnnualRecord={version:typeof annualSettlementVersion;key:string;createdAt:string;model:AnnualModel};
|
||||
export type AnnualBook={version:1;subjectNuit:string;year:string;reference:string;assessment:''|'normal'|'special';assessmentKey:string;assessmentReference:string;withholdings:Group&{rows:AnnualWithholding[]};ppc:Group&{rows:AnnualPpc[]};pec:Evidence;credits:Evidence;result?:AnnualRecord;history:AnnualRecord[]};
|
||||
const evidence=():Evidence=>({state:'',amount:'',reference:''});
|
||||
export const newAnnualBook=():AnnualBook=>({version:1,subjectNuit:'',year:'',reference:'',assessment:'',assessmentKey:'',assessmentReference:'',withholdings:{state:'',reference:'',rows:[]},ppc:{state:'',reference:'',rows:[]},pec:evidence(),credits:evidence(),history:[]});
|
||||
export const newAnnualPpc=(id:string):AnnualPpc=>({id,document:'',issuer:'',date:'',subjectNuit:'',year:'',currency:'MZN',proofAmount:'',amount:'',effective:'',reference:''});
|
||||
const zero=()=>fraction(0n),cmp=(a:FiscalFraction,b:FiscalFraction)=>BigInt(a.numerator)*BigInt(b.denominator)-BigInt(b.numerator)*BigInt(a.denominator);
|
||||
const iso=(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 moneyInput=(s:string)=>{if(!/^\d{1,18}(\.\d{1,2})?$/.test(s))throw Error('MontanteMZN em falta/inválido;máximo dois decimais,sem zero presumido.');return decimalFraction(s);};
|
||||
const canonical=(s:string)=>s.normalize('NFKC').trim().toLocaleLowerCase('pt').replace(/\s+/g,' ');
|
||||
const proofIdentity=(issuer:string,document:string,_date:string)=>JSON.stringify([canonical(issuer),canonical(document)]);
|
||||
function store(d:Record<string,unknown>){const s=JSON.parse(String(d.modelo_a_snapshot||'{"version":1,"books":{}}'));if(s.version!==1||!s.books||typeof s.books!=='object'||Array.isArray(s.books))throw Error('Livros fiscais não recuperáveis;originais conservados.');return s;}
|
||||
function books(d:Record<string,unknown>):Record<string,AnnualBook>{const m=store(d).irpcAnnual;if(m===undefined)return{};if(!m||typeof m!=='object'||Array.isArray(m))throw Error('MapaL8 não recuperável;originais conservados.');return m;}
|
||||
export function validateAnnual(b:AnnualBook){
|
||||
if(!b||b.version!==1||['subjectNuit','year','reference','assessmentKey','assessmentReference'].some(k=>typeof b[k as 'year']!=='string')||!['','normal','special'].includes(b.assessment)||!Array.isArray(b.history))throw Error('PreparaçãoL8 não recuperável.');
|
||||
for(const g of[b.withholdings,b.ppc,b.pec,b.credits])if(!g||!['','known','absent'].includes(g.state)||typeof g.reference!=='string')throw Error('EstadoL8 não recuperável.');
|
||||
for(const g of[b.pec,b.credits])if(typeof g.amount!=='string')throw Error('CréditoL8 não recuperável.');
|
||||
const w=['id','rowId','paymentId','sourceKey','subjectNuit','year','amount','incomeIncluded','reference'],p=Object.keys(newAnnualPpc(''));
|
||||
for(const[rows,keys]of[[b.withholdings.rows,w],[b.ppc.rows,p]]as const)if(!Array.isArray(rows)||new Set(rows.map(r=>r?.id)).size!==rows.length||rows.some(r=>!r||keys.some(k=>typeof(r as unknown as Record<string,unknown>)[k]!=='string')))throw Error('ComprovativosL8 não recuperáveis ou identificador repetido.');
|
||||
if(b.withholdings.rows.some(r=>!['','yes','no'].includes(r.incomeIncluded))||b.ppc.rows.some(r=>!['','yes','no'].includes(r.effective)))throw Error('QualificaçãoL8 não recuperável.');
|
||||
for(const r of[b.result,...b.history])if(r&&(r.version!==annualSettlementVersion||typeof r.key!=='string'||typeof r.createdAt!=='string'||!r.model||!Array.isArray(r.model.pending)))throw Error('HistóricoL8 não recuperável.');
|
||||
}
|
||||
export function annualBook(d:Record<string,unknown>){const b=books(d)[acquisitionScope(d)];if(b)validateAnnual(b);return b;}
|
||||
export function saveAnnualBook(d:Record<string,unknown>,b:AnnualBook){validateAnnual(b);return JSON.stringify({...store(d),irpcAnnual:{...books(d),[acquisitionScope(d)]:b}});}
|
||||
export function annualAssessment(d:Record<string,unknown>){
|
||||
const b=normalBook(d),r=b.result,m=r?.model;
|
||||
return{book:b,record:r,current:normalCurrent(d,b),key:r?closureFingerprint(r):'',subjectNuit:b.base?.subjectNuit??'',year:b.year,period:m?.periodsFx?{from:m.periodsFx.input.from,to:m.periodsFx.input.to}:m?.fiscalPeriod??m?.assessment?.period??null,kind:b.special?'special' as const:'normal' as const,colecta:b.special?m?.special?.colecta??null:m?.colecta??null,autonomous:b.special?m?.special?.expenses.autonomousTax??null:zero()};
|
||||
}
|
||||
export function reuseAnnualAssessment(d:Record<string,unknown>,b:AnnualBook):AnnualBook{const a=annualAssessment(d);return{...b,subjectNuit:b.subjectNuit||a.subjectNuit,year:b.year||a.year,assessment:a.kind,assessmentKey:a.key,assessmentReference:b.assessmentReference||a.book.assessment?.reference||''};}
|
||||
export function annualWithholdingOptions(d:Record<string,unknown>){
|
||||
const b=irpcBook(d),current=irpcCurrent(d,b);return b.rows.flatMap(r=>r.allocations.map(a=>{
|
||||
const p=b.payments.find(p=>p.id===a.paymentId),v=b.result?.rows.find(v=>v.id===r.id),issues:string[]=[];
|
||||
if(!current||!b.result)issues.push('Recalcule e guardeL5 com as entradas actuais.');
|
||||
if(r.direction!=='received'||r.origin!=='effective'||v?.direction!=='received'||v.status!=='calculated'||v.nature!=='on_account'||r.settlement!=='complete'||v.settled===null)issues.push('Apenas retenção recebida/efectiva por conta,calculada e reconciliada emL5;remessa por terceiros,definitiva ou projecção não é crédito próprio.');
|
||||
if(!p||p.kind!=='withholding_proof'||p.currency!=='MZN'||r.currency!=='MZN'||!iso(p.date)||!p.reference.trim())issues.push('Comprovativo de retenção sofrida emMZN em falta ou fora do recorte.');
|
||||
return{rowId:r.id,paymentId:a.paymentId,document:p?.reference??'',issuer:r.counterparty,date:p?.date??'',eventDate:v?.eventDate??'',year:v?.eventDate.slice(0,4)??'',amount:a.amount,proofAmount:p?.amount??'',identity:proofIdentity(r.counterparty,p?.reference??'',p?.date??''),key:closureFingerprint([r,v,p,a]),ready:!issues.length,reason:issues.join(' ')};
|
||||
}));
|
||||
}
|
||||
export function reuseAnnualWithholding(d:Record<string,unknown>,b:AnnualBook,rowId:string,paymentId:string,id:string):AnnualBook{
|
||||
const o=annualWithholdingOptions(d).find(o=>o.rowId===rowId&&o.paymentId===paymentId);if(!o)throw Error('ComprovativoL5 não encontrado;originais conservados.');
|
||||
if(b.withholdings.rows.some(r=>r.rowId===rowId&&r.paymentId===paymentId))throw Error('Retenção/prova já seleccionada;altere a parcela existente sem duplicar.');
|
||||
return{...b,withholdings:{...b.withholdings,rows:[...b.withholdings.rows,{id,rowId,paymentId,sourceKey:o.key,subjectNuit:b.subjectNuit,year:o.year,amount:o.amount,incomeIncluded:'',reference:''}]}};
|
||||
}
|
||||
function dependencies(d:Record<string,unknown>){let normal:unknown,L5:unknown;try{const a=annualAssessment(d);normal=[a.record,a.current];}catch(e){normal=String(e);}try{const b=irpcBook(d);L5=[b.rows,b.payments,b.result,irpcCurrent(d,b)];}catch(e){L5=String(e);}return[normal,L5,Object.entries(books(d)).filter(([k])=>k!==acquisitionScope(d)).map(([k,b])=>[k,b.subjectNuit,b.year,b.withholdings,b.ppc])];}
|
||||
function key(d:Record<string,unknown>,b:AnnualBook){const{result:_,history:__,...input}=b;return closureFingerprint([annualSettlementVersion,acquisitionScope(d),input,dependencies(d)]);}
|
||||
export function annualCurrent(d:Record<string,unknown>,b:AnnualBook){return !!b.result&&b.result.key===key(d,b);}
|
||||
export function calculateAnnual(d:Record<string,unknown>,b:AnnualBook){
|
||||
validateAnnual(b);const{result:_result,history:_history,...input}=b;const pending:string[]=[],warn:string[]=[];let assessment:ReturnType<typeof annualAssessment>|null=null,colecta:FiscalFraction|null=null;let options:ReturnType<typeof annualWithholdingOptions>=[];
|
||||
try{assessment=annualAssessment(d);const a=assessment,m=a.record?.model;
|
||||
if(!a.record||!a.current||a.key!==b.assessmentKey||b.assessment!==a.kind)pending.push('Colecta de origem ausente,alterada ou não corrente;reutilize a execuçãoL6/L7 aplicável,não some nem contorne efeitosL7.');
|
||||
if(!/^\d{9}$/.test(b.subjectNuit)||b.subjectNuit!==a.subjectNuit||b.year!=='2026'||b.year!==a.year||a.book.scope!=='resident_general'||m?.currency!=='MZN'||a.period?.from!=='2026-01-01'||a.period?.to!=='2026-12-31')pending.push('Entidade/NUIT,exercício ou âmbito fora do recorte residente/ano civil2026/MZN;períodos especiais permanecem separados.');
|
||||
if(!b.reference.trim()||!b.assessmentReference.trim())pending.push('Falta suporte do fecho do sujeito/exercício e da colecta seleccionada.');
|
||||
if(!a.colecta||cmp(a.colecta,zero())<0n)pending.push('Colecta aplicável indeterminada;uma parcelaL6 não substitui totalL7 pendente.');else colecta=structuredClone(a.colecta);
|
||||
if(a.kind==='special'&&(!m?.special||!a.autonomous||cmp(a.autonomous,zero())!==0n))pending.push('Tributação autónoma positiva/indeterminada exige liquidação própria;este primeiro saldo não a ignora nem a compensa.');
|
||||
}catch(e){pending.push('Origem da colecta não recuperável: '+String(e));}
|
||||
try{options=annualWithholdingOptions(d);}catch(e){if(b.withholdings.state==='known'||b.withholdings.rows.length)pending.push('OrigemL5 não recuperável: '+String(e));}
|
||||
const group=(g:Group,rows:unknown[],label:string)=>{if(!g.state||!g.reference.trim())pending.push(label+':identifique valor conhecido,ausência declarada ou informação em falta com suporte.');if(g.state==='absent'&&rows.length)pending.push(label+':ausência declarada contradiz parcelas presentes;conserve e reconcilie.');if(g.state==='known'&&!rows.length)pending.push(label+':não há comprovativos seleccionados;ausência exige declaração própria.');};
|
||||
group(b.withholdings,b.withholdings.rows,'Retenções sofridas por conta');group(b.ppc,b.ppc.rows,'PPC efectuados');
|
||||
try{if(b.withholdings.state==='absent'&&options.some(o=>o.ready&&o.year===b.year&&cmp(moneyInput(o.amount),zero())>0n))pending.push('Ausência de retenções sofridas contradiz provaL5 efectiva por conta no exercício;reconcilie sem a ignorar.');}catch(e){pending.push('Reconciliação da ausência de retenções: '+String(e));}
|
||||
for(const[label,e]of[['PEC',b.pec],['Créditos com regras próprias',b.credits]]as const){try{if(!e.state||!e.reference.trim())throw Error('presença/ausência e suporte em falta');if(e.state==='absent'){if(e.amount.trim())throw Error('ausência contradiz montante preenchido');}else if(cmp(moneyInput(e.amount),zero())!==0n)throw Error('montante positivo fora deste recorte:corresponder ordem,limite e transporte próprios;sem abatimento ou reembolso automático');}catch(err){pending.push(label+': '+String(err));}}
|
||||
const claims:Array<{id:string;kind:'withholding'|'ppc';identity:string;proofAmount:FiscalFraction|null;value:FiscalFraction|null;sourceKey:string;allocationAmount:FiscalFraction|null;document:string;issuer:string;date:string;subjectNuit:string;year:string;reason:string}>=[];
|
||||
for(const r of b.withholdings.rows){const o=options.find(o=>o.rowId===r.rowId&&o.paymentId===r.paymentId),issues:string[]=[];let value:FiscalFraction|null=null,proofAmount:FiscalFraction|null=null;
|
||||
try{if(!o||!o.ready||r.sourceKey!==o.key)throw Error(o?.reason||'provaL5 ausente ou alterada;reutilize sem duplicar');if(r.subjectNuit!==b.subjectNuit||r.year!==b.year||o.year!==b.year||!o.date.startsWith(b.year+'-'))throw Error('entidade/exercício da retenção não correspondem ao fecho');if(r.incomeIncluded!=='yes'||!r.reference.trim())throw Error('confirme o próprio beneficiário/NUIT e inclusão do rendimento na base do exercício com suporte');value=moneyInput(r.amount);proofAmount=moneyInput(o.proofAmount);if(cmp(value,moneyInput(o.amount))>0n)throw Error('parcela ultrapassa a retenção efectivamente comprovada e afectada emL5');if(b.withholdings.rows.filter(x=>x.rowId===r.rowId&&x.paymentId===r.paymentId).length!==1)throw Error('retenção/prova repetida;uma afectação por linhaL5');if(assessment?.record?.model.special?.cooperative.status==='calculated'&&cmp(value,zero())>0n)throw Error('cooperativa integralmente elegível declara ausência de rendimentos retidos;segregue o casoL7 antes do crédito anual');}catch(e){issues.push(String(e));value=null;}
|
||||
claims.push({id:r.id,kind:'withholding',identity:o?.identity??'',proofAmount,value,sourceKey:r.sourceKey,allocationAmount:o&&value?moneyInput(o.amount):null,document:o?.document??r.paymentId,issuer:o?.issuer??'',date:o?.date??'',subjectNuit:r.subjectNuit,year:r.year,reason:issues.join(' ')});
|
||||
}
|
||||
for(const r of b.ppc.rows){const issues:string[]=[];let value:FiscalFraction|null=null,proofAmount:FiscalFraction|null=null;
|
||||
try{if(!r.document.trim()||!r.issuer.trim()||!r.reference.trim()||r.effective!=='yes'||r.currency!=='MZN'||!iso(r.date)||!r.date.startsWith(b.year+'-')||r.subjectNuit!==b.subjectNuit||r.year!==b.year)throw Error('PPC exige comprovativo efectivo,MZN,data,entidade e exercício correspondentes;projecção/tesouraria não é pagamento');value=moneyInput(r.amount);proofAmount=moneyInput(r.proofAmount);if(cmp(value,proofAmount)>0n)throw Error('parcelaPPC ultrapassa o montante comprovado');}catch(e){issues.push(String(e));value=null;}
|
||||
claims.push({id:r.id,kind:'ppc',identity:proofIdentity(r.issuer,r.document,r.date),proofAmount,value,sourceKey:'',allocationAmount:null,document:r.document,issuer:r.issuer,date:r.date,subjectNuit:r.subjectNuit,year:r.year,reason:issues.join(' ')});
|
||||
}
|
||||
const elsewhere:Array<{identity:string;subjectNuit:string;year:string;value:FiscalFraction;proofAmount:FiscalFraction;sourceKey:string}>=[];
|
||||
for(const[scope,other]of Object.entries(books(d))){if(scope===acquisitionScope(d))continue;try{validateAnnual(other);for(const r of other.ppc.rows)elsewhere.push({identity:proofIdentity(r.issuer,r.document,r.date),subjectNuit:r.subjectNuit,year:r.year,value:moneyInput(r.amount),proofAmount:moneyInput(r.proofAmount),sourceKey:''});for(const r of other.withholdings.rows){const source=(store(d).irpcWithholdings??{})[scope];const row=source?.rows?.find((x:{id:string})=>x.id===r.rowId),p=source?.payments?.find((x:{id:string})=>x.id===r.paymentId);if(row&&p)elsewhere.push({identity:proofIdentity(row.counterparty,p.reference,p.date),subjectNuit:r.subjectNuit,year:r.year,value:moneyInput(r.amount),proofAmount:moneyInput(p.amount),sourceKey:r.sourceKey});}}catch(e){if(other?.subjectNuit===b.subjectNuit)pending.push('Outro mapa da mesma entidade não recuperável para conferir duplicações: '+scope+';'+String(e));}}
|
||||
for(const c of claims){if(!c.value||!c.proofAmount)continue;const same=claims.filter(x=>x.identity===c.identity&&x.value),other=elsewhere.filter(x=>x.identity===c.identity);let issue='';
|
||||
if(same.filter(x=>x.kind==='ppc').length>1||new Set(same.map(x=>x.kind)).size>1)issue='Mesmo comprovativo repetido ou usado como retenção ePPC;reconcilie sem dedução dupla.';
|
||||
if(other.some(x=>x.subjectNuit!==b.subjectNuit||x.year!==b.year))issue='Mesmo comprovativo já associado a outra entidade/exercício;reconcilie a titularidade e a afectação.';
|
||||
if([...same,...other].some(x=>x.proofAmount&&cmp(x.proofAmount,c.proofAmount!)!==0n)||cmp(addFractions([...same.flatMap(x=>x.value?[x.value]:[]),...other.map(x=>x.value)]),c.proofAmount)>0n)issue='Comprovativo utilizado além do montante disponível ou com montantes divergentes neste/outro mapa.';
|
||||
if(c.kind==='withholding'&&c.sourceKey&&c.allocationAmount&&cmp(addFractions([...same.filter(x=>x.sourceKey===c.sourceKey).flatMap(x=>x.value?[x.value]:[]),...other.filter(x=>x.sourceKey===c.sourceKey).map(x=>x.value)]),c.allocationAmount)>0n)issue='Mesma afectação L5 reutilizada além da parcela disponível, ainda que o comprovativo global seja maior.';
|
||||
if(issue)c.reason=[c.reason,issue].filter(Boolean).join(' ');
|
||||
}
|
||||
for(const c of claims)if(c.reason)pending.push((c.document||c.id)+': '+c.reason);
|
||||
const knownWithholdings=addFractions(claims.filter(c=>c.kind==='withholding'&&!c.reason&&c.value).map(c=>c.value!)),knownPpc=addFractions(claims.filter(c=>c.kind==='ppc'&&!c.reason&&c.value).map(c=>c.value!));
|
||||
const ready=pending.length===0&&colecta!==null,arithmeticBalance=ready?subtractFractions(subtractFractions(colecta!,knownWithholdings),knownPpc):null;
|
||||
let payable:FiscalFraction|null=null,recoverable:FiscalFraction|null=null,belowMinimum:FiscalFraction|null=null;let status:'payable'|'recoverable'|'balanced'|'below_minimum'|'indeterminate'='indeterminate';
|
||||
if(arithmeticBalance){const n=BigInt(arithmeticBalance.numerator),abs=fraction(n<0n?-n:n,BigInt(arithmeticBalance.denominator));payable=zero();recoverable=zero();belowMinimum=zero();if(!n)status='balanced';else if(cmp(abs,fraction(100n))<0n){status='below_minimum';belowMinimum=abs;warn.push('Art27(6):saldo inferior a100MZN,sem cobrança/reembolso neste recorte;aritmética conservada,não é transporte de crédito.');}else if(n>0n){status='payable';payable=abs;}else{status='recoverable';recoverable=abs;}}
|
||||
return{version:annualSettlementVersion,input:structuredClone(input),subjectNuit:b.subjectNuit,year:b.year,assessment:assessment?.record?{key:b.assessmentKey,kind:b.assessment,version:assessment.record.model.version,createdAt:assessment.record.createdAt,colecta,period:assessment.period,origin:assessment.record.model.origin}:null,claims,knownWithholdings,knownPpc,arithmeticBalance,payable,recoverable,belowMinimum,status,pending:[...new Set(pending)],warnings:warn,source:annualSettlementSource,officialBalancesConsumed:false,submitted:false,refundReceived:false,limitations:['L8 parcial:PEC e créditos positivos,tributação autónoma,períodos especiais e pagamentos noutra moeda exigem regras próprias.','PPC aqui é pagamento comprovado;não cálculo automático da obrigação ou calendário.','Retenção sofrida não é retenção entregue pela empresa por conta de terceiros.','Valores racionais exactos;camposM20/M22 e representação não presumidos.','Saldo simulado por colecta projectada;sem pagamento,submissão ou reembolso automático.']};
|
||||
}
|
||||
export type AnnualModel=ReturnType<typeof calculateAnnual>;
|
||||
export function recordAnnual(d:Record<string,unknown>,b:AnnualBook,now=new Date().toISOString()):AnnualBook{return{...b,history:b.result?[...b.history,structuredClone(b.result)]:b.history,result:{version:annualSettlementVersion,key:key(d,b),createdAt:now,model:calculateAnnual(d,b)}};}
|
||||
const show=(v:FiscalFraction|null)=>v?(fractionPresentation(v).approximate?'≈':'')+fractionPresentation(v).decimal+' MZN (exacto '+v.numerator+'/'+v.denominator+')':'Indeterminado';
|
||||
const stateLabel=(s:State)=>s==='known'?'Valor conhecido':s==='absent'?'Ausência declarada':'Informação em falta';
|
||||
export function annualReportRows(d:Record<string,unknown>):Array<[string,string,string,string]>{
|
||||
try{
|
||||
const b=annualBook(d),r=b?.result;if(!b||!r)return[];const m=r.model;
|
||||
const status={payable:'A pagar',recoverable:'A recuperar',balanced:'Saldo zero',below_minimum:'Inferior ao mínimo de cobrança/reembolso',indeterminate:'Indeterminado'}[m.status];
|
||||
return[
|
||||
['IRPC — L8','Fecho anual — âmbito e origem',
|
||||
'NUIT: '+m.subjectNuit+'; exercício: '+m.year+'; colecta: '+show(m.assessment?.colecta??null)+'; origem: '+(m.assessment?.kind==='special'?'L7 qualificado':'L6 normal')+'. '+(annualCurrent(d,b)?'Entradas correspondentes.':'Histórico; origem/entradas alteradas: recalcule o saldo corrente.')+' Saldo simulado, não liquidação submetida.',
|
||||
m.input.reference+'; '+m.input.assessmentReference+'; '+m.source],
|
||||
...m.claims.map(c=>[
|
||||
'IRPC — L8',(c.kind==='withholding'?'Retenção sofrida':'PPC')+' — '+(c.document||c.id),
|
||||
'Entidade: '+c.subjectNuit+'; exercício: '+c.year+'; emitente: '+c.issuer+'; data: '+c.date+'; comprovado: '+show(c.proofAmount)+'; parcela: '+(c.reason?'Indeterminado':show(c.value))+'. '+(c.reason||'Reconciliada sem utilização duplicada.'),
|
||||
c.kind==='withholding'?m.input.withholdings.reference+'; '+(m.input.withholdings.rows.find(r=>r.id===c.id)?.reference??''):m.input.ppc.reference+'; '+(m.input.ppc.rows.find(r=>r.id===c.id)?.reference??'')
|
||||
]as[string,string,string,string]),
|
||||
['IRPC — L8','Saldo anual simulado',
|
||||
'Estado: '+status+'. Retenções conhecidas: '+show(m.knownWithholdings)+'; PPC conhecidos: '+show(m.knownPpc)+'; saldo aritmético: '+show(m.arithmeticBalance)+'; a pagar: '+show(m.payable)+'; a recuperar: '+show(m.recoverable)+'. '+m.pending.join(' | ')+' '+m.warnings.join(' '),m.source],
|
||||
['IRPC — L8','Estados dos comprovativos',
|
||||
'Retenções sofridas: '+stateLabel(m.input.withholdings.state)+'; PPC: '+stateLabel(m.input.ppc.state)+'. Subtotais conhecidos não substituem informação em falta.',m.input.withholdings.reference+'; '+m.input.ppc.reference],
|
||||
['IRPC — L8','PEC e créditos separados',
|
||||
'PEC: '+stateLabel(m.input.pec.state)+'; montante declarado: '+(m.input.pec.amount||'Não preenchido')+'; créditos: '+stateLabel(m.input.credits.state)+'; montante declarado: '+(m.input.credits.amount||'Não preenchido')+'. Sem dedução, transporte ou reembolso presumidos. '+m.limitations.join(' '),m.input.pec.reference+'; '+m.input.credits.reference]
|
||||
];
|
||||
}catch(e){return[['IRPC — L8','Não recuperável',String(e),'Originais e históricos conservados.']];}
|
||||
}
|
||||
export function annualHtml(d:Record<string,unknown>){const b=annualBook(d);if(!b?.result||!annualCurrent(d,b))throw Error('Calcule e guardeL8 com as fontes actuais antes de exportar.');const esc=(s:string)=>s.replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]!));return'<!doctype html><html lang="pt"><meta charset="utf-8"><title>IRPC — L8 saldo anual simulado</title><h1>IRPC — colecta e saldo anual simulado</h1><p>Não é declaração submetida,pagamento efectuado ou reembolso recebido.</p>'+annualReportRows(d).map(r=>'<section><h2>'+esc(r[1])+'</h2><p>'+esc(r[2])+'</p><small>'+esc(r[3])+'</small></section>').join('')+'</html>';}
|
||||
@@ -179,6 +179,7 @@ const rowSection: Record<string, DossierReportSectionId> = {
|
||||
'IRPC — retenções': 'finance',
|
||||
'IRPC normal — L6': 'finance',
|
||||
'IRPC — L7': 'finance',
|
||||
'IRPC — L8': 'finance',
|
||||
Equipa: 'team',
|
||||
'Necessidade de negócio': 'resources',
|
||||
Financiamento: 'resources',
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import {annualReportRows} from './irpc-annual-settlement.ts';
|
||||
import {normalReportRows} from './irpc-normal.ts';
|
||||
import {irpcReportRows} from './irpc-withholdings.ts';
|
||||
import {ispcResidualReportRows} from './ispc-residuals.ts';
|
||||
@@ -697,6 +698,7 @@ export function buildProjectDossierRows(
|
||||
rows.push(...ispcResidualReportRows(draft));
|
||||
rows.push(...irpcReportRows(draft));
|
||||
rows.push(...normalReportRows(draft));
|
||||
rows.push(...annualReportRows(draft));
|
||||
rows.push(...fiscalCalculationGapReportRows(draft));
|
||||
rows.push(...l3FormPreparationReportRows(draft));
|
||||
const operationRows = salesReportRows(draft);
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Tests\TestCase;
|
||||
|
||||
final class IrpcAnnualSettlementDraftTest extends TestCase
|
||||
{
|
||||
use DatabaseTransactions;
|
||||
|
||||
public function test_annual_simulated_balances_round_trip_with_proofs_states_and_preserved_books(): void
|
||||
{
|
||||
$target=DB::selectOne('select current_database() as name,inet_server_port() as port');
|
||||
$this->assertSame('simulator_fiscal_delta_20261021_test',$target->name);
|
||||
$this->assertSame(55441,(int)$target->port);
|
||||
config(['investor-path.enabled'=>true,'suitecrm.enabled'=>false]);
|
||||
$this->actingAs(User::factory()->create(['email_verified_at'=>now()]));
|
||||
$id=null;$etag=null;
|
||||
foreach(['payable','recoverable','balanced','absent','missing','duplicate','pec']as$case){
|
||||
$d=json_decode(file_get_contents(base_path('outputs/irpc-l8-settlement/'.$case.'.json')),true,flags:JSON_THROW_ON_ERROR);
|
||||
$personal=['person_type'=>$d['person_type']];unset($d['person_type']);
|
||||
if($id===null)$id=$this->postJson('/api/v1/simulation-drafts',['personal_snapshot'=>$personal,'economic_snapshot'=>$d],['Idempotency-Key'=>(string)Str::uuid()])->assertCreated()->json('data.id');
|
||||
else $this->putJson('/api/v1/simulation-drafts/'.$id,['personal_snapshot'=>$personal,'economic_snapshot'=>$d],['If-Match'=>$etag])->assertOk();
|
||||
$r=$this->getJson('/api/v1/simulation-drafts/'.$id)->assertOk();$etag=$r->headers->get('ETag');$back=$r->json('data.economic_snapshot');
|
||||
$this->assertSame($d['modelo_a_snapshot'],$back['modelo_a_snapshot']);
|
||||
$this->assertSame($d['business_plan_snapshot'],$back['business_plan_snapshot']);
|
||||
$before=json_decode($d['modelo_a_snapshot'],true,flags:JSON_THROW_ON_ERROR);
|
||||
$s=json_decode($back['modelo_a_snapshot'],true,flags:JSON_THROW_ON_ERROR);$scope=$d['context_opportunity'];
|
||||
$b=$s['irpcAnnual'][$scope];$m=$b['result']['model'];
|
||||
foreach(['irpcNormal','irpcWithholdings','books','currencyEvents','ispcQuarters']as$map)$this->assertSame($before[$map],$s[$map]);
|
||||
$this->assertSame('IRPC-annual-settlement/1',$m['version']);
|
||||
$this->assertSame('123456789',$m['subjectNuit']);$this->assertSame('2026',$m['year']);
|
||||
$this->assertSame(['numerator'=>'1408','denominator'=>'1'],$m['assessment']['colecta']);
|
||||
$this->assertFalse($m['officialBalancesConsumed']);$this->assertFalse($m['submitted']);$this->assertFalse($m['refundReceived']);
|
||||
$this->assertSame('known',$m['input']['credits']['state']);$this->assertSame('0',$m['input']['credits']['amount']);
|
||||
if(in_array($case,['missing','duplicate','pec'],true)){
|
||||
$this->assertSame('indeterminate',$m['status']);$this->assertNotEmpty($m['pending']);
|
||||
$this->assertNull($m['arithmeticBalance']);$this->assertNull($m['payable']);$this->assertNull($m['recoverable']);
|
||||
if($case==='missing')$this->assertSame('',$m['input']['ppc']['rows'][0]['effective']);
|
||||
if($case==='pec')$this->assertSame('10',$m['input']['pec']['amount']);
|
||||
if($case==='duplicate')$this->assertCount(2,$m['input']['ppc']['rows']);
|
||||
}else{
|
||||
$this->assertEmpty($m['pending']);
|
||||
$this->assertSame(['payable'=>'payable','recoverable'=>'recoverable','balanced'=>'balanced','absent'=>'payable'][$case],$m['status']);
|
||||
$this->assertSame(['numerator'=>['payable'=>'608','recoverable'=>'-192','balanced'=>'0','absent'=>'1408'][$case],'denominator'=>'1'],$m['arithmeticBalance']);
|
||||
$this->assertSame(['numerator'=>$case==='recoverable'?'192':'0','denominator'=>'1'],$m['recoverable']);
|
||||
if($case==='absent'){$this->assertSame('absent',$m['input']['withholdings']['state']);$this->assertSame('absent',$m['input']['ppc']['state']);}
|
||||
else{$this->assertSame('yes',$m['input']['withholdings']['rows'][0]['incomeIncluded']);$this->assertSame('yes',$m['input']['ppc']['rows'][0]['effective']);}
|
||||
}
|
||||
file_put_contents(base_path('outputs/irpc-l8-settlement/recovered-'.$case.'.json'),json_encode($back,JSON_THROW_ON_ERROR|JSON_UNESCAPED_UNICODE));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import {normalBook,normalCurrent,saveNormalBook} from '../../resources/js/lib/irpc-normal.ts';
|
||||
import {irpcBook,newIrpcRow,saveIrpcBook,recordIrpc} from '../../resources/js/lib/irpc-withholdings.ts';
|
||||
import {annualBook,newAnnualBook,newAnnualPpc,saveAnnualBook,reuseAnnualAssessment,annualAssessment,annualWithholdingOptions,reuseAnnualWithholding,calculateAnnual,recordAnnual,annualCurrent,annualReportRows,annualHtml} from '../../resources/js/lib/irpc-annual-settlement.ts';
|
||||
import {createProjectDossierReport} from '../../resources/js/lib/project-dossier-report.ts';
|
||||
const read=n=>JSON.parse(fs.readFileSync(new URL('./fixtures/'+n,import.meta.url),'utf8'));
|
||||
const val=v=>v?Number(v.numerator)/Number(v.denominator):null;
|
||||
const now='2026-10-26T18:00:00+02:00';
|
||||
const original=()=>read('irpc-assessment-preserved.json');
|
||||
function acceptedL7(expense=false){const d=read('irpc-l7-preserved.json')[expense?'expense':'acceleration'];if(!expense){const f=read('irpc-l7-continuation-preserved.json');d.business_plan_snapshot=f.plan;d.modelo_a_snapshot=saveNormalBook(d,f.book);}return d;}
|
||||
function setup(rf='300',ppc='500'){
|
||||
const d=original(),l5=irpcBook(d);
|
||||
if(rf!==null){
|
||||
const row={...newIrpcRow('L8-received'),direction:'received',origin:'effective',counterparty:'QA pagador A',documentRef:'QA rendimento próprio 2026',base:String(Number(rf)*5),currency:'MZN',kind:'royalty',recipientIrpc:'yes',payerIrpc:'yes',residence:'resident',mozambique:'yes',qualificationRef:'QA royalties próprios incluídos no resultado anual',specialRelief:'none',eventsComplete:'yes',events:[{kind:'payment',date:'2026-10-02',reference:'QA rendimento e primeiro evento 2026'}],settlement:'complete',settlementRef:'QA retenção efectivamente sofrida e reconciliada',allocations:[{paymentId:'RF-L8',amount:rf}],ordinary:{nonDigital:'yes',capitalType:'',rentOwnPropertyManagement:'',specialFacts:'no',capitalSeatAndManagement:'',reference:'QA royalties não digitais de residente'}};
|
||||
l5.rows.push(row);l5.payments.push({id:'RF-L8',kind:'withholding_proof',reference:'QA-RF-2026-1',date:'2026-10-02',amount:rf,currency:'MZN'});
|
||||
d.modelo_a_snapshot=saveIrpcBook(d,recordIrpc(d,l5,now));
|
||||
}
|
||||
let b=reuseAnnualAssessment(d,newAnnualBook());b.reference='QA entidade123456789,exercício2026,declaração integral dos abatimentos';b.assessmentReference='QA colecta válida já demonstrada1408';
|
||||
b.withholdings={state:rf===null?'absent':'known',reference:'QA inventário completo das retenções efectivamente sofridas por este sujeito no exercício',rows:[]};
|
||||
b.ppc={state:ppc===null?'absent':'known',reference:'QA inventário completo dos PPC efectuados no exercício',rows:[]};
|
||||
b.pec={state:'absent',amount:'',reference:'QA ausência de PEC'};b.credits={state:'known',amount:'0',reference:'QA créditos com regras próprias conhecidos zero'};
|
||||
if(rf!==null){b=reuseAnnualWithholding(d,b,'L8-received','RF-L8','claim-rf');Object.assign(b.withholdings.rows[0],{incomeIncluded:'yes',reference:'QA próprio beneficiário NUIT123456789;rendimento incluído na base anual2026'});}
|
||||
if(ppc!==null)b.ppc.rows.push({...newAnnualPpc('claim-ppc'),document:'QA-PPC-2026-1',issuer:'QA AT',date:'2026-06-30',subjectNuit:b.subjectNuit,year:b.year,proofAmount:ppc,amount:ppc,effective:'yes',reference:'QA recibo de PPC efectivo e afectação ao sujeito/exercício'});
|
||||
return{d,b};
|
||||
}
|
||||
const model=(d,b)=>calculateAnnual(d,b);
|
||||
const pending=(d,b)=>{const m=model(d,b);assert.equal(m.status,'indeterminate',m.pending.join(' | '));assert.equal(m.arithmeticBalance,null);assert.equal(m.payable,null);assert.equal(m.recoverable,null);return m;};
|
||||
|
||||
test('optional L8 leaves accepted assessment, other books and report rows untouched',()=>{
|
||||
const d=original(),before=JSON.stringify(d),b=normalBook(d);assert.equal(normalCurrent(d,b),true);assert.equal(val(annualAssessment(d).colecta),1408);assert.equal(annualBook(d),undefined);assert.deepEqual(annualReportRows(d),[]);assert.equal(JSON.stringify(d),before);
|
||||
const old=acceptedL7();assert.deepEqual(annualReportRows(old),[]);assert.equal(normalBook(old).result.model.version,'IRPC-normal-adjustments/13');
|
||||
});
|
||||
test('1408 minus actual received300 and provenPPC500 yields608 payable without consuming official balances',()=>{
|
||||
const{d,b}=setup(),before=d.modelo_a_snapshot,m=model(d,b);assert.deepEqual(m.pending,[]);assert.equal(m.status,'payable');assert.equal(val(m.knownWithholdings),300);assert.equal(val(m.knownPpc),500);assert.equal(val(m.payable),608);assert.equal(val(m.recoverable),0);assert.equal(m.officialBalancesConsumed,false);assert.equal(m.submitted,false);assert.equal(d.modelo_a_snapshot,before);
|
||||
});
|
||||
test('1408 minus1000 and600 yields192 recoverable;408 and1000 balances exactly',()=>{
|
||||
for(const[rf,ppc,status,balance]of[['1000','600','recoverable',-192],['408','1000','balanced',0]]){const{d,b}=setup(rf,ppc),m=model(d,b);assert.deepEqual(m.pending,[]);assert.equal(m.status,status);assert.equal(val(m.arithmeticBalance),balance);assert.equal(val(m.recoverable),balance<0?-balance:0);}
|
||||
});
|
||||
test('declared absence yields1408;known zero has its own proof and missing never becomes zero',()=>{
|
||||
const{d,b}=setup(null,null);assert.equal(val(model(d,b).payable),1408);b.credits.state='';assert.match(pending(d,b).pending.join(' '),/Créditos/);b.credits={state:'absent',amount:'',reference:'QA ausência'};b.ppc.state='';assert.match(pending(d,b).pending.join(' '),/PPC/);
|
||||
const zero=setup('0','0');assert.equal(val(model(zero.d,zero.b).payable),1408);assert.equal(model(zero.d,zero.b).input.withholdings.state,'known');
|
||||
});
|
||||
test('effective received L5 proof is required, not a projection,remittance or definitive withholding',()=>{
|
||||
for(const patch of[{origin:'projected'},{direction:'paid'},{residence:'nonresident',permanentEstablishment:'no'}]){const{d,b}=setup(),l5=irpcBook(d);Object.assign(l5.rows.find(r=>r.id==='L8-received'),patch);d.modelo_a_snapshot=saveIrpcBook(d,recordIrpc(d,l5,now));const option=annualWithholdingOptions(d).find(o=>o.rowId==='L8-received');assert.equal(option.ready,false);pending(d,b);}
|
||||
const{d,b}=setup(),l5=irpcBook(d);l5.payments.find(p=>p.id==='RF-L8').kind='remittance';d.modelo_a_snapshot=saveIrpcBook(d,recordIrpc(d,l5,now));pending(d,b);
|
||||
});
|
||||
test('beneficiary,income inclusion,proof date,entity,exercise and actualPPC are mandatory',()=>{
|
||||
for(const change of[b=>b.withholdings.rows[0].incomeIncluded='',b=>b.withholdings.rows[0].reference='',b=>b.withholdings.rows[0].subjectNuit='999999999',b=>b.year='2025',b=>b.ppc.rows[0].subjectNuit='999999999',b=>b.ppc.rows[0].year='2025',b=>b.ppc.rows[0].currency='USD',b=>b.ppc.rows[0].date='2026-02-30',b=>b.ppc.rows[0].date='2027-01-01',b=>b.ppc.rows[0].effective='no',b=>b.ppc.rows[0].reference='',b=>b.reference='']){const{d,b}=setup();change(b);pending(d,b);}
|
||||
});
|
||||
test('selected current source and reusing collection never overwrite deliberate entity/year edits',()=>{
|
||||
const{d,b}=setup();b.subjectNuit='999999999';b.year='2025';const next=reuseAnnualAssessment(d,b);assert.equal(next.subjectNuit,'999999999');assert.equal(next.year,'2025');pending(d,next);
|
||||
const other=setup();other.b.assessmentKey='old-source';pending(other.d,other.b);
|
||||
});
|
||||
test('absence contradiction and incomplete group inventories are indeterminate,not assumed totals',()=>{
|
||||
for(const change of[b=>{b.withholdings={state:'absent',reference:'QA ausência contraditória',rows:[]};},b=>b.ppc.rows=[],b=>b.ppc.state='absent',b=>b.withholdings.reference='']){const{d,b}=setup();change(b);pending(d,b);}
|
||||
});
|
||||
test('duplicate pair,duplicatePPC,overclaim and cross-kind physical proof cannot be deducted twice',()=>{
|
||||
for(const change of[b=>b.withholdings.rows.push({...b.withholdings.rows[0],id:'duplicate'}),b=>b.ppc.rows.push({...b.ppc.rows[0],id:'duplicate'}),b=>b.withholdings.rows[0].amount='301',b=>b.ppc.rows[0].amount='501',b=>Object.assign(b.ppc.rows[0],{document:' QA-RF-2026-1 ',issuer:'QA pagador A',date:'2026-10-02',amount:'100',proofAmount:'300'})]){const{d,b}=setup();change(b);pending(d,b);}
|
||||
const{d,b}=setup();assert.throws(()=>reuseAnnualWithholding(d,b,'L8-received','RF-L8','again'),/já seleccionada/);
|
||||
});
|
||||
test('partial proof allocations allowed only within its available amount across context maps',()=>{
|
||||
const{d,b}=setup(null,'500');b.ppc.rows[0].amount='300';const other=structuredClone(b);other.ppc.rows[0].id='other';other.ppc.rows[0].amount='200';const s=JSON.parse(d.modelo_a_snapshot);s.irpcAnnual={'opportunity:other':other};d.modelo_a_snapshot=JSON.stringify(s);assert.equal(val(model(d,b).payable),1108);other.ppc.rows[0].amount='201';d.modelo_a_snapshot=JSON.stringify(s);pending(d,b);
|
||||
other.ppc.rows[0].amount='100';other.ppc.rows[0].subjectNuit='999999999';d.modelo_a_snapshot=JSON.stringify(s);pending(d,b);
|
||||
other.ppc.rows[0].subjectNuit=b.subjectNuit;other.ppc.rows[0].year='2025';d.modelo_a_snapshot=JSON.stringify(s);pending(d,b);
|
||||
});
|
||||
test('positive PEC or specialcredit stays separated with null final balances,explicitzero permitted',()=>{
|
||||
for(const key of['pec','credits']){const{d,b}=setup();b[key]={state:'known',amount:'10',reference:'QA saldo positivo exige regra própria'};const m=pending(d,b);assert.match(m.pending.join(' '),/fora deste recorte/);assert.equal(val(m.knownPpc),500);b[key].amount='0';assert.equal(model(d,b).status,'payable');b[key]={state:'absent',amount:'0',reference:'QA ausência contradiz montante'};pending(d,b);}
|
||||
});
|
||||
test('changing receipt date cannot evade identity checks;copied L5 allocation cannot consume larger proof twice',()=>{
|
||||
const p=setup();p.b.ppc.rows.push({...p.b.ppc.rows[0],id:'other-date',date:'2026-07-01',amount:'100'});pending(p.d,p.b);
|
||||
const{d,b}=setup();const l5=irpcBook(d);l5.payments.find(p=>p.id==='RF-L8').amount='1000';d.modelo_a_snapshot=saveIrpcBook(d,recordIrpc(d,l5,now));b.withholdings.rows=[];const fresh=reuseAnnualWithholding(d,b,'L8-received','RF-L8','rf');Object.assign(fresh.withholdings.rows[0],{amount:'200',incomeIncluded:'yes',reference:'QA próprio beneficiário e rendimento incluído'});
|
||||
const other=structuredClone(fresh);other.withholdings.rows[0].amount='101';other.ppc.rows=[];other.ppc.state='absent';const s=JSON.parse(d.modelo_a_snapshot);s.irpcAnnual={'opportunity:other':other};s.irpcWithholdings['opportunity:other']=structuredClone(l5);d.modelo_a_snapshot=JSON.stringify(s);d.modelo_a_snapshot=saveIrpcBook(d,recordIrpc(d,irpcBook(d),now));fresh.withholdings.rows[0].sourceKey=annualWithholdingOptions(d).find(o=>o.rowId==='L8-received').key;other.withholdings.rows[0].sourceKey=fresh.withholdings.rows[0].sourceKey;const current=JSON.parse(d.modelo_a_snapshot);current.irpcAnnual['opportunity:other']=other;d.modelo_a_snapshot=JSON.stringify(current);assert.match(pending(d,fresh).pending.join(' '),/Mesma afectação L5/);
|
||||
other.withholdings.rows[0].amount='100';d.modelo_a_snapshot=JSON.stringify(current);assert.equal(model(d,fresh).status,'payable');
|
||||
});
|
||||
test('annual accepted L7 accelerated assessment is reused once rather than summed with L6',()=>{
|
||||
const d=read('irpc-l7-preserved.json').acceleration,b=reuseAnnualAssessment(d,newAnnualBook());b.reference='QA entidade/exercício';b.assessmentReference='QA colecta L7';for(const key of['withholdings','ppc','pec','credits']){b[key].state='absent';b[key].reference='QA ausência declarada';}
|
||||
const m=model(d,b);assert.deepEqual(m.pending,[]);assert.equal(m.assessment.kind,'special');assert.equal(val(m.payable),1996.8);assert.notEqual(val(m.payable),val(normalBook(d).result.model.colecta)+1996.8);
|
||||
});
|
||||
test('100 minimum is applied symmetrically;99.99 kept exact without invented rounding or carry',()=>{
|
||||
for(const[ppc,status,balance]of[['1308.01','below_minimum',99.99],['1308','payable',100],['1507.99','below_minimum',-99.99],['1508','recoverable',-100]]){const{d,b}=setup(null,ppc),m=model(d,b);assert.equal(m.status,status);assert.equal(val(m.arithmeticBalance),balance);if(status==='below_minimum'){assert.deepEqual(m.belowMinimum,{numerator:'9999',denominator:'100'});assert.equal(val(m.payable),0);assert.equal(val(m.recoverable),0);}}
|
||||
const{d,b}=setup();b.ppc.rows[0].amount='0.001';pending(d,b);
|
||||
});
|
||||
test('existing L7 source cannot be bypassed by choosing L6 and positiveautonomous/shortperiod remain scoped',()=>{
|
||||
for(const expense of[false,true]){const d=acceptedL7(expense);let b=reuseAnnualAssessment(d,newAnnualBook());b.reference='QA fecho';b.assessmentReference='QA colecta';for(const key of['withholdings','ppc','pec','credits']){b[key].state='absent';b[key].reference='QA sem movimentos';}assert.equal(b.assessment,'special');let m=pending(d,b);if(!expense)assert.match(m.pending.join(' '),/períodos especiais/);if(expense)assert.match(m.pending.join(' '),/Tributação autónoma/);b.assessment='normal';assert.match(pending(d,b).pending.join(' '),/não corrente/);}
|
||||
});
|
||||
test('record/recover/recalculate appends history unchanged,preserves all old books/plan,no double deduction',()=>{
|
||||
const{d,b}=setup(),before=JSON.parse(d.modelo_a_snapshot),plan=d.business_plan_snapshot;d.modelo_a_snapshot=saveAnnualBook(d,recordAnnual(d,b,now));const once=annualBook(d),old=structuredClone(once.result);assert.equal(annualCurrent(d,once),true);d.modelo_a_snapshot=saveAnnualBook(d,recordAnnual(d,once,now));const back=annualBook(JSON.parse(JSON.stringify(d)));assert.deepEqual(back.history,[old]);assert.equal(val(back.result.model.payable),608);assert.equal(d.business_plan_snapshot,plan);const snapshot=JSON.parse(d.modelo_a_snapshot);delete snapshot.irpcAnnual;assert.deepEqual(snapshot,before);
|
||||
b.ppc.rows[0].amount='400';assert.equal(annualCurrent(d,b),false);
|
||||
});
|
||||
test('changed L5 or assessment invalidates current L8 while retaining historical report and references',()=>{
|
||||
const{d,b}=setup();d.modelo_a_snapshot=saveAnnualBook(d,recordAnnual(d,b,now));const back=annualBook(d),old=structuredClone(back.result);back.reference='NEW reference';d.modelo_a_snapshot=saveAnnualBook(d,back);assert.equal(annualCurrent(d,back),false);assert.match(annualReportRows(d).flat().join(' '),/Histórico/);assert.doesNotMatch(annualReportRows(d).flat().join(' '),/NEW reference/);assert.throws(()=>annualHtml(d),/fontes actuais/);assert.deepEqual(back.result,old);
|
||||
const n=normalBook(d);n.base.identityReference+=' changed';d.modelo_a_snapshot=saveNormalBook(d,n);assert.equal(annualCurrent(d,back),false);pending(d,back);
|
||||
const changed=setup();changed.d.modelo_a_snapshot=saveAnnualBook(changed.d,recordAnnual(changed.d,changed.b,now));const saved=annualBook(changed.d),prior=structuredClone(saved.result);const l5=irpcBook(changed.d);l5.payments.find(p=>p.id==='RF-L8').reference+=' corrected';changed.d.modelo_a_snapshot=saveIrpcBook(changed.d,recordIrpc(changed.d,l5,now));assert.equal(annualCurrent(changed.d,saved),false);pending(changed.d,saved);assert.deepEqual(saved.result,prior);
|
||||
});
|
||||
test('malformed optionalbook never resets accepted books;report explicitly preserves missing states',()=>{
|
||||
const{d,b}=setup();b.credits.state='';d.modelo_a_snapshot=saveAnnualBook(d,recordAnnual(d,b,now));assert.match(annualHtml(d),/Indeterminado/);assert.match(annualHtml(d),/Informação em falta/);assert.match(JSON.stringify(createProjectDossierReport(d,{selectedSections:new Set(['finance'])})),/Saldo anual simulado/);
|
||||
const old=d.modelo_a_snapshot;b.ppc.rows.push({...b.ppc.rows[0]});assert.throws(()=>saveAnnualBook(d,b),/identificador repetido/);assert.equal(d.modelo_a_snapshot,old);
|
||||
});
|
||||
test('prepare only L8 delta cases for guarded API and report recovery',()=>{
|
||||
const cases={payable:setup(),recoverable:setup('1000','600'),balanced:setup('408','1000'),absent:setup(null,null),missing:setup(),duplicate:setup(),pec:setup()};cases.missing.b.ppc.rows[0].effective='';cases.duplicate.b.ppc.rows.push({...cases.duplicate.b.ppc.rows[0],id:'duplicate'});cases.pec.b.pec={state:'known',amount:'10',reference:'QA PEC separado'};
|
||||
fs.mkdirSync('outputs/irpc-l8-settlement',{recursive:true});for(const[name,{d,b}]of Object.entries(cases)){d.modelo_a_snapshot=saveAnnualBook(d,recordAnnual(d,b,now));fs.writeFileSync('outputs/irpc-l8-settlement/'+name+'.json',JSON.stringify(d));fs.writeFileSync('outputs/irpc-l8-settlement/'+name+'.html',annualHtml(d));}
|
||||
const{d}=setup();fs.writeFileSync('outputs/irpc-l8-settlement/initial.json',JSON.stringify(d));
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
$root=dirname(__DIR__);
|
||||
$operation=$argv[1]??'';
|
||||
if(!in_array($operation,['routes','test'],true)||basename($root)!=='portal-investidor-irpc-l7-common-qa-20261026')throw new RuntimeException('Explicit isolated QA workspace and routes/test operation required.');
|
||||
foreach(['storage/framework/views','storage/framework/cache','storage/framework/sessions','bootstrap/cache','storage/logs','outputs/irpc-l8-settlement']as$dir)if(!is_dir($root.'/'.$dir))mkdir($root.'/'.$dir,0700,true);
|
||||
foreach(['APP_BASE_PATH'=>$root,'APP_ENV'=>'testing','APP_KEY'=>'base64:'.base64_encode(str_repeat('Q',32)),'DB_CONNECTION'=>'pgsql','DB_HOST'=>'127.0.0.1','DB_PORT'=>'55441','DB_DATABASE'=>'simulator_fiscal_delta_20261021_test','DB_USERNAME'=>'postgres','DB_PASSWORD'=>'','DB_URL'=>'','CACHE_STORE'=>'array','SESSION_DRIVER'=>'array','QUEUE_CONNECTION'=>'sync','MAIL_MAILER'=>'array'] as $k=>$v){putenv($k.'='.$v);$_ENV[$k]=$_SERVER[$k]=$v;}
|
||||
$loader=require $root.'/vendor/autoload.php';
|
||||
spl_autoload_register(function($class)use($root){foreach(['App\\'=>'app/','Tests\\'=>'tests/','Database\\Factories\\'=>'database/factories/']as$prefix=>$dir){if(str_starts_with($class,$prefix)){$file=$root.'/'.$dir.str_replace('\\','/',substr($class,strlen($prefix))).'.php';if(is_file($file)){require $file;return;}}}},true,true);
|
||||
$app=require $root.'/bootstrap/app.php';
|
||||
(new Illuminate\Foundation\Bootstrap\LoadEnvironmentVariables)->bootstrap($app);
|
||||
(new Illuminate\Foundation\Bootstrap\LoadConfiguration)->bootstrap($app);
|
||||
$c=config('database.connections.pgsql');
|
||||
if($app->configurationIsCached()||$c['host']!=='127.0.0.1'||(int)$c['port']!==55441||$c['database']!=='simulator_fiscal_delta_20261021_test'||!empty($c['url']))throw new RuntimeException('QA guard');
|
||||
$pdo=new PDO('pgsql:host=127.0.0.1;port=55441;dbname='.$c['database'],$c['username'],$c['password']);
|
||||
if($pdo->query('select current_database()')->fetchColumn()!==$c['database'])throw new RuntimeException('QA identity');
|
||||
if($operation==='routes'){
|
||||
$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();
|
||||
$status=Illuminate\Support\Facades\Artisan::call('wayfinder:generate',['--with-form'=>true]);
|
||||
echo Illuminate\Support\Facades\Artisan::output();exit($status);
|
||||
}
|
||||
$argv=['phpunit','-c','phpunit.investmoz.xml','tests/Feature/IrpcAnnualSettlementDraftTest.php'];$argc=count($argv);$_SERVER['argv']=$argv;$_SERVER['argc']=$argc;require $root.'/vendor/phpunit/phpunit/phpunit';
|
||||
Reference in New Issue
Block a user