feat(iva): prepare P101 consideration composition without doublecounting in QA

This commit is contained in:
2026-10-24 22:49:56 +02:00
parent 0bef805ede
commit e3caf55361
8 changed files with 204 additions and 0 deletions
@@ -0,0 +1,34 @@
import {useState} from 'react';
import {newCompositionBase,newCompositionPart,switchCompositionPart,compositionBook,compositionConditions,compositionCurrent,compositionReportRows,compositionBaseSource,compositionTreatments,recordCompositions,saveCompositionBook,type CompositionBook,type CompositionRow,type CompositionPart} from '@/lib/iva-composition-base';
export function SimulationCompositionBase({draft,onChange}:{draft:Record<string,unknown>;onChange:(field:string,value:string)=>void}){
const[error,setError]=useState('');let book:CompositionBook;
try{book=compositionBook(draft);}catch(cause){return<p role="alert">{String(cause)}</p>;}
const save=(next:CompositionBook)=>{try{onChange('modelo_a_snapshot',saveCompositionBook(draft,next));setError('');}catch(cause){setError(String(cause));}};
const change=(id:string,patch:Partial<CompositionRow>)=>save({...book,rows:book.rows.map(row=>row.id===id?{...row,...patch}:row)});
return<details className="space-y-3 rounded border p-3">
<summary>Base tributável — impostos e despesas acessórias da contraprestação</summary>
<p>Oportunidade: {String(draft.context_opportunity||'entrada geral')}. Prepare uma composição por operação. Parta do montante sem próprio IVA e com as exclusões aplicáveis já consideradas. Identifique as parcelas e se já estão incluídas;acrescente apenas as que ainda não constam do montante inicial.</p>
<p>Impostos,direitos,taxas e outras imposições excluem o próprio IVA. Nas despesas acessórias,confirme que são debitadas por conta do cliente e relativas a comissões,embalagem,transporte ou seguros. Respeite as regras especiais da operação,incluindo a exclusão da TC nos combustíveis. Base e IVA são resultados distintos.</p>
<p>{compositionBaseSource}</p>{error&&<p role="alert">{error}</p>}
<button type="button" className="underline" onClick={()=>save({...book,rows:[...book.rows,newCompositionBase(crypto.randomUUID(),String(draft.currency??''))]})}>Acrescentar composição de operação</button>
{book.rows.map(row=>{const partChange=(id:string,patch:Partial<CompositionPart>)=>change(row.id,{parts:row.parts.map(p=>p.id===id?{...p,...patch}:p)});return<details key={row.id} className="rounded border p-3"><summary>{row.description||'Operação por identificar'} — {row.period||'mês por indicar'}</summary>
<div className="grid gap-3 md:grid-cols-2">
<label>Natureza dos dados<select className="block rounded border p-2" value={row.origin} onChange={e=>change(row.id,{origin:e.target.value as CompositionRow['origin']})}><option value="projected">Projectados / pressupostos</option><option value="effective">Efectivos declarados</option></select></label>
{([['description','Operação identificada'],['operationRef','Referência da operação / pressuposto'],['initialAmount','Montante inicial sem próprio IVA (moeda indicada)'],['initialRef','Referência do montante inicial e da sua composição'],['documentRef','Documento da operação / referência da previsão'],['documentDate','Data do documento / prevista'],['period','Mês da operação'],['currency','Moeda de origem'],['compositionRef','Referência da composição e exclusões aplicáveis'],['evidence','Fundamento factual / pressupostos']] as const).map(([field,label])=><label key={field}>{label}<input className="block rounded border p-2" type={field==='documentDate'?'date':field==='period'?'month':'text'} value={row[field]} onChange={e=>change(row.id,{[field]:e.target.value})}/></label>)}
</div>
<fieldset><legend>Condições da composição</legend>{compositionConditions.map(([key,label])=><label className="block" key={key}>{label}<select className="block rounded border p-2" value={row.conditions[key]??''} onChange={e=>change(row.id,{conditions:{...row.conditions,[key]:e.target.value}})}><option value="">Por indicar</option><option value="yes">Sim</option><option value="no">Não</option></select></label>)}</fieldset>
<button type="button" className="underline" onClick={()=>change(row.id,{parts:[...row.parts,newCompositionPart(crypto.randomUUID())]})}>Acrescentar parcela desta operação</button>
{row.parts.map(part=><fieldset key={part.id} className="rounded border p-3"><legend>{part.description||'Parcela por identificar'}</legend>
<label>Natureza da parcela<select className="block rounded border p-2" value={part.treatment} onChange={e=>partChange(part.id,switchCompositionPart(part,e.target.value))}><option value="">Por indicar</option>{compositionTreatments.map(t=><option key={t.id} value={t.id}>{t.label}</option>)}</select></label>
{([['description','Descrição da parcela'],['amount','Montante da parcela sem próprio IVA (moeda indicada)'],['reference','Referência da parcela / pressuposto']] as const).map(([field,label])=><label className="block" key={field}>{label}<input className="block rounded border p-2" value={part[field]} onChange={e=>partChange(part.id,{[field]:e.target.value})}/></label>)}
<label>Esta parcela já está incluída no montante inicial?<select className="block rounded border p-2" value={part.included} onChange={e=>partChange(part.id,{included:e.target.value as CompositionPart['included']})}><option value="">Por indicar</option><option value="yes">Sim — conservar sem somar novamente</option><option value="no">Não — acrescentar uma vez</option></select></label>
{part.treatment&&<label>{part.treatment==='tax_component'?'É imposto,direito,taxa ou outra imposição que integra esta contraprestação nos termos15(5)(a)?':'É despesa acessória debitada por conta do cliente,relativa a comissões,embalagem,transporte ou seguros nos termos15(5)(b)?'}<select className="block rounded border p-2" value={part.natureConfirmed} onChange={e=>partChange(part.id,{natureConfirmed:e.target.value as CompositionPart['natureConfirmed']})}><option value="">Por indicar</option><option value="yes">Sim</option><option value="no">Não</option></select></label>}
<label>A parcela exclui o próprio IVA?<select className="block rounded border p-2" value={part.ownVatExcluded} onChange={e=>partChange(part.id,{ownVatExcluded:e.target.value as CompositionPart['ownVatExcluded']})}><option value="">Por indicar</option><option value="yes">Sim</option><option value="no">Não</option></select></label>
<p>Mudar a natureza conserva os montantes e referências,mas exige rever a correspondência da parcela. Cada parcela será considerada uma única vez nesta operação.</p>
</fieldset>)}
</details>;})}
{!!book.rows.length&&<button type="button" className="rounded bg-emerald-800 p-2 text-white" onClick={()=>{try{save(recordCompositions(draft,book));}catch(cause){setError(String(cause));}}}>Compor bases e guardar versão</button>}
{book.result&&<><p role="status">{compositionCurrent(draft,book)?'Composições correspondentes às entradas guardadas.':'Entradas alteradas;composições históricas conservadas. Reveja e determine novamente.'} {book.history?.length??0} versões anteriores. IVA e campo11 não são preenchidos por estas bases.</p><dl>{compositionReportRows(draft).map(([,label,value,origin],index)=><div key={index}><dt className="font-semibold">{label}</dt><dd>{value}<small className="block">{origin}</small></dd></div>)}</dl></>}
</details>;
}
@@ -11,6 +11,7 @@ import { SimulationCommissionBase } from './simulation-commission-base';
import { SimulationActSaleBase } from './simulation-act-sale-base';
import { SimulationFuelBase } from './simulation-fuel-base';
import { SimulationNoncashBase } from './simulation-noncash-base';
import { SimulationCompositionBase } from './simulation-composition-base';
import { SimulationOfferPreparations } from './simulation-offer-preparations';
import { SimulationOperations } from './simulation-operations';
@@ -100,6 +101,7 @@ export function SimulationMonthlyIva({draft, busy, onCalculate, onChange}: {
<SimulationActSaleBase draft={draft} onChange={onChange} />
<SimulationFuelBase draft={draft} onChange={onChange} />
<SimulationNoncashBase draft={draft} onChange={onChange} />
<SimulationCompositionBase draft={draft} onChange={onChange} />
<SimulationOfferPreparations draft={draft} onChange={onChange} />
<SimulationModeloA draft={draft} />
</section>;
+65
View File
@@ -0,0 +1,65 @@
import {acquisitionScope,readAcquisitionStore} from './iva-acquisitions.ts';
export const compositionBaseVersion='consideration-composition-base/1';
export const compositionBaseSource='CIVA republicado Lei13/2016,15(5)(a)/(b),PDF14/p1426(272):o valor tributável inclui impostos,direitos,taxas e outras imposições,excepto o próprioIVA;e despesas acessórias debitadas relativas a comissões,embalagem,transporte e seguros por conta do cliente. Respeitar regras especiais15(2) e exclusões15(6);não substitui a exclusãoTC emP095. Lei22/2022PDF3mantém15(5)por remissão;Lei10/2025não altera15. Recorte2026/MZN,composição da base apenas,sem incidência,taxas,IVA,dedução/campo11 ou totaisModeloA.';
export const compositionTreatments=[{id:'tax_component',label:'Impostos,direitos,taxas ou outras imposições,excepto próprio IVA'},{id:'accessory_component',label:'Despesas acessórias debitadas por conta do cliente'}] as const;
export const compositionConditions=[
['taxableOperation','A operação sujeita a IVA e a contraprestação correspondente estão identificadas'],
['initialNet','O montante inicial está identificado,sem próprio IVA e com as exclusões aplicáveis15(6)já consideradas; a referência explica a composição'],
['completeComposition','As parcelas aplicáveis15(5)estão reconciliadas com o montante inicial,sem omissões ou dupla contagem'],
['specialRules','Esta composição respeita a regra aplicável da operação,sem substituir bases especiais15(2),exclusões15(6)ou a exclusão da TC de combustíveis'],
] as const;
type Choice=''|'yes'|'no';type Condition=typeof compositionConditions[number][0];
export type CompositionPart={id:string;treatment:string;description:string;amount:string;included:Choice;reference:string;natureConfirmed:Choice;ownVatExcluded:Choice};
export type CompositionRow={id:string;description:string;period:string;origin:'projected'|'effective';currency:string;initialAmount:string;initialRef:string;operationRef:string;documentRef:string;documentDate:string;compositionRef:string;evidence:string;conditions:Partial<Record<Condition,Choice>>;parts:CompositionPart[]};
type LineResult={id:string;status:'determined'|'not_applicable'|'indeterminate';baseAmount:string|null;vat:null;rate:null;reason:string};
export type CompositionCalculation={version:string;revision:number;calculatedAt:string;opportunity:string;location:string;sourceKey:string;source:string;inputs:CompositionRow[];rows:LineResult[]};
export type CompositionBook={rows:CompositionRow[];result?:CompositionCalculation;history?:CompositionCalculation[]};
type Store=ReturnType<typeof readAcquisitionStore>&{considerationCompositions?:Record<string,CompositionBook>};
export function newCompositionPart(id:string):CompositionPart{return{id,treatment:'',description:'',amount:'',included:'',reference:'',natureConfirmed:'',ownVatExcluded:''};}
export function newCompositionBase(id:string,currency:string):CompositionRow{return{id,description:'',period:'',origin:'projected',currency,initialAmount:'',initialRef:'',operationRef:'',documentRef:'',documentDate:'',compositionRef:'',evidence:'',conditions:{},parts:[]};}
export function switchCompositionPart(part:CompositionPart,treatment:string):CompositionPart{return{...part,treatment,natureConfirmed:''};}
function readStore(draft:Record<string,unknown>):Store{
const store=readAcquisitionStore(draft.modelo_a_snapshot) as Store;
if(store.considerationCompositions!==undefined&&(!store.considerationCompositions||typeof store.considerationCompositions!=='object'||Array.isArray(store.considerationCompositions)))throw new Error('Composições não recuperáveis;originais preservados.');
const fields=Object.keys(newCompositionBase('','')).filter(key=>!['conditions','parts'].includes(key));const partFields=Object.keys(newCompositionPart(''));
for(const book of Object.values(store.considerationCompositions??{}))if(!book||!Array.isArray(book.rows)||(book.history!==undefined&&!Array.isArray(book.history))||book.rows.some(row=>!row||fields.some(key=>typeof row[key as keyof CompositionRow]!=='string')||!row.conditions||typeof row.conditions!=='object'||Array.isArray(row.conditions)||!Array.isArray(row.parts)||row.parts.some(part=>!part||partFields.some(key=>typeof part[key as keyof CompositionPart]!=='string'))))throw new Error('Linhas de composição não recuperáveis;originais preservados,sem substituição por lista vazia.');
return store;
}
export function compositionBook(draft:Record<string,unknown>):CompositionBook{return readStore(draft).considerationCompositions?.[acquisitionScope(draft)]??{rows:[]};}
export function saveCompositionBook(draft:Record<string,unknown>,book:CompositionBook):string{const current=readStore(draft),encoded=JSON.stringify({...current,considerationCompositions:{...current.considerationCompositions,[acquisitionScope(draft)]:book}});readStore({...draft,modelo_a_snapshot:encoded});return encoded;}
function decimal(raw:unknown):bigint|null{if(typeof raw!=='string'||!/^\d{1,15}(\.\d{1,6})?$/.test(raw.trim()))return null;const[whole,fraction='']=raw.trim().split('.');return BigInt(whole)*1000000n+BigInt(fraction.padEnd(6,'0'));}
function money(n:bigint):string{return`${n/1000000n}.${(n%1000000n).toString().padStart(6,'0').replace(/0+$/,'').padEnd(2,'0')}`;}
function validDate(value:string):boolean{const d=new Date(value+'T00:00:00Z');return /^\d{4}-\d{2}-\d{2}$/.test(value)&&Number.isFinite(d.getTime())&&d.toISOString().slice(0,10)===value;}
export function evaluateComposition(row:CompositionRow,currency:unknown):LineResult{
const result:LineResult={id:row.id,status:'indeterminate',baseAmount:null,vat:null,rate:null,reason:''};const stop=(reason:string,status:LineResult['status']='indeterminate')=>({...result,status,reason});
if(currency!=='MZN'||row.currency!==currency)return stop('Moeda fora do âmbito MZN ou divergente do projecto;sem conversão silenciosa.');
if(!/^2026-(0[1-9]|1[0-2])$/.test(row.period)||!['projected','effective'].includes(row.origin))return stop('Indique mês em2026 e dados efectivos declarados ou pressupostos de projecção.');
const failed=compositionConditions.filter(([key])=>row.conditions[key]==='no');if(failed.length)return stop('Não se aplica esta composição: '+failed.map(([,label])=>label).join('; ')+'. Não é isenção nem IVA zero.','not_applicable');
const missing=compositionConditions.filter(([key])=>row.conditions[key]!=='yes');if(missing.length)return stop('Condições por indicar: '+missing.map(([,label])=>label).join('; ')+'. Desconhecido não é zero.');
if(!row.description.trim()||!row.initialRef.trim()||!row.operationRef.trim()||!row.documentRef.trim()||!row.compositionRef.trim()||!row.evidence.trim()||!validDate(row.documentDate))return stop('Identifique operação,montante inicial,documento/data,composição e fundamento ou pressupostos expressos;reutilize referências existentes,sem certificado/aprovador novo.');
const initial=decimal(row.initialAmount);if(initial===null)return stop('Indique o montante inicial sem próprio IVA e com exclusões aplicáveis consideradas;desconhecido não é zero.');
if(!row.parts.length||new Set(row.parts.map(p=>p.id)).size!==row.parts.length||row.parts.some(p=>!p.id.trim()))return stop('Identifique as parcelas desta operação com identificadores distintos;sem soma de linhas vazias ou duplicadas.');
let additions=0n;const formulas:string[]=[];
for(const part of row.parts){
const rule=compositionTreatments.find(t=>t.id===part.treatment);if(!rule)return stop('Identifique a natureza de cada parcela:imposição excepto próprioIVA ou despesa acessória debitada por conta do cliente.');
if(part.natureConfirmed==='no'||part.ownVatExcluded==='no')return stop('A parcela '+part.id+' não corresponde ao tratamento ou inclui próprioIVA;reveja a composição,não se transforma em exclusão/isenção automática.','not_applicable');
if(part.natureConfirmed!=='yes'||part.ownVatExcluded!=='yes'||!['yes','no'].includes(part.included))return stop('Confirme a natureza,exclusão do próprioIVA e se a parcela '+part.id+' já está no montante inicial;desconhecido não permite presumir soma ou ausência.');
if(!part.description.trim()||!part.reference.trim())return stop('Identifique descrição e referência da parcela '+part.id+',ou pressuposto expresso;sem novo documento obrigatório.');
const amount=decimal(part.amount);if(amount===null)return stop('Indique o montante exacto da parcela '+part.id+';desconhecido não é zero.');
if(part.included==='no')additions+=amount;
formulas.push(`${part.id}:${rule.label},${money(amount)}MZN,${part.included==='yes'?'já incluída,sem segunda soma':'acrescida uma vez'}`);
}
const base=initial+additions;return{...result,status:'determined',baseAmount:money(base),reason:`Base15(5):montante inicial ${money(initial)} + parcelas ainda não incluídas ${money(additions)} = ${money(base)}MZN;${formulas.join('; ')},${row.origin==='projected'?'nas condições expressamente assumidas na projecção':'nos dados efectivos declarados'}. Base única por operação,não uma base por parcela. Base não é imposto;IVA/taxa,dedução/campo11 e totaisModeloA não são determinados por esta composição.`};
}
function key(draft:Record<string,unknown>,book:CompositionBook):string{return JSON.stringify({version:compositionBaseVersion,scope:acquisitionScope(draft),location:draft.selection_labels??{},currency:draft.currency,rows:book.rows});}
export function compositionCurrent(draft:Record<string,unknown>,book:CompositionBook):boolean{return!!book.result&&book.result.sourceKey===key(draft,book);}
export function recordCompositions(draft:Record<string,unknown>,book:CompositionBook):CompositionBook{
if(!book.rows.length||new Set(book.rows.map(row=>row.id)).size!==book.rows.length)throw new Error('Indique operações com identificadores distintos;originais conservados.');
const result:CompositionCalculation={version:compositionBaseVersion,revision:(book.result?.revision??0)+1,calculatedAt:new Date().toISOString(),opportunity:acquisitionScope(draft),location:JSON.stringify(draft.selection_labels??{}),sourceKey:key(draft,book),source:compositionBaseSource,inputs:structuredClone(book.rows),rows:book.rows.map(row=>evaluateComposition(row,draft.currency))};
const next={...book,result,history:[...(book.history??[]),...(book.result?[book.result]:[])]};saveCompositionBook(draft,next);return next;
}
export function compositionReportRows(draft:Record<string,unknown>):Array<[string,string,string,string]>{
try{const book=compositionBook(draft),result=book.result;if(!result)return[];return result.inputs.map((row,index)=>['IVA mensal',`Base P101 — ${row.period} — ${row.id}`,`${row.description};base tributável: ${result.rows[index].baseAmount??'Indeterminada'} ${row.currency};IVA: Indeterminado;${result.rows[index].reason}`,`${result.opportunity};${result.location};${compositionCurrent(draft,book)?'Resultado actual':'Entradas alteradas;resultado histórico conservado'};versão${result.revision};${result.calculatedAt};${result.source};${row.origin==='projected'?'Projecção':'Dados efectivos declarados'};operação ${row.operationRef};montante inicial ${row.initialAmount} ${row.currency},referência ${row.initialRef};parcelas ${row.parts.map(p=>`${p.id}/${p.treatment},${p.description},${p.amount} ${row.currency},já incluída:${p.included==='yes'?'Sim':p.included==='no'?'Não':'Por indicar'},natureza:${p.natureConfirmed},sem próprioIVA:${p.ownVatExcluded},referência ${p.reference}`).join('; ')};composição ${row.compositionRef};documento ${row.documentRef},${row.documentDate};condições ${compositionConditions.map(([key,label])=>label+': '+(row.conditions[key]==='yes'?'Sim':row.conditions[key]==='no'?'Não':'Por indicar')).join('; ')};fundamento ${row.evidence};não preenche totaisModeloA ou campo11.`]);}
catch{return[['IVA mensal','Composição da contraprestação','Dados não recuperáveis','Originais preservados;sem substituir por zero.']];}
}
+2
View File
@@ -10,6 +10,7 @@ import { commissionBaseReportRows } from './iva-commission-base.ts';
import { actSaleReportRows } from './iva-act-sale-base.ts';
import { fuelBaseReportRows } from './iva-fuel-base.ts';
import { noncashBaseReportRows } from './iva-noncash-base.ts';
import { compositionReportRows } from './iva-composition-base.ts';
import { offerPreparationReportRows } from './iva-offer-preparations.ts';
import { salesReportRows } from './iva-operations.ts';
import { fiscalQaRows } from './fiscal-qa-preview.ts';
@@ -630,6 +631,7 @@ export function buildProjectDossierRows(
rows.push(...actSaleReportRows(draft));
rows.push(...fuelBaseReportRows(draft));
rows.push(...noncashBaseReportRows(draft));
rows.push(...compositionReportRows(draft));
rows.push(...offerPreparationReportRows(draft));
const operationRows = salesReportRows(draft);
rows.push(...operationRows);
@@ -0,0 +1,58 @@
<?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 IvaCompositionBaseDraftTest extends TestCase
{
use DatabaseTransactions;
public function test_composition_bases_recover_privately_preserving_all_delivered_books(): void
{
$target = DB::selectOne('select current_database() as name,inet_server_port() as port');
$this->assertSame('simulator_fiscal_delta_20261021_test', $target->name);
$this->assertSame(55441, (int) $target->port);
config(['investor-path.enabled' => true, 'suitecrm.enabled' => false]);
$this->actingAs(User::factory()->create(['email_verified_at' => now()]));
$draft = json_decode(file_get_contents(base_path('tests/frontend/fixtures/iva-g7f-bases.json')), true, flags: JSON_THROW_ON_ERROR);
$id = $this->postJson('/api/v1/simulation-drafts', ['personal_snapshot' => [], 'economic_snapshot' => $draft], ['Idempotency-Key' => (string) Str::uuid()])->assertCreated()->json('data.id');
$recovered = $this->getJson('/api/v1/simulation-drafts/'.$id)->assertOk()->json('data.economic_snapshot');
$this->assertSame($draft['modelo_a_snapshot'], $recovered['modelo_a_snapshot']);
$this->assertSame('opportunity:344', $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['considerationCompositions']['opportunity:344'];
$this->assertCount(1, $book['rows']);
$this->assertCount(2, $book['rows'][0]['parts']);
$this->assertSame(['tax_component','accessory_component'], array_column($book['rows'][0]['parts'], 'treatment'));
$this->assertSame(['1150.60'], array_column($book['result']['rows'], 'baseAmount'));
$this->assertSame(['determined'], array_column($book['result']['rows'], 'status'));
$this->assertSame([null], array_column($book['result']['rows'], 'vat'));
$this->assertSame([null], array_column($book['result']['rows'], 'rate'));
$this->assertSame('1000.25', $book['rows'][0]['initialAmount']);
$this->assertSame(['100.10','50.25'], array_column($book['rows'][0]['parts'], 'amount'));
$this->assertSame(['no','no'], array_column($book['rows'][0]['parts'], 'included'));
$this->assertSame(['yes','yes'], array_column($book['rows'][0]['parts'], 'ownVatExcluded'));
$this->assertStringContainsString('operação A', $book['rows'][0]['operationRef']);
$this->assertStringContainsString('próprioIVA', $book['rows'][0]['initialRef']);
$this->assertStringContainsString('referência tax_component', $book['rows'][0]['parts'][0]['reference']);
$this->assertStringContainsString('cliente', $book['rows'][0]['parts'][1]['description']);
$this->assertStringContainsString('15(5)', $book['result']['source']);
$this->assertSame($book['rows'], $book['result']['inputs']);
$this->assertCount(2, $book['history']);
$this->assertNull($book['history'][0]['rows'][0]['baseAmount']);
$frozen = json_decode(file_get_contents(base_path('tests/frontend/fixtures/iva-g7f-preserved-inputs.json')), true, flags: JSON_THROW_ON_ERROR);
$prior = json_decode($frozen['modelo_a_snapshot'], true, flags: JSON_THROW_ON_ERROR);
$this->assertSame($prior, array_diff_key($store, ['considerationCompositions' => true]));
$this->assertSame('1250.30', $store['publicAuthorityBases']['opportunity:339']['result']['rows'][1]['baseAmount']);
$this->assertNull($store['publicAuthorityBases']['opportunity:339']['result']['rows'][1]['vat']);
$this->assertSame('400.00', $store['importExemptions']['opportunity:338']['result']['rows'][1]['importVat']);
$this->assertNull($store['books']['opportunity:329']['result']['periods'][0]['fields']['11']);
file_put_contents(base_path('outputs/iva-l1-g7f-composition-20261024/recovered.json'), json_encode($recovered, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE));
}
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,41 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import {newCompositionBase,newCompositionPart,switchCompositionPart,compositionTreatments,compositionConditions,evaluateComposition,recordCompositions,saveCompositionBook,compositionBook,compositionCurrent,compositionReportRows} from '../../resources/js/lib/iva-composition-base.ts';
import {noncashBaseCurrent} from '../../resources/js/lib/iva-noncash-base.ts';
import {fuelBaseCurrent} from '../../resources/js/lib/iva-fuel-base.ts';
import {createProjectDossierReport} from '../../resources/js/lib/project-dossier-report.ts';
const frozen=JSON.parse(fs.readFileSync(new URL('./fixtures/iva-g7f-preserved-inputs.json',import.meta.url),'utf8'));
const part=(treatment='tax_component',id=treatment)=>({...newCompositionPart(id),treatment,description:'SYNTHETIC QA '+(treatment==='tax_component'?'imposição A':'transporte por conta do cliente'),amount:treatment==='tax_component'?'100.10':'50.25',included:'no',reference:'SYNTHETIC QA referência '+id,natureConfirmed:'yes',ownVatExcluded:'yes'});
const row=()=>({...newCompositionBase('g7f-operation-A','MZN'),description:'SYNTHETIC QA composição operação A',period:'2026-12',operationRef:'SYNTHETIC QA operação A',initialAmount:'1000.25',initialRef:'SYNTHETIC QA preço sem próprioIVA/exclusões15(6)consideradas',documentRef:'SYNTHETIC QA G7F documento A',documentDate:'2026-12-12',compositionRef:'SYNTHETIC QA parcelas A,B e montante inicial reconciliados,sem duplicação',evidence:'SYNTHETIC QA pressupostos expressos,não documento real',conditions:Object.fromEntries(compositionConditions.map(([key])=>[key,'yes'])),parts:compositionTreatments.map(t=>part(t.id))});
const draft=()=>({...structuredClone(frozen),context_opportunity:'opportunity:344',project_title:'SYNTHETIC QA — G7F / Composição da contraprestação'});
test('P101 operation and each tax/accessory condition required;ownIVA or wrong nature not silently included',()=>{
const input=row(),ok=evaluateComposition(input,'MZN');assert.equal(ok.baseAmount,'1150.60');assert.equal(ok.vat,null);assert.equal(ok.rate,null);
for(const[key]of compositionConditions)for(const value of ['','unknown','no']){const r=evaluateComposition({...input,conditions:{...input.conditions,[key]:value}},'MZN');assert.equal(r.status,value==='no'?'not_applicable':'indeterminate');assert.equal(r.baseAmount,null);}
for(const t of compositionTreatments)for(const key of ['natureConfirmed','ownVatExcluded'])for(const value of ['','unknown','no']){const p={...part(t.id),[key]:value},r=evaluateComposition({...input,parts:[p]},'MZN');assert.equal(r.status,value==='no'?'not_applicable':'indeterminate');assert.equal(r.baseAmount,null);assert.equal(r.vat,null);}
for(const patch of [{description:''},{operationRef:''},{initialRef:''},{documentRef:''},{documentDate:'2026-02-30'},{compositionRef:''},{evidence:''},{period:'2025-12'},{currency:'USD'}])assert.equal(evaluateComposition({...input,...patch},'MZN').baseAmount,null);
for(const patch of [{treatment:''},{description:''},{reference:''},{included:''},{included:'unknown'},{amount:''}])assert.equal(evaluateComposition({...input,parts:[{...part(),...patch}]},'MZN').baseAmount,null);
});
test('single operation base adds missing components only;alreadyincluded does not doublecount;exactdecimal and preserved edits',()=>{
const input=row();for(const[parts,expected]of [[[part('tax_component')],'1100.35'],[[part('accessory_component')],'1050.50'],[[{...part(),included:'yes'},part('accessory_component')],'1050.50'],[input.parts.map(p=>({...p,included:'yes'})),'1000.25']])assert.equal(evaluateComposition({...input,parts},'MZN').baseAmount,expected);
assert.equal(evaluateComposition({...input,initialAmount:'0',parts:[{...part(),amount:'0'}]},'MZN').baseAmount,'0.00');assert.equal(evaluateComposition({...input,initialAmount:'1.000002',parts:[{...part(),amount:'0.000001'}]},'MZN').baseAmount,'1.000003');assert.equal(evaluateComposition({...input,initialAmount:'999999999999999.999999',parts:[{...part(),amount:'999999999999999.999999'}]},'MZN').baseAmount,'1999999999999999.999998');
for(const amount of ['-1','NaN','0.0000001',''])assert.equal(evaluateComposition({...input,parts:[{...part(),amount}]},'MZN').baseAmount,null);assert.equal(evaluateComposition({...input,initialAmount:''},'MZN').baseAmount,null);
const switched=switchCompositionPart(part(),'accessory_component');assert.equal(switched.natureConfirmed,'');assert.equal(switched.amount,'100.10');assert.equal(switched.reference,part().reference);assert.equal(evaluateComposition({...input,parts:[switched]},'MZN').baseAmount,null);
assert.equal(evaluateComposition({...input,parts:[]},'MZN').baseAmount,null);assert.equal(evaluateComposition({...input,parts:[part(),part()]},'MZN').baseAmount,null);
});
test('save/recovery/history/report keep one base peroperation and all83 previous books/currentkeys unchanged',()=>{
const d=draft(),prior=JSON.parse(d.modelo_a_snapshot);assert.deepEqual(compositionBook(d),{rows:[]});assert.deepEqual(compositionReportRows(d),[]);
let book=recordCompositions(d,{rows:[{...row(),conditions:{}}]});book=recordCompositions(d,{...book,rows:[{...row(),parts:[{...part(),ownVatExcluded:'no'}]}]});book=recordCompositions(d,{...book,rows:[row()]});assert.equal(book.history.length,2);assert.ok(book.history.every(h=>h.rows.every(r=>r.baseAmount===null&&r.vat===null)));assert.ok(compositionCurrent(d,book));assert.equal(book.result.rows.length,1);assert.equal(book.result.inputs[0].parts.length,2);
for(const field of ['amount','included','reference','treatment'])assert.ok(!compositionCurrent(d,{...book,rows:book.rows.map(r=>({...r,parts:r.parts.map(p=>({...p,[field]:'changed'}))}))}));for(const patch of [{context_opportunity:'opportunity:345'},{currency:'USD'},{selection_labels:{province_id:'Maputo'}}])assert.ok(!compositionCurrent({...d,...patch},book));
d.modelo_a_snapshot=saveCompositionBook(d,book);assert.deepEqual(compositionBook(JSON.parse(JSON.stringify(d))),book);const next=JSON.parse(d.modelo_a_snapshot);const{considerationCompositions,...untouched}=next;assert.deepEqual(untouched,prior);for(const[scope,previous]of Object.entries(prior.noncashBases))assert.ok(noncashBaseCurrent({...d,context_opportunity:scope},previous));for(const[scope,previous]of Object.entries(prior.regulatedFuelBases))assert.ok(fuelBaseCurrent({...d,context_opportunity:scope},previous));
assert.equal(next.noncashBases['opportunity:343'].result.rows[1].baseAmount,'2700.75');assert.equal(next.regulatedFuelBases['opportunity:342'].result.rows[0].baseAmount,'1400.15');assert.equal(next.regulatedFuelBases['opportunity:342'].result.rows[1].baseAmount,'1500.25');assert.equal(next.importExemptions['opportunity:338'].result.rows[1].importVat,'400.00');assert.equal(next.books['opportunity:329'].result.periods[0].fields['11'],null);
const report=JSON.stringify(createProjectDossierReport(d,{selectedSections:new Set(['finance']),version:'QA G7F'}));for(const token of ['Base P101','1150.60','IVA: Indeterminado','opportunity:344','Manica','2026-12','operação A','15(5)','100.10','50.25','accessory_component','campo11'])assert.ok(report.includes(token),token);assert.equal(compositionReportRows(d).length,1);assert.deepEqual(compositionReportRows({...d,context_opportunity:'opportunity:345'}),[]);
if(process.env.IVA_G7F_CALCULATED)fs.writeFileSync(process.env.IVA_G7F_CALCULATED,JSON.stringify(d));
});
test('corrupt nested components/store cannot overwrite originals;duplicate operation ids cannot be recorded',()=>{
const d=draft(),original=d.modelo_a_snapshot;for(const patch of [{initialAmount:4},{conditions:[]},{parts:{}},{parts:[{...part(),amount:4}]},{parts:[null]}])assert.throws(()=>saveCompositionBook(d,{rows:[{...row(),...patch}]}),/originais preservados/);assert.equal(d.modelo_a_snapshot,original);assert.throws(()=>recordCompositions(d,{rows:[row(),row()]}),/distintos/);assert.throws(()=>recordCompositions(d,{rows:[]}),/distintos/);assert.throws(()=>compositionBook({...d,modelo_a_snapshot:JSON.stringify({...JSON.parse(original),considerationCompositions:[]})}),/originais preservados/);
});