feat(iva): add travel period margin and special travel suspensive events
This commit is contained in:
@@ -1,4 +1,6 @@
|
||||
import { SimulationDigital } from './simulation-digital';
|
||||
import { SimulationTravel } from './simulation-travel';
|
||||
import { SimulationTravelEvents } from './simulation-travel-events';
|
||||
import { SimulationForeignOperations } from './simulation-foreign-operations';
|
||||
import { SimulationCurrencyEvents } from './simulation-currency-events';
|
||||
import { SimulationPropertyTransfers } from './simulation-property-transfers';
|
||||
@@ -128,6 +130,8 @@ export function SimulationMonthlyIva({draft, busy, onCalculate, onChange}: {
|
||||
<SimulationGoodsAllocation draft={draft} onChange={onChange} />
|
||||
<SimulationForeignOperations draft={draft} onChange={onChange} />
|
||||
<SimulationDigital draft={draft} onChange={onChange} />
|
||||
<SimulationTravel draft={draft} onChange={onChange} />
|
||||
<SimulationTravelEvents draft={draft} onChange={onChange} />
|
||||
<SimulationCurrencyEvents draft={draft} onChange={onChange} />
|
||||
<SimulationPropertyTransfers draft={draft} onChange={onChange} />
|
||||
<SimulationGoodsPresumptions draft={draft} onChange={onChange} />
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import {useState} from 'react';
|
||||
import {travelEventBook,travelEventCurrent,travelEventReportRows,travelEventSource,travelEventSources,newTravelEvent,recordTravelEvents,saveTravelEventBook,switchTravelEvent,type TravelEventBook,type TravelEventRow} from '@/lib/iva-travel-events';
|
||||
export function SimulationTravelEvents({draft,onChange}:{draft:Record<string,unknown>;onChange:(field:string,value:string)=>void}){
|
||||
const[error,setError]=useState('');let book:TravelEventBook;try{book=travelEventBook(draft);}catch(e){return<p role="alert">{String(e)}</p>;}
|
||||
const save=(next:TravelEventBook)=>{try{onChange('modelo_a_snapshot',saveTravelEventBook(draft,next));setError('');}catch(e){setError(String(e));}};
|
||||
const change=(id:string,patch:Partial<TravelEventRow>)=>save({...book,rows:book.rows.map(r=>r.id===id?{...r,...patch}:r)});
|
||||
const fields=(row:TravelEventRow,list:ReadonlyArray<readonly[keyof TravelEventRow,string]>)=>list.map(([key,label])=><label className="block" key={key}>{label}<input type={key==='period'?'month':String(key).endsWith('Date')||key==='legalDeadline'?'date':'text'} value={row[key]} onChange={e=>change(row.id,{[key]:e.target.value})}/></label>);
|
||||
const select=(row:TravelEventRow,key:keyof TravelEventRow,label:string,options:ReadonlyArray<readonly[string,string]>)=><label>{label}<select value={row[key]} onChange={e=>change(row.id,{[key]:e.target.value})}><option value="">Por esclarecer</option>{options.map(([value,text])=><option key={value} value={value}>{text}</option>)}</select></label>;
|
||||
const yn=[['yes','Sim'],['no','Não']] as const;
|
||||
let sources:ReturnType<typeof travelEventSources>=[];try{sources=travelEventSources(draft);}catch{/* Origin reader preserves originals. */}
|
||||
return<details className="rounded border p-3"><summary>Eventos de viagens e cessação de regimes suspensivos</summary><p>Conserve separados facto gerador e exigibilidade. Não inferir pagamento integral por um sinal, início pela emissão do bilhete ou cessação do regime por mudança de armazém. Estes eventos não calculam imposto nem concedem benefício.</p><p>{travelEventSource}</p>{error&&<p role="alert">{error}</p>}
|
||||
<button type="button" onClick={()=>save({...book,rows:[...book.rows,newTravelEvent(crypto.randomUUID(),String(draft.currency??''))]})}>Acrescentar evento especial</button>
|
||||
{book.rows.map(row=>{const source=sources.find(s=>s.id===row.travelRowId);return<details className="rounded border p-3" key={row.id}><summary>{row.description||'Evento por identificar'} — {row.period||'período por indicar'}</summary>
|
||||
<label>Percurso do evento especial<select value={row.treatment} onChange={e=>change(row.id,switchTravelEvent(row,e.target.value))}><option value="">Por esclarecer</option><option value="travel_special">Viagens — pagamento integral/início7(9)</option><option value="suspensive_exit">Cessação de regime/procedimento7(8)</option></select></label>
|
||||
{fields(row,[['description','Evento ou operação identificada'],['operationRef','Referência da operação existente'],['period','Mês de contexto do evento'],['documentRef','Documento disponível / referência da previsão'],['documentDate','Data do documento / prevista'],['reviewDate','Data até à qual os eventos são conferidos'],['scope','Âmbito e parcela efectivamente abrangidos'],['evidence','Factos ou pressupostos expressos']])}
|
||||
{select(row,'origin','Natureza dos dados',[['projected','Projectados / pressupostos'],['effective','Efectivos declarados']])}
|
||||
{select(row,'factsConfirmed','Factos correspondem ao percurso e âmbito identificados',yn)}
|
||||
{row.treatment==='suspensive_exit'&&<>
|
||||
{select(row,'regimeClause','Regime/procedimento identificado no artigo14(1)',[['a','a — Importação/colocação em regimes ou depósitos indicados'],['b','b — Expedição/transporte para as zonas ou depósitos e conexos'],['c','c — Transmissão e serviços conexos sob o regime'],['d','d — Trânsito, transbordo, draubaque ou importação temporária e conexos']])}
|
||||
{fields(row,[['entryDate','Data de colocação sob o regime/procedimento'],['entryRef','Referência do regime, bens e operação de entrada']])}
|
||||
{select(row,'regimeStatus','Situação do regime na conferência',[['still','Continua abrangido pelo regime'],['ceased','Cessou efectivamente o regime']])}
|
||||
{row.regimeStatus==='ceased'&&<>{fields(row,[['exitDate','Data da cessação efectiva'],['exitRef','Referência e âmbito da cessação']])}{select(row,'cessationConfirmed','Confirmada cessação fiscal, distinguida de transferência entre regimes ou mudança física',yn)}</>}
|
||||
<p>A referência demonstra o facto e o âmbito. Não se exige certificado ou autorização inventados; não há desembaraço, valor aduaneiro, direitos ou ICE automáticos.</p>
|
||||
</>}
|
||||
{row.treatment==='travel_special'&&<>
|
||||
<label>Operação de viagens já classificada nesta oportunidade<select value={row.travelRowId} onChange={e=>change(row.id,{travelRowId:e.target.value})}><option value="">Escolher operação</option>{sources.map(s=><option key={s.id} value={s.id}>{s.description} — {s.period} — {s.classification.regime}{s.current?'':' — histórico'}</option>)}</select></label>
|
||||
{source&&<><p>Origem: {source.operationRef}; {source.scope}. Período do registo: {source.period}. {source.classification.reason}</p><button type="button" onClick={()=>change(row.id,{operationRef:row.operationRef||source.operationRef,description:row.description||source.description,documentRef:row.documentRef||source.input.documentRef,documentDate:row.documentDate||source.input.documentDate,currency:row.currency||source.input.currency,scope:row.scope||source.scope})}>Preencher apenas referências em falta a partir da origem</button><p>Referências já preenchidas são conservadas; divergências precisam de conferência. O período do evento pode diferir do período da margem.</p></>}
|
||||
{select(row,'fullPaymentState','Estado do pagamento da contraprestação',[['full','Pagamento integral confirmado'],['partial','Apenas pagamento parcial'],['none','Sem pagamentos']])}
|
||||
{row.fullPaymentState==='full'&&fields(row,[['fullPaymentDate','Data do pagamento integral'],['fullPaymentRef','Referência da liquidação integral / conjunto de recebimentos']])}
|
||||
{select(row,'startState','Início da viagem/alojamento e primeiro serviço',[['started','Primeiro serviço/início ocorrido no cenário conferido'],['not_started','Ainda não iniciado']])}
|
||||
{row.startState==='started'&&fields(row,[['startDate','Data do primeiro serviço ao cliente'],['startRef','Referência do primeiro serviço e âmbito']])}
|
||||
<p>«Imediatamente antes» identifica o momento; não se subtrai um dia à data. Pagamento e início na mesma data não demonstram ordem intradiária.</p>
|
||||
{fields(row,[['amount','Contraprestação total da parcela analisada'],['currency','Moeda da contraprestação']])}
|
||||
{select(row,'invoiceRequired','Operação com obrigação de emitir factura25',yn)}
|
||||
{row.invoiceRequired==='yes'&&<>{fields(row,[['legalDeadline','Termo do prazo legal de emissão aplicável'],['deadlineRef','Disposição, calendário e âmbito que fundamentam o prazo']])}{select(row,'invoiceState','Factura até à conferência',[['issued','Emitida'],['not_issued','Não emitida']])}{row.invoiceState==='issued'&&fields(row,[['invoiceDate','Data de emissão da factura'],['invoiceRef','Referência da factura']])}</>}
|
||||
{select(row,'paymentState','Recebimento ou parcela anterior à liquidação integral',[['none','Sem recebimento'],['received','Recebimento identificado']])}
|
||||
{row.paymentState==='received'&&fields(row,[['paymentDate','Data do recebimento identificado'],['paymentAmount','Montante recebido nessa parcela'],['paymentRef','Referência e âmbito da parcela recebida']])}
|
||||
<p>Se existe sinal anterior e posterior liquidação integral, identifique ambos: a parcela e o saldo não são somados duas vezes. A menção «IVA incluído» não é factura emitida por este simulador.</p>
|
||||
</>}
|
||||
<p>Mudar percurso conserva campos ocultos e referências; reveja as confirmações próprias.</p></details>;})}
|
||||
{!!book.rows.length&&<button type="button" onClick={()=>{try{save(recordTravelEvents(draft,book));}catch(e){setError(String(e));}}}>Conferir eventos especiais e guardar versão</button>}
|
||||
{book.result&&<><p role="status">{travelEventCurrent(draft,book)?'Resultados correspondem às entradas e origens guardadas.':'Entradas/origens alteradas; resultado histórico conservado. Reveja o delta.'} {book.history?.length??0} versões anteriores. Sem base/IVA/dedução calculados.</p><dl>{travelEventReportRows(draft).map(([,label,value,origin],i)=><div key={i}><dt>{label}</dt><dd>{value}<small className="block">{origin}</small></dd></div>)}</dl></>}
|
||||
</details>;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import {useState} from 'react';
|
||||
import {travelBook,travelConditions,travelCostKinds,travelCurrent,travelReportRows,travelSource,newTravel,newTravelCost,newTravelPeriod,recordTravel,saveTravelBook,switchTravelResources,type TravelBook,type TravelRow,type TravelPeriod,type TravelCost} from '@/lib/iva-travel';
|
||||
export function SimulationTravel({draft,onChange}:{draft:Record<string,unknown>;onChange:(field:string,value:string)=>void}){
|
||||
const[error,setError]=useState('');let book:TravelBook;try{book=travelBook(draft);}catch(e){return<p role="alert">{String(e)}</p>;}
|
||||
const save=(next:TravelBook)=>{try{onChange('modelo_a_snapshot',saveTravelBook(draft,next));setError('');}catch(e){setError(String(e));}};
|
||||
const change=(id:string,patch:Partial<TravelRow>)=>save({...book,rows:book.rows.map(r=>r.id===id?{...r,...patch}:r)});
|
||||
const period=(id:string,patch:Partial<TravelPeriod>)=>save({...book,periods:book.periods.map(p=>p.id===id?{...p,...patch}:p)});
|
||||
const cost=(row:TravelRow,id:string,patch:Partial<TravelCost>)=>change(row.id,{costs:row.costs.map(c=>c.id===id?{...c,...patch}:c)});
|
||||
return<details className="space-y-3 rounded border p-3"><summary>Agências de viagens — pacote, meios utilizados e margem do período</summary>
|
||||
<p>Preço único, actuação em nome próprio e meios de terceiros determinam o percurso específico. Meios próprios, intermediação e revenda simples conservam o regime geral aplicável. Reutilize documentos e referências; não é necessário criar certificados ou aprovadores.</p><p>A margem é global por operador e período do registo simulado, com âmbito completo declarado. Não somar margem por pacote nem misturar dados efectivos e projectados. A base não é IVA devido nem dedução. Facturação oficial, regularização e apuramento integral continuam fora deste cálculo.</p><p>{travelSource}</p>{error&&<p role="alert">{error}</p>}
|
||||
<button type="button" onClick={()=>save({...book,rows:[...book.rows,newTravel(crypto.randomUUID(),String(draft.currency??''))]})}>Acrescentar operação de viagem</button>
|
||||
{book.rows.map(row=><details key={row.id} className="rounded border p-3"><summary>{row.description||'Viagem por identificar'} — {row.period||'período do registo por indicar'}</summary><div className="grid gap-3 md:grid-cols-2">
|
||||
{([['description','Viagem ou conjunto de serviços'],['operatorRef','Operador: referência da agência/organizador'],['operationRef','Referência da operação / pressuposto'],['period','Período do registo de contraprestação e custos'],['documentRef','Documento existente / referência da previsão'],['documentDate','Data do documento / prevista'],['scope','Âmbito: pacote, parcela, período e condições'],['evidence','Factos / pressupostos expressos'],['agencyRef','Sede/estabelecimento a partir do qual presta e referência'],['currency','Moeda original do registo']] as const).map(([field,label])=><label key={field}>{label}<input type={field==='period'?'month':field==='documentDate'?'date':'text'} value={row[field]} onChange={e=>change(row.id,{[field]:e.target.value})}/></label>)}
|
||||
<label>Natureza dos dados<select value={row.origin} onChange={e=>change(row.id,{origin:e.target.value as TravelRow['origin']})}><option value="projected">Projectados / pressupostos</option><option value="effective">Efectivos declarados</option></select></label>
|
||||
<label>País do estabelecimento prestador<select value={row.agencyCountry} onChange={e=>change(row.id,{agencyCountry:e.target.value})}><option value="">Por esclarecer</option><option value="MZ">Moçambique</option><option value="foreign">Estrangeiro</option></select></label>
|
||||
<label>Meios utilizados na viagem<select value={row.resources} onChange={e=>change(row.id,switchTravelResources(row,e.target.value))}><option value="">Por esclarecer</option><option value="third_party">Apenas meios de terceiros</option><option value="own">Apenas meios próprios</option><option value="mixed">Meios próprios e de terceiros</option></select></label>
|
||||
<label>Revenda retalhista de viagem organizada por grossista<select value={row.retailer} onChange={e=>change(row.id,{retailer:e.target.value as TravelRow['retailer']})}><option value="">Por esclarecer</option><option value="yes">Sim</option><option value="no">Não</option></select></label>
|
||||
{row.retailer==='yes'&&<><label>Acrescenta serviços não meramente acessórios/complementares e apresenta novo conjunto em nome próprio?<select value={row.newPackage} onChange={e=>change(row.id,{newPackage:e.target.value as TravelRow['newPackage']})}><option value="">Por esclarecer</option><option value="yes">Sim</option><option value="no">Não</option></select></label><label>Referência do novo conjunto e serviços acrescidos<input value={row.packageRef} onChange={e=>change(row.id,{packageRef:e.target.value})}/></label></>}
|
||||
</div><fieldset><legend>Factos do percurso</legend>{travelConditions.map(([key,label])=><label className="block" key={key}>{label}<select value={row.conditions[key]??''} onChange={e=>change(row.id,{conditions:{...row.conditions,[key]:e.target.value as ''|'yes'|'no'}})}><option value="">Por esclarecer</option><option value="yes">Sim</option><option value="no">Não</option></select></label>)}</fieldset>
|
||||
{['third_party','mixed'].includes(row.resources)&&<><label>Parcela especial identificada como tributável no âmbito nacional<select value={row.domesticTaxable} onChange={e=>change(row.id,{domesticTaxable:e.target.value as TravelRow['domesticTaxable']})}><option value="">Por esclarecer</option><option value="yes">Sim, segundo fundamento indicado</option><option value="no">Não</option></select></label><label>Fundamento do enquadramento tributável<input value={row.taxRef} onChange={e=>change(row.id,{taxRef:e.target.value})}/></label>
|
||||
<label>Contraprestação registada no período, com IVA incluído<input inputMode="decimal" value={row.grossPrice} onChange={e=>change(row.id,{grossPrice:e.target.value})}/></label>
|
||||
{row.resources==='mixed'&&<><label>Contraprestação da parte que utiliza meios de terceiros, com IVA<input inputMode="decimal" value={row.thirdPartyGross} onChange={e=>change(row.id,{thirdPartyGross:e.target.value})}/></label><label>Fundamento da repartição entre meios próprios e terceiros<input value={row.allocationRef} onChange={e=>change(row.id,{allocationRef:e.target.value})}/></label></>}
|
||||
<p>Custos registados neste mesmo período; indicar montantes com IVA incluído. A lista vazia só representa ausência declarada de custos no âmbito completo. Publicidade, câmbio, comunicações da agência e comissões de venda não reduzem esta margem41(3); a sua eventual dedução não é calculada aqui.</p>
|
||||
<button type="button" onClick={()=>change(row.id,{costs:[...row.costs,newTravelCost(crypto.randomUUID())]})}>Acrescentar custo da viagem</button>
|
||||
{row.costs.map(c=><fieldset key={c.id}><legend>{c.description||'Custo por identificar'}</legend><label>Natureza do custo<select value={c.kind} onChange={e=>cost(row,c.id,{kind:e.target.value})}><option value="">Por esclarecer</option>{travelCostKinds.map(([id,label])=><option key={id} value={id}>{label}</option>)}</select></label>{([['description','Custo identificado'],['amount','Montante com IVA incluído'],['ref','Referência existente do custo / pressuposto']] as const).map(([field,label])=><label key={field}>{label}<input value={c[field]} onChange={e=>cost(row,c.id,{[field]:e.target.value})}/></label>)}
|
||||
{['direct','other'].includes(c.kind)&&([['thirdParty','Fornecimento por terceiro'],['directBenefit','Benefício directo do cliente durante a viagem']] as const).map(([field,label])=><label key={field}>{label}<select value={c[field]} onChange={e=>cost(row,c.id,{[field]:e.target.value})}><option value="">Por esclarecer</option><option value="yes">Sim</option><option value="no">Não</option></select></label>)}</fieldset>)}
|
||||
</>}
|
||||
<p>Alterar meios ou percurso conserva referências e custos ocultos; reveja as confirmações próprias. Alterações de custos ou preço ficam em nova versão referenciada, sem regularização automática.</p></details>)}
|
||||
<h4>Registo global por operador/período</h4><button type="button" onClick={()=>save({...book,periods:[...book.periods,newTravelPeriod(crypto.randomUUID())]})}>Acrescentar período de margem</button>
|
||||
{book.periods.map(p=><fieldset key={p.id}><legend>{p.period||'Período por indicar'} — {p.operatorRef||'operador por indicar'}</legend>{([['period','Período do registo global'],['operatorRef','Mesma referência do operador nas operações'],['scope','Âmbito completo do registo simulado'],['registerRef','Referência do registo ou pressuposto de completude']] as const).map(([field,label])=><label key={field}>{label}<input type={field==='period'?'month':'text'} value={p[field]} onChange={e=>period(p.id,{[field]:e.target.value})}/></label>)}<label>Contraprestações e custos completos neste âmbito/período<select value={p.complete} onChange={e=>period(p.id,{complete:e.target.value as TravelPeriod['complete']})}><option value="">Por esclarecer</option><option value="yes">Sim</option><option value="no">Não, registo parcial</option></select></label>
|
||||
<label>Excesso negativo do período anterior<select value={p.priorMode} onChange={e=>period(p.id,{priorMode:e.target.value})}><option value="">Por esclarecer</option><option value="none">Inexistência expressamente indicada</option><option value="manual">Montante anterior com referência</option><option value="previous">Reutilizar período imediatamente anterior calculado do mesmo operador</option></select></label>{p.priorMode==='manual'&&<><label>Excesso negativo anterior<input value={p.priorAmount} onChange={e=>period(p.id,{priorAmount:e.target.value})}/></label><label>Referência do registo anterior / alteração fundamentada<input value={p.priorRef} onChange={e=>period(p.id,{priorRef:e.target.value})}/></label></>}
|
||||
{p.priorMode!=='previous'&&<label>Se o excesso indicado divergir do anterior calculado, confirmar a correcção referenciada após conferir o impacto na base e reporte<select value={p.priorOverride} onChange={e=>period(p.id,{priorOverride:e.target.value as TravelPeriod['priorOverride']})}><option value="">Conferir divergência se existir</option><option value="yes">Confirmo usar o valor indicado e a referência da correcção</option><option value="no">Conservar/reutilizar o anterior</option></select></label>}
|
||||
</fieldset>)}
|
||||
{!!book.rows.length&&<button type="button" onClick={()=>{try{save(recordTravel(draft,book));}catch(e){setError(String(e));}}}>Classificar viagens e apurar margem do período</button>}
|
||||
{book.result&&<><p role="status">{travelCurrent(draft,book)?'Resultados correspondem ao registo guardado.':'Entradas alteradas; resultado histórico conservado. Reveja o delta.'} {book.history?.length??0} versões anteriores. Sem IVA devido ou dedução calculados.</p><dl>{travelReportRows(draft).map(([,label,value,origin],i)=><div key={i}><dt>{label}</dt><dd>{value}<small className="block">{origin}</small></dd></div>)}</dl></>}
|
||||
</details>;
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import {acquisitionScope,readAcquisitionStore} from './iva-acquisitions.ts';
|
||||
import {newCurrencyEvent,evaluateCurrencyEvent,type CurrencyEventRow} from './iva-currency-events.ts';
|
||||
import {travelBook,travelCurrent,travelDecimal,travelMoney,type TravelLine,type TravelRow} from './iva-travel.ts';
|
||||
export const travelEventVersion='travel-suspensive-events/1';
|
||||
export const travelEventSource='CIVA7(8)/(9),8,5(2),14(1),Lei13/2016PDF7/9/13/p1426(265/267/271); RIVA38/40,PDF6–7/p104–105.7(8) defere facto gerador/exigibilidade até cessar regime; mudança de local sem cessação não é saída fiscal.7(9)/40: primeiro entre pagamento integral e imediatamente antes do início/primeiro serviço. Facto gerador separado de factura/recebimento8; reutiliza motor entregue. Sem base/IVA/dedução, desembaraço ou pagamento automáticos.';
|
||||
export type TravelEventRow=CurrencyEventRow&{travelRowId:string;fullPaymentState:string;fullPaymentDate:string;fullPaymentRef:string;startState:string;startDate:string;startRef:string;regimeClause:string;entryDate:string;entryRef:string;regimeStatus:string;exitDate:string;exitRef:string;cessationConfirmed:string};
|
||||
type TravelOrigin={id:string;period:string;operatorRef:string;operationRef:string;description:string;scope:string;revision:number;current:boolean;input:TravelRow;classification:TravelLine};
|
||||
type DueEvent={date:string;amount:string|null;reason:string};
|
||||
export type TravelEventLine={id:string;status:'determined'|'not_applicable'|'indeterminate'|'deferred';eventDate:string|null;eventTiming:string|null;dueEvents:DueEvent[];remainingPending:boolean;source:TravelOrigin|null;baseAmount:null;vat:null;deduction:null;reason:string};
|
||||
export type TravelEventCalculation={version:string;revision:number;calculatedAt:string;opportunity:string;sourceKey:string;source:string;inputs:TravelEventRow[];rows:TravelEventLine[]};
|
||||
export type TravelEventBook={rows:TravelEventRow[];result?:TravelEventCalculation;history?:TravelEventCalculation[]};
|
||||
type Store=ReturnType<typeof readAcquisitionStore>&{travelSpecialEvents?:Record<string,TravelEventBook>};
|
||||
export function newTravelEvent(id:string,currency:string):TravelEventRow{return{...newCurrencyEvent(id,currency),travelRowId:'',fullPaymentState:'',fullPaymentDate:'',fullPaymentRef:'',startState:'',startDate:'',startRef:'',regimeClause:'',entryDate:'',entryRef:'',regimeStatus:'',exitDate:'',exitRef:'',cessationConfirmed:''};}
|
||||
export function switchTravelEvent(row:TravelEventRow,treatment:string):TravelEventRow{return{...row,treatment,factsConfirmed:'',invoiceRequired:''};}
|
||||
export function travelEventSources(draft:Record<string,unknown>):TravelOrigin[]{const b=travelBook(draft),r=b.result;return r?r.inputs.map((input,i)=>({id:input.id,period:input.period,operatorRef:input.operatorRef,operationRef:input.operationRef,description:input.description,scope:input.scope,revision:r.revision,current:travelCurrent(draft,b),input,classification:r.rows[i]})):[];}
|
||||
function readStore(draft:Record<string,unknown>):Store{
|
||||
const s=readAcquisitionStore(draft.modelo_a_snapshot) as Store;
|
||||
if(s.travelSpecialEvents!==undefined&&(!s.travelSpecialEvents||typeof s.travelSpecialEvents!=='object'||Array.isArray(s.travelSpecialEvents)))throw new Error('Eventos especiais não recuperáveis; originais conservados.');
|
||||
const fields=Object.keys(newTravelEvent('',''));
|
||||
for(const b of Object.values(s.travelSpecialEvents??{}))if(!b||!Array.isArray(b.rows)||(b.history!==undefined&&!Array.isArray(b.history))||b.rows.some(r=>!r||fields.some(k=>typeof r[k as keyof TravelEventRow]!=='string')))throw new Error('Linhas de eventos especiais não recuperáveis; sem lista vazia por defeito.');return s;
|
||||
}
|
||||
export function travelEventBook(draft:Record<string,unknown>):TravelEventBook{return readStore(draft).travelSpecialEvents?.[acquisitionScope(draft)]??{rows:[]};}
|
||||
export function saveTravelEventBook(draft:Record<string,unknown>,book:TravelEventBook):string{const s=readStore(draft),encoded=JSON.stringify({...s,travelSpecialEvents:{...s.travelSpecialEvents,[acquisitionScope(draft)]:book}});readStore({...draft,modelo_a_snapshot:encoded});return encoded;}
|
||||
function date(s:string):number|null{const n=Date.parse(s+'T00:00:00Z');return /^\d{4}-\d{2}-\d{2}$/.test(s)&&Number.isFinite(n)&&new Date(n).toISOString().slice(0,10)===s?n:null;}
|
||||
export function evaluateTravelEvent(draft:Record<string,unknown>,row:TravelEventRow):TravelEventLine{
|
||||
const result:TravelEventLine={id:row.id,status:'indeterminate',eventDate:null,eventTiming:null,dueEvents:[],remainingPending:false,source:null,baseAmount:null,vat:null,deduction:null,reason:''};const stop=(reason:string,status:TravelEventLine['status']='indeterminate')=>({...result,status,reason});
|
||||
if(!['travel_special','suspensive_exit'].includes(row.treatment)||!/^2026-(0[1-9]|1[0-2])$/.test(row.period)||!['projected','effective'].includes(row.origin)||date(row.documentDate)===null||date(row.reviewDate)===null||![row.description,row.operationRef,row.documentRef,row.scope,row.evidence].every(v=>!!v.trim()))return stop('Identifique percurso, período2026, operação, âmbito, documentos/pressupostos e data de conferência.');
|
||||
if(row.factsConfirmed==='no')return stop('Factos não correspondem ao percurso especial; manter enquadramento aplicável, sem isenção ou IVAzero presumidos.','not_applicable');
|
||||
if(row.factsConfirmed!=='yes')return stop('Factos próprios do evento por esclarecer; não inferir pela factura, licenciamento ou viagem anunciada.');
|
||||
const review=date(row.reviewDate)!;
|
||||
if(row.treatment==='suspensive_exit'){
|
||||
const entry=date(row.entryDate);
|
||||
if(!['a','b','c','d'].includes(row.regimeClause)||entry===null||entry>review||!row.entryRef.trim())return stop('Identifique bens/operação e regime ou procedimento14(1)/5(2), sua colocação/data e referência; uma localização em armazém não demonstra regime suspensivo.');
|
||||
if(row.regimeStatus==='still')return stop('CIVA7(8): bens/operação continuam abrangidos pelo regime; facto gerador e exigibilidade diferidos. Factura ou recebimento não são tratados como saída fiscal por este percurso.','deferred');
|
||||
if(row.regimeStatus!=='ceased')return stop('Situação actual do regime por esclarecer; ausência de documento não prova permanência nem saída.');
|
||||
const exit=date(row.exitDate);if(exit===null||exit<entry||!row.exitRef.trim()||row.cessationConfirmed!=='yes')return stop('Identifique cessação efectiva do regime/procedimento e sua data/âmbito; transferência entre regimes ou mudança física de armazém não demonstram por si cessação.');
|
||||
result.eventDate=row.exitDate;result.eventTiming='cessação efectiva do regime/procedimento';
|
||||
if(exit>review)return stop('Cessação futura na conferência; não declarar facto gerador/exigibilidade já ocorridos.');
|
||||
result.dueEvents=[{date:row.exitDate,amount:null,reason:'7(8): cessação do regime/procedimento identificado; montante não apurado'}];return stop('P133: facto gerador e exigibilidade pelo momento de cessação efectiva7(8). Não concede isenção14, liquida importação, determina valor aduaneiro, direito, ICE ou dedução.','determined');
|
||||
}
|
||||
let source:TravelOrigin|undefined;try{source=travelEventSources(draft).find(s=>s.id===row.travelRowId);}catch{return stop('Origem de viagens não recuperável; conservar originais e histórico.');}
|
||||
if(!source)return stop('Associe uma operação de viagens já classificada nesta oportunidade; não copiar de outro projecto.');result.source=source;
|
||||
if(!source.current||source.classification.regime==='indeterminate')return stop('Origem alterada ou natureza do regime indeterminada; rever apenas a associação/versão afectada.');
|
||||
if(!['special','mixed'].includes(source.classification.regime)||source.classification.territory!=='national')return stop('Origem segue regime geral ou outro âmbito; evento específico da parte especial não se aplica. Reutilizar eventos gerais próprios sem os substituir.','not_applicable');
|
||||
if(source.input.domesticTaxable!=='yes'||!source.input.taxRef.trim())return stop('Enquadramento tributável da parcela especial por esclarecer. Montantes da margem não são pré-condição do evento, mas o âmbito fiscal deve estar identificado.');
|
||||
if(row.operationRef!==source.operationRef||row.currency!==source.input.currency)return stop('Referência da operação ou moeda diverge da origem; conservar dados e conferir a associação. O período deste evento pode diferir do período do registo da margem.');
|
||||
if(!['full','partial','none'].includes(row.fullPaymentState)||!['started','not_started'].includes(row.startState))return stop('Distinga pagamento integral, parcial ou inexistente e início/primeiro serviço ocorrido ou ainda não iniciado. Desconhecido não é ausência de pagamento.');
|
||||
const full=row.fullPaymentState==='full'?date(row.fullPaymentDate):null,start=row.startState==='started'?date(row.startDate):null;
|
||||
if(row.fullPaymentState==='full'&&(full===null||!row.fullPaymentRef.trim()||full>review))return stop('Identifique pagamento integral efectivamente ocorrido ou no cenário de conferência declarado, sem o inferir de sinal/adiantamento.');
|
||||
if(row.startState==='started'&&(start===null||!row.startRef.trim()||start>review))return stop('Identifique o primeiro serviço ao cliente/início da viagem ou alojamento, até à conferência. Não subtrair um dia à data: a regra é imediatamente antes desse momento.');
|
||||
if(full!==null&&(start===null||full<start)){result.eventDate=row.fullPaymentDate;result.eventTiming='acto do pagamento integral';}
|
||||
else if(start!==null){result.eventDate=row.startDate;result.eventTiming=full===start?'pagamento integral e início na mesma data; ordem intradiária não apurada':'imediatamente antes do início/primeiro serviço';}
|
||||
if(row.fullPaymentState==='partial'&&row.paymentState!=='received')return stop('Pagamento parcial declarado exige identificar a parcela recebida; não o transformar em pagamento integral.');
|
||||
if(row.fullPaymentState==='none'&&row.paymentState!=='none')return stop('Estado sem pagamentos diverge da parcela recebida/por esclarecer; reconciliar factos sem sobrescrever referências.');
|
||||
if(row.fullPaymentState==='full'&&row.paymentState==='none')return stop('Pagamento integral diverge de ausência de recebimento; conferir estados.');
|
||||
if(row.fullPaymentState==='partial'){
|
||||
const amount=travelDecimal(row.amount),part=travelDecimal(row.paymentAmount);
|
||||
if(amount===null||part===null||part<=0n||part>=amount)return stop('Parcial exige parcela positiva inferior à contraprestação total identificada; igualdade não pode ser tratada como simples sinal.');
|
||||
}
|
||||
if(row.fullPaymentState==='full'&&row.invoiceRequired==='yes'){
|
||||
const amount=travelDecimal(row.amount),part=travelDecimal(row.paymentAmount),payment=date(row.paymentDate);
|
||||
if(amount===null||part===null||payment===null||payment>full!||part<=0n||part>amount||(payment===full&&part!==amount)||(payment<full!&&part>=amount))return stop('Reconcilie parcela anterior e liquidação integral: data/valor do recebimento deve corresponder ao total na data integral ou a parcela anterior inferior ao total.');
|
||||
}
|
||||
if(result.eventDate===null){
|
||||
if(row.invoiceRequired==='no')return stop('Sem pagamento integral e sem início: facto gerador7(9) ainda não ocorrido no âmbito declarado; sem antecipação automática por adiantamento.','deferred');
|
||||
if(row.invoiceRequired==='yes'&&row.paymentState==='none'){
|
||||
const deadline=date(row.legalDeadline),invoice=row.invoiceState==='issued'?date(row.invoiceDate):null;
|
||||
if(deadline===null||!row.deadlineRef.trim()||!['issued','not_issued'].includes(row.invoiceState)||(row.invoiceState==='issued'&&(invoice===null||invoice>review||!row.invoiceRef.trim())))return stop('Facto gerador7(9) ainda não ocorrido; identificar factura e prazo/disposição aplicáveis antes de avaliar8a/b, sem inventar data de viagem.');
|
||||
const due=invoice!==null&&invoice<=deadline?row.invoiceDate:row.legalDeadline;
|
||||
if(date(due)!>review){result.remainingPending=true;return stop('Facto gerador7(9) não ocorrido e nenhuma parcela8a/b exigível até à conferência declarada.','deferred');}
|
||||
const amount=travelDecimal(row.amount);result.dueEvents=[{date:due,amount:amount===null?null:travelMoney(amount),reason:invoice!==null&&invoice<=deadline?'8a: factura antecipada emitida no prazo; facto gerador especial ainda não ocorrido':'8b: termo do prazo expressamente identificado; facto gerador especial ainda não ocorrido'}];
|
||||
return stop('Exigibilidade8a/b por factura/prazo identificados, sem usar essa data como pagamento integral ou início7(9).','determined');
|
||||
}
|
||||
if(row.invoiceRequired!=='yes'||row.paymentState!=='received')return stop('Facto gerador especial ainda não ocorrido; conferir eventual factura ou recebimento8 sem inventar data do início.');
|
||||
const early=evaluateCurrencyEvent({...row,treatment:'advance_payment',eventDate:'',eventRef:'CIVA7(9)/RIVA40 — sem evento integral/início ocorrido'});
|
||||
result.dueEvents=early.dueEvents;result.remainingPending=early.remainingPending;
|
||||
return stop('Facto gerador7(9) ainda não ocorrido. Parcela8 avaliada pelo motor existente: '+early.reason,early.status==='determined'?'determined':early.status==='not_applicable'?'not_applicable':'indeterminate');
|
||||
}
|
||||
const existing=evaluateCurrencyEvent({...row,treatment:'service_completed',eventDate:result.eventDate!,eventRef:'CIVA7(9)/RIVA40: '+result.eventTiming});
|
||||
result.dueEvents=existing.dueEvents;result.remainingPending=existing.remainingPending;
|
||||
if(existing.status==='determined'&&row.fullPaymentState==='full'&&row.invoiceRequired==='yes'){
|
||||
const total=travelDecimal(row.amount)!;const before=result.dueEvents.filter(e=>date(e.date)!<full!);let already=0n;
|
||||
for(const e of before){const value=e.amount===null?null:travelDecimal(e.amount);if(value===null)return stop('Parcela anterior com montante indeterminado; conservar evento e esclarecer total antes de reconciliar pagamento integral.');already+=value;}
|
||||
if(already>total)return stop('Parcelas anteriores excedem total; sem dupla contagem.');
|
||||
result.dueEvents=[...before,...(total>already?[{date:row.fullPaymentDate,amount:travelMoney(total-already),reason:'8c: saldo recebido na liquidação integral, descontando parcelas já exigíveis'}]:[])];result.remainingPending=false;
|
||||
}
|
||||
return stop('P133:7(9)/RIVA40, '+result.eventTiming+' em '+result.eventDate+'. Exigibilidade8 separada por factura/recebimento, sem duplicação de parcela e restante. '+existing.reason,existing.status==='determined'?'determined':existing.status==='not_applicable'?'not_applicable':'indeterminate');
|
||||
}
|
||||
function key(draft:Record<string,unknown>,book:TravelEventBook):string{return JSON.stringify({version:travelEventVersion,scope:acquisitionScope(draft),location:draft.selection_labels??{},rows:book.rows,dependencies:book.rows.map(row=>row.treatment==='travel_special'?travelEventSources(draft).find(s=>s.id===row.travelRowId):null)});}
|
||||
export function travelEventCurrent(draft:Record<string,unknown>,book:TravelEventBook):boolean{try{return!!book.result&&book.result.sourceKey===key(draft,book);}catch{return false;}}
|
||||
export function recordTravelEvents(draft:Record<string,unknown>,book:TravelEventBook):TravelEventBook{
|
||||
if(!book.rows.length||new Set(book.rows.map(r=>r.id)).size!==book.rows.length)throw new Error('Identifique eventos distintos; originais conservados.');
|
||||
const result:TravelEventCalculation={version:travelEventVersion,revision:(book.result?.revision??0)+1,calculatedAt:new Date().toISOString(),opportunity:acquisitionScope(draft),sourceKey:key(draft,book),source:travelEventSource,inputs:structuredClone(book.rows),rows:book.rows.map(row=>evaluateTravelEvent(draft,row))};
|
||||
const next={...book,result,history:[...(book.history??[]),...(book.result?[book.result]:[])]};saveTravelEventBook(draft,next);return next;
|
||||
}
|
||||
export function travelEventReportRows(draft:Record<string,unknown>):Array<[string,string,string,string]>{try{const b=travelEventBook(draft),r=b.result;if(!r)return[];return r.inputs.map((row,i)=>{const line=r.rows[i];return['IVA mensal',`P133 — ${row.period} — ${row.id}`,`${row.description};${line.reason};facto gerador:${line.eventDate??'Não apurado/ainda não ocorrido'};momento:${line.eventTiming??'Indeterminado'};parcelas:${line.dueEvents.map(p=>p.date+':'+(p.amount??'Montante indeterminado')+' '+row.currency+' ('+p.reason+')').join('; ')||'Nenhuma apurada'};restante:${line.status==='indeterminate'?'Indeterminado':line.remainingPending?'Pendente':'Sem restante apurado'};base/IVA/dedução:Indeterminados`,`${r.opportunity};${travelEventCurrent(draft,b)?'Resultado actual':'Origem/entradas alteradas; resultado histórico conservado'};versão${r.revision};${r.calculatedAt};${r.source};${row.origin};operação${row.operationRef};âmbito${row.scope};documento${row.documentRef},${row.documentDate};conferência${row.reviewDate};${row.treatment==='suspensive_exit'?`regime14(1)${row.regimeClause};entrada${row.entryDate},${row.entryRef};situação${row.regimeStatus};cessação${row.exitDate},${row.exitRef},confirmada${row.cessationConfirmed}`:`origem viagem${row.travelRowId},versão${line.source?.revision??'Indeterminada'},${line.source?.scope??''};pagamento integral${row.fullPaymentState},${row.fullPaymentDate},${row.fullPaymentRef};início${row.startState},${row.startDate},${row.startRef};factura${row.invoiceRequired},${row.invoiceState},${row.invoiceDate},${row.invoiceRef};prazo${row.legalDeadline},${row.deadlineRef};recebimento${row.paymentState},${row.paymentDate},${row.paymentAmount},${row.paymentRef};total${row.amount}${row.currency}`};factos${row.evidence};sem lançamento fiscal, nova dedução/campo11 ou totaisModeloA.`];});}catch{return[['IVA mensal','P133','Dados não recuperáveis','Originais conservados; nenhum evento por defeito.']];}}
|
||||
@@ -0,0 +1,96 @@
|
||||
import {acquisitionScope,readAcquisitionStore} from './iva-acquisitions.ts';
|
||||
export const travelVersion='travel-period-margin/1';
|
||||
export const travelSource='CIVA4(5),Lei13/2016PDF7/p1426(265); RIVA Decreto7/2008,38–46,PDF6–8/p104–106: âmbito, prestação única, margem global por período, custos directos de terceiros, excesso negativo e facturação IVA incluído. CIVA17(1),Lei22/2022PDF3:16% usado apenas no divisor da base em2026;Lei10/2025 não altera4/7/8/17. Sem IVA devido, dedução/campo11, regularização ou totaisModeloA calculados. O cálculo fica limitado ao registo e âmbito declarados, não fecha apuramento de toda a organização.';
|
||||
type Choice=''|'yes'|'no';
|
||||
export const travelConditions=[['operator','Agência de viagens ou organizador de circuitos turísticos identificado, actuando nessa qualidade'],['singlePrice','Conjunto combinado de serviços por preço único, sem discriminar preços finais por componente'],['ownName','Actua em nome próprio perante o cliente']] as const;
|
||||
export const travelCostKinds=[['direct','Bem/serviço de terceiro para benefício directo do cliente'],['exchange','Compra/venda/troca de divisas'],['advertising','Publicidade/promoção'],['communications','Comunicações e correspondência da agência'],['sales_commission','Comissão paga à agência vendedora'],['other','Outro custo com natureza a esclarecer']] as const;
|
||||
export type TravelCost={id:string;kind:string;description:string;amount:string;ref:string;thirdParty:Choice;directBenefit:Choice};
|
||||
export type TravelRow={id:string;description:string;period:string;origin:'projected'|'effective';currency:string;operatorRef:string;operationRef:string;documentRef:string;documentDate:string;scope:string;evidence:string;agencyCountry:string;agencyRef:string;resources:string;retailer:Choice;newPackage:Choice;packageRef:string;grossPrice:string;thirdPartyGross:string;allocationRef:string;domesticTaxable:Choice;taxRef:string;conditions:Partial<Record<string,Choice>>;costs:TravelCost[]};
|
||||
export type TravelPeriod={id:string;period:string;operatorRef:string;scope:string;complete:Choice;registerRef:string;priorMode:string;priorAmount:string;priorRef:string;priorOverride:Choice};
|
||||
export type TravelLine={id:string;status:'determined'|'not_applicable'|'indeterminate';regime:'special'|'mixed'|'general'|'outside'|'indeterminate';territory:'national'|'outside'|null;specialGross:string|null;generalGross:string|null;eligibleCosts:string|null;excludedCosts:string|null;reason:string};
|
||||
export type TravelPeriodResult={id:string;period:string;operatorRef:string;status:'determined'|'indeterminate';grossReceipts:string|null;directCosts:string|null;excludedCosts:string|null;priorExcess:string|null;grossDifference:string|null;carryForward:string|null;baseAmount:string|null;baseDivisorRate:string|null;vat:null;deduction:null;reason:string};
|
||||
export type TravelCalculation={version:string;revision:number;calculatedAt:string;opportunity:string;sourceKey:string;source:string;inputs:TravelRow[];periodInputs:TravelPeriod[];rows:TravelLine[];periods:TravelPeriodResult[]};
|
||||
export type TravelBook={rows:TravelRow[];periods:TravelPeriod[];result?:TravelCalculation;history?:TravelCalculation[]};
|
||||
type Store=ReturnType<typeof readAcquisitionStore>&{travelOperations?:Record<string,TravelBook>};
|
||||
export function newTravelCost(id:string):TravelCost{return{id,kind:'',description:'',amount:'',ref:'',thirdParty:'',directBenefit:''};}
|
||||
export function newTravel(id:string,currency:string):TravelRow{return{id,description:'',period:'',origin:'projected',currency,operatorRef:'',operationRef:'',documentRef:'',documentDate:'',scope:'',evidence:'',agencyCountry:'',agencyRef:'',resources:'',retailer:'',newPackage:'',packageRef:'',grossPrice:'',thirdPartyGross:'',allocationRef:'',domesticTaxable:'',taxRef:'',conditions:{},costs:[]};}
|
||||
export function newTravelPeriod(id:string):TravelPeriod{return{id,period:'',operatorRef:'',scope:'',complete:'',registerRef:'',priorMode:'',priorAmount:'',priorRef:'',priorOverride:''};}
|
||||
export function switchTravelResources(row:TravelRow,resources:string):TravelRow{return{...row,resources,conditions:{...row.conditions,singlePrice:'',ownName:''}};}
|
||||
function readStore(draft:Record<string,unknown>):Store{
|
||||
const s=readAcquisitionStore(draft.modelo_a_snapshot) as Store;
|
||||
if(s.travelOperations!==undefined&&(!s.travelOperations||typeof s.travelOperations!=='object'||Array.isArray(s.travelOperations)))throw new Error('Viagens não recuperáveis; originais conservados.');
|
||||
const rf=Object.keys(newTravel('','')).filter(k=>!['conditions','costs'].includes(k)),pf=Object.keys(newTravelPeriod('')),cf=Object.keys(newTravelCost(''));
|
||||
for(const b of Object.values(s.travelOperations??{}))if(!b||!Array.isArray(b.rows)||!Array.isArray(b.periods)||(b.history!==undefined&&!Array.isArray(b.history))||b.rows.some(r=>!r||rf.some(k=>typeof r[k as keyof TravelRow]!=='string')||!r.conditions||typeof r.conditions!=='object'||Array.isArray(r.conditions)||!Array.isArray(r.costs)||r.costs.some(c=>!c||cf.some(k=>typeof c[k as keyof TravelCost]!=='string')))||b.periods.some(p=>!p||pf.some(k=>typeof p[k as keyof TravelPeriod]!=='string')))throw new Error('Registo de viagens não recuperável; sem substituição por lista vazia.');return s;
|
||||
}
|
||||
export function travelBook(draft:Record<string,unknown>):TravelBook{return readStore(draft).travelOperations?.[acquisitionScope(draft)]??{rows:[],periods:[]};}
|
||||
export function saveTravelBook(draft:Record<string,unknown>,book:TravelBook):string{const s=readStore(draft),encoded=JSON.stringify({...s,travelOperations:{...s.travelOperations,[acquisitionScope(draft)]:book}});readStore({...draft,modelo_a_snapshot:encoded});return encoded;}
|
||||
export function travelDecimal(s:string):bigint|null{if(!/^\d{1,15}(\.\d{1,6})?$/.test(s.trim()))return null;const[w,f='']=s.trim().split('.');return BigInt(w)*1000000n+BigInt(f.padEnd(6,'0'));}
|
||||
function money(n:bigint):string{if(n<0n)return'-'+money(-n);return`${n/1000000n}.${(n%1000000n).toString().padStart(6,'0').replace(/0+$/,'').padEnd(2,'0')}`;}
|
||||
export {money as travelMoney};
|
||||
function validDate(s:string):boolean{const d=new Date(s+'T00:00:00Z');return /^\d{4}-\d{2}-\d{2}$/.test(s)&&Number.isFinite(d.getTime())&&d.toISOString().slice(0,10)===s;}
|
||||
export function evaluateTravel(row:TravelRow,currency:unknown):TravelLine{
|
||||
const result:TravelLine={id:row.id,status:'indeterminate',regime:'indeterminate',territory:null,specialGross:null,generalGross:null,eligibleCosts:null,excludedCosts:null,reason:''};const stop=(reason:string,status:TravelLine['status']='indeterminate')=>({...result,status,reason});
|
||||
if(!/^2026-(0[1-9]|1[0-2])$/.test(row.period)||!['projected','effective'].includes(row.origin)||![row.description,row.operatorRef,row.operationRef,row.documentRef,row.scope,row.evidence,row.agencyRef].every(v=>!!v.trim())||!validDate(row.documentDate))return stop('Identifique operador, operação, período do registo2026, documento ou previsão, estabelecimento, âmbito e factos. O período do registo não é inferido da data da viagem.');
|
||||
if(row.conditions.operator==='no')return stop('Qualidade de agência/organizador não demonstrada; aplicar enquadramento próprio, sem benefício presumido.','not_applicable');
|
||||
if(row.conditions.operator!=='yes')return stop('Qualidade em que o operador actua por esclarecer.');
|
||||
if(row.conditions.singlePrice==='no'||row.conditions.ownName==='no'||row.resources==='own')return{...stop('RIVA38(2)/(3): operação avulsa/em nome de outrem ou meios próprios segue regime geral, sem margem especial. Reutilize o percurso de serviço/localização aplicável.','not_applicable'),regime:'general'};
|
||||
if(!['third_party','mixed'].includes(row.resources)||row.conditions.singlePrice!=='yes'||row.conditions.ownName!=='yes'||!['yes','no'].includes(row.retailer))return stop('Esclareça pacote a preço único, nome próprio, meios próprios/terceiros e venda retalhista.');
|
||||
if(row.retailer==='yes'){
|
||||
if(row.newPackage==='no')return{...stop('RIVA38(4): revenda retalhista sem novo conjunto com serviços não meramente acessórios/complementares segue regime geral.','not_applicable'),regime:'general'};
|
||||
if(row.newPackage!=='yes'||!row.packageRef.trim())return stop('Na revenda retalhista, identifique novo conjunto em nome próprio e serviços acrescidos não meramente acessórios/complementares.');
|
||||
}
|
||||
if(!['MZ','foreign'].includes(row.agencyCountry))return stop('Identifique sede/estabelecimento a partir do qual a agência presta o serviço.');
|
||||
if(row.agencyCountry==='foreign')return{...stop('RIVA39(2): sede/estabelecimento prestador fora do território; esta margem nacional não se aplica. Conferir localização/regime próprio, sem isenção ou IVA estrangeiro presumido.','not_applicable'),regime:'outside',territory:'outside'};
|
||||
result.regime=row.resources==='mixed'?'mixed':'special';result.territory='national';
|
||||
if(currency!=='MZN'||row.currency!==currency)return stop('Natureza especial identificada; cálculo em moeda estrangeira ou divergente não apurado, sem conversão fiscal presumida. P104 mantém âmbito próprio.');
|
||||
if(row.domesticTaxable!=='yes'||!row.taxRef.trim())return stop('Localização39(2) identificada; enquadramento tributável desta parcela/período por esclarecer antes do divisor17(1). Não presumir isenção, taxa ou benefício pela categoria turística.');
|
||||
const gross=travelDecimal(row.grossPrice);if(gross===null)return stop('Identifique contraprestação com IVA incluído registada neste período; desconhecido não é zero.');
|
||||
const special=row.resources==='mixed'?travelDecimal(row.thirdPartyGross):gross;
|
||||
if(special===null||special>gross||(row.resources==='mixed'&&!row.allocationRef.trim()))return stop('Meios mistos: delimite contraprestação da parte de terceiros e fundamento de repartição; não aplicar margem ao conjunto nem repartir por percentagem inventada.');
|
||||
let eligible=0n,excluded=0n;
|
||||
if(new Set(row.costs.map(c=>c.id)).size!==row.costs.length)return stop('Identifique custos sem duplicar identidades.');
|
||||
for(const cost of row.costs){
|
||||
const value=travelDecimal(cost.amount);if(value===null||!cost.ref.trim()||!cost.description.trim()||!travelCostKinds.some(([k])=>k===cost.kind))return stop('Identifique natureza, montante com IVA e referência de cada custo registado no período. Lista vazia corresponde apenas à ausência declarada de custos no âmbito completo.');
|
||||
if(['exchange','advertising','communications','sales_commission'].includes(cost.kind)){excluded+=value;continue;}
|
||||
if(cost.thirdParty==='no'||cost.directBenefit==='no'){excluded+=value;continue;}
|
||||
if(cost.thirdParty!=='yes'||cost.directBenefit!=='yes')return stop('Benefício directo e fornecimento por terceiro por esclarecer neste custo; não o excluir ou incluir por defeito.');
|
||||
eligible+=value;
|
||||
}
|
||||
return{...result,status:'determined',specialGross:money(special),generalGross:money(gross-special),eligibleCosts:money(eligible),excludedCosts:money(excluded),reason:'RIVA38/39: prestação única no âmbito da parte de terceiros; registo43 de contraprestações e custos directos com IVA incluído. Custos41(3) excluídos da margem, não transformados em dedução. A base é apurada globalmente por operador/período, não por pacote. Meios próprios/parte geral conservados fora da margem; factura45 sem discriminar IVA, menção IVA incluído; registo especial46. Sem factura oficial emitida, dedução ou regularização automática.'};
|
||||
}
|
||||
function previousMonth(period:string):string{const[y,m]=period.split('-').map(Number);return new Date(Date.UTC(y,m-2,1)).toISOString().slice(0,7);}
|
||||
export function evaluateTravelPeriods(book:TravelBook,lines:TravelLine[]):TravelPeriodResult[]{
|
||||
const results:TravelPeriodResult[]=[];
|
||||
for(const input of [...book.periods].sort((a,b)=>a.period.localeCompare(b.period))){
|
||||
const r:TravelPeriodResult={id:input.id,period:input.period,operatorRef:input.operatorRef,status:'indeterminate',grossReceipts:null,directCosts:null,excludedCosts:null,priorExcess:null,grossDifference:null,carryForward:null,baseAmount:null,baseDivisorRate:null,vat:null,deduction:null,reason:''};const stop=(reason:string)=>{r.reason=reason;results.push(r);};
|
||||
if(!/^2026-(0[1-9]|1[0-2])$/.test(input.period)||!input.operatorRef.trim()||!input.scope.trim()||!input.registerRef.trim()||input.complete!=='yes'){stop('Identifique período, operador e âmbito completo do registo simulado, incluindo contraprestações/custos e referência. Não apresentar soma parcial como apuramento global da organização.');continue;}
|
||||
const rows=book.rows.map((row,i)=>({row,line:lines[i]})).filter(({row})=>row.operatorRef===input.operatorRef&&row.period===input.period);
|
||||
if(book.rows.some(row=>!row.operatorRef.trim()||!row.period.trim())||!rows.length||rows.some(({line})=>line.status==='indeterminate')){stop('Linhas por esclarecer ou âmbito sem registo: período indeterminado; não omitir silenciosamente operação/custo da margem global.');continue;}
|
||||
const selected=rows.filter(({line})=>line.status==='determined'&&['special','mixed'].includes(line.regime));
|
||||
if(new Set(selected.map(({row})=>row.origin)).size>1){stop('Não somar dados efectivos e pressupostos projectados no mesmo registo global sem distinguir o cenário. Conservar as linhas e delimitar o âmbito.');continue;}
|
||||
let prior:bigint|null=null;
|
||||
if(input.priorMode==='none')prior=0n;
|
||||
else if(input.priorMode==='manual'&&input.priorRef.trim())prior=travelDecimal(input.priorAmount);
|
||||
else if(input.priorMode==='previous'){
|
||||
const prev=results.find(x=>x.period===previousMonth(input.period)&&x.operatorRef===input.operatorRef&&x.status==='determined');if(prev?.carryForward!==null&&prev?.carryForward!==undefined)prior=travelDecimal(prev.carryForward);
|
||||
}
|
||||
if(prior===null){stop('Excesso negativo anterior por esclarecer: indicar inexistência, montante/referência ou reutilizar período imediatamente anterior determinado do mesmo operador.');continue;}
|
||||
const existingPrevious=results.find(x=>x.period===previousMonth(input.period)&&x.operatorRef===input.operatorRef&&x.status==='determined');
|
||||
if(input.priorMode!=='previous'&&existingPrevious?.carryForward!==null&&existingPrevious?.carryForward!==undefined&&travelDecimal(existingPrevious.carryForward)!==prior&&(input.priorMode!=='manual'||!input.priorRef.trim()||input.priorOverride!=='yes')){
|
||||
stop('Excesso indicado '+money(prior)+' diverge do período anterior calculado '+existingPrevious.carryForward+'. A alteração muda os custos/base e o excesso seguinte; indique montante manual e referência da correcção e confirme a utilização, ou reutilize o anterior. Nada foi sobrescrito.');continue;
|
||||
}
|
||||
let gross=0n,costs=0n,excluded=0n;for(const{line}of selected){gross+=travelDecimal(line.specialGross!)!;costs+=travelDecimal(line.eligibleCosts!)!;excluded+=travelDecimal(line.excludedCosts!)!;}
|
||||
const difference=gross-costs-prior,positive=difference>0n?difference:0n,baseWhole=(positive*100n+58000000n)/116000000n;
|
||||
Object.assign(r,{status:'determined',grossReceipts:money(gross),directCosts:money(costs),excludedCosts:money(excluded),priorExcess:money(prior),grossDifference:money(difference),carryForward:money(difference<0n?-difference:0n),baseAmount:money(baseWhole*1000000n),baseDivisorRate:'16%',reason:'RIVA41(4)/(5),43a/b: soma global das parcelas especiais registadas no âmbito declarado, custos directos de terceiros e excesso anterior. Diferença positiva dividida por1.16, base arredondada à unidade; diferença negativa transportada como custo para o período seguinte, não dedução nem imposto a reembolsar. Taxa16% apenas no divisor da base; IVA devido e dedução43d não calculados. Alteração de custos/contraprestação44 exige versão referenciada, sem reescrever histórico ou gerar regularização automática.'});results.push(r);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
function key(draft:Record<string,unknown>,book:TravelBook):string{return JSON.stringify({version:travelVersion,scope:acquisitionScope(draft),location:draft.selection_labels??{},currency:draft.currency,rows:book.rows,periods:book.periods});}
|
||||
export function travelCurrent(draft:Record<string,unknown>,book:TravelBook):boolean{return!!book.result&&book.result.sourceKey===key(draft,book);}
|
||||
export function recordTravel(draft:Record<string,unknown>,book:TravelBook):TravelBook{
|
||||
const costIds=book.rows.flatMap(r=>r.costs.map(c=>c.id));
|
||||
if(!book.rows.length||new Set(book.rows.map(r=>r.id)).size!==book.rows.length||new Set(book.rows.map(r=>r.operatorRef+'|'+r.period+'|'+r.operationRef)).size!==book.rows.length||new Set(costIds).size!==costIds.length||new Set(book.periods.map(p=>p.id)).size!==book.periods.length||new Set(book.periods.map(p=>p.operatorRef+'|'+p.period)).size!==book.periods.length)throw new Error('Identidades de operações/custos/períodos devem ser distintas; evite dupla soma, originais conservados.');
|
||||
const rows=book.rows.map(row=>evaluateTravel(row,draft.currency));const result:TravelCalculation={version:travelVersion,revision:(book.result?.revision??0)+1,calculatedAt:new Date().toISOString(),opportunity:acquisitionScope(draft),sourceKey:key(draft,book),source:travelSource,inputs:structuredClone(book.rows),periodInputs:structuredClone(book.periods),rows,periods:evaluateTravelPeriods(book,rows)};
|
||||
const next={...book,result,history:[...(book.history??[]),...(book.result?[book.result]:[])]};saveTravelBook(draft,next);return next;
|
||||
}
|
||||
export function travelReportRows(draft:Record<string,unknown>):Array<[string,string,string,string]>{try{const b=travelBook(draft),r=b.result;if(!r)return[];const state=`${r.opportunity};${travelCurrent(draft,b)?'Resultado actual':'Entradas alteradas; resultado histórico conservado'};versão${r.revision};${r.calculatedAt};${r.source}`;return[...r.inputs.map((row,i):[string,string,string,string]=>['IVA mensal',`P129 — ${row.period} — ${row.id}`,`${row.description};regime:${r.rows[i].regime};${r.rows[i].reason};contraprestação parte terceiros:${r.rows[i].specialGross??'Indeterminada'};parte geral:${r.rows[i].generalGross??'Indeterminada'};custos elegíveis:${r.rows[i].eligibleCosts??'Indeterminados'};custos excluídos:${r.rows[i].excludedCosts??'Indeterminados'};base apenas no período global`,`${state};${row.origin};operação${row.operationRef};operador${row.operatorRef};sede/estabelecimento${row.agencyCountry},${row.agencyRef};âmbito${row.scope};documento${row.documentRef},${row.documentDate};meios${row.resources};retalhista${row.retailer};novo conjunto${row.newPackage},${row.packageRef};preço comIVA${row.grossPrice}${row.currency};parte terceiros${row.thirdPartyGross},repartição${row.allocationRef};enquadramento${row.domesticTaxable},${row.taxRef};condições${JSON.stringify(row.conditions)};custos${JSON.stringify(row.costs)};factos${row.evidence}`]),...r.periods.map((p,i):[string,string,string,string]=>['IVA mensal',`P129 — margem global ${p.period} — ${p.operatorRef}`,`contraprestações:${p.grossReceipts??'Indeterminadas'};custos directos:${p.directCosts??'Indeterminados'};excesso anterior:${p.priorExcess??'Indeterminado'};diferença:${p.grossDifference??'Indeterminada'};base:${p.baseAmount??'Indeterminada'}MZN;excesso seguinte:${p.carryForward??'Indeterminado'};${p.reason};IVA/dedução:Indeterminados`,`${state};registo${JSON.stringify(r.periodInputs.find(x=>x.id===p.id)??r.periodInputs[i])};sem total organizacional presumido, nova dedução/campo11, regularização ou totaisModeloA.`])];}catch{return[['IVA mensal','P129','Dados não recuperáveis','Originais preservados; sem margem por defeito.']];}}
|
||||
@@ -1,4 +1,6 @@
|
||||
import { digitalReportRows } from './iva-digital.ts';
|
||||
import { travelReportRows } from './iva-travel.ts';
|
||||
import { travelEventReportRows } from './iva-travel-events.ts';
|
||||
import { foreignOperationReportRows } from './iva-foreign-operations.ts';
|
||||
import { currencyEventReportRows } from './iva-currency-events.ts';
|
||||
import { propertyTransferReportRows } from './iva-property-transfers.ts';
|
||||
@@ -662,6 +664,8 @@ export function buildProjectDossierRows(
|
||||
rows.push(...currencyEventReportRows(draft));
|
||||
rows.push(...foreignOperationReportRows(draft));
|
||||
rows.push(...digitalReportRows(draft));
|
||||
rows.push(...travelReportRows(draft));
|
||||
rows.push(...travelEventReportRows(draft));
|
||||
rows.push(...offerPreparationReportRows(draft));
|
||||
const operationRows = salesReportRows(draft);
|
||||
rows.push(...operationRows);
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
<?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 IvaTravelEventDraftTest extends TestCase
|
||||
{
|
||||
use DatabaseTransactions;
|
||||
|
||||
public function test_travel_margin_and_special_events_recover_without_changing_accepted_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-travel-events.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:360',$recovered['context_opportunity']);
|
||||
$this->assertSame('Manica',$recovered['selection_labels']['province_id']);
|
||||
$store=json_decode($recovered['modelo_a_snapshot'],true,flags:JSON_THROW_ON_ERROR);
|
||||
$travel=$store['travelOperations']['opportunity:360'];$events=$store['travelSpecialEvents']['opportunity:360'];
|
||||
$this->assertCount(10,$travel['rows']);$this->assertCount(5,$travel['periods']);$this->assertCount(12,$events['rows']);
|
||||
$this->assertSame('0.00',$travel['result']['periods'][0]['baseAmount']);
|
||||
$this->assertSame('1160.00',$travel['result']['periods'][1]['carryForward']);
|
||||
$this->assertSame('500.00',$travel['result']['periods'][2]['baseAmount']);
|
||||
$this->assertSame('1160.00',$travel['result']['rows'][4]['generalGross']);
|
||||
$this->assertSame('general',$travel['result']['rows'][5]['regime']);
|
||||
$this->assertSame('outside',$travel['result']['rows'][9]['regime']);
|
||||
$this->assertSame('2026-12-05',$events['result']['rows'][0]['eventDate']);
|
||||
$this->assertSame('2026-12-10',$events['result']['rows'][1]['eventDate']);
|
||||
$this->assertNull($events['result']['rows'][3]['eventDate']);
|
||||
$this->assertSame('232.00',$events['result']['rows'][4]['dueEvents'][0]['amount']);
|
||||
$this->assertSame('928.00',$events['result']['rows'][4]['dueEvents'][1]['amount']);
|
||||
$this->assertSame('2026-12-08',$events['result']['rows'][4]['dueEvents'][1]['date']);
|
||||
$this->assertSame('deferred',$events['result']['rows'][11]['status']);
|
||||
foreach($events['result']['rows'] as $row){$this->assertNull($row['baseAmount']);$this->assertNull($row['vat']);$this->assertNull($row['deduction']);}
|
||||
$this->assertCount(1,$travel['history']);$this->assertCount(1,$events['history']);
|
||||
$this->assertSame($travel['rows'],$travel['result']['inputs']);$this->assertSame($events['rows'],$events['result']['inputs']);
|
||||
$frozen=json_decode(file_get_contents(base_path('tests/frontend/fixtures/iva-travel-events-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,['travelOperations'=>true,'travelSpecialEvents'=>true]));
|
||||
$this->assertSame('regulation_pending',$store['digitalOperations']['opportunity:359']['result']['rows'][0]['procedure']);
|
||||
$this->assertNull($store['digitalOperations']['opportunity:359']['result']['rows'][0]['vat']);
|
||||
$this->assertSame('potential',$store['foreignOperations']['opportunity:358']['result']['rows'][0]['relation19']);
|
||||
$this->assertNull($store['books']['opportunity:329']['result']['periods'][0]['fields']['11']);
|
||||
file_put_contents(base_path('outputs/iva-l1-travel-events-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
@@ -0,0 +1,57 @@
|
||||
import test from 'node:test';import assert from 'node:assert/strict';import fs from 'node:fs';
|
||||
import {newTravel,newTravelCost,newTravelPeriod,travelConditions,travelCostKinds,evaluateTravel,recordTravel,saveTravelBook,travelBook,travelCurrent,travelReportRows,switchTravelResources} from '../../resources/js/lib/iva-travel.ts';
|
||||
import {newTravelEvent,evaluateTravelEvent,recordTravelEvents,saveTravelEventBook,travelEventBook,travelEventCurrent,travelEventReportRows,switchTravelEvent} from '../../resources/js/lib/iva-travel-events.ts';
|
||||
const frozen=JSON.parse(fs.readFileSync('tests/frontend/fixtures/iva-travel-events-preserved.json','utf8')),prior=JSON.parse(frozen.modelo_a_snapshot);
|
||||
const draft={...frozen,context_opportunity:'opportunity:360',project_title:'SYNTHETIC QA — Viagens e eventos / Manica'};
|
||||
function cost(id,amount,kind='direct'){return{...newTravelCost(id),kind,description:'SYNTHETIC QA '+kind,amount,ref:'SYNTHETIC QA documento/custo '+id,thirdParty:'yes',directBenefit:'yes'};}
|
||||
function trip(id,period,gross,costAmount,patch={}){return{...newTravel(id,'MZN'),description:'SYNTHETIC QA viagem '+id,period,operatorRef:'SYNTHETIC QA operador1',operationRef:'SYNTHETIC QA op '+id,documentRef:'SYNTHETIC QA contrato/registo',documentDate:'2026-10-01',scope:'SYNTHETIC QA pacote/parcela/período identificado',evidence:'SYNTHETIC QA factos/pressupostos',agencyCountry:'MZ',agencyRef:'SYNTHETIC QA sede fornecedora MZ',resources:'third_party',retailer:'no',grossPrice:gross,domesticTaxable:'yes',taxRef:'SYNTHETIC QA parcela tributável nacional/divisor17(1)',conditions:Object.fromEntries(travelConditions.map(([k])=>[k,'yes'])),costs:[cost(id+'-cost',costAmount)],...patch};}
|
||||
function period(id,month,operator='SYNTHETIC QA operador1',mode='none'){return{...newTravelPeriod(id),period:month,operatorRef:operator,scope:'SYNTHETIC QA registo completo do âmbito simulado',complete:'yes',registerRef:'SYNTHETIC QA livro/previsão completo',priorMode:mode};}
|
||||
const trips=[trip('A','2026-10','1160','580'),trip('B','2026-10','1160','1740'),trip('C','2026-11','1160','2320'),trip('D','2026-12','2320','580'),trip('mixed','2026-12','2320','580',{operatorRef:'SYNTHETIC QA operador2',resources:'mixed',thirdPartyGross:'1160',allocationRef:'SYNTHETIC QA repartição contratual sem percentagem presumida'}),trip('own','2026-12','1160','0',{resources:'own'}),trip('intermediary','2026-12','1160','0',{conditions:{operator:'yes',singlePrice:'yes',ownName:'no'}}),trip('retail','2026-12','1160','0',{retailer:'yes',newPackage:'no'}),trip('retail_new','2026-12','1160','580',{operatorRef:'SYNTHETIC QA operador4',retailer:'yes',newPackage:'yes',packageRef:'SYNTHETIC QA conjunto novo/serviços substanciais'}),trip('foreign','2026-12','1160','580',{agencyCountry:'foreign'})];
|
||||
trips[0].costs.push(...travelCostKinds.filter(([k])=>['exchange','advertising','communications','sales_commission'].includes(k)).map(([kind],i)=>cost('A-'+kind,String((i+1)*10),kind)));
|
||||
const periods=[period('oct','2026-10'),period('nov','2026-11',undefined,'previous'),period('dec','2026-12',undefined,'previous'),period('mixed','2026-12','SYNTHETIC QA operador2'),period('retail_new','2026-12','SYNTHETIC QA operador4')];
|
||||
let travel=recordTravel(draft,{rows:trips,periods});draft.modelo_a_snapshot=saveTravelBook(draft,travel);
|
||||
for(const row of trips)test('P129 '+row.id+' regime and component scope',()=>{const v=evaluateTravel(row,'MZN');assert.equal(v.regime,row.id==='mixed'?'mixed':['own','intermediary','retail'].includes(row.id)?'general':row.id==='foreign'?'outside':'special');assert.equal(v.status,['own','intermediary','retail','foreign'].includes(row.id)?'not_applicable':'determined');if(row.id==='mixed'){assert.equal(v.specialGross,'1160.00');assert.equal(v.generalGross,'1160.00');}});
|
||||
test('P129 period-global margin offsets package gains/losses; excludes four legal costs; no individual-margin aggregation',()=>{
|
||||
const p=travel.result.periods;assert.equal(p[0].grossReceipts,'2320.00');assert.equal(p[0].directCosts,'2320.00');assert.equal(p[0].excludedCosts,'100.00');assert.equal(p[0].baseAmount,'0.00');assert.equal(p[0].carryForward,'0.00');assert.equal(p[1].grossDifference,'-1160.00');assert.equal(p[1].carryForward,'1160.00');assert.equal(p[2].priorExcess,'1160.00');assert.equal(p[2].baseAmount,'500.00');assert.equal(p[2].carryForward,'0.00');assert.equal(p[3].baseAmount,'500.00');for(const x of p){assert.equal(x.vat,null);assert.equal(x.deduction,null);assert.equal(x.baseDivisorRate,'16%');}
|
||||
});
|
||||
test('P129 unknown amounts, scope, cost benefit, currency and previous excess do not become zero',()=>{
|
||||
for(const patch of [{grossPrice:''},{currency:'USD'},{taxRef:''},{domesticTaxable:''},{agencyCountry:''},{documentDate:'2026-02-30'},{operatorRef:''},{resources:''},{conditions:{operator:'yes',singlePrice:'',ownName:'yes'}},{costs:[{...trips[0].costs[0],directBenefit:''}]}])assert.equal(evaluateTravel({...trips[0],...patch},'MZN').status,'indeterminate');
|
||||
for(const patch of [{complete:'no'},{priorMode:''},{priorMode:'previous'},{registerRef:''}])assert.equal(recordTravel(draft,{rows:[trips[0]],periods:[{...periods[0],...patch}]}).result.periods[0].status,'indeterminate');
|
||||
assert.equal(evaluateTravel({...trips[4],thirdPartyGross:'9999'},'MZN').status,'indeterminate');assert.equal(evaluateTravel({...trips[8],packageRef:''},'MZN').status,'indeterminate');
|
||||
const unknown={...trips[1],grossPrice:''};assert.equal(recordTravel(draft,{rows:[trips[0],unknown],periods:[periods[0]]}).result.periods[0].status,'indeterminate');
|
||||
const both={...trips[1],origin:'effective'};assert.equal(recordTravel(draft,{rows:[trips[0],both],periods:[periods[0]]}).result.periods[0].status,'indeterminate');
|
||||
});
|
||||
test('P129 explicit prior excess and nearest-unit rounding; cost exclusions never become deduction',()=>{
|
||||
const t={...trips[0],grossPrice:'1.75',costs:[]},p={...periods[0],priorMode:'manual',priorAmount:'0',priorRef:'SYNTHETIC QA fecho anterior sem excesso'};assert.equal(recordTravel(draft,{rows:[t],periods:[p]}).result.periods[0].baseAmount,'2.00');
|
||||
const indirect={...trips[0],costs:[cost('indirect','500','other')]};indirect.costs[0].directBenefit='no';assert.equal(evaluateTravel(indirect,'MZN').eligibleCosts,'0.00');assert.equal(evaluateTravel(indirect,'MZN').excludedCosts,'500.00');
|
||||
const correction={...periods[2],priorMode:'manual',priorAmount:'500',priorRef:'SYNTHETIC QA correcção referenciada'};const pending=recordTravel(draft,{rows:trips,periods:[periods[0],periods[1],correction]});assert.equal(pending.result.periods[2].status,'indeterminate');assert.match(pending.result.periods[2].reason,/Nada foi sobrescrito/);const confirmed=recordTravel(draft,{rows:trips,periods:[periods[0],periods[1],{...correction,priorOverride:'yes'}]});assert.equal(confirmed.result.periods[2].priorExcess,'500.00');assert.equal(confirmed.result.periods[2].baseAmount,'1069.00');
|
||||
});
|
||||
function event(id,patch={}){return{...newTravelEvent(id,'MZN'),treatment:'travel_special',travelRowId:'A',description:'SYNTHETIC QA evento '+id,period:'2026-12',operationRef:trips[0].operationRef,documentRef:'SYNTHETIC QA documento evento',documentDate:'2026-12-01',reviewDate:'2026-12-31',scope:'SYNTHETIC QA parcela viagem/bens identificados',evidence:'SYNTHETIC QA factos/pressupostos',factsConfirmed:'yes',fullPaymentState:'full',fullPaymentDate:'2026-12-05',fullPaymentRef:'SYNTHETIC QA liquidação integral',startState:'started',startDate:'2026-12-10',startRef:'SYNTHETIC QA primeiro serviço',invoiceRequired:'no',paymentState:'received',paymentDate:'2026-12-05',paymentAmount:'1160',paymentRef:'SYNTHETIC QA recebimento',amount:'1160',...patch};}
|
||||
const events=[event('full_first'),event('start_first',{fullPaymentDate:'2026-12-20',paymentDate:'2026-12-20'}),event('same_day',{fullPaymentDate:'2026-12-10',paymentDate:'2026-12-10'}),event('partial_8c',{fullPaymentState:'partial',startState:'not_started',paymentAmount:'232',invoiceRequired:'yes',invoiceState:'not_issued',legalDeadline:'2026-12-20',deadlineRef:'SYNTHETIC QA prazo identificado,sem cinco dias por defeito',reviewDate:'2026-12-10'}),event('partial_then_full',{fullPaymentDate:'2026-12-08',paymentAmount:'232',invoiceRequired:'yes',invoiceState:'issued',invoiceDate:'2026-12-15',invoiceRef:'SYNTHETIC QA factura',legalDeadline:'2026-12-20',deadlineRef:'SYNTHETIC QA prazo identificado'}),event('invoice_before_start',{fullPaymentState:'none',startState:'not_started',paymentState:'none',invoiceRequired:'yes',invoiceState:'issued',invoiceDate:'2026-12-02',invoiceRef:'SYNTHETIC QA factura antecipada',legalDeadline:'2026-12-20',deadlineRef:'SYNTHETIC QA prazo identificado'}),event('travel_deferred',{fullPaymentState:'none',startState:'not_started',paymentState:'none'})];
|
||||
for(const clause of ['a','b','c','d'])events.push(event('exit_'+clause,{treatment:'suspensive_exit',travelRowId:'',regimeClause:clause,entryDate:'2026-11-01',entryRef:'SYNTHETIC QA colocação regime/bens',regimeStatus:'ceased',exitDate:'2026-12-10',exitRef:'SYNTHETIC QA cessação efectiva',cessationConfirmed:'yes'}));
|
||||
events.push({...events[7],id:'still_held',regimeStatus:'still'});
|
||||
for(const row of events)test('P133 '+row.id+' special event separate from tax amounts',()=>{const v=evaluateTravelEvent(draft,row);assert.equal(v.status,['travel_deferred','still_held'].includes(row.id)?'deferred':'determined');assert.equal(v.baseAmount,null);assert.equal(v.vat,null);assert.equal(v.deduction,null);if(row.id.startsWith('exit_')){assert.equal(v.eventDate,'2026-12-10');assert.equal(v.dueEvents[0].amount,null);}});
|
||||
test('P133 full/start timing and partial/full/early invoice never duplicate amounts',()=>{
|
||||
assert.equal(evaluateTravelEvent(draft,events[0]).eventDate,'2026-12-05');assert.equal(evaluateTravelEvent(draft,events[1]).eventDate,'2026-12-10');assert.match(evaluateTravelEvent(draft,events[2]).eventTiming,/ordem intradiária não apurada/);
|
||||
const early=evaluateTravelEvent(draft,events[3]);assert.equal(early.eventDate,null);assert.deepEqual(early.dueEvents.map(x=>[x.date,x.amount]),[['2026-12-05','232.00']]);assert.equal(early.remainingPending,true);
|
||||
const full=evaluateTravelEvent(draft,events[4]);assert.equal(full.eventDate,'2026-12-08');assert.deepEqual(full.dueEvents.map(x=>[x.date,x.amount]),[['2026-12-05','232.00'],['2026-12-08','928.00']]);assert.equal(full.remainingPending,false);
|
||||
const invoice=evaluateTravelEvent(draft,events[5]);assert.equal(invoice.eventDate,null);assert.equal(invoice.dueEvents[0].date,'2026-12-02');
|
||||
const invoiceFirst={...events[4],invoiceDate:'2026-12-03'};assert.deepEqual(evaluateTravelEvent(draft,invoiceFirst).dueEvents.map(x=>[x.date,x.amount]),[['2026-12-03','1160.00']]);
|
||||
});
|
||||
test('P133 absence, conflicting facts, future/invalid dates and regime transfer do not invent events',()=>{
|
||||
for(const patch of [{travelRowId:''},{operationRef:'another'},{fullPaymentState:''},{startState:''},{fullPaymentState:'partial',paymentAmount:'1160'},{fullPaymentDate:'2026-12-99'},{startDate:'2027-01-01'},{fullPaymentState:'none',paymentState:'received'},{factsConfirmed:''}])assert.equal(evaluateTravelEvent(draft,{...events[0],...patch}).status,'indeterminate');
|
||||
for(const patch of [{regimeClause:''},{entryRef:''},{entryDate:'2026-12-20'},{regimeStatus:''},{cessationConfirmed:'no'},{exitRef:''},{exitDate:'2027-01-01'}])assert.equal(evaluateTravelEvent(draft,{...events[7],...patch}).status,'indeterminate');
|
||||
assert.equal(evaluateTravelEvent({...draft,context_opportunity:'opportunity:361'},events[0]).status,'indeterminate');assert.equal(evaluateTravelEvent(draft,{...events[0],travelRowId:'own'}).status,'not_applicable');
|
||||
});
|
||||
test('P133 classification dependency is guarded; unknown margin amounts do not block an independently documented event',()=>{
|
||||
const changed={...travel,rows:travel.rows.map(r=>r.id==='A'?{...r,grossPrice:''}:r)},d={...draft,modelo_a_snapshot:saveTravelBook(draft,changed)};assert.equal(evaluateTravelEvent(d,events[0]).status,'indeterminate');d.modelo_a_snapshot=saveTravelBook(d,recordTravel(d,changed));assert.equal(evaluateTravelEvent(d,events[0]).status,'determined');
|
||||
const b=recordTravelEvents(draft,{rows:events}),fresh={...draft,modelo_a_snapshot:saveTravelEventBook(draft,b)};assert.equal(travelEventCurrent(fresh,b),true);fresh.modelo_a_snapshot=saveTravelBook(fresh,recordTravel(fresh,travel));assert.equal(travelEventCurrent(fresh,b),false);assert.equal(b.result.rows[0].source.revision,1);
|
||||
});
|
||||
test('Private save/recovery/history/report preserve123 delivered books, digital limitation and partial drafts',()=>{
|
||||
const t=recordTravel(draft,travel),d={...draft,modelo_a_snapshot:saveTravelBook(draft,t)},a=recordTravelEvents(d,{rows:events});d.modelo_a_snapshot=saveTravelEventBook(d,a);const b=recordTravelEvents(d,a);d.modelo_a_snapshot=saveTravelEventBook(d,b);
|
||||
assert.equal(t.history.length,1);assert.equal(b.history.length,1);assert.equal(travelReportRows(d).length,15);assert.equal(travelEventReportRows(d).length,12);assert.equal(travelCurrent(d,t),true);assert.equal(travelEventCurrent(d,b),true);
|
||||
const store=JSON.parse(d.modelo_a_snapshot);delete store.travelOperations;delete store.travelSpecialEvents;assert.deepEqual(store,prior);assert.deepEqual(travelBook({...d,context_opportunity:'opportunity:361'}),{rows:[],periods:[]});assert.deepEqual(travelEventBook({...d,context_opportunity:'opportunity:361'}),{rows:[]});
|
||||
assert.throws(()=>recordTravel(d,{rows:[trips[0],trips[0]],periods}));assert.throws(()=>recordTravelEvents(d,{rows:[events[0],events[0]]}));assert.throws(()=>travelBook({...d,modelo_a_snapshot:JSON.stringify({...store,travelOperations:[]})}));assert.throws(()=>travelEventBook({...d,modelo_a_snapshot:JSON.stringify({...store,travelSpecialEvents:[]})}));
|
||||
const switched=switchTravelResources(trips[4],'own');assert.equal(switched.allocationRef,trips[4].allocationRef);assert.deepEqual(switched.costs,trips[4].costs);assert.equal(switched.conditions.singlePrice,'');assert.equal(switchTravelEvent(events[0],'suspensive_exit').fullPaymentRef,events[0].fullPaymentRef);
|
||||
if(process.env.IVA_TRAVEL_EVENTS_FIXTURE)fs.writeFileSync(process.env.IVA_TRAVEL_EVENTS_FIXTURE,JSON.stringify(d,null,2)+'\n');
|
||||
});
|
||||
Reference in New Issue
Block a user