feat(iva): prepare four P102 exclusions with own conditions in QA
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
import {useState} from 'react';
|
||||
import {newExclusionBase,newExclusionPart,switchExclusionPart,exclusionBook,exclusionConditions,exclusionCurrent,exclusionReportRows,exclusionBaseSource,exclusionTreatments,partConditions,recordExclusions,saveExclusionBook,type ExclusionBook,type ExclusionRow,type ExclusionPart} from '@/lib/iva-exclusion-base';
|
||||
|
||||
export function SimulationExclusionBase({draft,onChange}:{draft:Record<string,unknown>;onChange:(field:string,value:string)=>void}){
|
||||
const[error,setError]=useState('');let book:ExclusionBook;
|
||||
try{book=exclusionBook(draft);}catch(cause){return<p role="alert">{String(cause)}</p>;}
|
||||
const save=(next:ExclusionBook)=>{try{onChange('modelo_a_snapshot',saveExclusionBook(draft,next));setError('');}catch(cause){setError(String(cause));}};
|
||||
const change=(id:string,patch:Partial<ExclusionRow>)=>save({...book,rows:book.rows.map(row=>row.id===id?{...row,...patch}:row)});
|
||||
return<details className="space-y-3 rounded border p-3">
|
||||
<summary>Base tributável — exclusões da contraprestação</summary>
|
||||
<p>Oportunidade: {String(draft.context_opportunity||'entrada geral')}. Prepare uma composição por operação,partindo do montante sem próprio IVA e com as inclusões aplicáveis já consideradas. Identifique as parcelas e se ainda estão incluídas;subtraia apenas essas parcelas,sem segunda exclusão.</p>
|
||||
<p>Cada tratamento tem condições próprias. Indemnização judicial não é qualquer compensação;desconto deve estar concedido;pagamento por conta alheia exige nome/conta do adquirente e adequadas contas de terceiros. Nas embalagens,não se exige devolução já realizada:confira a não transmissão,a indicação separada e a menção de devolução acordada. Reutilize documentos ou declare pressupostos de projecção. Base não é IVA nem regularização.</p>
|
||||
<p>{exclusionBaseSource}</p>{error&&<p role="alert">{error}</p>}
|
||||
<button type="button" className="underline" onClick={()=>save({...book,rows:[...book.rows,newExclusionBase(crypto.randomUUID(),String(draft.currency??''))]})}>Acrescentar exclusões de operação</button>
|
||||
{book.rows.map(row=>{const partChange=(id:string,patch:Partial<ExclusionPart>)=>change(row.id,{parts:row.parts.map(p=>p.id===id?{...p,...patch}:p)});return<details key={row.id} className="rounded border p-3"><summary>{row.description||'Operação por identificar'} — {row.period||'mês por indicar'}</summary>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<label>Natureza dos dados<select className="block rounded border p-2" value={row.origin} onChange={e=>change(row.id,{origin:e.target.value as ExclusionRow['origin']})}><option value="projected">Projectados / pressupostos</option><option value="effective">Efectivos declarados</option></select></label>
|
||||
{([['description','Operação identificada'],['operationRef','Referência da operação / pressuposto'],['initialAmount','Montante inicial sem próprio IVA (moeda indicada)'],['initialRef','Referência do montante inicial e da sua composição'],['documentRef','Documento da operação / referência da previsão'],['documentDate','Data do documento / prevista'],['period','Mês da operação'],['currency','Moeda de origem'],['exclusionRef','Referência da composição e exclusões aplicáveis'],['evidence','Fundamento factual / pressupostos']] as const).map(([field,label])=><label key={field}>{label}<input className="block rounded border p-2" type={field==='documentDate'?'date':field==='period'?'month':'text'} value={row[field]} onChange={e=>change(row.id,{[field]:e.target.value})}/></label>)}
|
||||
</div>
|
||||
<fieldset><legend>Condições das exclusões</legend>{exclusionConditions.map(([key,label])=><label className="block" key={key}>{label}<select className="block rounded border p-2" value={row.conditions[key]??''} onChange={e=>change(row.id,{conditions:{...row.conditions,[key]:e.target.value}})}><option value="">Por indicar</option><option value="yes">Sim</option><option value="no">Não</option></select></label>)}</fieldset>
|
||||
<button type="button" className="underline" onClick={()=>change(row.id,{parts:[...row.parts,newExclusionPart(crypto.randomUUID())]})}>Acrescentar parcela desta operação</button>
|
||||
{row.parts.map(part=><fieldset key={part.id} className="rounded border p-3"><legend>{part.description||'Parcela por identificar'}</legend>
|
||||
<label>Natureza da parcela<select className="block rounded border p-2" value={part.treatment} onChange={e=>partChange(part.id,switchExclusionPart(part,e.target.value))}><option value="">Por indicar</option>{exclusionTreatments.map(t=><option key={t.id} value={t.id}>{t.label}</option>)}</select></label>
|
||||
{([['description','Descrição da parcela'],['amount','Montante da parcela sem próprio IVA (moeda indicada)'],['reference','Referência da parcela / pressuposto']] as const).map(([field,label])=><label className="block" key={field}>{label}<input className="block rounded border p-2" value={part[field]} onChange={e=>partChange(part.id,{[field]:e.target.value})}/></label>)}
|
||||
<label>Esta parcela ainda está incluída no montante inicial?<select className="block rounded border p-2" value={part.included} onChange={e=>partChange(part.id,{included:e.target.value as ExclusionPart['included']})}><option value="">Por indicar</option><option value="yes">Sim — excluir uma vez</option><option value="no">Não — já excluída,conservar sem nova subtracção</option></select></label>
|
||||
<label>Âmbito da exclusão: operação, montantes e condições<input className="block rounded border p-2" value={part.scope} onChange={e=>partChange(part.id,{scope:e.target.value})}/></label>
|
||||
{part.treatment==='third_party_payment'&&<><label>Referência das adequadas contas de terceiros<input className="block rounded border p-2" value={part.accountRef} onChange={e=>partChange(part.id,{accountRef:e.target.value})}/></label><label>Adquirente/destinatário em nome e por conta de quem se pagou<input className="block rounded border p-2" value={part.beneficiaryRef} onChange={e=>partChange(part.id,{beneficiaryRef:e.target.value})}/></label></>}
|
||||
{part.treatment==='returnable_packaging'&&<label>Indicação separada e menção de devolução acordada na factura/documento<input className="block rounded border p-2" value={part.documentExcerpt} onChange={e=>partChange(part.id,{documentExcerpt:e.target.value})}/></label>}
|
||||
{partConditions(part.treatment).map(([key,label])=><label className="block" key={key}>{label}<select className="block rounded border p-2" value={part.conditions[key]??''} onChange={e=>partChange(part.id,{conditions:{...part.conditions,[key]:e.target.value as ExclusionPart['included']}})}><option value="">Por indicar</option><option value="yes">Sim</option><option value="no">Não</option></select></label>)}
|
||||
<p>Mudar a natureza conserva os montantes e referências,mas exige rever a correspondência da parcela. Cada parcela será excluída uma única vez nesta operação. Os campos sem aplicação ficam ocultos e conservados.</p>
|
||||
</fieldset>)}
|
||||
</details>;})}
|
||||
{!!book.rows.length&&<button type="button" className="rounded bg-emerald-800 p-2 text-white" onClick={()=>{try{save(recordExclusions(draft,book));}catch(cause){setError(String(cause));}}}>Aplicar exclusões à base e guardar versão</button>}
|
||||
{book.result&&<><p role="status">{exclusionCurrent(draft,book)?'Exclusões correspondentes às entradas guardadas.':'Entradas alteradas;exclusões históricas conservadas. Reveja e determine novamente.'} {book.history?.length??0} versões anteriores. IVA e campo11 não são preenchidos por estas bases.</p><dl>{exclusionReportRows(draft).map(([,label,value,origin],index)=><div key={index}><dt className="font-semibold">{label}</dt><dd>{value}<small className="block">{origin}</small></dd></div>)}</dl></>}
|
||||
</details>;
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { SimulationActSaleBase } from './simulation-act-sale-base';
|
||||
import { SimulationFuelBase } from './simulation-fuel-base';
|
||||
import { SimulationNoncashBase } from './simulation-noncash-base';
|
||||
import { SimulationCompositionBase } from './simulation-composition-base';
|
||||
import { SimulationExclusionBase } from './simulation-exclusion-base';
|
||||
import { SimulationOfferPreparations } from './simulation-offer-preparations';
|
||||
import { SimulationOperations } from './simulation-operations';
|
||||
|
||||
@@ -102,6 +103,7 @@ export function SimulationMonthlyIva({draft, busy, onCalculate, onChange}: {
|
||||
<SimulationFuelBase draft={draft} onChange={onChange} />
|
||||
<SimulationNoncashBase draft={draft} onChange={onChange} />
|
||||
<SimulationCompositionBase draft={draft} onChange={onChange} />
|
||||
<SimulationExclusionBase draft={draft} onChange={onChange} />
|
||||
<SimulationOfferPreparations draft={draft} onChange={onChange} />
|
||||
<SimulationModeloA draft={draft} />
|
||||
</section>;
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import {acquisitionScope,readAcquisitionStore} from './iva-acquisitions.ts';
|
||||
|
||||
export const exclusionBaseVersion='consideration-exclusions-base/1';
|
||||
export const exclusionBaseSource='CIVA republicado Lei13/2016,15(6)(a–d),PDF14/p1426(272):exclui indemnizações declaradas judicialmente por incumprimento total/parcial de contratos;descontos,abatimentos ou bónus concedidos;quantias pagas em nome e por conta do adquirente/destinatário registadas em adequadas contas de terceiros;embalagens não efectivamente transaccionadas com elementos27(5)(b)na factura/documento equivalente.27(5)(b),PDF17/p1426(275):indicação separada e menção expressa da devolução acordada. Lei22/2022PDF3mantém15(6);Lei10/2025PDF14mantém27(5)(b),altera27(10),não15. Recorte2026/MZN,base apenas,sem IVA/dedução/campo11/regularizações ou totaisModeloA.';
|
||||
export const exclusionTreatments=[{id:'judicial_indemnity',label:'Indemnização declarada judicialmente por incumprimento contratual'},{id:'granted_discount',label:'Desconto,abatimento ou bónus concedido'},{id:'third_party_payment',label:'Quantia paga em nome e por conta do adquirente/destinatário'},{id:'returnable_packaging',label:'Embalagem não efectivamente transaccionada,com devolução acordada'}] as const;
|
||||
export const exclusionConditions=[
|
||||
['operationIdentified','A operação e os montantes relacionados estão identificados'],
|
||||
['initialNet','O montante inicial exclui o próprio IVA e tem a composição aplicável15(5)identificada,antes das exclusões ainda não consideradas'],
|
||||
['completeComposition','As exclusões aplicáveis estão reconciliadas com o montante inicial,sem omissões ou dupla subtracção'],
|
||||
['specialRules','Esta composição respeita a regra aplicável da operação,sem substituir bases especiais ou regularizações de outros períodos'],
|
||||
] as const;
|
||||
export const exclusionPartConditions={
|
||||
judicial_indemnity:[['judicialDeclaration','A indemnização recebida foi declarada judicialmente'],['contractBreach','Corresponde ao incumprimento total ou parcial de contrato']],
|
||||
granted_discount:[['granted','O desconto,abatimento ou bónus está concedido e corresponde a esta operação']],
|
||||
third_party_payment:[['paidInName','A quantia foi paga em nome do adquirente/destinatário identificado'],['paidForAccount','A quantia foi paga por conta desse adquirente/destinatário'],['thirdPartyAccounts','Está registada pelo contribuinte em adequadas contas de terceiros']],
|
||||
returnable_packaging:[['notTransacted','As embalagens não foram efectivamente transaccionadas'],['separateIndication','Constam separadamente na factura ou documento equivalente'],['returnMention','Consta menção expressa de que foi acordada a devolução das embalagens']],
|
||||
} as const;
|
||||
export function partConditions(treatment:string):ReadonlyArray<readonly[string,string]>{return exclusionPartConditions[treatment as keyof typeof exclusionPartConditions]??[];}
|
||||
type Choice=''|'yes'|'no';type Condition=typeof exclusionConditions[number][0];
|
||||
export type ExclusionPart={id:string;treatment:string;description:string;amount:string;included:Choice;reference:string;scope:string;accountRef:string;beneficiaryRef:string;documentExcerpt:string;conditions:Partial<Record<string,Choice>>};
|
||||
export type ExclusionRow={id:string;description:string;period:string;origin:'projected'|'effective';currency:string;initialAmount:string;initialRef:string;operationRef:string;documentRef:string;documentDate:string;exclusionRef:string;evidence:string;conditions:Partial<Record<Condition,Choice>>;parts:ExclusionPart[]};
|
||||
type LineResult={id:string;status:'determined'|'not_applicable'|'indeterminate';baseAmount:string|null;vat:null;rate:null;reason:string};
|
||||
export type ExclusionCalculation={version:string;revision:number;calculatedAt:string;opportunity:string;location:string;sourceKey:string;source:string;inputs:ExclusionRow[];rows:LineResult[]};
|
||||
export type ExclusionBook={rows:ExclusionRow[];result?:ExclusionCalculation;history?:ExclusionCalculation[]};
|
||||
type Store=ReturnType<typeof readAcquisitionStore>&{considerationExclusions?:Record<string,ExclusionBook>};
|
||||
export function newExclusionPart(id:string):ExclusionPart{return{id,treatment:'',description:'',amount:'',included:'',reference:'',scope:'',accountRef:'',beneficiaryRef:'',documentExcerpt:'',conditions:{}};}
|
||||
export function newExclusionBase(id:string,currency:string):ExclusionRow{return{id,description:'',period:'',origin:'projected',currency,initialAmount:'',initialRef:'',operationRef:'',documentRef:'',documentDate:'',exclusionRef:'',evidence:'',conditions:{},parts:[]};}
|
||||
export function switchExclusionPart(part:ExclusionPart,treatment:string):ExclusionPart{return{...part,treatment,conditions:{}};}
|
||||
function readStore(draft:Record<string,unknown>):Store{
|
||||
const store=readAcquisitionStore(draft.modelo_a_snapshot) as Store;
|
||||
if(store.considerationExclusions!==undefined&&(!store.considerationExclusions||typeof store.considerationExclusions!=='object'||Array.isArray(store.considerationExclusions)))throw new Error('Exclusões não recuperáveis;originais preservados.');
|
||||
const fields=Object.keys(newExclusionBase('','')).filter(key=>!['conditions','parts'].includes(key));const partFields=Object.keys(newExclusionPart('')).filter(key=>key!=='conditions');
|
||||
for(const book of Object.values(store.considerationExclusions??{}))if(!book||!Array.isArray(book.rows)||(book.history!==undefined&&!Array.isArray(book.history))||book.rows.some(row=>!row||fields.some(key=>typeof row[key as keyof ExclusionRow]!=='string')||!row.conditions||typeof row.conditions!=='object'||Array.isArray(row.conditions)||!Array.isArray(row.parts)||row.parts.some(part=>!part||partFields.some(key=>typeof part[key as keyof ExclusionPart]!=='string')||!part.conditions||typeof part.conditions!=='object'||Array.isArray(part.conditions))))throw new Error('Linhas de exclusão não recuperáveis;originais preservados,sem substituição por lista vazia.');
|
||||
return store;
|
||||
}
|
||||
export function exclusionBook(draft:Record<string,unknown>):ExclusionBook{return readStore(draft).considerationExclusions?.[acquisitionScope(draft)]??{rows:[]};}
|
||||
export function saveExclusionBook(draft:Record<string,unknown>,book:ExclusionBook):string{const current=readStore(draft),encoded=JSON.stringify({...current,considerationExclusions:{...current.considerationExclusions,[acquisitionScope(draft)]:book}});readStore({...draft,modelo_a_snapshot:encoded});return encoded;}
|
||||
function decimal(raw:unknown):bigint|null{if(typeof raw!=='string'||!/^\d{1,15}(\.\d{1,6})?$/.test(raw.trim()))return null;const[whole,fraction='']=raw.trim().split('.');return BigInt(whole)*1000000n+BigInt(fraction.padEnd(6,'0'));}
|
||||
function money(n:bigint):string{return`${n/1000000n}.${(n%1000000n).toString().padStart(6,'0').replace(/0+$/,'').padEnd(2,'0')}`;}
|
||||
function validDate(value:string):boolean{const d=new Date(value+'T00:00:00Z');return /^\d{4}-\d{2}-\d{2}$/.test(value)&&Number.isFinite(d.getTime())&&d.toISOString().slice(0,10)===value;}
|
||||
export function evaluateExclusion(row:ExclusionRow,currency:unknown):LineResult{
|
||||
const result:LineResult={id:row.id,status:'indeterminate',baseAmount:null,vat:null,rate:null,reason:''};const stop=(reason:string,status:LineResult['status']='indeterminate')=>({...result,status,reason});
|
||||
if(currency!=='MZN'||row.currency!==currency)return stop('Moeda fora do âmbito MZN ou divergente do projecto;sem conversão silenciosa.');
|
||||
if(!/^2026-(0[1-9]|1[0-2])$/.test(row.period)||!['projected','effective'].includes(row.origin))return stop('Indique mês em2026 e dados efectivos declarados ou pressupostos de projecção.');
|
||||
const failed=exclusionConditions.filter(([key])=>row.conditions[key]==='no');if(failed.length)return stop('Não se aplica esta composição: '+failed.map(([,label])=>label).join('; ')+'. Não é isenção nem IVA zero.','not_applicable');
|
||||
const missing=exclusionConditions.filter(([key])=>row.conditions[key]!=='yes');if(missing.length)return stop('Condições por indicar: '+missing.map(([,label])=>label).join('; ')+'. Desconhecido não é zero.');
|
||||
if(!row.description.trim()||!row.initialRef.trim()||!row.operationRef.trim()||!row.documentRef.trim()||!row.exclusionRef.trim()||!row.evidence.trim()||!validDate(row.documentDate))return stop('Identifique operação,montante inicial,documento/data,composição e fundamento ou pressupostos expressos;reutilize referências existentes,sem certificado/aprovador novo.');
|
||||
const initial=decimal(row.initialAmount);if(initial===null)return stop('Indique o montante inicial sem próprio IVA,com as inclusões aplicáveis consideradas e antes das exclusões ainda não realizadas;desconhecido não é zero.');
|
||||
if(!row.parts.length||new Set(row.parts.map(p=>p.id)).size!==row.parts.length||row.parts.some(p=>!p.id.trim()))return stop('Identifique as parcelas desta operação com identificadores distintos;sem subtracção de linhas vazias ou duplicadas.');
|
||||
let exclusions=0n;const formulas:string[]=[];
|
||||
for(const part of row.parts){
|
||||
const rule=exclusionTreatments.find(t=>t.id===part.treatment);if(!rule)return stop('Identifique o tratamento de cada parcela;uma descrição genérica não demonstra exclusão.');
|
||||
const own=partConditions(part.treatment),failed=own.filter(([key])=>part.conditions[key]==='no');
|
||||
if(failed.length)return stop('Não se aplica a exclusão da parcela '+part.id+': '+failed.map(([,label])=>label).join('; ')+'. Sem subtracção automática ou IVA zero.','not_applicable');
|
||||
if(own.some(([key])=>part.conditions[key]!=='yes')||!['yes','no'].includes(part.included))return stop('Confirme as condições próprias da parcela '+part.id+' e se ainda está incluída no montante inicial;desconhecido não permite presumir exclusão ou zero.');
|
||||
if(!part.description.trim()||!part.reference.trim()||!part.scope.trim())return stop('Identifique descrição,referência e âmbito da parcela '+part.id+',ou pressuposto expresso. Na indemnização,a referência identifica a declaração judicial e o contrato;no desconto,a concessão.');
|
||||
if(part.treatment==='third_party_payment'&&(!part.accountRef.trim()||!part.beneficiaryRef.trim()))return stop('Identifique as adequadas contas de terceiros e o adquirente/destinatário em nome e por conta de quem se pagou,ou pressupostos expressos;despesa própria não equivale a pagamento por conta alheia.');
|
||||
if(part.treatment==='returnable_packaging'&&!part.documentExcerpt.trim())return stop('Identifique na referência da factura/documento a indicação separada e a menção expressa da devolução acordada,ou pressuposto expresso;sem exigir devolução já realizada.');
|
||||
const amount=decimal(part.amount);if(amount===null)return stop('Indique o montante exacto da parcela '+part.id+';desconhecido não é zero.');
|
||||
if(part.included==='yes')exclusions+=amount;
|
||||
formulas.push(`${part.id}:${rule.label},${money(amount)}MZN,${part.included==='yes'?'excluída uma vez':'já excluída,sem segunda subtracção'}`);
|
||||
}
|
||||
if(exclusions>initial)return stop('As exclusões ainda incluídas excedem o montante inicial;reveja a composição,sem base negativa ou piso zero presumido.');
|
||||
const base=initial-exclusions;return{...result,status:'determined',baseAmount:money(base),reason:`Base15(6):montante inicial ${money(initial)} − exclusões ainda incluídas ${money(exclusions)} = ${money(base)}MZN;${formulas.join('; ')},${row.origin==='projected'?'nas condições expressamente assumidas na projecção':'nos dados efectivos declarados'}. Base única por operação. Base não é imposto;IVA/taxa,dedução/campo11,regularizações e totaisModeloA não são determinados por esta composição.`};
|
||||
|
||||
}
|
||||
function key(draft:Record<string,unknown>,book:ExclusionBook):string{return JSON.stringify({version:exclusionBaseVersion,scope:acquisitionScope(draft),location:draft.selection_labels??{},currency:draft.currency,rows:book.rows});}
|
||||
export function exclusionCurrent(draft:Record<string,unknown>,book:ExclusionBook):boolean{return!!book.result&&book.result.sourceKey===key(draft,book);}
|
||||
export function recordExclusions(draft:Record<string,unknown>,book:ExclusionBook):ExclusionBook{
|
||||
if(!book.rows.length||new Set(book.rows.map(row=>row.id)).size!==book.rows.length)throw new Error('Indique operações com identificadores distintos;originais conservados.');
|
||||
const result:ExclusionCalculation={version:exclusionBaseVersion,revision:(book.result?.revision??0)+1,calculatedAt:new Date().toISOString(),opportunity:acquisitionScope(draft),location:JSON.stringify(draft.selection_labels??{}),sourceKey:key(draft,book),source:exclusionBaseSource,inputs:structuredClone(book.rows),rows:book.rows.map(row=>evaluateExclusion(row,draft.currency))};
|
||||
const next={...book,result,history:[...(book.history??[]),...(book.result?[book.result]:[])]};saveExclusionBook(draft,next);return next;
|
||||
}
|
||||
export function exclusionReportRows(draft:Record<string,unknown>):Array<[string,string,string,string]>{
|
||||
try{const book=exclusionBook(draft),result=book.result;if(!result)return[];return result.inputs.map((row,index)=>['IVA mensal',`Base P102 — ${row.period} — ${row.id}`,`${row.description};base tributável: ${result.rows[index].baseAmount??'Indeterminada'} ${row.currency};IVA: Indeterminado;${result.rows[index].reason}`,`${result.opportunity};${result.location};${exclusionCurrent(draft,book)?'Resultado actual':'Entradas alteradas;resultado histórico conservado'};versão${result.revision};${result.calculatedAt};${result.source};${row.origin==='projected'?'Projecção':'Dados efectivos declarados'};operação ${row.operationRef};montante inicial ${row.initialAmount} ${row.currency},referência ${row.initialRef};parcelas ${row.parts.map(p=>`${p.id}/${p.treatment},${p.description},${p.amount} ${row.currency},já incluída:${p.included==='yes'?'Sim':p.included==='no'?'Não':'Por indicar'},referência ${p.reference},âmbito ${p.scope}${p.treatment==='third_party_payment'?`,contas ${p.accountRef},adquirente/destinatário ${p.beneficiaryRef}`:''}${p.treatment==='returnable_packaging'?`,menção documental ${p.documentExcerpt}`:''},condições ${partConditions(p.treatment).map(([key,label])=>label+': '+(p.conditions[key]==='yes'?'Sim':p.conditions[key]==='no'?'Não':'Por indicar')).join('; ')}`).join('; ')};composição ${row.exclusionRef};documento ${row.documentRef},${row.documentDate};condições ${exclusionConditions.map(([key,label])=>label+': '+(row.conditions[key]==='yes'?'Sim':row.conditions[key]==='no'?'Não':'Por indicar')).join('; ')};fundamento ${row.evidence};não preenche totaisModeloA ou campo11.`]);}
|
||||
catch{return[['IVA mensal','Composição da contraprestação','Dados não recuperáveis','Originais preservados;sem substituir por zero.']];}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { actSaleReportRows } from './iva-act-sale-base.ts';
|
||||
import { fuelBaseReportRows } from './iva-fuel-base.ts';
|
||||
import { noncashBaseReportRows } from './iva-noncash-base.ts';
|
||||
import { compositionReportRows } from './iva-composition-base.ts';
|
||||
import { exclusionReportRows } from './iva-exclusion-base.ts';
|
||||
import { offerPreparationReportRows } from './iva-offer-preparations.ts';
|
||||
import { salesReportRows } from './iva-operations.ts';
|
||||
import { fiscalQaRows } from './fiscal-qa-preview.ts';
|
||||
@@ -632,6 +633,7 @@ export function buildProjectDossierRows(
|
||||
rows.push(...fuelBaseReportRows(draft));
|
||||
rows.push(...noncashBaseReportRows(draft));
|
||||
rows.push(...compositionReportRows(draft));
|
||||
rows.push(...exclusionReportRows(draft));
|
||||
rows.push(...offerPreparationReportRows(draft));
|
||||
const operationRows = salesReportRows(draft);
|
||||
rows.push(...operationRows);
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
<?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 IvaExclusionBaseDraftTest extends TestCase
|
||||
{
|
||||
use DatabaseTransactions;
|
||||
|
||||
public function test_exclusion_bases_recover_privately_preserving_all_delivered_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()]));
|
||||
$draft = json_decode(file_get_contents(base_path('tests/frontend/fixtures/iva-g7g-bases.json')), true, flags: JSON_THROW_ON_ERROR);
|
||||
$id = $this->postJson('/api/v1/simulation-drafts', ['personal_snapshot' => [], 'economic_snapshot' => $draft], ['Idempotency-Key' => (string) Str::uuid()])->assertCreated()->json('data.id');
|
||||
$recovered = $this->getJson('/api/v1/simulation-drafts/'.$id)->assertOk()->json('data.economic_snapshot');
|
||||
$this->assertSame($draft['modelo_a_snapshot'], $recovered['modelo_a_snapshot']);
|
||||
$this->assertSame('opportunity:345', $recovered['context_opportunity']);
|
||||
$this->assertSame('Manica', $recovered['selection_labels']['province_id']);
|
||||
$store = json_decode($recovered['modelo_a_snapshot'], true, flags: JSON_THROW_ON_ERROR);
|
||||
$book = $store['considerationExclusions']['opportunity:345'];
|
||||
$this->assertCount(1, $book['rows']);
|
||||
$this->assertCount(4, $book['rows'][0]['parts']);
|
||||
$this->assertSame(['judicial_indemnity','granted_discount','third_party_payment','returnable_packaging'], array_column($book['rows'][0]['parts'], 'treatment'));
|
||||
$this->assertSame(['799.70'], array_column($book['result']['rows'], 'baseAmount'));
|
||||
$this->assertSame(['determined'], array_column($book['result']['rows'], 'status'));
|
||||
$this->assertSame([null], array_column($book['result']['rows'], 'vat'));
|
||||
$this->assertSame([null], array_column($book['result']['rows'], 'rate'));
|
||||
$this->assertSame('1000.25', $book['rows'][0]['initialAmount']);
|
||||
$this->assertSame(['100.10','50.25','30.15','20.05'], array_column($book['rows'][0]['parts'], 'amount'));
|
||||
$this->assertSame(['yes','yes','yes','yes'], array_column($book['rows'][0]['parts'], 'included'));
|
||||
$this->assertSame('yes', $book['rows'][0]['parts'][0]['conditions']['judicialDeclaration']);
|
||||
$this->assertSame('yes', $book['rows'][0]['parts'][1]['conditions']['granted']);
|
||||
$this->assertSame('yes', $book['rows'][0]['parts'][2]['conditions']['thirdPartyAccounts']);
|
||||
$this->assertSame('yes', $book['rows'][0]['parts'][3]['conditions']['returnMention']);
|
||||
$this->assertStringContainsString('contas de terceiros A', $book['rows'][0]['parts'][2]['accountRef']);
|
||||
$this->assertStringContainsString('adquirente B', $book['rows'][0]['parts'][2]['beneficiaryRef']);
|
||||
$this->assertStringContainsString('devolução acordada', $book['rows'][0]['parts'][3]['documentExcerpt']);
|
||||
$this->assertStringContainsString('declaração judicial', $book['rows'][0]['parts'][0]['reference']);
|
||||
$this->assertStringContainsString('15(6)', $book['result']['source']);
|
||||
$this->assertSame($book['rows'], $book['result']['inputs']);
|
||||
$this->assertCount(2, $book['history']);
|
||||
$this->assertNull($book['history'][0]['rows'][0]['baseAmount']);
|
||||
$frozen = json_decode(file_get_contents(base_path('tests/frontend/fixtures/iva-g7g-preserved-inputs.json')), true, flags: JSON_THROW_ON_ERROR);
|
||||
$prior = json_decode($frozen['modelo_a_snapshot'], true, flags: JSON_THROW_ON_ERROR);
|
||||
$this->assertSame($prior, array_diff_key($store, ['considerationExclusions' => true]));
|
||||
$this->assertSame('1250.30', $store['publicAuthorityBases']['opportunity:339']['result']['rows'][1]['baseAmount']);
|
||||
$this->assertNull($store['publicAuthorityBases']['opportunity:339']['result']['rows'][1]['vat']);
|
||||
$this->assertSame('400.00', $store['importExemptions']['opportunity:338']['result']['rows'][1]['importVat']);
|
||||
$this->assertNull($store['books']['opportunity:329']['result']['periods'][0]['fields']['11']);
|
||||
file_put_contents(base_path('outputs/iva-l1-g7g-exclusions-20261024/recovered.json'), json_encode($recovered, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE));
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,42 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import {newExclusionBase,newExclusionPart,switchExclusionPart,exclusionTreatments,exclusionConditions,partConditions,evaluateExclusion,recordExclusions,saveExclusionBook,exclusionBook,exclusionCurrent,exclusionReportRows} from '../../resources/js/lib/iva-exclusion-base.ts';
|
||||
import {compositionCurrent} from '../../resources/js/lib/iva-composition-base.ts';
|
||||
import {fuelBaseCurrent} from '../../resources/js/lib/iva-fuel-base.ts';
|
||||
import {createProjectDossierReport} from '../../resources/js/lib/project-dossier-report.ts';
|
||||
const frozen=JSON.parse(fs.readFileSync(new URL('./fixtures/iva-g7g-preserved-inputs.json',import.meta.url),'utf8'));
|
||||
const part=(treatment='judicial_indemnity',id=treatment)=>({...newExclusionPart(id),treatment,description:'SYNTHETIC QA '+treatment,amount:({judicial_indemnity:'100.10',granted_discount:'50.25',third_party_payment:'30.15',returnable_packaging:'20.05'})[treatment],included:'yes',reference:'SYNTHETIC QA referência '+id+' / declaração judicial e contrato quando aplicável',scope:'SYNTHETIC QA operação A,montante e condições deste tratamento',accountRef:'SYNTHETIC QA contas de terceiros A',beneficiaryRef:'SYNTHETIC QA adquirente B',documentExcerpt:'SYNTHETIC QA factura A,embalagens separadas,devolução acordada expressamente',conditions:Object.fromEntries(partConditions(treatment).map(([key])=>[key,'yes']))});
|
||||
const row=()=>({...newExclusionBase('g7g-operation-A','MZN'),description:'SYNTHETIC QA exclusões operação A',period:'2026-12',operationRef:'SYNTHETIC QA operação A',initialAmount:'1000.25',initialRef:'SYNTHETIC QA montante sem próprioIVA,inclusões15(5)consideradas',documentRef:'SYNTHETIC QA G7G documento A',documentDate:'2026-12-12',exclusionRef:'SYNTHETIC QA quatro exclusões reconciliadas,sem duplicação',evidence:'SYNTHETIC QA pressupostos expressos,não documento real',conditions:Object.fromEntries(exclusionConditions.map(([key])=>[key,'yes'])),parts:exclusionTreatments.map(t=>part(t.id))});
|
||||
const draft=()=>({...structuredClone(frozen),context_opportunity:'opportunity:345',project_title:'SYNTHETIC QA — G7G / Exclusões da contraprestação'});
|
||||
|
||||
test('P102 four treatments each own statutoryconditions and references;unknown/no never become automatic exclusion',()=>{
|
||||
const input=row(),ok=evaluateExclusion(input,'MZN');assert.equal(ok.baseAmount,'799.70');assert.equal(ok.vat,null);assert.equal(ok.rate,null);
|
||||
for(const[key]of exclusionConditions)for(const value of ['','unknown','no']){const r=evaluateExclusion({...input,conditions:{...input.conditions,[key]:value}},'MZN');assert.equal(r.status,value==='no'?'not_applicable':'indeterminate');assert.equal(r.baseAmount,null);}
|
||||
for(const t of exclusionTreatments){const p=part(t.id);assert.equal(evaluateExclusion({...input,parts:[p]},'MZN').status,'determined');for(const[key]of partConditions(t.id))for(const value of ['','unknown','no']){const r=evaluateExclusion({...input,parts:[{...p,conditions:{...p.conditions,[key]:value}}]},'MZN');assert.equal(r.status,value==='no'?'not_applicable':'indeterminate');assert.equal(r.baseAmount,null);assert.equal(r.vat,null);}
|
||||
for(const patch of [{reference:''},{scope:''},{description:''},{included:''},{amount:''}])assert.equal(evaluateExclusion({...input,parts:[{...p,...patch}]},'MZN').baseAmount,null);
|
||||
}
|
||||
for(const patch of [{description:''},{operationRef:''},{initialRef:''},{documentRef:''},{documentDate:'2026-02-30'},{exclusionRef:''},{evidence:''},{period:'2025-12'},{currency:'USD'}])assert.equal(evaluateExclusion({...input,...patch},'MZN').baseAmount,null);
|
||||
for(const patch of [{accountRef:''},{beneficiaryRef:''}])assert.equal(evaluateExclusion({...input,parts:[{...part('third_party_payment'),...patch}]},'MZN').baseAmount,null);assert.equal(evaluateExclusion({...input,parts:[{...part('returnable_packaging'),documentExcerpt:''}]},'MZN').baseAmount,null);
|
||||
});
|
||||
|
||||
test('subtract included exclusions only,alreadyexcluded retained;exactdecimal/zero/excess;switch retains unrelated references and clearsconditions',()=>{
|
||||
const input=row();assert.equal(evaluateExclusion({...input,parts:input.parts.map(p=>({...p,included:'no'}))},'MZN').baseAmount,'1000.25');assert.equal(evaluateExclusion({...input,parts:input.parts.map(p=>({...p,included:p.treatment==='judicial_indemnity'?'no':'yes'}))},'MZN').baseAmount,'899.80');
|
||||
assert.equal(evaluateExclusion({...input,initialAmount:'0',parts:[{...part(),amount:'0'}]},'MZN').baseAmount,'0.00');assert.equal(evaluateExclusion({...input,initialAmount:'1.000002',parts:[{...part(),amount:'0.000001'}]},'MZN').baseAmount,'1.000001');assert.equal(evaluateExclusion({...input,initialAmount:'999999999999999.999999',parts:[{...part(),amount:'0.000001'}]},'MZN').baseAmount,'999999999999999.999998');
|
||||
for(const amount of ['-1','NaN','0.0000001','','1000.26'])assert.equal(evaluateExclusion({...input,parts:[{...part(),amount}]},'MZN').baseAmount,null);assert.equal(evaluateExclusion({...input,initialAmount:''},'MZN').baseAmount,null);
|
||||
const switched=switchExclusionPart(part('third_party_payment'),'returnable_packaging');assert.deepEqual(switched.conditions,{});for(const field of ['amount','reference','scope','accountRef','beneficiaryRef','documentExcerpt','included'])assert.equal(switched[field],part('third_party_payment')[field]);assert.equal(evaluateExclusion({...input,parts:[switched]},'MZN').baseAmount,null);assert.equal(evaluateExclusion({...input,parts:[]},'MZN').baseAmount,null);assert.equal(evaluateExclusion({...input,parts:[part(),part()]},'MZN').baseAmount,null);
|
||||
});
|
||||
|
||||
test('save/recovery/history/report keep one base/four exclusions and all84 previous books/currentkeys unchanged',()=>{
|
||||
const d=draft(),prior=JSON.parse(d.modelo_a_snapshot);assert.deepEqual(exclusionBook(d),{rows:[]});assert.deepEqual(exclusionReportRows(d),[]);
|
||||
let book=recordExclusions(d,{rows:[{...row(),conditions:{}}]});book=recordExclusions(d,{...book,rows:[{...row(),parts:[{...part('granted_discount'),conditions:{granted:'no'}}]}]});book=recordExclusions(d,{...book,rows:[row()]});assert.equal(book.history.length,2);assert.ok(book.history.every(h=>h.rows.every(r=>r.baseAmount===null&&r.vat===null)));assert.ok(exclusionCurrent(d,book));assert.equal(book.result.rows.length,1);assert.equal(book.result.inputs[0].parts.length,4);
|
||||
for(const field of ['amount','included','reference','treatment','scope','accountRef','beneficiaryRef','documentExcerpt'])assert.ok(!exclusionCurrent(d,{...book,rows:book.rows.map(r=>({...r,parts:r.parts.map(p=>({...p,[field]:'changed'}))}))}));for(const patch of [{context_opportunity:'opportunity:346'},{currency:'USD'},{selection_labels:{province_id:'Maputo'}}])assert.ok(!exclusionCurrent({...d,...patch},book));
|
||||
d.modelo_a_snapshot=saveExclusionBook(d,book);assert.deepEqual(exclusionBook(JSON.parse(JSON.stringify(d))),book);const next=JSON.parse(d.modelo_a_snapshot);const{considerationExclusions,...untouched}=next;assert.deepEqual(untouched,prior);for(const[scope,previous]of Object.entries(prior.considerationCompositions))assert.ok(compositionCurrent({...d,context_opportunity:scope},previous));for(const[scope,previous]of Object.entries(prior.regulatedFuelBases))assert.ok(fuelBaseCurrent({...d,context_opportunity:scope},previous));
|
||||
assert.equal(next.considerationCompositions['opportunity:344'].result.rows[0].baseAmount,'1150.60');assert.equal(next.noncashBases['opportunity:343'].result.rows[1].baseAmount,'2700.75');assert.equal(next.regulatedFuelBases['opportunity:342'].result.rows[0].baseAmount,'1400.15');assert.equal(next.importExemptions['opportunity:338'].result.rows[1].importVat,'400.00');assert.equal(next.books['opportunity:329'].result.periods[0].fields['11'],null);
|
||||
const report=JSON.stringify(createProjectDossierReport(d,{selectedSections:new Set(['finance']),version:'QA G7G'}));for(const token of ['Base P102','799.70','IVA: Indeterminado','opportunity:345','Manica','2026-12','operação A','15(6)','100.10','27(5)(b)','contas de terceiros A','devolução acordada','campo11'])assert.ok(report.includes(token),token);assert.equal(exclusionReportRows(d).length,1);assert.deepEqual(exclusionReportRows({...d,context_opportunity:'opportunity:346'}),[]);
|
||||
if(process.env.IVA_G7G_CALCULATED)fs.writeFileSync(process.env.IVA_G7G_CALCULATED,JSON.stringify(d));
|
||||
});
|
||||
|
||||
test('corrupt nested exclusions/store cannot overwrite originals;duplicate operation ids cannot be recorded',()=>{
|
||||
const d=draft(),original=d.modelo_a_snapshot;for(const patch of [{initialAmount:4},{conditions:[]},{parts:{}},{parts:[{...part(),amount:4}]},{parts:[{...part(),conditions:[]}]},{parts:[null]}])assert.throws(()=>saveExclusionBook(d,{rows:[{...row(),...patch}]}),/originais preservados/);assert.equal(d.modelo_a_snapshot,original);assert.throws(()=>recordExclusions(d,{rows:[row(),row()]}),/distintos/);assert.throws(()=>recordExclusions(d,{rows:[]}),/distintos/);assert.throws(()=>exclusionBook({...d,modelo_a_snapshot:JSON.stringify({...JSON.parse(original),considerationExclusions:[]})}),/originais preservados/);
|
||||
});
|
||||
Reference in New Issue
Block a user