Implement P002 reusing territoriality and events and clarify P104 legal cotejo

This commit is contained in:
2026-10-25 01:13:46 +02:00
parent 2de67bebf9
commit 86970cd507
9 changed files with 185 additions and 2 deletions
@@ -0,0 +1,26 @@
import {useState} from 'react';
import {foreignOperationBook,foreignOperationCurrent,foreignOperationReportRows,foreignOperationSource,foreignSources,foreignEvents,newForeignOperation,recordForeignOperations,saveForeignOperationBook,switchForeignSource,type ForeignOperationBook,type ForeignOperationRow} from '@/lib/iva-foreign-operations';
export function SimulationForeignOperations({draft,onChange}:{draft:Record<string,unknown>;onChange:(field:string,value:string)=>void}){
const[error,setError]=useState('');let book:ForeignOperationBook;try{book=foreignOperationBook(draft);}catch(e){return<p role="alert">{String(e)}</p>;}
const save=(next:ForeignOperationBook)=>{try{onChange('modelo_a_snapshot',saveForeignOperationBook(draft,next));setError('');}catch(e){setError(String(e));}};
const change=(id:string,patch:Partial<ForeignOperationRow>)=>save({...book,rows:book.rows.map(r=>r.id===id?{...r,...patch}:r)});
return<details className="rounded border p-3"><summary>Operações no estrangeiro — localização e enquadramento</summary><p>Reutilize uma localização já guardada nesta oportunidade. Fora do território não significa isenção,IVA zero ou dedução concedida. Não é necessário repetir os factos/documentos da localização.</p><p>{foreignOperationSource}</p>{error&&<p role="alert">{error}</p>}
<button type="button" onClick={()=>save({...book,rows:[...book.rows,newForeignOperation(crypto.randomUUID())]})}>Acrescentar análise de operação no estrangeiro</button>
{book.rows.map(row=>{let sources:ReturnType<typeof foreignSources>=[],events:ReturnType<typeof foreignEvents>=[];try{sources=foreignSources(draft,row.sourceKind);events=foreignEvents(draft);}catch{/* Existing readers retain corrupt originals. */}const source=sources.find(s=>s.id===row.sourceRowId);return<details key={row.id}><summary>{source?.description||'Operação por associar'} — {source?.period||'período da origem'}</summary>
<label>Origem da localização<select value={row.sourceKind} onChange={e=>change(row.id,switchForeignSource(row,e.target.value as ForeignOperationRow['sourceKind']))}><option value="">Por indicar</option><option value="goods">Bens corpóreos</option><option value="services">Serviços e transporte</option><option value="listed">Serviços enumerados não digitais</option></select></label>
<label>Resultado de localização existente<select value={row.sourceRowId} onChange={e=>change(row.id,switchForeignSource(row,row.sourceKind,e.target.value))}><option value="">Escolher operação</option>{sources.map(s=><option key={s.id} value={s.id}>{s.description} — {s.period} — {s.territory??'Indeterminado'}{s.current?'':' — histórico'}</option>)}</select></label>
{row.sourceKind&&!sources.length&&<p>Prepare a localização nas secções de bens/serviços existentes desta oportunidade. Nenhuma localização é presumida ou copiada de outro projecto.</p>}
{source&&<p>Operação: {source.operationRef}. Âmbito: {source.scope}. {source.reason}</p>}
{source?.territory==='partial'&&<label>Parcela deste trajecto analisada<select value={row.territorialPart} onChange={e=>change(row.id,{territorialPart:e.target.value as ForeignOperationRow['territorialPart']})}><option value="">Delimitar parcela</option><option value="outside">Parcela fora do território nacional</option><option value="national">Parcela nacional</option></select></label>}
{source&&source.territory!=='national'&&<>
<label>Evento já guardado da mesma operação,quando aplicável<select value={row.eventRowId} onChange={e=>change(row.id,{eventRowId:e.target.value})}><option value="">Sem associação necessária nesta análise territorial</option>{events.filter(e=>e.operationRef===source.operationRef&&e.period===source.period).map(e=><option key={e.id} value={e.id}>{e.description} — {e.period}{e.current?'':' — histórico'}</option>)}</select></label>
<label>Seria tributável se efectuada no território nacional?<select value={row.hypotheticalTaxable} onChange={e=>change(row.id,{hypotheticalTaxable:e.target.value as ForeignOperationRow['hypotheticalTaxable']})}><option value="">Por esclarecer</option><option value="yes">Sim,segundo o fundamento identificado</option><option value="no">Não,segundo o fundamento identificado</option></select></label>
<label>Fundamento da hipótese interna e exclusões aplicáveis<input value={row.hypotheticalRef} onChange={e=>change(row.id,{hypotheticalRef:e.target.value})}/></label>
{row.hypotheticalTaxable==='yes'&&<label>Existe exclusão19(2) aplicável:17-A ou pagamentos15(6)c?<select value={row.excluded19_2} onChange={e=>change(row.id,{excluded19_2:e.target.value as ForeignOperationRow['excluded19_2']})}><option value="">Por esclarecer</option><option value="yes">Sim</option><option value="no">Não</option></select></label>}
</>}
<label>Âmbito da análise,conjunto/parcela e período<input value={row.scope} onChange={e=>change(row.id,{scope:e.target.value})}/></label><label>Factos ou pressupostos expressos<input value={row.evidence} onChange={e=>change(row.id,{evidence:e.target.value})}/></label><p>Trocar origem conserva fundamentos ocultos;reveja as confirmações. Nenhuma dedução é lançada por esta análise.</p>
</details>;})}
{!!book.rows.length&&<button type="button" onClick={()=>{try{save(recordForeignOperations(draft,book));}catch(e){setError(String(e));}}}>Analisar operação estrangeira e guardar versão</button>}
{book.result&&<><p role="status">{foreignOperationCurrent(draft,book)?'Análise corresponde às entradas e origens guardadas.':'Entradas ou origens alteradas;resultado histórico conservado. Reveja apenas o delta afectado.'} {book.history?.length??0} versões anteriores.Sem dedução/IVA calculados.</p><dl>{foreignOperationReportRows(draft).map(([,label,value,origin],i)=><div key={i}><dt>{label}</dt><dd>{value}<small className="block">{origin}</small></dd></div>)}</dl></>}
</details>;
}
@@ -1,3 +1,4 @@
import { SimulationForeignOperations } from './simulation-foreign-operations';
import { SimulationCurrencyEvents } from './simulation-currency-events';
import { SimulationPropertyTransfers } from './simulation-property-transfers';
import { SimulationGoodsPresumptions } from './simulation-goods-presumptions';
@@ -124,6 +125,7 @@ export function SimulationMonthlyIva({draft, busy, onCalculate, onChange}: {
<SimulationReducedBases draft={draft} onChange={onChange} />
<SimulationOwnName draft={draft} onChange={onChange} />
<SimulationGoodsAllocation draft={draft} onChange={onChange} />
<SimulationForeignOperations draft={draft} onChange={onChange} />
<SimulationCurrencyEvents draft={draft} onChange={onChange} />
<SimulationPropertyTransfers draft={draft} onChange={onChange} />
<SimulationGoodsPresumptions draft={draft} onChange={onChange} />
+2 -2
View File
@@ -1,7 +1,7 @@
import {acquisitionScope,readAcquisitionStore} from './iva-acquisitions.ts';
import {convertCents as convertScaledAmount} from './business-financial-plan.ts';
export const currencyEventVersion='currency-events/1';
export const currencyEventSource='CIVA Lei13/2016,7(1)–(7),PDF8/p1426(266);8,PDF9/p1426(267);15(8),PDF14/p1426(272).Lei22/2022 eLei10/2025 não alteram7/8,15(8)mantido.RIVA21/2008PDF4:regra histórica5dias úteis,não prazo automático vigente;usar prazo e disposição aplicáveis identificados. P104:Lei2/2006 ainda por corresponder;conversão apenas cenário condicionado,sem base fiscal confirmada.';
export const currencyEventSource='CIVA Lei13/2016,7(1)–(7),PDF8/p1426(266);8,PDF9/p1426(267);15(8),PDF14/p1426(272).Lei22/2022 eLei10/2025 não alteram7/8,15(8)mantido.RIVA21/2008PDF4:regra histórica5dias úteis,não prazo automático vigente;usar prazo e disposição aplicáveis identificados. P104:Lei2/2006 obtida,106(5)conferido;regra taxa/data ainda por demonstrar;conversão apenas cenário condicionado,sem base fiscal confirmada.';
export const currencyEventTreatments=[
{id:'foreign_scenario',branch:'P104',label:'Moeda estrangeira — cenário condicionado'},
{id:'goods_available',branch:'P131',label:'Bens postos à disposição7(1)a'},
@@ -47,7 +47,7 @@ export function evaluateCurrencyEvent(row:CurrencyEventRow):LineResult{
const amount=decimal(row.amount),fx=decimal(row.fxRate,8);
if(row.currency!=='MZN'||!['USD','EUR','ZAR','GBP','CNY','BRL','JPY','CHF','AUD','CAD'].includes(row.sourceCurrency)||amount===null||fx===null||fx===0n||date(row.fxDate)===null||![row.fxSource,row.fxRuleRef].every(v=>!!v.trim())||!['mzn_per_unit','units_per_mzn'].includes(row.fxDirection))return stop('Indique moeda original,valor,taxa positiva,direcção,data,fonte e regra/pressuposto expresso.Sem substituir moeda original porMZN ou reutilizar taxa de apresentação como regra fiscal.');
result.scenarioAmount=money(row.fxDirection==='mzn_per_unit'?convertScaledAmount(amount,fx,100000000n):(amount*100000000n+fx/2n)/fx);
return stop(`P104:cenário condicionado=${result.scenarioAmount}MZN,origem${row.sourceCurrency},taxa${row.fxRate},${row.fxDirection},data${row.fxDate}. Regra fiscal15(8)/Lei2/2006 ainda por corresponder;referência indicada é pressuposto,não validação da taxa legal.Base fiscal permanece indeterminada.Sem IVA ou activação nos livros anteriores.`,`conditional`);
return stop(`P104:cenário condicionado=${result.scenarioAmount}MZN,origem${row.sourceCurrency},taxa${row.fxRate},${row.fxDirection},data${row.fxDate}. CotejoLei2/2006 concluido:106(5)nao define taxa/data;regra complementar15(8) ainda por demonstrar;referência indicada é pressuposto,não validação da taxa legal.Base fiscal permanece indeterminada.Sem IVA ou activação nos livros anteriores.`,`conditional`);
}
const review=date(row.reviewDate),event=date(row.eventDate);if(review===null||!row.eventRef.trim())return stop('Identifique data de conferência e documento/âmbito do evento específico.');
if(row.treatment==='consignment_deadline'){
@@ -0,0 +1,48 @@
import {acquisitionScope,readAcquisitionStore} from './iva-acquisitions.ts';
import {goodsLocationBook,goodsLocationCurrent} from './iva-goods-location.ts';
import {serviceLocationBook,serviceLocationCurrent} from './iva-service-location.ts';
import {listedServiceBook,listedServiceCurrent} from './iva-listed-services.ts';
import {currencyEventBook,currencyEventCurrent} from './iva-currency-events.ts';
export const foreignOperationVersion='foreign-operations/1';
export const foreignOperationSource='CIVA6,Lei13/2016PDF7–8 e redacção2025:reutilizar localização já apurada.19(1)bii,2016PDF15/p1426(273),mantidoLei10/2025PDF13:operações no estrangeiro que seriam tributáveis se efectuadas no território nacional;19(2)alterado2025 mantém exclusões próprias. Enquadramento potencial,não dedução concedida/campo11.Não confundir fora do território com isenção/IVAzero,moeda estrangeira,cliente estrangeiro ou caixa06.';
type Choice=''|'yes'|'no';
export type ForeignOperationRow={id:string;sourceKind:''|'goods'|'services'|'listed';sourceRowId:string;territorialPart:''|'outside'|'national';eventRowId:string;hypotheticalTaxable:Choice;hypotheticalRef:string;excluded19_2:Choice;scope:string;evidence:string;};
type Source={id:string;description:string;period:string;operationRef:string;scope:string;status:string;territory:string|null;current:boolean;revision:number;source:string;reason:string;};
type LineResult={id:string;status:'determined'|'not_applicable'|'indeterminate';territory:string|null;relation19:'potential'|'not_demonstrated'|'excluded_19_2'|'indeterminate';source:Source|null;event:Record<string,unknown>|null;deduction:null;baseAmount:null;vat:null;rate:null;reason:string};
export type ForeignOperationCalculation={version:string;revision:number;calculatedAt:string;opportunity:string;sourceKey:string;source:string;inputs:ForeignOperationRow[];rows:LineResult[]};
export type ForeignOperationBook={rows:ForeignOperationRow[];result?:ForeignOperationCalculation;history?:ForeignOperationCalculation[]};
type Store=ReturnType<typeof readAcquisitionStore>&{foreignOperations?:Record<string,ForeignOperationBook>};
export function newForeignOperation(id:string):ForeignOperationRow{return{id,sourceKind:'',sourceRowId:'',territorialPart:'',eventRowId:'',hypotheticalTaxable:'',hypotheticalRef:'',excluded19_2:'',scope:'',evidence:''};}
export function switchForeignSource(row:ForeignOperationRow,sourceKind:ForeignOperationRow['sourceKind'],sourceRowId=''):ForeignOperationRow{return{...row,sourceKind,sourceRowId,territorialPart:'',eventRowId:'',hypotheticalTaxable:'',excluded19_2:''};}
export function foreignSources(draft:Record<string,unknown>,kind:ForeignOperationRow['sourceKind']):Source[]{
if(kind==='goods'){const b=goodsLocationBook(draft),r=b.result;return r?r.inputs.map((input,i)=>({id:input.id,description:input.description,period:input.period,operationRef:input.operationRef,scope:input.scope,status:r.rows[i].status,territory:r.rows[i].territory,current:goodsLocationCurrent(draft,b),revision:r.revision,source:r.source,reason:r.rows[i].reason})):[];}
if(kind==='services'){const b=serviceLocationBook(draft),r=b.result;return r?r.inputs.map((input,i)=>({id:input.id,description:input.description,period:input.period,operationRef:input.operationRef,scope:input.scope,status:r.rows[i].status,territory:r.rows[i].territory,current:serviceLocationCurrent(draft,b),revision:r.revision,source:r.source,reason:r.rows[i].reason})):[];}
if(kind==='listed'){const b=listedServiceBook(draft),r=b.result;return r?r.inputs.map((input,i)=>({id:input.id,description:input.description,period:input.period,operationRef:input.operationRef,scope:input.scope,status:r.rows[i].status,territory:r.rows[i].territory,current:listedServiceCurrent(draft,b),revision:r.revision,source:r.source,reason:r.rows[i].reason})):[];}
return[];
}
export function foreignEvents(draft:Record<string,unknown>):Array<{id:string;description:string;operationRef:string;period:string;current:boolean;revision:number;status:string;eventDate:string|null;dueEvents:unknown[];remainingPending:boolean}>{const b=currencyEventBook(draft),r=b.result;return r?r.inputs.filter(x=>x.treatment!=='foreign_scenario').map(input=>{const line=r.rows[r.inputs.findIndex(x=>x.id===input.id)];return{id:input.id,description:input.description,operationRef:input.operationRef,period:input.period,current:currencyEventCurrent(draft,b),revision:r.revision,status:line.status,eventDate:line.eventDate,dueEvents:line.dueEvents,remainingPending:line.remainingPending};}):[];}
function readStore(draft:Record<string,unknown>):Store{const s=readAcquisitionStore(draft.modelo_a_snapshot) as Store;if(s.foreignOperations!==undefined&&(!s.foreignOperations||typeof s.foreignOperations!=='object'||Array.isArray(s.foreignOperations)))throw new Error('Operações no estrangeiro não recuperáveis;originais conservados.');const fields=Object.keys(newForeignOperation(''));for(const b of Object.values(s.foreignOperations??{}))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 ForeignOperationRow]!=='string')))throw new Error('Linhas de operações no estrangeiro não recuperáveis;sem lista vazia por defeito.');return s;}
export function foreignOperationBook(draft:Record<string,unknown>):ForeignOperationBook{return readStore(draft).foreignOperations?.[acquisitionScope(draft)]??{rows:[]};}
export function saveForeignOperationBook(draft:Record<string,unknown>,book:ForeignOperationBook):string{const s=readStore(draft),encoded=JSON.stringify({...s,foreignOperations:{...s.foreignOperations,[acquisitionScope(draft)]:book}});readStore({...draft,modelo_a_snapshot:encoded});return encoded;}
export function evaluateForeignOperation(draft:Record<string,unknown>,row:ForeignOperationRow):LineResult{
const result:LineResult={id:row.id,status:'indeterminate',territory:null,relation19:'indeterminate',source:null,event:null,deduction:null,baseAmount:null,vat:null,rate:null,reason:''};const stop=(reason:string,status:LineResult['status']='indeterminate')=>({...result,status,reason});
try{const source=foreignSources(draft,row.sourceKind).find(s=>s.id===row.sourceRowId);if(!source)return stop('Escolha uma classificação de localização já guardada nesta oportunidade. Prepare-a nas secções existentes;sem nova classificação por endereço,nacionalidade ou moeda.');result.source=source;
if(!source.current||source.status!=='determined'||!source.territory)return stop('Localização alterada ou indeterminada;resultado anterior conservado,sem o tratar como classificação actual.');result.territory=source.territory;
if(!/^2026-(0[1-9]|1[0-2])$/.test(source.period)||!row.scope.trim()||!row.evidence.trim())return stop('Identifique âmbito da análise e factos/pressupostos;reutilize operação/período/documentos da localização.');
if(source.territory==='national')return stop('Localização reutilizada aponta para Moçambique;P002operação no estrangeiro não se aplica neste âmbito.Não equivale a isenção nem decide tributação interna.','not_applicable');
if(source.territory==='partial'){
if(!['outside','national'].includes(row.territorialPart))return stop('Transporte tem parcela nacional e exterior;delimite a parcela desta análise,sem transformar todo o trajecto em estrangeiro.');
if(row.territorialPart==='national'){result.territory='national';return stop('Análise limitada à parcela nacional do trajecto;P002estrangeiro não se aplica a essa parcela.','not_applicable');}result.territory='outside_portion';
}
if(row.eventRowId){const event=foreignEvents(draft).find(e=>e.id===row.eventRowId);if(!event||!event.current||event.status!=='determined'||event.operationRef!==source.operationRef||event.period!==source.period)return stop('Evento escolhido não está actual ou não corresponde à mesma operação/período;conserve localização e corrija apenas a associação.Não inferir evento pela data do documento.');result.event=event;}
if(!row.hypotheticalRef.trim()||!['yes','no'].includes(row.hypotheticalTaxable))return stop('Operação/parcela estrangeira identificada;fundamente se seria tributável caso efectuada em Moçambique19(1)bii.Não presumir esse facto pela localização ou pela taxa estrangeira.');
if(row.hypotheticalTaxable==='no'){result.relation19='not_demonstrated';return stop('Operação no estrangeiro identificada;pressuposto de tributabilidade interna19(1)bii não demonstrado pelos factos declarados.Isto não decide outros enquadramentos de dedução.','determined');}
if(!['yes','no'].includes(row.excluded19_2))return stop('Tributabilidade interna hipotética indicada;conferir exclusões19(2),incluindo17-A e pagamentos15(6)c.Sem presumir dedução.');
if(row.excluded19_2==='yes'){result.relation19='excluded_19_2';return stop('Operação no estrangeiro identificada;exclusão19(2) declarada e fundamentada afasta este enquadramento potencial.Não calcula ou regulariza dedução.','determined');}
result.relation19='potential';return stop('Operação/parcela no estrangeiro reutilizada da localização;seria tributável em Moçambique segundo fundamento declarado,sem exclusão19(2) indicada.Enquadramento potencial19(1)bii,dependente das demais condições18/20 e âmbito dos bens/serviços utilizados.Não é concessão/cálculo de dedução,campo11,isenção ou IVA zero.','determined');
}catch{return stop('Localização/eventos de origem não recuperáveis;originais preservados,sem inferir classificação ou valores.');}
}
function key(draft:Record<string,unknown>,book:ForeignOperationBook):string{return JSON.stringify({version:foreignOperationVersion,scope:acquisitionScope(draft),location:draft.selection_labels??{},rows:book.rows,dependencies:book.rows.map(row=>({source:foreignSources(draft,row.sourceKind).find(s=>s.id===row.sourceRowId),event:row.eventRowId?foreignEvents(draft).find(e=>e.id===row.eventRowId):null}))});}
export function foreignOperationCurrent(draft:Record<string,unknown>,book:ForeignOperationBook):boolean{try{return!!book.result&&book.result.sourceKey===key(draft,book);}catch{return false;}}
export function recordForeignOperations(draft:Record<string,unknown>,book:ForeignOperationBook):ForeignOperationBook{if(!book.rows.length||new Set(book.rows.map(r=>r.id)).size!==book.rows.length)throw new Error('Indique identidades distintas;originais conservados.');const result:ForeignOperationCalculation={version:foreignOperationVersion,revision:(book.result?.revision??0)+1,calculatedAt:new Date().toISOString(),opportunity:acquisitionScope(draft),sourceKey:key(draft,book),source:foreignOperationSource,inputs:structuredClone(book.rows),rows:book.rows.map(r=>evaluateForeignOperation(draft,r))};const next={...book,result,history:[...(book.history??[]),...(book.result?[book.result]:[])]};saveForeignOperationBook(draft,next);return next;}
export function foreignOperationReportRows(draft:Record<string,unknown>):Array<[string,string,string,string]>{try{const b=foreignOperationBook(draft),r=b.result;if(!r)return[];return r.inputs.map((row,i)=>{const line=r.rows[i];return['IVA mensal',`P002 — ${line.source?.period??'Período indeterminado'} — ${row.id}`,`${line.source?.description??'Origem por escolher'};${line.reason};dedução/IVA:Indeterminados`,`${r.opportunity};${foreignOperationCurrent(draft,b)?'Resultado actual':'Origens/entradas alteradas;resultado histórico conservado'};versão${r.revision};${r.calculatedAt};${r.source};localização${row.sourceKind}/${row.sourceRowId},versão${line.source?.revision??'indeterminada'};${line.source?.reason??''};operação${line.source?.operationRef??''};âmbito origem${line.source?.scope??''};parcela${row.territorialPart};evento${row.eventRowId||'Sem associação necessária'},${line.event?JSON.stringify(line.event):'Não apurado por esta análise'};hipótese interna${row.hypotheticalTaxable},fundamento${row.hypotheticalRef};exclusão19(2)${row.excluded19_2};âmbito${row.scope};factos${row.evidence};sem base/IVA/dedução/campo11/totaisModeloA.`];});}catch{return[['IVA mensal','P002','Dados não recuperáveis','Originais preservados;sem classificação por defeito.']];}}
+2
View File
@@ -1,3 +1,4 @@
import { foreignOperationReportRows } from './iva-foreign-operations.ts';
import { currencyEventReportRows } from './iva-currency-events.ts';
import { propertyTransferReportRows } from './iva-property-transfers.ts';
import { goodsPresumptionReportRows } from './iva-goods-presumptions.ts';
@@ -658,6 +659,7 @@ export function buildProjectDossierRows(
rows.push(...goodsPresumptionReportRows(draft));
rows.push(...propertyTransferReportRows(draft));
rows.push(...currencyEventReportRows(draft));
rows.push(...foreignOperationReportRows(draft));
rows.push(...offerPreparationReportRows(draft));
const operationRows = salesReportRows(draft);
rows.push(...operationRows);
@@ -0,0 +1,51 @@
<?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 IvaForeignOperationDraftTest extends TestCase
{
use DatabaseTransactions;
public function test_foreignoperations_recover_privately_preserving_all_delivered_books(): void
{
$target = DB::selectOne('select current_database() as name,inet_server_port() as port');
$this->assertSame('simulator_fiscal_delta_20261021_test', $target->name);
$this->assertSame(55441, (int) $target->port);
config(['investor-path.enabled' => true, 'suitecrm.enabled' => false]);
$this->actingAs(User::factory()->create(['email_verified_at' => now()]));
$draft = json_decode(file_get_contents(base_path('tests/frontend/fixtures/iva-foreign-operations.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:358', $recovered['context_opportunity']);
$this->assertSame('Manica', $recovered['selection_labels']['province_id']);
$store = json_decode($recovered['modelo_a_snapshot'], true, flags: JSON_THROW_ON_ERROR);
$book = $store['foreignOperations']['opportunity:358'];
$this->assertCount(7, $book['rows']);
$this->assertSame(['potential','potential','potential','potential','indeterminate','not_demonstrated','excluded_19_2'],array_column($book['result']['rows'],'relation19'));
$this->assertSame('outside_portion',$book['result']['rows'][3]['territory']);
$this->assertSame('2026-12-10',$book['result']['rows'][0]['event']['eventDate']);
foreach ($book['result']['rows'] as $row) {
$this->assertNull($row['deduction']);$this->assertNull($row['baseAmount']);$this->assertNull($row['vat']);$this->assertNull($row['rate']);
}
$this->assertSame($book['rows'], $book['result']['inputs']);
$this->assertCount(1, $book['history']);
$this->assertSame('potential',$book['history'][0]['rows'][0]['relation19']);
$frozen = json_decode(file_get_contents(base_path('tests/frontend/fixtures/iva-foreign-operations-preserved.json')), true, flags: JSON_THROW_ON_ERROR);
$prior = json_decode($frozen['modelo_a_snapshot'], true, flags: JSON_THROW_ON_ERROR);
$preserved = array_diff_key($store, ['foreignOperations' => true]);
foreach (['goodsLocations','serviceLocations','listedServices','currencyEvents'] as $map) unset($preserved[$map]['opportunity:358']);
$this->assertSame($prior, $preserved);
$this->assertSame('1250.30', $store['publicAuthorityBases']['opportunity:339']['result']['rows'][1]['baseAmount']);
$this->assertNull($store['publicAuthorityBases']['opportunity:339']['result']['rows'][1]['vat']);
$this->assertSame('400.00', $store['importExemptions']['opportunity:338']['result']['rows'][1]['importVat']);
$this->assertNull($store['books']['opportunity:329']['result']['periods'][0]['fields']['11']);
file_put_contents(base_path('outputs/iva-l1-foreign-operations-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,32 @@
import test from 'node:test';import assert from 'node:assert/strict';import fs from 'node:fs';
import {newForeignOperation,evaluateForeignOperation,foreignSources,foreignEvents,recordForeignOperations,saveForeignOperationBook,foreignOperationBook,foreignOperationCurrent,foreignOperationReportRows,switchForeignSource} from '../../resources/js/lib/iva-foreign-operations.ts';
import {recordGoodsLocations,saveGoodsLocationBook,goodsLocationBook} from '../../resources/js/lib/iva-goods-location.ts';
import {recordServiceLocations,saveServiceLocationBook} from '../../resources/js/lib/iva-service-location.ts';
import {recordListedServices,saveListedServiceBook} from '../../resources/js/lib/iva-listed-services.ts';
import {recordCurrencyEvents,saveCurrencyEventBook} from '../../resources/js/lib/iva-currency-events.ts';
const frozen=JSON.parse(fs.readFileSync('tests/frontend/fixtures/iva-foreign-operations-preserved.json','utf8')),original=JSON.parse(frozen.modelo_a_snapshot);
function seed(){const d={...frozen,context_opportunity:'opportunity:358',project_title:'SYNTHETIC QA — Operações no estrangeiro / Manica'};
for(const[map,record,save]of [['goodsLocations',recordGoodsLocations,saveGoodsLocationBook],['serviceLocations',recordServiceLocations,saveServiceLocationBook],['listedServices',recordListedServices,saveListedServiceBook]]){const rows=structuredClone(Object.values(original[map])[0].rows).map(r=>({...r,operationRef:'SYNTHETIC QA op '+r.id}));if(map==='serviceLocations'){const r=structuredClone(rows.find(x=>x.id==='transport_service-MZ'));rows.push({...r,id:'transport_partial',operationRef:'SYNTHETIC QA op transport_partial',departure:'MZ',arrival:'foreign',nationalDistance:'20',totalDistance:'100'});}d.modelo_a_snapshot=save(d,record(d,{rows}));}
const event=structuredClone(original.currencyEvents['opportunity:357'].rows.find(r=>r.treatment==='goods_available'));event.operationRef='SYNTHETIC QA op g8a-goods_without_transport';d.modelo_a_snapshot=saveCurrencyEventBook(d,recordCurrencyEvents(d,{rows:[event]}));return d;}
const draft=seed();
function row(id,kind,source){return{...newForeignOperation(id),sourceKind:kind,sourceRowId:source,territorialPart:'outside',hypotheticalTaxable:'yes',hypotheticalRef:'SYNTHETIC QA hipótese interna/natureza tributável e19(2),fundamento identificado',excluded19_2:'no',scope:'SYNTHETIC QA conjunto/parcela exterior/período identificado',evidence:'SYNTHETIC QA factos/pressupostos expressos'};}
const cases=[row('foreign_goods','goods','g8a-goods_without_transport'),row('foreign_services','services','general_service-foreign'),row('foreign_listed','listed','rights_service-outbound'),row('foreign_transport_part','services','transport_partial')];cases[0].eventRowId='goods_available';
for(const r of cases)test('P002 '+r.id+' reuses delivered territoriality and potential19only',()=>{const v=evaluateForeignOperation(draft,r);assert.equal(v.status,'determined');assert.equal(v.relation19,'potential');assert.equal(v.territory,r.id==='foreign_transport_part'?'outside_portion':'outside');assert.equal(v.deduction,null);assert.equal(v.vat,null);assert.equal(v.baseAmount,null);assert.equal(v.source.id,r.sourceRowId);assert.equal(v.source.current,true);if(r.eventRowId)assert.equal(v.event.eventDate,'2026-12-10');});
test('P002 unknown/national/partial do not imply foreign or exemption',()=>{
assert.equal(evaluateForeignOperation(draft,{...cases[0],sourceRowId:''}).status,'indeterminate');assert.equal(evaluateForeignOperation(draft,{...cases[0],sourceRowId:'g8a-goods_with_transport'}).status,'not_applicable');assert.equal(evaluateForeignOperation(draft,{...cases[3],territorialPart:''}).status,'indeterminate');assert.equal(evaluateForeignOperation(draft,{...cases[3],territorialPart:'national'}).status,'not_applicable');
for(const patch of [{scope:''},{evidence:''},{hypotheticalRef:''},{hypotheticalTaxable:''},{excluded19_2:''}])assert.equal(evaluateForeignOperation(draft,{...cases[0],...patch}).status,'indeterminate');
const no=evaluateForeignOperation(draft,{...cases[0],hypotheticalTaxable:'no'});assert.equal(no.relation19,'not_demonstrated');assert.equal(no.deduction,null);const excluded=evaluateForeignOperation(draft,{...cases[0],excluded19_2:'yes'});assert.equal(excluded.relation19,'excluded_19_2');assert.equal(excluded.deduction,null);
});
test('P002 stale source and event scope mismatch never overwrite prior acceptance',()=>{
const b=goodsLocationBook(draft),changed={...draft,modelo_a_snapshot:saveGoodsLocationBook(draft,{...b,rows:b.rows.map(r=>r.id===cases[0].sourceRowId?{...r,placeCountry:'MZ'}:r)})};assert.equal(evaluateForeignOperation(changed,cases[0]).status,'indeterminate');
assert.equal(evaluateForeignOperation({...draft,context_opportunity:'opportunity:359'},cases[0]).status,'indeterminate');assert.equal(evaluateForeignOperation(draft,{...cases[1],eventRowId:'goods_available'}).status,'indeterminate');
const sw=switchForeignSource(cases[0],'services','general_service-foreign');assert.equal(sw.hypotheticalRef,cases[0].hypotheticalRef);assert.equal(sw.hypotheticalTaxable,'');assert.equal(sw.eventRowId,'');assert.equal(foreignEvents(draft).length,1);assert.equal(foreignSources(draft,'goods').length,3);
});
test('P002 dependency changes invalidate new analysis while snapshot/history stay immutable',()=>{
const a=recordForeignOperations(draft,{rows:cases}),d={...draft,modelo_a_snapshot:saveForeignOperationBook(draft,a)};assert.equal(foreignOperationCurrent(d,a),true);const b=goodsLocationBook(d),changed={...d,modelo_a_snapshot:saveGoodsLocationBook(d,recordGoodsLocations(d,b))};assert.equal(foreignOperationCurrent(changed,a),false);assert.deepEqual(a.result.inputs,cases);assert.equal(foreignOperationReportRows(d).length,4);
});
test('P002 private save/recovery/report preserve 120delivered books and P104currency facts',()=>{
const rows=[...cases,row('national','goods','g8a-goods_with_transport'),{...cases[1],id:'hypothesis_not_taxable',hypotheticalTaxable:'no'},{...cases[2],id:'exclusion19_2',excluded19_2:'yes'}],a=recordForeignOperations(draft,{rows}),d={...draft,modelo_a_snapshot:saveForeignOperationBook(draft,a)},b=recordForeignOperations(d,a);d.modelo_a_snapshot=saveForeignOperationBook(d,b);assert.equal(b.history.length,1);assert.equal(foreignOperationReportRows(d).length,7);
const store=JSON.parse(d.modelo_a_snapshot);delete store.foreignOperations;for(const name of ['goodsLocations','serviceLocations','listedServices','currencyEvents'])delete store[name]['opportunity:358'];assert.deepEqual(store,original);assert.deepEqual(foreignOperationBook({...d,context_opportunity:'opportunity:359'}),{rows:[]});assert.throws(()=>recordForeignOperations(d,{rows:[rows[0],rows[0]]}));assert.throws(()=>foreignOperationBook({...d,modelo_a_snapshot:JSON.stringify({...store,foreignOperations:[]})}));
if(process.env.IVA_FOREIGN_OPERATIONS_FIXTURE)fs.writeFileSync(process.env.IVA_FOREIGN_OPERATIONS_FIXTURE,JSON.stringify(d,null,2)+'\n');
});