Implement P092 P093 P125 individual secondhand bases in QA

This commit is contained in:
2026-10-25 00:27:37 +02:00
parent ec786f4ae2
commit b0818c0163
8 changed files with 221 additions and 0 deletions
@@ -21,6 +21,7 @@ import { SimulationListedServices } from './simulation-listed-services';
import { SimulationReducedBases } from './simulation-reduced-bases';
import { SimulationOwnName } from './simulation-own-name';
import { SimulationGoodsAllocation } from './simulation-goods-allocation';
import { SimulationSecondhand } from './simulation-secondhand';
import { SimulationOfferPreparations } from './simulation-offer-preparations';
import { SimulationOperations } from './simulation-operations';
@@ -120,6 +121,7 @@ export function SimulationMonthlyIva({draft, busy, onCalculate, onChange}: {
<SimulationReducedBases draft={draft} onChange={onChange} />
<SimulationOwnName draft={draft} onChange={onChange} />
<SimulationGoodsAllocation draft={draft} onChange={onChange} />
<SimulationSecondhand draft={draft} onChange={onChange} />
<SimulationOfferPreparations draft={draft} onChange={onChange} />
<SimulationModeloA draft={draft} />
</section>;
@@ -0,0 +1,35 @@
import {useState} from 'react';
import {newSecondhand,switchSecondhandTreatment,switchSecondhandCategory,secondhandBook,secondhandConditions,secondhandCategoryConditions,secondhandCurrent,secondhandReportRows,secondhandSource,secondhandTreatments,secondhandCategories,recordSecondhands,saveSecondhandBook,type SecondhandBook,type SecondhandRow} from '@/lib/iva-secondhand';
export function SimulationSecondhand({draft,onChange}:{draft:Record<string,unknown>;onChange:(field:string,value:string)=>void}){
const[error,setError]=useState('');let book:SecondhandBook;
try{book=secondhandBook(draft);}catch(cause){return<p role="alert">{String(cause)}</p>;}
const save=(next:SecondhandBook)=>{try{onChange('modelo_a_snapshot',saveSecondhandBook(draft,next));setError('');}catch(cause){setError(String(cause));}};
const change=(id:string,patch:Partial<SecondhandRow>)=>save({...book,rows:book.rows.map(row=>row.id===id?{...row,...patch}:row)});
return<details className="space-y-3 rounded border p-3">
<summary>Bens emsegunda mão — revenda pela margem eleilão</summary>
<p>Oportunidade: {String(draft.context_opportunity||'entrada geral')}. Uma linha porbem.P125identifica categoria/estatuto;P092ouP093determina a base aplicável.Sem compensação de perdas entrebens,nova dedução ou IVAcalculado.</p>
<p>Revendedor:contraprestação total comIVA−preço compra,parcela positiva÷1.16 ebase arredondada à unidade48(6)b.16%é parâmetro do divisor,não IVAcalculado neste percurso.Leilão:facturado−líquidocomitente−IVApróprio identificado;líquido=adjudicação−comissão.Não substituir IVA desconhecido porzero.</p>
<p>{secondhandSource}</p>{error&&<p role="alert">{error}</p>}
<button type="button" className="underline" onClick={()=>save({...book,rows:[...book.rows,newSecondhand(crypto.randomUUID(),String(draft.currency??''))]})}>Acrescentar bem individual</button>
{book.rows.map(row=><details key={row.id} className="rounded border p-3"><summary>{row.description||'Bem por identificar'} — {row.period||'mês por indicar'}</summary>
<div className="grid gap-3 md:grid-cols-2">
<label>Percurso da margem<select className="block rounded border p-2" value={row.treatment} onChange={e=>change(row.id,switchSecondhandTreatment(row,e.target.value))}><option value="">Por indicar</option>{secondhandTreatments.map(t=><option key={t.id} value={t.id}>{t.label}</option>)}</select></label>
<label>Categoria do bem<select className="block rounded border p-2" value={row.category} onChange={e=>change(row.id,switchSecondhandCategory(row,e.target.value))}><option value="">Por indicar</option>{secondhandCategories.map(c=><option key={c.id} value={c.id}>{c.label}</option>)}</select></label>
<label>Natureza dos dados<select className="block rounded border p-2" value={row.origin} onChange={e=>change(row.id,{origin:e.target.value as SecondhandRow['origin']})}><option value="projected">Projectados / pressupostos</option><option value="effective">Efectivos declarados</option></select></label>
{([['description','Bem/operação identificados'],['operationRef','Referência da operação'],['operationDate','Data da operação / prevista'],['period','Mês da operação'],['currency','Moeda dos montantes'],['documentRef','Documento / referência da previsão'],['documentDate','Data do documento / prevista'],['goodsRef','Identidade do bem individual'],['operatorRef','Operador e estatuto revendedor/organizador'],['categoryRef','Características e referência da classificação'],['sourceRef','Fornecedor/comitente,origem e referência'],['scope','Âmbito:bem,partes,operação eperíodo'],['evidence','Fundamento factual / pressupostos']] as const).map(([field,label])=><label key={field}>{label}<input className="block rounded border p-2" type={['operationDate','documentDate'].includes(field)?'date':field==='period'?'month':'text'} value={row[field]} onChange={e=>change(row.id,{[field]:e.target.value})}/></label>)}
<label>Qualidade do fornecedor/comitente<select className="block rounded border p-2" value={row.sourceType} onChange={e=>change(row.id,{sourceType:e.target.value})}><option value="">Por esclarecer</option><option value="private">Particular</option><option value="margin_reseller">Revendedor sujeito à margem</option><option value="exempt_9_12_e">Transmissão no âmbito9(12)e</option><option value="legacy_35_42">Referência histórica a35/42 — conferir situação2026</option><option value="other">Outro enquadramento por esclarecer</option></select></label>
{([['domesticPurchase','Aquisição dos bens no território nacional'],['domesticTaxable','Transmissão interna tributável,não exportação isenta13']] as const).map(([field,label])=><label key={field}>{label}<select className="block rounded border p-2" value={row[field]} onChange={e=>change(row.id,{[field]:e.target.value})}><option value="">Por esclarecer</option><option value="yes">Sim</option><option value="no">Não</option></select></label>)}
{row.category==='art'&&<><label>Tipo de objecto de arte<select className="block rounded border p-2" value={row.artSubtype} onChange={e=>change(row.id,{artSubtype:e.target.value})}><option value="">Por indicar</option><option value="original">Quadro,pintura ou desenho original</option><option value="print">Gravura,estampa ou litografia</option><option value="sculpture">Escultura ou estatuária</option><option value="ceramic">Cerâmica</option></select></label>{row.artSubtype==='print'&&<label>Tiragem total<input className="block rounded border p-2" value={row.editionCount} onChange={e=>change(row.id,{editionCount:e.target.value})}/></label>}{row.artSubtype==='ceramic'&&<label>Cerâmica única,executada eassinada pelo artista<select className="block rounded border p-2" value={row.ceramicUniqueSigned} onChange={e=>change(row.id,{ceramicUniqueSigned:e.target.value})}><option value="">Por esclarecer</option><option value="yes">Sim</option><option value="no">Não</option></select></label>}</>}
{row.category==='collection'&&<><label>Tipo de colecção<select className="block rounded border p-2" value={row.collectionSubtype} onChange={e=>change(row.id,{collectionSubtype:e.target.value})}><option value="">Por indicar</option><option value="postal">Postal/filatélica</option><option value="scientific">Colecções/espécimes científicos3(6)c</option><option value="historical">Interesse histórico,arqueológico,paleontológico,etnográfico ou numismático</option></select></label>{row.collectionSubtype==='postal'&&<label>Fora decirculação esem destino aser posto emcirculação<select className="block rounded border p-2" value={row.postalNotCirculating} onChange={e=>change(row.id,{postalNotCirculating:e.target.value})}><option value="">Por esclarecer</option><option value="yes">Sim</option><option value="no">Não</option></select></label>}</>}
{row.category==='antique'&&<label>Idade emanos<input className="block rounded border p-2" value={row.ageYears} onChange={e=>change(row.id,{ageYears:e.target.value})}/></label>}
{row.treatment==='reseller_margin'&&<><label>Opção do revendedor<select className="block rounded border p-2" value={row.regimeChoice} onChange={e=>change(row.id,{regimeChoice:e.target.value})}><option value="">Por esclarecer</option><option value="margin">Regime da margem</option><option value="general">Regime geral — outra fórmula</option></select></label>{([['grossSale','Contraprestação total devida pelocliente,comIVA incluído'],['purchasePrice','Preço compra do mesmo bem'],['purchaseRef','Justificação/referência do preço compra']] as const).map(([field,label])=><label key={field}>{label}<input className="block rounded border p-2" value={row[field]} onChange={e=>change(row.id,{[field]:e.target.value})}/></label>)}</>}
{row.treatment==='auction_margin'&&([['grossInvoice','Montante facturado ao comprador49(4)'],['hammerPrice','Preço de adjudicação'],['principalCommission','Comissão obtida/aobter do comitente'],['auctionRef','Contrato de comissão de venda emnome próprio eleilão'],['principalRef','Comitente e referência do líquido'],['ownVat','IVApróprio relativo àtransmissão,identificado'],['ownVatRef','Referência/composição do IVApróprio identificado']] as const).map(([field,label])=><label key={field}>{label}<input className="block rounded border p-2" value={row[field]} onChange={e=>change(row.id,{[field]:e.target.value})}/></label>)}
</div>
<p>Os montantes ocultos de outro percurso são conservados esó entram na fórmula escolhida.Situação antiga35/42não activa margem automaticamente em2026;sem novo certificado/aprovador.</p>
<fieldset><legend>Condições da classificação eregime</legend>{[...secondhandConditions,...secondhandCategoryConditions(row.category),['regimeConfirmed','Condições47/48(1)ou49(1),papéis/contrato equadro temporal conferidos'],['compositionConfirmed','Composição monetária corresponde ao percurso:revenda comIVA no total,ou leilão comfacturado,líquidocomitente eIVApróprio separados,sem dupla subtracção']] .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 as ''|'yes'|'no'}})}><option value="">Por indicar</option><option value="yes">Sim</option><option value="no">Não</option></select></label>)}</fieldset>
</details>)}
{!!book.rows.length&&<button type="button" className="rounded bg-emerald-800 p-2 text-white" onClick={()=>{try{save(recordSecondhands(draft,book));}catch(cause){setError(String(cause));}}}>Classificar bens,calcular bases eguardar versão</button>}
{book.result&&<><p role="status">{secondhandCurrent(draft,book)?'Classificações/bases correspondentes às entradas guardadas.':'Entradas alteradas;resultados históricos conservados.'} {book.history?.length??0} versões anteriores.Sem compensar margens entrebens,calcular IVA ou dedução/campo11.</p><dl>{secondhandReportRows(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>;
}
+84
View File
@@ -0,0 +1,84 @@
import {acquisitionScope,readAcquisitionStore} from './iva-acquisitions.ts';
export const secondhandVersion='secondhand-individual-margin/1';
export const secondhandSource='CIVA3(3)g/3(6),15(2)f/g,Lei13/2016PDF7/13;Lei22/2022PDF3mantém15f/g efixa17(1)16%;Lei10/2025não altera15/17,revoga regimes35/42. RIVADecreto7/2008arts47–49,BR16-I16/04/2008PDF8–9/p106–107:revenda porbem,margem positiva dividida por1.16 ebase arredondada à unidade;sem compensar perdas entrebens. Leilão:facturado−líquidocomitente−IVApróprio identificado,líquido=adjudicação−comissão. ClassificaçãoP125 separada da baseP092/P093. Sem nova dedução/campo11,isenção automática,IVA calculado ou totaisModeloA.';
export const secondhandTreatments=[{id:'reseller_margin',branch:'P092',label:'Revendedor — margem individual'},{id:'auction_margin',branch:'P093',label:'Organizador emnome próprio — venda em leilão'}] as const;
export const secondhandCategories=[{id:'used_goods',label:'Bem móvel usado3(6)a'},{id:'art',label:'Objecto de arte3(6)b'},{id:'collection',label:'Objecto de colecção3(6)c'},{id:'antique',label:'Antiguidade3(6)d'}] as const;
export const secondhandConditions=[['goodsIdentity','Bem individual identificado,com aquisição,transmissão e referências correspondentes'],['operatorRole','Sujeito passivo revendedor ou organizador de leilão identificado,no exercício da actividade epercurso escolhido']] as const;
export const categoryConditions={
used_goods:[['reusableUsed','Bem móvel usado susceptível de reutilização no estado ou após reparação'],['notRenewedTransformed','Não é renovado nem transformado:renovado se materiais de reparação excedem aquisição+mão-de-obra;transformado se reparação modifica características essenciais'],['notPreciousExcluded','Não são pedras/metais preciosos excluídos3(6)a;moedas/artefactos desses materiais não são excluídos só por esse motivo']],
art:[['artistOriginal','Obra da autoria do artista,no âmbito3(6)b;sem desenho industrial,ourivesaria ou joalharia']],
collection:[['collectionNature','Objecto de colecção no âmbito3(6)c,com natureza e características identificadas']],
antique:[['notArtCollection','Não é objecto de arte ou de colecção já classificado nessas categorias']],
} as const;
export function secondhandCategoryConditions(category:string):ReadonlyArray<readonly[string,string]>{return categoryConditions[category as keyof typeof categoryConditions]??[];}
type Choice=''|'yes'|'no';type Condition=typeof secondhandConditions[number][0];
export type SecondhandRow={id:string;treatment:string;category:string;description:string;period:string;origin:'projected'|'effective';currency:string;operationDate:string;operationRef:string;documentRef:string;documentDate:string;goodsRef:string;operatorRef:string;categoryRef:string;artSubtype:string;editionCount:string;ceramicUniqueSigned:string;collectionSubtype:string;postalNotCirculating:string;ageYears:string;sourceType:string;sourceRef:string;domesticPurchase:string;regimeChoice:string;domesticTaxable:string;grossSale:string;purchasePrice:string;purchaseRef:string;grossInvoice:string;hammerPrice:string;principalCommission:string;auctionRef:string;principalRef:string;ownVat:string;ownVatRef:string;scope:string;evidence:string;conditions:Partial<Record<string,Choice>>};
type LineResult={id:string;status:'determined'|'not_applicable'|'indeterminate';classification:'qualified_goods'|'not_applicable'|'indeterminate';baseAmount:string|null;grossMargin:string|null;principalNet:string|null;baseDivisorRate:string|null;vat:null;rate:null;reason:string};
export type SecondhandCalculation={version:string;revision:number;calculatedAt:string;opportunity:string;location:string;sourceKey:string;source:string;inputs:SecondhandRow[];rows:LineResult[]};
export type SecondhandBook={rows:SecondhandRow[];result?:SecondhandCalculation;history?:SecondhandCalculation[]};
type Store=ReturnType<typeof readAcquisitionStore>&{regulatedSecondhands?:Record<string,SecondhandBook>};
export function newSecondhand(id:string,currency:string):SecondhandRow{return{id,treatment:'',category:'',description:'',period:'',origin:'projected',currency,operationDate:'',operationRef:'',documentRef:'',documentDate:'',goodsRef:'',operatorRef:'',categoryRef:'',artSubtype:'',editionCount:'',ceramicUniqueSigned:'',collectionSubtype:'',postalNotCirculating:'',ageYears:'',sourceType:'',sourceRef:'',domesticPurchase:'',regimeChoice:'',domesticTaxable:'',grossSale:'',purchasePrice:'',purchaseRef:'',grossInvoice:'',hammerPrice:'',principalCommission:'',auctionRef:'',principalRef:'',ownVat:'',ownVatRef:'',scope:'',evidence:'',conditions:{}};}
export function switchSecondhandTreatment(row:SecondhandRow,treatment:string):SecondhandRow{return{...row,treatment,conditions:{...row.conditions,regimeConfirmed:'',compositionConfirmed:''}};}
export function switchSecondhandCategory(row:SecondhandRow,category:string):SecondhandRow{return{...row,category,conditions:Object.fromEntries(secondhandConditions.map(([key])=>[key,row.conditions[key]??'']))};}
function readStore(draft:Record<string,unknown>):Store{
const store=readAcquisitionStore(draft.modelo_a_snapshot) as Store;
if(store.regulatedSecondhands!==undefined&&(!store.regulatedSecondhands||typeof store.regulatedSecondhands!=='object'||Array.isArray(store.regulatedSecondhands)))throw new Error('Afectações de bens não recuperáveis;originais preservados.');
const fields=Object.keys(newSecondhand('','')).filter(key=>key!=='conditions');
for(const book of Object.values(store.regulatedSecondhands??{}))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 SecondhandRow]!=='string')||!row.conditions||typeof row.conditions!=='object'||Array.isArray(row.conditions)))throw new Error('Linhas de afectações de bens não recuperáveis;originais preservados,sem substituição por lista vazia.');
return store;
}
export function secondhandBook(draft:Record<string,unknown>):SecondhandBook{return readStore(draft).regulatedSecondhands?.[acquisitionScope(draft)]??{rows:[]};}
export function saveSecondhandBook(draft:Record<string,unknown>,book:SecondhandBook):string{const current=readStore(draft),encoded=JSON.stringify({...current,regulatedSecondhands:{...current.regulatedSecondhands,[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 evaluateSecondhand(row:SecondhandRow,currency:unknown):LineResult{
const result:LineResult={id:row.id,status:'indeterminate',classification:'indeterminate',baseAmount:null,grossMargin:null,principalNet:null,baseDivisorRate:null,vat:null,rate:null,reason:''};const stop=(reason:string,status:LineResult['status']='indeterminate')=>({...result,status,reason});
if(!secondhandTreatments.some(t=>t.id===row.treatment)||!secondhandCategories.some(c=>c.id===row.category))return stop('Identifique revendedor/leilão e categoria do bem;sem regime ou base por defeito.');
if(!/^2026-(0[1-9]|1[0-2])$/.test(row.period)||!['projected','effective'].includes(row.origin)||!validDate(row.operationDate)||row.operationDate.slice(0,7)!==row.period)return stop('Identifique mês2026,data correspondente e natureza efectiva/projectada dos dados.');
const required=[...secondhandConditions,...secondhandCategoryConditions(row.category)];if(required.some(([k])=>row.conditions[k]==='no'))return{...stop('Definição/estatuto deste percurso não correspondem aos factos;rever classificação3(6),sem basezero ou isenção presumida.','not_applicable'),classification:'not_applicable'};
if(required.some(([k])=>row.conditions[k]!=='yes'))return stop('Natureza do bem ou estatuto do operador por esclarecer;desconhecido não é zero.');
if(![row.description,row.operationRef,row.documentRef,row.goodsRef,row.operatorRef,row.categoryRef,row.scope,row.evidence].every(v=>!!v.trim())||!validDate(row.documentDate))return stop('Identifique bem individual,operador,categoria,operação,documento/data,âmbito e factos ou pressupostos expressos;sem certificado/aprovador novo.');
if(row.category==='art'){
if(!['original','print','sculpture','ceramic'].includes(row.artSubtype))return stop('Identifique pintura/desenho original,gravura/estampa/litografia,escultura/estatuária ou cerâmica.');
if(row.artSubtype==='print'&&(!/^\d+$/.test(row.editionCount)||BigInt(row.editionCount)<1n))return stop('Identifique tiragem da gravura/estampa/litografia.');
if(row.artSubtype==='print'&&BigInt(row.editionCount)>200n)return{...stop('Tiragem superior a200 não corresponde a esta definição de arte3(6)b.','not_applicable'),classification:'not_applicable'};
if(row.artSubtype==='ceramic'&&row.ceramicUniqueSigned!=='yes')return stop('Esclareça se a cerâmica é exemplar único executado eassinado pelo artista3(6)b.');
}
if(row.category==='collection'&&(!['postal','scientific','historical'].includes(row.collectionSubtype)||(row.collectionSubtype==='postal'&&row.postalNotCirculating!=='yes')))return stop('Identifique colecção postal não emcirculação/nem destinada acircular,colecções/espécimes científicos ou interesse histórico/arqueológico/paleontológico/etnográfico/numismático3(6)c.');
if(row.category==='antique'){
const age=decimal(row.ageYears);if(age===null)return stop('Identifique idade emanos do bem.');if(age<=100000000n)return{...stop('A definição de antiguidade3(6)d exige mais de100anos.','not_applicable'),classification:'not_applicable'};
}
result.classification='qualified_goods';
if(row.domesticPurchase!=='yes'||!row.sourceRef.trim())return stop('P125identificado;P092/P093 por esclarecer:origem nacional equalidade do fornecedor/comitente comreferência48(1)/49(1).');
if(row.sourceType==='legacy_35_42')return stop('P125identificado;referência histórica aos regimes35/42 não activa margem em2026:Lei10/2025revogou essas subsecções. Conferir situação aplicável sem reactivar regime revogado.');
if(!['private','margin_reseller','exempt_9_12_e'].includes(row.sourceType))return stop('P125identificado;origem elegível particular,revendedor na margem ou transmissão9(12)e não demonstrada. Outro regime permanece por esclarecer.');
if(row.treatment==='reseller_margin'&&row.regimeChoice==='general')return stop('P125identificado;opção pelo regime geral47(1)não aplica esta fórmula de margem.','not_applicable');
if(row.treatment==='reseller_margin'&&row.regimeChoice!=='margin')return stop('P125identificado;esclareça opção margem/regime geral do revendedor47(1).');
if(row.domesticTaxable!=='yes'||row.conditions.regimeConfirmed!=='yes'||row.conditions.compositionConfirmed!=='yes')return stop('P125identificado;base por esclarecer:transmissão interna tributável,natureza/regime elegível ecomposição confirmados;exportação13/48(7)tem percurso próprio.');
if(currency!=='MZN'||row.currency!==currency)return stop('P125identificado;base fora do recorteMZN ou moeda divergente,sem conversão silenciosa.');
if(row.treatment==='reseller_margin'){
const sale=decimal(row.grossSale),purchase=decimal(row.purchasePrice);if(sale===null||purchase===null||!row.purchaseRef.trim())return stop('Identifique contraprestação total devida pelocliente comIVA incluído epreço compra justificado do mesmo bem;desconhecido não é zero.');
const diff=sale-purchase,positive=diff>0n?diff:0n,denominator=116000000n;const whole=(positive*100n+denominator/2n)/denominator;
return{...result,status:'determined',baseAmount:money(whole*1000000n),grossMargin:diff<0n?'-'+money(-diff):money(diff),baseDivisorRate:'16%',reason:`P092:48(2)/(3)/(6),margem individual=${money(sale)}−${money(purchase)}=${diff<0n?'-'+money(-diff):money(diff)}MZN;parcela positiva÷1.16,base arredondada à unidade=${money(whole*1000000n)}MZN. ${diff<0n?'Excesso do preço compra registado neste bem,sem compensar outras transmissões;sem margem positiva tributável.':''} Taxa16% utilizada só no divisor da base17(1)/48(6)b;não há IVA ou dedução calculados. Factura com menção IVA-Bens emSegundaMão,sem discriminarIVA48(8)/(9);registos separados48(10)/(11).`};
}
const invoice=decimal(row.grossInvoice),hammer=decimal(row.hammerPrice),commission=decimal(row.principalCommission),tax=decimal(row.ownVat);
if(invoice===null||hammer===null||commission===null||tax===null||![row.auctionRef,row.principalRef,row.ownVatRef].every(v=>!!v.trim()))return stop('Identifique montante facturado,adjudicação,comissão do comitente eIVApróprio relativo à transmissão,comreferências;IVA desconhecido não é zero.');
if(commission>hammer)return stop('Comissão excede adjudicação;conferir componentes antes de determinar líquido comitente49(3).');
const net=hammer-commission;if(invoice<net+tax)return stop('Facturado inferior ao líquido comitente+IVAindicado;conferir composição,sem base negativa ou zero substituto.');
return{...result,status:'determined',baseAmount:money(invoice-net-tax),principalNet:money(net),reason:`P093:15(2)g/49(2)/(3),líquido comitente=${money(hammer)}−${money(commission)}=${money(net)}MZN;base=${money(invoice)}facturado−${money(net)}líquido−${money(tax)}IVApróprio identificado=${money(invoice-net-tax)}MZN. IVAindicado é componente referenciada,não calculada aqui. Factura IVA-Regime específico devendas emleilão,sem discriminarIVA49(5);relatório ao comitente em5dias úteis49(6)/(7),sem calendário de feriados presumido;contas deterceiros49(8). Sem nova dedução/campo11 ou totaisModeloA.`};
}
function key(draft:Record<string,unknown>,book:SecondhandBook):string{return JSON.stringify({version:secondhandVersion,scope:acquisitionScope(draft),location:draft.selection_labels??{},currency:draft.currency,rows:book.rows});}
export function secondhandCurrent(draft:Record<string,unknown>,book:SecondhandBook):boolean{return!!book.result&&book.result.sourceKey===key(draft,book);}
export function recordSecondhands(draft:Record<string,unknown>,book:SecondhandBook):SecondhandBook{
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:SecondhandCalculation={version:secondhandVersion,revision:(book.result?.revision??0)+1,calculatedAt:new Date().toISOString(),opportunity:acquisitionScope(draft),location:JSON.stringify(draft.selection_labels??{}),sourceKey:key(draft,book),source:secondhandSource,inputs:structuredClone(book.rows),rows:book.rows.map(row=>evaluateSecondhand(row,draft.currency))};
const next={...book,result,history:[...(book.history??[]),...(book.result?[book.result]:[])]};saveSecondhandBook(draft,next);return next;
}
export function secondhandReportRows(draft:Record<string,unknown>):Array<[string,string,string,string]>{
try{const book=secondhandBook(draft),result=book.result;if(!result)return[];return result.inputs.map((row,i)=>{const line=result.rows[i],rule=secondhandTreatments.find(t=>t.id===row.treatment);return['IVA mensal',`Bem P125/base${rule?.branch??'margem'} — ${row.period} — ${row.id}`,`${row.description};P125:${line.classification==='qualified_goods'?'Categoria/estatuto identificados':line.classification==='not_applicable'?'Percurso não aplicável':'Indeterminado'};base:${line.baseAmount??'Indeterminada'} ${row.currency};IVA: Indeterminado;${line.reason}`,`${result.opportunity};${result.location};${secondhandCurrent(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'};${rule?.label};categoria ${row.category},${row.categoryRef};critérios ${row.category==='art'?`${row.artSubtype},tiragem ${row.editionCount},cerâmica única/assinada ${row.ceramicUniqueSigned}`:row.category==='collection'?`${row.collectionSubtype},postal fora de circulação ${row.postalNotCirculating}`:row.category==='antique'?`idade ${row.ageYears} anos`:JSON.stringify(row.conditions)};bem ${row.goodsRef};operador ${row.operatorRef};origem ${row.sourceType},${row.sourceRef};operação ${row.operationRef},${row.operationDate};documento ${row.documentRef},${row.documentDate};${row.treatment==='reseller_margin'?`venda comIVA ${row.grossSale};compra ${row.purchasePrice},${row.purchaseRef};margem ${line.grossMargin??'indeterminada'};divisor ${line.baseDivisorRate??'indeterminado'}`:`facturado ${row.grossInvoice};adjudicação ${row.hammerPrice};comissão comitente ${row.principalCommission};líquido ${line.principalNet??'indeterminado'},${row.principalRef};IVAcomponente ${row.ownVat},${row.ownVatRef};leilão ${row.auctionRef}`};âmbito ${row.scope};fundamento ${row.evidence};sem nova dedução/campo11/totaisModeloA.`];});}
catch{return[['IVA mensal','Bens usados/margem','Dados não recuperáveis','Originais preservados;sem base por defeito.']];}
}
+2
View File
@@ -20,6 +20,7 @@ import { listedServiceReportRows } from './iva-listed-services.ts';
import { reducedBaseReportRows } from './iva-reduced-bases.ts';
import { ownNameReportRows } from './iva-own-name.ts';
import { goodsAllocationReportRows } from './iva-goods-allocation.ts';
import { secondhandReportRows } from './iva-secondhand.ts';
import { offerPreparationReportRows } from './iva-offer-preparations.ts';
import { salesReportRows } from './iva-operations.ts';
import { fiscalQaRows } from './fiscal-qa-preview.ts';
@@ -650,6 +651,7 @@ export function buildProjectDossierRows(
rows.push(...reducedBaseReportRows(draft));
rows.push(...ownNameReportRows(draft));
rows.push(...goodsAllocationReportRows(draft));
rows.push(...secondhandReportRows(draft));
rows.push(...offerPreparationReportRows(draft));
const operationRows = salesReportRows(draft);
rows.push(...operationRows);
+49
View File
@@ -0,0 +1,49 @@
<?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 IvaSecondhandDraftTest extends TestCase
{
use DatabaseTransactions;
public function test_secondhand_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-secondhand.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:354', $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['regulatedSecondhands']['opportunity:354'];
$this->assertCount(8, $book['rows']);
$this->assertSame(array_merge(array_fill(0,4,'reseller_margin'),array_fill(0,4,'auction_margin')),array_column($book['rows'],'treatment'));
$this->assertSame(array_merge(array_fill(0,4,'138.00'),array_fill(0,4,'344.83')),array_column($book['result']['rows'],'baseAmount'));
$this->assertSame(array_fill(0,8,'qualified_goods'),array_column($book['result']['rows'],'classification'));
foreach ($book['result']['rows'] as $row) {
$this->assertNull($row['vat']);$this->assertNull($row['rate']);
}
$this->assertSame($book['rows'], $book['result']['inputs']);
$this->assertCount(1, $book['history']);
$this->assertSame('138.00',$book['history'][0]['rows'][0]['baseAmount']);
$frozen = json_decode(file_get_contents(base_path('tests/frontend/fixtures/iva-secondhand-preserved.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, ['regulatedSecondhands' => 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-secondhand-20261025/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
+27
View File
@@ -0,0 +1,27 @@
import test from 'node:test';import assert from 'node:assert/strict';import fs from 'node:fs';
import {newSecondhand,switchSecondhandTreatment,switchSecondhandCategory,secondhandConditions,secondhandCategoryConditions,secondhandTreatments,secondhandCategories,evaluateSecondhand,recordSecondhands,saveSecondhandBook,secondhandBook,secondhandCurrent,secondhandReportRows} from '../../resources/js/lib/iva-secondhand.ts';
const frozen=JSON.parse(fs.readFileSync('tests/frontend/fixtures/iva-secondhand-preserved.json','utf8'));const draft={...frozen,currency:'MZN',context_opportunity:'opportunity:354',project_title:'SYNTHETIC QA — Segunda mão / Manica'};
function row(t,c){return{...newSecondhand(t+'-'+c,'MZN'),treatment:t,category:c,description:'SYNTHETIC QA '+t+'/'+c,period:'2026-12',origin:'projected',operationDate:'2026-12-12',operationRef:'SYNTHETIC QA operação',documentRef:'SYNTHETIC QA documento/pressuposto',documentDate:'2026-12-12',goodsRef:'SYNTHETIC QA bem individual '+t+c,operatorRef:'SYNTHETIC QA estatuto operador',categoryRef:'SYNTHETIC QA características/definição3(6)',artSubtype:'print',editionCount:'200',ceramicUniqueSigned:'yes',collectionSubtype:'postal',postalNotCirculating:'yes',ageYears:'101',sourceType:'private',sourceRef:'SYNTHETIC QA origem particular nacional',domesticPurchase:'yes',regimeChoice:'margin',domesticTaxable:'yes',grossSale:'1160',purchasePrice:'1000',purchaseRef:'SYNTHETIC QA preço compra justificado',grossInvoice:'1300',hammerPrice:'1000',principalCommission:'100',auctionRef:'SYNTHETIC QA organizador emnome próprio/contrato comissão/leilão',principalRef:'SYNTHETIC QA comitente/líquido',ownVat:'55.17',ownVatRef:'SYNTHETIC QA IVApróprio transmissão identificado',scope:'SYNTHETIC QA bem/partes/operação/período',evidence:'SYNTHETIC QA pressupostos expressos',conditions:{...Object.fromEntries([...secondhandConditions,...secondhandCategoryConditions(c)].map(([k])=>[k,'yes'])),regimeConfirmed:'yes',compositionConfirmed:'yes'}};}
for(const t of secondhandTreatments)for(const c of secondhandCategories)test(t.branch+'/P125 '+c.id+' ownfacts,eligibleorigin and specificformula',()=>{
const r=row(t.id,c.id),result=evaluateSecondhand(r,'MZN');assert.equal(result.status,'determined');assert.equal(result.classification,'qualified_goods');assert.equal(result.baseAmount,t.id==='reseller_margin'?'138.00':'344.83');assert.equal(result.vat,null);assert.equal(result.rate,null);
for(const [k] of [...secondhandConditions,...secondhandCategoryConditions(c.id)]){const missing=structuredClone(r);delete missing.conditions[k];assert.equal(evaluateSecondhand(missing,'MZN').classification,'indeterminate');missing.conditions[k]='no';assert.equal(evaluateSecondhand(missing,'MZN').classification,'not_applicable');assert.equal(evaluateSecondhand(missing,'MZN').baseAmount,null);}
for(const field of ['description','operationRef','documentRef','goodsRef','operatorRef','categoryRef','scope','evidence'])assert.equal(evaluateSecondhand({...r,[field]:''},'MZN').classification,'indeterminate',field);
for(const patch of [{period:'2025-12'},{operationDate:'2026-11-12'},{documentDate:'2026-02-30'},{currency:'USD'},{sourceType:'legacy_35_42'},{sourceType:'other'},{sourceRef:''},{domesticPurchase:''},{domesticTaxable:''}])assert.equal(evaluateSecondhand({...r,...patch},'MZN').baseAmount,null);
assert.equal(evaluateSecondhand({...r,sourceType:'margin_reseller'},'MZN').status,'determined');assert.equal(evaluateSecondhand({...r,sourceType:'exempt_9_12_e'},'MZN').status,'determined');
if(t.id==='reseller_margin'){assert.equal(evaluateSecondhand({...r,regimeChoice:'general'},'MZN').baseAmount,null);for(const field of ['grossSale','purchasePrice','purchaseRef'])assert.equal(evaluateSecondhand({...r,[field]:''},'MZN').baseAmount,null);}
else{for(const field of ['grossInvoice','hammerPrice','principalCommission','ownVat','ownVatRef','auctionRef','principalRef'])assert.equal(evaluateSecondhand({...r,[field]:''},'MZN').baseAmount,null);assert.equal(evaluateSecondhand({...r,principalCommission:'1001'},'MZN').baseAmount,null);assert.equal(evaluateSecondhand({...r,ownVat:'401'},'MZN').baseAmount,null);}
});
test('P092 rounding towholeunit,zero vsunknown and negative individualmargin no compensation',()=>{
const r=row('reseller_margin','used_goods');assert.equal(evaluateSecondhand({...r,grossSale:'1000.58'},'MZN').baseAmount,'1.00');assert.equal(evaluateSecondhand({...r,grossSale:'1000.579999'},'MZN').baseAmount,'0.00');assert.equal(evaluateSecondhand({...r,grossSale:'1000'},'MZN').baseAmount,'0.00');
const loss=evaluateSecondhand({...r,grossSale:'900'},'MZN');assert.equal(loss.grossMargin,'-100.00');assert.equal(loss.baseAmount,'0.00');assert.match(loss.reason,/sem compensar/);assert.equal(evaluateSecondhand({...r,purchasePrice:''},'MZN').baseAmount,null);
const positive=evaluateSecondhand(r,'MZN');assert.equal(positive.baseAmount,'138.00');assert.equal(positive.baseDivisorRate,'16%');assert.equal(positive.vat,null);
});
test('P125 boundaries and both switches preserve hidden values withoutautomaticregime',()=>{
const art=row('reseller_margin','art');assert.equal(evaluateSecondhand({...art,editionCount:'201'},'MZN').classification,'not_applicable');assert.equal(evaluateSecondhand({...art,editionCount:''},'MZN').classification,'indeterminate');assert.equal(evaluateSecondhand({...art,artSubtype:'ceramic',ceramicUniqueSigned:''},'MZN').baseAmount,null);
const antique=row('reseller_margin','antique');assert.equal(evaluateSecondhand({...antique,ageYears:'100'},'MZN').classification,'not_applicable');assert.equal(evaluateSecondhand({...antique,ageYears:'100.1'},'MZN').classification,'qualified_goods');
const changed=switchSecondhandTreatment(art,'auction_margin');assert.equal(changed.grossSale,art.grossSale);assert.equal(changed.ownVat,art.ownVat);assert.equal(changed.conditions.compositionConfirmed,'');assert.equal(evaluateSecondhand(changed,'MZN').baseAmount,null);const cat=switchSecondhandCategory(art,'collection');assert.equal(cat.editionCount,'200');assert.equal(cat.conditions.collectionNature,undefined);
});
test('All8individualgoods persistwithhistory/report;all priorbooks preserved,noaggregateoffset',()=>{
const rows=secondhandTreatments.flatMap(t=>secondhandCategories.map(c=>row(t.id,c.id)));let book=recordSecondhands(draft,{rows});book=recordSecondhands(draft,book);assert.equal(book.history.length,1);const next={...draft,modelo_a_snapshot:saveSecondhandBook(draft,book)};assert.deepEqual(secondhandBook(next),book);assert.ok(secondhandCurrent(next,book));assert.equal(secondhandCurrent({...next,currency:'USD'},book),false);assert.equal(secondhandReportRows(next).length,8);
const store=JSON.parse(next.modelo_a_snapshot);delete store.regulatedSecondhands;assert.deepEqual(store,JSON.parse(frozen.modelo_a_snapshot));assert.throws(()=>recordSecondhands(next,{rows:[rows[0],rows[0]]}));assert.throws(()=>secondhandBook({...next,modelo_a_snapshot:JSON.stringify({...JSON.parse(next.modelo_a_snapshot),regulatedSecondhands:[]})}));if(process.env.IVA_SECONDHAND_FIXTURE)fs.writeFileSync(process.env.IVA_SECONDHAND_FIXTURE,JSON.stringify(next,null,2)+'\n');
});