feat(iva): prepare four proportional tax bases in one QA set

This commit is contained in:
2026-10-24 23:58:06 +02:00
parent b0e01ef737
commit 67a96085c8
8 changed files with 201 additions and 0 deletions
@@ -18,6 +18,7 @@ import { SimulationServiceNormal } from './simulation-service-normal';
import { SimulationGoodsLocation } from './simulation-goods-location';
import { SimulationServiceLocation } from './simulation-service-location';
import { SimulationListedServices } from './simulation-listed-services';
import { SimulationReducedBases } from './simulation-reduced-bases';
import { SimulationOfferPreparations } from './simulation-offer-preparations';
import { SimulationOperations } from './simulation-operations';
@@ -114,6 +115,7 @@ export function SimulationMonthlyIva({draft, busy, onCalculate, onChange}: {
<SimulationGoodsLocation draft={draft} onChange={onChange} />
<SimulationServiceLocation draft={draft} onChange={onChange} />
<SimulationListedServices draft={draft} onChange={onChange} />
<SimulationReducedBases draft={draft} onChange={onChange} />
<SimulationOfferPreparations draft={draft} onChange={onChange} />
<SimulationModeloA draft={draft} />
</section>;
@@ -0,0 +1,35 @@
import {useState} from 'react';
import {newReducedBase,switchReducedTreatment,reducedBaseBook,reducedBaseConditions,ownReducedConditions,reducedBaseCurrent,reducedBaseReportRows,reducedBaseSource,reducedBaseTreatments,publicWorkCategories,recordReducedBases,saveReducedBaseBook,type ReducedBaseBook,type ReducedBaseRow} from '@/lib/iva-reduced-bases';
export function SimulationReducedBases({draft,onChange}:{draft:Record<string,unknown>;onChange:(field:string,value:string)=>void}){
const[error,setError]=useState('');let book:ReducedBaseBook;
try{book=reducedBaseBook(draft);}catch(cause){return<p role="alert">{String(cause)}</p>;}
const save=(next:ReducedBaseBook)=>{try{onChange('modelo_a_snapshot',saveReducedBaseBook(draft,next));setError('');}catch(cause){setError(String(cause));}};
const change=(id:string,patch:Partial<ReducedBaseRow>)=>save({...book,rows:book.rows.map(row=>row.id===id?{...row,...patch}:row)});
return<details className="space-y-3 rounded border p-3">
<summary>Bases reduzidas — energia,taxas aeronáuticas,obras públicas eágua</summary>
<p>Oportunidade: {String(draft.context_opportunity||'entrada geral')}. Indique a contraprestação total antes da redução,sem o próprioIVA ecom a composição conferida. Se o montante já está reduzido,reutilize o valor original antes de calcular;se só existe total comIVA,esclareça a composição antes de usar este campo.</p>
<p>Energia62%,taxas aeronáuticas85%,obras públicas40%(redução60%)eágua75%. Percentagem da base não é taxaIVA. A dedução relativa a estas operações fica sujeita à mesma proporção18(6),mas este percurso não calcula nem concede dedução ou preenche campo11.</p>
<p>{reducedBaseSource}</p>{error&&<p role="alert">{error}</p>}
<button type="button" className="underline" onClick={()=>save({...book,rows:[...book.rows,newReducedBase(crypto.randomUUID(),String(draft.currency??''))]})}>Acrescentar base reduzida</button>
{book.rows.map(row=><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>Tratamento da base reduzida<select className="block rounded border p-2" value={row.treatment} onChange={e=>change(row.id,switchReducedTreatment(row,e.target.value))}><option value="">Por indicar</option>{reducedBaseTreatments.map(t=><option key={t.id} value={t.id}>{t.label}</option>)}</select></label>
<label>Natureza dos dados<select className="block rounded border p-2" value={row.origin} onChange={e=>change(row.id,{origin:e.target.value as ReducedBaseRow['origin']})}><option value="projected">Projectados / pressupostos</option><option value="effective">Efectivos declarados</option></select></label>
{([['description','Operação identificada'],['currency','Moeda do montante'],['operationDate','Data da operação / prevista'],['period','Mês da operação'],['documentRef','Documento / referência da previsão'],['documentDate','Data do documento / prevista'],['priceScope','Âmbito:objecto,operação,local eperíodo'],['consideration','Contraprestação total antes da redução,sem próprioIVA'],['compositionRef','Referência da composição15(1)/(5)/(6)'],['evidence','Fundamento factual / pressupostos']] as const).map(([field,label])=><label key={field}>{label}<input className="block rounded border p-2" type={['operationDate','documentDate'].includes(field)?'date':field==='period'?'month':'text'} value={row[field]} onChange={e=>change(row.id,{[field]:e.target.value})}/></label>)}
{row.treatment==='public_works'?<>
<label>Projecto ou contrato de obra pública<input className="block rounded border p-2" value={row.projectRef} onChange={e=>change(row.id,{projectRef:e.target.value})}/></label>
<label>Categoria da obra<select className="block rounded border p-2" value={row.workCategory} onChange={e=>change(row.id,{workCategory:e.target.value})}><option value="">Por indicar</option>{publicWorkCategories.map(c=><option key={c.id} value={c.id}>{c.label}</option>)}</select></label>
</>:row.treatment&&<>
<label>Referência do preço fixado ou taxa aeronáutica<input className="block rounded border p-2" value={row.priceRef} onChange={e=>change(row.id,{priceRef:e.target.value})}/></label>
<label>Preço/taxa aplicável desde<input type="date" className="block rounded border p-2" value={row.priceFrom} onChange={e=>change(row.id,{priceFrom:e.target.value})}/></label>
<label>Preço/taxa aplicável até,se houver termo<input type="date" className="block rounded border p-2" value={row.priceTo} onChange={e=>change(row.id,{priceTo:e.target.value})}/></label>
</>}
</div>
<p>Mudar tratamento conserva dados e referências ocultos,mas exige rever os factos próprios. Não se exige referência de preço fixado para obras públicas;estas dependem do projecto/categoria/âmbito aplicáveis.</p>
<fieldset><legend>Condições deste tratamento</legend>{[...reducedBaseConditions,...ownReducedConditions(row.treatment)].map(([key,label])=><label className="block" key={key}>{label}<select className="block rounded border p-2" value={row.conditions[key]??''} onChange={e=>change(row.id,{conditions:{...row.conditions,[key]:e.target.value as ''|'yes'|'no'}})}><option value="">Por indicar</option><option value="yes">Sim</option><option value="no">Não</option></select></label>)}</fieldset>
</details>)}
{!!book.rows.length&&<button type="button" className="rounded bg-emerald-800 p-2 text-white" onClick={()=>{try{save(recordReducedBases(draft,book));}catch(cause){setError(String(cause));}}}>Calcular bases reduzidas e guardar versão</button>}
{book.result&&<><p role="status">{reducedBaseCurrent(draft,book)?'Bases correspondentes às entradas guardadas.':'Entradas alteradas;bases históricas conservadas. Reveja e calcule novamente.'} {book.history?.length??0} versões anteriores. Base eproporção18(6)não são imposto ou dedução concedida.</p><dl>{reducedBaseReportRows(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>;
}
+63
View File
@@ -0,0 +1,63 @@
import {acquisitionScope,readAcquisitionStore} from './iva-acquisitions.ts';
export const reducedBaseVersion='proportional-bases/1';
export const reducedBaseSource='CIVA15(2)j/k/l/m,15(1)/(5)/(6)/(9),Lei13/2016PDF13–14/p1426(271–272);Lei22/2022PDF3mantém15(2)j–m;Lei10/2025não altera15 eadita18(6),PDF13:dedução do imposto suportado relativo a estas operações na mesma proporção da base reduzida. Recorte2026/MZN;base apenas,sem taxaIVA/IVA/dedução/campo11 ou totaisModeloA. Energia62%,taxas aeronáuticas85%,obras redução60%(base40%),água75%. Percentagem de base não é taxaIVA.';
export const reducedBaseTreatments=[{id:'regulated_energy',branch:'P096',clause:'j',label:'P096 — Energia com preço fixado por Autoridade Pública',percent:62},{id:'aeronautical_fees',branch:'P097',clause:'k',label:'P097 — Serviços com preço fixado por taxas aeronáuticas',percent:85},{id:'public_works',branch:'P098',clause:'l',label:'P098 — Obras públicas abrangidas',percent:40},{id:'public_water',branch:'P099',clause:'m',label:'P099 — Água potável por rede pública e preço fixado',percent:75}] as const;
export const reducedBaseConditions=[['taxableOperation','Está identificada a operação sujeita a IVA e a correspondência com este tratamento'],['fullNetInvoice','O montante é a contraprestação total da operação/factura antes desta redução,sem o próprioIVA ecom composição15(1)/(5)/(6)conferida'],['scopeMatches','Documentos/referências ou pressupostos expressos correspondem ao objecto,local,operação eperíodo']] as const;
export const reducedOwnConditions={
regulated_energy:[['energySupply','Transmissão de energia identificada'],['publicFixedPrice','Preço fixado pela Autoridade Pública identificada na referência aplicável']],
aeronautical_fees:[['aeronauticalPrice','Preço do serviço fixado através de taxas aeronáuticas;uma descrição de comunicações,saneamento ou telecomunicações não basta']],
public_works:[['publicWorks','Construção ou reabilitação de obras públicas abrangidas15(2)l/15(9)'],['workCategoryMatches','A categoria eâmbito da obra correspondem ao projecto público identificado']],
public_water:[['potableWater','Fornecimento de água potável'],['publicNetwork','Fornecimento através de rede pública'],['publicFixedPrice','Preço fixado pela Autoridade Pública identificada na referência aplicável']],
} as const;
export const publicWorkCategories=[{id:'roads',label:'Estradas'},{id:'bridges',label:'Pontes'},{id:'water_infrastructure',label:'Infra-estruturas de abastecimento de água/saneamento15(9)a'},{id:'rural_electricity',label:'Electrificação rural em projectos públicos15(9)b'},{id:'agricultural_hydraulics',label:'Hidráulica agrícola no âmbito definido15(9)c'}] as const;
export function ownReducedConditions(treatment:string):ReadonlyArray<readonly[string,string]>{return reducedOwnConditions[treatment as keyof typeof reducedOwnConditions]??[];}
type Choice=''|'yes'|'no';type Condition=typeof reducedBaseConditions[number][0];
export type ReducedBaseRow={id:string;treatment:string;description:string;period:string;origin:'projected'|'effective';currency:string;operationDate:string;priceRef:string;priceScope:string;priceFrom:string;priceTo:string;documentRef:string;documentDate:string;consideration:string;projectRef:string;workCategory:string;compositionRef:string;evidence:string;conditions:Partial<Record<string,Choice>>};
type LineResult={id:string;status:'determined'|'not_applicable'|'indeterminate';baseAmount:string|null;deductionProportion:string|null;vat:null;rate:null;reason:string};
export type ReducedBaseCalculation={version:string;revision:number;calculatedAt:string;opportunity:string;location:string;sourceKey:string;source:string;inputs:ReducedBaseRow[];rows:LineResult[]};
export type ReducedBaseBook={rows:ReducedBaseRow[];result?:ReducedBaseCalculation;history?:ReducedBaseCalculation[]};
type Store=ReturnType<typeof readAcquisitionStore>&{regulatedReducedBases?:Record<string,ReducedBaseBook>};
export function newReducedBase(id:string,currency:string):ReducedBaseRow{return{id,treatment:'',description:'',period:'',origin:'projected',currency,operationDate:'',priceRef:'',priceScope:'',priceFrom:'',priceTo:'',documentRef:'',documentDate:'',consideration:'',projectRef:'',workCategory:'',compositionRef:'',evidence:'',conditions:{}};}
export function switchReducedTreatment(row:ReducedBaseRow,treatment:string):ReducedBaseRow{return{...row,treatment,conditions:Object.fromEntries(reducedBaseConditions.map(([key])=>[key,row.conditions[key]??'']))};}
function readStore(draft:Record<string,unknown>):Store{
const store=readAcquisitionStore(draft.modelo_a_snapshot) as Store;
if(store.regulatedReducedBases!==undefined&&(!store.regulatedReducedBases||typeof store.regulatedReducedBases!=='object'||Array.isArray(store.regulatedReducedBases)))throw new Error('Bases reduzidas não recuperáveis;originais preservados.');
const fields=Object.keys(newReducedBase('','')).filter(key=>key!=='conditions');
for(const book of Object.values(store.regulatedReducedBases??{}))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 ReducedBaseRow]!=='string')||!row.conditions||typeof row.conditions!=='object'||Array.isArray(row.conditions)))throw new Error('Linhas de bases reduzidas não recuperáveis;originais preservados,sem substituição por lista vazia.');
return store;
}
export function reducedBaseBook(draft:Record<string,unknown>):ReducedBaseBook{return readStore(draft).regulatedReducedBases?.[acquisitionScope(draft)]??{rows:[]};}
export function saveReducedBaseBook(draft:Record<string,unknown>,book:ReducedBaseBook):string{const current=readStore(draft),encoded=JSON.stringify({...current,regulatedReducedBases:{...current.regulatedReducedBases,[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/100000000n}.${(n%100000000n).toString().padStart(8,'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 evaluateReducedBase(row:ReducedBaseRow,currency:unknown):LineResult{
const result:LineResult={id:row.id,status:'indeterminate',baseAmount:null,deductionProportion:null,vat:null,rate:null,reason:''};const stop=(reason:string,status:LineResult['status']='indeterminate')=>({...result,status,reason});
const rule=reducedBaseTreatments.find(t=>t.id===row.treatment);if(!rule)return stop('Identifique energia,taxas aeronáuticas,obras públicas ou água;sem percentagem por defeito.');
if(currency!=='MZN'||row.currency!==currency)return stop('Moeda fora do âmbitoMZN 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ês2026 e dados efectivos ou pressupostos expressos de projecção.');
const conditions=[...reducedBaseConditions,...ownReducedConditions(row.treatment)];if(conditions.some(([key])=>row.conditions[key]==='no'))return stop('Este tratamento de base reduzida não se aplica aos factos declarados;rever natureza/composição/âmbito,sem isenção ou IVAzero presumido.','not_applicable');
if(conditions.some(([key])=>row.conditions[key]!=='yes'))return stop('Condições por esclarecer;desconhecido não é não aplicável ou zero. Confirme o montante total antes da redução,sem próprioIVA.');
if(!row.description.trim()||!row.documentRef.trim()||!row.compositionRef.trim()||!row.evidence.trim()||!row.priceScope.trim()||!validDate(row.documentDate)||!validDate(row.operationDate)||row.operationDate.slice(0,7)!==row.period)return stop('Identifique operação,data/mês,documento/data,âmbito e composição ou pressupostos expressos;reutilize referências sem certificado/aprovador novo.');
if(row.treatment==='public_works'){
if(!row.projectRef.trim()||!publicWorkCategories.some(c=>c.id===row.workCategory))return stop('Identifique projecto/contrato público,categoria eâmbito da obra15(2)l/15(9);não aplicar a qualquer obra privada.');
}else if(!row.priceRef.trim()||!validDate(row.priceFrom)||row.operationDate<row.priceFrom||(row.priceTo!==''&&(!validDate(row.priceTo)||row.priceFrom>row.priceTo||row.operationDate>row.priceTo)))return stop('Identifique referência aplicável do preço/taxa e intervalo que abrange a operação;fim pode ficar vazio sem termo fixado,com correspondência temporal confirmada. Sem preço actual presumido para outro período.');
const amount=decimal(row.consideration);if(amount===null)return stop('Indique contraprestação total antes da redução,sem próprioIVA;desconhecido/inválido não é zero. Se só existe total comIVA,separe a composição no percurso aplicável antes de usar este campo.');
const base=money(amount*BigInt(rule.percent));
return{...result,status:'determined',baseAmount:base,deductionProportion:String(rule.percent)+'%',reason:`${rule.branch}:15(2)${rule.clause},base=${row.consideration}×${rule.percent}%=${base}MZN,${row.origin==='projected'?'sob pressupostos expressos':'nos dados efectivos declarados'}. ${row.treatment==='public_works'?'Redução de60% significa base40%,não base60%.':'Percentagem aplicada uma única vez ao montante total antes da redução,sem próprioIVA.'} Art18(6),Lei10/2025:dedução relativa a estas operações na mesma proporção(${rule.percent}%),sujeita às condições próprias;isto não calcula nem concede dedução/campo11. Base não é imposto;sem taxaIVA,IVA ou totaisModeloA. Decimal exacto,sem arredondamento fiscal presumido.`};
}
function key(draft:Record<string,unknown>,book:ReducedBaseBook):string{return JSON.stringify({version:reducedBaseVersion,scope:acquisitionScope(draft),location:draft.selection_labels??{},currency:draft.currency,rows:book.rows});}
export function reducedBaseCurrent(draft:Record<string,unknown>,book:ReducedBaseBook):boolean{return!!book.result&&book.result.sourceKey===key(draft,book);}
export function recordReducedBases(draft:Record<string,unknown>,book:ReducedBaseBook):ReducedBaseBook{
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:ReducedBaseCalculation={version:reducedBaseVersion,revision:(book.result?.revision??0)+1,calculatedAt:new Date().toISOString(),opportunity:acquisitionScope(draft),location:JSON.stringify(draft.selection_labels??{}),sourceKey:key(draft,book),source:reducedBaseSource,inputs:structuredClone(book.rows),rows:book.rows.map(row=>evaluateReducedBase(row,draft.currency))};
const next={...book,result,history:[...(book.history??[]),...(book.result?[book.result]:[])]};saveReducedBaseBook(draft,next);return next;
}
export function reducedBaseReportRows(draft:Record<string,unknown>):Array<[string,string,string,string]>{
try{const book=reducedBaseBook(draft),result=book.result;if(!result)return[];return result.inputs.map((row,index)=>['IVA mensal',`Base ${reducedBaseTreatments.find(t=>t.id===row.treatment)?.branch??'reduzida'} — ${row.period} — ${row.id}`,`${row.description};base tributável: ${result.rows[index].baseAmount??'Indeterminada'} ${row.currency};IVA: Indeterminado;proporção18(6):${result.rows[index].deductionProportion??'Indeterminada'}(sem dedução calculada);${result.rows[index].reason}`,`${result.opportunity};${result.location};${reducedBaseCurrent(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.operationDate};âmbito ${row.priceScope};${row.treatment==='public_works'?`projecto/contrato ${row.projectRef};categoria ${row.workCategory}`:`preço/taxa ${row.priceRef};intervalo ${row.priceFrom} a ${row.priceTo||'sem termo fixado,correspondência confirmada'}`};contraprestação total antes redução ${row.consideration} ${row.currency};composição ${row.compositionRef};documento ${row.documentRef},${row.documentDate};condições ${[...reducedBaseConditions,...ownReducedConditions(row.treatment)].map(([key,label])=>label+': '+(row.conditions[key]==='yes'?'Sim':row.conditions[key]==='no'?'Não':'Por indicar')).join('; ')};fundamento ${row.evidence};sem dedução/campo11/totaisModeloA.`]);}
catch{return[['IVA mensal','Bases reduzidas','Dados não recuperáveis','Originais preservados;sem substituir por zero.']];}
}
+2
View File
@@ -17,6 +17,7 @@ import { serviceNormalReportRows } from './iva-service-normal.ts';
import { goodsLocationReportRows } from './iva-goods-location.ts';
import { serviceLocationReportRows } from './iva-service-location.ts';
import { listedServiceReportRows } from './iva-listed-services.ts';
import { reducedBaseReportRows } from './iva-reduced-bases.ts';
import { offerPreparationReportRows } from './iva-offer-preparations.ts';
import { salesReportRows } from './iva-operations.ts';
import { fiscalQaRows } from './fiscal-qa-preview.ts';
@@ -644,6 +645,7 @@ export function buildProjectDossierRows(
rows.push(...goodsLocationReportRows(draft));
rows.push(...serviceLocationReportRows(draft));
rows.push(...listedServiceReportRows(draft));
rows.push(...reducedBaseReportRows(draft));
rows.push(...offerPreparationReportRows(draft));
const operationRows = salesReportRows(draft);
rows.push(...operationRows);
@@ -0,0 +1,49 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Tests\TestCase;
final class IvaReducedBasesDraftTest extends TestCase
{
use DatabaseTransactions;
public function test_reduced_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-g7-reduced.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:351', $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['regulatedReducedBases']['opportunity:351'];
$this->assertCount(4, $book['rows']);
$this->assertSame(['regulated_energy','aeronautical_fees','public_works','public_water'],array_column($book['rows'],'treatment'));
$this->assertSame(['620.155','850.2125','400.10','750.1875'],array_column($book['result']['rows'],'baseAmount'));
$this->assertSame(['62%','85%','40%','75%'],array_column($book['result']['rows'],'deductionProportion'));
foreach ($book['result']['rows'] as $row) {
$this->assertNull($row['vat']);$this->assertNull($row['rate']);
}
$this->assertSame($book['rows'], $book['result']['inputs']);
$this->assertCount(1, $book['history']);
$this->assertSame('620.155',$book['history'][0]['rows'][0]['baseAmount']);
$frozen = json_decode(file_get_contents(base_path('tests/frontend/fixtures/iva-g7-reduced-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, ['regulatedReducedBases' => 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-g7-reduced-bases-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
+28
View File
@@ -0,0 +1,28 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import {newReducedBase,switchReducedTreatment,reducedBaseConditions,ownReducedConditions,reducedBaseTreatments,publicWorkCategories,evaluateReducedBase,recordReducedBases,saveReducedBaseBook,reducedBaseBook,reducedBaseCurrent,reducedBaseReportRows} from '../../resources/js/lib/iva-reduced-bases.ts';
const frozen=JSON.parse(fs.readFileSync('tests/frontend/fixtures/iva-g7-reduced-preserved.json','utf8'));const draft={...frozen,currency:'MZN',context_opportunity:'opportunity:351',project_title:'SYNTHETIC QA — G7 bases reduzidas / Manica'};
function row(t){const rule=reducedBaseTreatments.find(x=>x.id===t);return{...newReducedBase(t,'MZN'),treatment:t,description:'SYNTHETIC QA '+rule.branch,period:'2026-12',origin:'projected',operationDate:'2026-12-12',documentRef:'SYNTHETIC QA documento/pressuposto',documentDate:'2026-12-12',priceScope:'SYNTHETIC QA objecto/operação/local/período',priceRef:'SYNTHETIC QA preço/taxa aplicável',priceFrom:'2026-01-01',priceTo:'2026-12-31',consideration:'1000.25',compositionRef:'SYNTHETIC QA total antes redução,semIVA,15(1)/(5)/(6)',evidence:'SYNTHETIC QA pressupostos expressos',projectRef:'SYNTHETIC QA projecto público/contrato',workCategory:'roads',conditions:Object.fromEntries([...reducedBaseConditions,...ownReducedConditions(t)].map(([key])=>[key,'yes']))};}
const expected=['620.155','850.2125','400.10','750.1875'];
for(const [i,rule] of reducedBaseTreatments.entries())test(rule.branch+' ownconditions,exact base and separate deduction proportion',()=>{
const r=row(rule.id),result=evaluateReducedBase(r,'MZN');assert.equal(result.status,'determined');assert.equal(result.baseAmount,expected[i]);assert.equal(result.deductionProportion,rule.percent+'%');assert.equal(result.vat,null);assert.equal(result.rate,null);
for(const [key] of [...reducedBaseConditions,...ownReducedConditions(rule.id)]){const missing=structuredClone(r);delete missing.conditions[key];assert.equal(evaluateReducedBase(missing,'MZN').status,'indeterminate');missing.conditions[key]='no';assert.equal(evaluateReducedBase(missing,'MZN').status,'not_applicable');assert.equal(evaluateReducedBase(missing,'MZN').baseAmount,null);assert.equal(evaluateReducedBase(missing,'MZN').deductionProportion,null);}
for(const field of ['description','documentRef','priceScope','compositionRef','evidence','consideration'])assert.equal(evaluateReducedBase({...r,[field]:''},'MZN').status,'indeterminate',field);
for(const patch of [{operationDate:'2026-11-12'},{documentDate:'2026-02-30'},{period:'2025-12'},{currency:'USD'},{consideration:'1,5'},{consideration:'-1'},{consideration:'1e3'}])assert.equal(evaluateReducedBase({...r,...patch},'MZN').status,'indeterminate');assert.equal(evaluateReducedBase(r,'USD').status,'indeterminate');
assert.equal(evaluateReducedBase({...r,consideration:'0'},'MZN').baseAmount,'0.00');
if(rule.id==='public_works'){assert.equal(evaluateReducedBase({...r,priceRef:'',priceFrom:'',priceTo:''},'MZN').baseAmount,expected[i]);for(const c of publicWorkCategories)assert.equal(evaluateReducedBase({...r,workCategory:c.id},'MZN').status,'determined');assert.equal(evaluateReducedBase({...r,workCategory:'private_work'},'MZN').status,'indeterminate');assert.equal(evaluateReducedBase({...r,projectRef:''},'MZN').status,'indeterminate');assert.match(result.reason,/base40%/);}
else{for(const patch of [{priceRef:''},{priceFrom:''},{priceFrom:'2027-01-01'},{priceTo:'2026-11-30'},{priceTo:'2025-12-31'}])assert.equal(evaluateReducedBase({...r,...patch},'MZN').status,'indeterminate');assert.equal(evaluateReducedBase({...r,priceTo:''},'MZN').status,'determined');}
});
test('Exactfraction retained,no 60percent works base,no duplicate reduction and no automatic deduction',()=>{
assert.equal(evaluateReducedBase({...row('regulated_energy'),consideration:'0.000001'},'MZN').baseAmount,'0.00000062');assert.equal(evaluateReducedBase({...row('public_works'),consideration:'0.000001'},'MZN').baseAmount,'0.0000004');
assert.equal(evaluateReducedBase({...row('public_water'),consideration:'999999999999999.999999'},'MZN').baseAmount,'749999999999999.99999925');
const reduced=row('public_works');reduced.conditions.fullNetInvoice='no';assert.equal(evaluateReducedBase(reduced,'MZN').baseAmount,null);
const changed=switchReducedTreatment(row('regulated_energy'),'public_works');assert.equal(changed.priceRef,row('regulated_energy').priceRef);assert.equal(changed.projectRef,row('regulated_energy').projectRef);assert.equal(changed.conditions.publicWorks,undefined);assert.equal(evaluateReducedBase(changed,'MZN').status,'indeterminate');
});
test('Four branches save/recover/history/report and all previous books are preserved',()=>{
const rows=reducedBaseTreatments.map(t=>row(t.id));let book=recordReducedBases(draft,{rows});book=recordReducedBases(draft,book);assert.equal(book.history.length,1);assert.notEqual(book.result.inputs,book.rows);
const next={...draft,modelo_a_snapshot:saveReducedBaseBook(draft,book)};assert.deepEqual(reducedBaseBook(next),book);assert.ok(reducedBaseCurrent(next,book));assert.equal(reducedBaseCurrent({...next,currency:'USD'},book),false);assert.equal(reducedBaseCurrent({...next,context_opportunity:'other'},book),false);assert.equal(reducedBaseReportRows(next).length,4);for(const t of reducedBaseTreatments)assert.ok(reducedBaseReportRows(next).some(r=>r[1].includes(t.branch)));
const store=JSON.parse(next.modelo_a_snapshot);delete store.regulatedReducedBases;assert.deepEqual(store,JSON.parse(frozen.modelo_a_snapshot));assert.throws(()=>recordReducedBases(next,{rows:[rows[0],rows[0]]}));assert.throws(()=>reducedBaseBook({...next,modelo_a_snapshot:JSON.stringify({...JSON.parse(next.modelo_a_snapshot),regulatedReducedBases:[]})}));
if(process.env.IVA_REDUCED_FIXTURE)fs.writeFileSync(process.env.IVA_REDUCED_FIXTURE,JSON.stringify(next,null,2)+'\n');
});