97 lines
19 KiB
TypeScript
97 lines
19 KiB
TypeScript
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.']];}}
|