feat(iva): prepare P089 service normal values with distinct own conditions in QA

This commit is contained in:
2026-10-24 23:19:00 +02:00
parent 33cc971a12
commit 90b31c677f
8 changed files with 186 additions and 0 deletions
@@ -14,6 +14,7 @@ import { SimulationNoncashBase } from './simulation-noncash-base';
import { SimulationCompositionBase } from './simulation-composition-base';
import { SimulationExclusionBase } from './simulation-exclusion-base';
import { SimulationValueReview } from './simulation-value-review';
import { SimulationServiceNormal } from './simulation-service-normal';
import { SimulationOfferPreparations } from './simulation-offer-preparations';
import { SimulationOperations } from './simulation-operations';
@@ -106,6 +107,7 @@ export function SimulationMonthlyIva({draft, busy, onCalculate, onChange}: {
<SimulationCompositionBase draft={draft} onChange={onChange} />
<SimulationExclusionBase draft={draft} onChange={onChange} />
<SimulationValueReview draft={draft} onChange={onChange} />
<SimulationServiceNormal draft={draft} onChange={onChange} />
<SimulationOfferPreparations draft={draft} onChange={onChange} />
<SimulationModeloA draft={draft} />
</section>;
@@ -0,0 +1,28 @@
import {useState} from 'react';
import {newServiceNormal,switchServiceTreatment,serviceNormalBook,serviceNormalConditions,ownServiceConditions,serviceNormalCurrent,serviceNormalReportRows,serviceNormalSource,serviceNormalTreatments,recordServiceNormals,saveServiceNormalBook,type ServiceNormalBook,type ServiceNormalRow} from '@/lib/iva-service-normal';
export function SimulationServiceNormal({draft,onChange}:{draft:Record<string,unknown>;onChange:(field:string,value:string)=>void}){
const[error,setError]=useState('');let book:ServiceNormalBook;
try{book=serviceNormalBook(draft);}catch(cause){return<p role="alert">{String(cause)}</p>;}
const save=(next:ServiceNormalBook)=>{try{onChange('modelo_a_snapshot',saveServiceNormalBook(draft,next));setError('');}catch(cause){setError(String(cause));}};
const change=(id:string,patch:Partial<ServiceNormalRow>)=>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 — serviços gratuitos e utilização de bens da empresa</summary>
<p>Oportunidade: {String(draft.context_opportunity||'entrada geral')}. Identifique o serviço e a finalidade abrangida pelo artigo4(2). A base é o valor normal do serviço,com fundamento e âmbito identificados;nenhum custo ou preço de aquisição dos bens é usado como substituto automático.</p>
<p>O serviço gratuito da alínea a)não exige a dedução anterior prevista na alínea b). A utilização de bens exige identificar os bens/elementos e a dedução total/parcial anterior,sem a calcular ou conceder aqui. Utilização não é transmissão permanente de bens. Base e IVA são resultados distintos.</p>
<p>{serviceNormalSource}</p>{error&&<p role="alert">{error}</p>}
<button type="button" className="underline" onClick={()=>save({...book,rows:[...book.rows,newServiceNormal(crypto.randomUUID(),String(draft.currency??''))]})}>Acrescentar serviço ou utilização</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 do serviço ou utilização<select className="block rounded border p-2" value={row.treatment} onChange={e=>change(row.id,switchServiceTreatment(row,e.target.value))}><option value="">Por indicar</option>{serviceNormalTreatments.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 ServiceNormalRow['origin']})}><option value="projected">Projectados / pressupostos</option><option value="effective">Efectivos declarados</option></select></label>
{([['description','Serviço e operação identificados'],['operationRef','Referência da operação / pressuposto'],['purposeRef','Finalidade e destinatário / sector isento'],['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'],['normalValue','Valor normal do serviço sem IVA (moeda indicada)'],['valuationRef','Fundamento do valor normal / pressuposto'],['valuationScope','Âmbito do valor normal: serviço, estádio, tempo, lugar e condições'],['compositionRef','Referência da composição sem próprio IVA'],['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>)}
{row.treatment&&row.treatment!=='free_company_service'&&<><label>Bens da empresa / elementos constitutivos identificados<input className="block rounded border p-2" value={row.assetRef} onChange={e=>change(row.id,{assetRef:e.target.value})}/></label><label>Referência da dedução anterior total ou parcial / pressuposto<input className="block rounded border p-2" value={row.deductionRef} onChange={e=>change(row.id,{deductionRef:e.target.value})}/></label></>}
</div>
<p>Mudar o tratamento conserva os valores e documentos,mas exige rever as condições próprias. Os campos sem aplicação ficam ocultos e conservados.</p>
<fieldset><legend>Condições próprias deste percurso</legend>{[...serviceNormalConditions,...ownServiceConditions(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(recordServiceNormals(draft,book));}catch(cause){setError(String(cause));}}}>Determinar valores normais e guardar versão</button>}
{book.result&&<><p role="status">{serviceNormalCurrent(draft,book)?'Valores normais correspondentes às entradas guardadas.':'Entradas alteradas;valores normais históricos conservados. Reveja e determine novamente.'} {book.history?.length??0} versões anteriores. IVA e campo11 não são preenchidos por estas bases.</p><dl>{serviceNormalReportRows(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>;
}
+61
View File
@@ -0,0 +1,61 @@
import {acquisitionScope,readAcquisitionStore} from './iva-acquisitions.ts';
export const serviceNormalVersion='assimilated-service-normal/1';
export const serviceNormalSource='CIVA republicado Lei13/2016,15(2)(c),PDF13/p1426(271):operações4(2),base=valor normal do serviço definido15(4),PDF14/p1426(272).4(2)(a),PDF7/p1426(265):serviços gratuitos pela própria empresa para necessidades particulares do titular/pessoal ou fins alheios;4(2)(b):utilização de bens da empresa para uso próprio/titular/pessoal/fins alheios ou sectores isentos,quando houve dedução total/parcial nesses bens ou elementos constitutivos. A condição de dedução anterior é de4(2)(b),não de4(2)(a). Composição15(4)/(5)/(6),sem próprioIVA. Lei22/2022mantém15c/4por remissão,não altera4;Lei10/2025não altera4/15. Recorte2026/MZN;base apenas,sem calcularIVA/dedução/campo11/regularizações/totaisModeloA,nem confundir uso com transmissão permanenteP088.';
export const serviceNormalTreatments=[{id:'free_company_service',label:'Serviço gratuito pela própria empresa para necessidades particulares ou fins alheios'},{id:'private_company_asset_use',label:'Utilização de bens da empresa para uso próprio,particular ou fins alheios'},{id:'exempt_sector_asset_use',label:'Utilização de bens da empresa em sector de actividade isento'}] as const;
export const serviceNormalConditions=[
['domesticOperation','A operação e o âmbito territorial aplicável estão identificados para esta base'],
['normalValueBasis','O valor normal do serviço corresponde15(4):concorrência normal,fornecedor independente,estádio de comercialização,tempo/lugar da operação ou mais próximos,com fundamento e âmbito identificados'],
['netComposition','O montante exclui o próprio IVA e reflecte15(4)/(5)/(6),sem omissões ou dupla contagem'],
] as const;
export const serviceOwnConditions={
free_company_service:[['freeByCompany','A prestação é gratuita e efectuada pela própria empresa'],['nonBusinessPurpose','Destina-se a necessidades particulares do titular/pessoal ou,em geral,a fins alheios à empresa']],
private_company_asset_use:[['companyAssetUse','É utilização de bens da empresa,não afectação/transmissão permanente de bens'],['privatePurpose','Destina-se a uso próprio do titular/pessoal ou,em geral,a fins alheios à empresa'],['priorDeduction','Houve dedução total ou parcial relativamente aos bens ou aos elementos que os constituem']],
exempt_sector_asset_use:[['companyAssetUse','É utilização de bens da empresa,não afectação/transmissão permanente de bens'],['exemptSector','A utilização corresponde a sector de actividade isento identificado'],['priorDeduction','Houve dedução total ou parcial relativamente aos bens ou aos elementos que os constituem']],
} as const;
export function ownServiceConditions(treatment:string):ReadonlyArray<readonly[string,string]>{return serviceOwnConditions[treatment as keyof typeof serviceOwnConditions]??[];}
type Choice=''|'yes'|'no';type Condition=typeof serviceNormalConditions[number][0];
export type ServiceNormalRow={id:string;treatment:string;description:string;period:string;origin:'projected'|'effective';currency:string;operationRef:string;purposeRef:string;assetRef:string;deductionRef:string;documentRef:string;documentDate:string;normalValue:string;valuationRef:string;valuationScope:string;compositionRef:string;evidence:string;conditions:Partial<Record<string,Choice>>};
type LineResult={id:string;status:'determined'|'not_applicable'|'indeterminate';baseAmount:string|null;vat:null;rate:null;reason:string};
export type ServiceNormalCalculation={version:string;revision:number;calculatedAt:string;opportunity:string;location:string;sourceKey:string;source:string;inputs:ServiceNormalRow[];rows:LineResult[]};
export type ServiceNormalBook={rows:ServiceNormalRow[];result?:ServiceNormalCalculation;history?:ServiceNormalCalculation[]};
type Store=ReturnType<typeof readAcquisitionStore>&{serviceNormals?:Record<string,ServiceNormalBook>};
export function newServiceNormal(id:string,currency:string):ServiceNormalRow{return{id,treatment:'',description:'',period:'',origin:'projected',currency,operationRef:'',purposeRef:'',assetRef:'',deductionRef:'',documentRef:'',documentDate:'',normalValue:'',valuationRef:'',valuationScope:'',compositionRef:'',evidence:'',conditions:{}};}
export function switchServiceTreatment(row:ServiceNormalRow,treatment:string):ServiceNormalRow{return{...row,treatment,conditions:Object.fromEntries(serviceNormalConditions.map(([key])=>[key,row.conditions[key]??'']))};}
function readStore(draft:Record<string,unknown>):Store{
const store=readAcquisitionStore(draft.modelo_a_snapshot) as Store;
if(store.serviceNormals!==undefined&&(!store.serviceNormals||typeof store.serviceNormals!=='object'||Array.isArray(store.serviceNormals)))throw new Error('Bases de contraprestação não recuperáveis;originais preservados.');
const fields=Object.keys(newServiceNormal('','')).filter(key=>key!=='conditions');
for(const book of Object.values(store.serviceNormals??{}))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 ServiceNormalRow]!=='string')||!row.conditions||typeof row.conditions!=='object'||Array.isArray(row.conditions)))throw new Error('Linhas de contraprestação não recuperáveis;originais preservados,sem substituição por lista vazia.');
return store;
}
export function serviceNormalBook(draft:Record<string,unknown>):ServiceNormalBook{return readStore(draft).serviceNormals?.[acquisitionScope(draft)]??{rows:[]};}
export function saveServiceNormalBook(draft:Record<string,unknown>,book:ServiceNormalBook):string{const current=readStore(draft),encoded=JSON.stringify({...current,serviceNormals:{...current.serviceNormals,[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 evaluateServiceNormal(row:ServiceNormalRow,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});
const rule=serviceNormalTreatments.find(t=>t.id===row.treatment);if(!rule)return stop('Identifique serviço gratuito,utilização particular/alheia ou em sector isento;desconhecido não permite presumir aplicação.');
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 expressos de projecção.');
const required=[...serviceNormalConditions,...ownServiceConditions(row.treatment)];const failed=required.filter(([key])=>row.conditions[key]==='no');if(failed.length)return stop('Não se aplica esta base4(2)/15(2)(c): '+failed.map(([,label])=>label).join('; ')+'. Não é isenção nem IVA zero.','not_applicable');
if(required.some(([key])=>row.conditions[key]!=='yes'))return stop('Confirme as condições próprias deste percurso;desconhecido não é zero,não aplicável ou dedução presumida.');
if(!row.description.trim()||!row.operationRef.trim()||!row.purposeRef.trim()||!row.documentRef.trim()||!validDate(row.documentDate)||!row.valuationRef.trim()||!row.valuationScope.trim()||!row.compositionRef.trim()||!row.evidence.trim())return stop('Identifique serviço/operação,finalidade,documento/data,fundamento e âmbito do valor normal e composição,ou pressupostos expressos;reutilize dados existentes,sem certificado/avaliador/aprovador novo.');
if(row.treatment!=='free_company_service'&&(!row.assetRef.trim()||!row.deductionRef.trim()))return stop('Identifique os bens/elementos e a referência da dedução anterior total/parcial,ou pressupostos expressos;esta referência não calcula nem concede dedução/campo11.');
const normal=decimal(row.normalValue);if(normal===null)return stop('Indique o valor normal do serviço;desconhecido não é zero nem custo/preço de aquisição dos bens presumido.');
return{...result,status:'determined',baseAmount:money(normal),reason:`Base15(2)(c),${rule.label}:valor normal do serviço ${money(normal)}MZN,${row.origin==='projected'?'nas condições expressamente assumidas na projecção':'nos dados efectivos declarados'}. ${row.treatment==='free_company_service'?'4(2)(a),sem impor a condição de dedução anterior da alínea b).':'4(2)(b),bens/elementos e dedução anterior total/parcial referenciados;uso não é transmissão permanente.'} Valor normal com fundamento/âmbito,sem avaliação automática. Base não é imposto;IVA/taxa,dedução/campo11,regularizações e totaisModeloA não determinados por este cálculo.`};
}
function key(draft:Record<string,unknown>,book:ServiceNormalBook):string{return JSON.stringify({version:serviceNormalVersion,scope:acquisitionScope(draft),location:draft.selection_labels??{},currency:draft.currency,rows:book.rows});}
export function serviceNormalCurrent(draft:Record<string,unknown>,book:ServiceNormalBook):boolean{return!!book.result&&book.result.sourceKey===key(draft,book);}
export function recordServiceNormals(draft:Record<string,unknown>,book:ServiceNormalBook):ServiceNormalBook{
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:ServiceNormalCalculation={version:serviceNormalVersion,revision:(book.result?.revision??0)+1,calculatedAt:new Date().toISOString(),opportunity:acquisitionScope(draft),location:JSON.stringify(draft.selection_labels??{}),sourceKey:key(draft,book),source:serviceNormalSource,inputs:structuredClone(book.rows),rows:book.rows.map(row=>evaluateServiceNormal(row,draft.currency))};
const next={...book,result,history:[...(book.history??[]),...(book.result?[book.result]:[])]};saveServiceNormalBook(draft,next);return next;
}
export function serviceNormalReportRows(draft:Record<string,unknown>):Array<[string,string,string,string]>{
try{const book=serviceNormalBook(draft),result=book.result;if(!result)return[];return result.inputs.map((row,index)=>['IVA mensal',`Base P089 — ${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};${serviceNormalCurrent(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'};${serviceNormalTreatments.find(t=>t.id===row.treatment)?.label??'Percurso por indicar'};operação ${row.operationRef};finalidade ${row.purposeRef};${row.treatment==='free_company_service'?'Dedução anterior não é condição de4(2)(a)':`bens/elementos ${row.assetRef},dedução anterior ${row.deductionRef}`};valor normal ${row.normalValue} ${row.currency};fundamento ${row.valuationRef};âmbito ${row.valuationScope};composição ${row.compositionRef};documento ${row.documentRef},${row.documentDate};condições ${[...serviceNormalConditions,...ownServiceConditions(row.treatment)].map(([key,label])=>label+': '+(row.conditions[key]==='yes'?'Sim':row.conditions[key]==='no'?'Não':'Por indicar')).join('; ')};fundamento factual ${row.evidence};não preenche totaisModeloA ou campo11.`]);}
catch{return[['IVA mensal','Valor normal de serviços assimilados','Dados não recuperáveis','Originais preservados;sem substituir por zero.']];}
}
+2
View File
@@ -13,6 +13,7 @@ import { noncashBaseReportRows } from './iva-noncash-base.ts';
import { compositionReportRows } from './iva-composition-base.ts';
import { exclusionReportRows } from './iva-exclusion-base.ts';
import { valueReviewReportRows } from './iva-value-review.ts';
import { serviceNormalReportRows } from './iva-service-normal.ts';
import { offerPreparationReportRows } from './iva-offer-preparations.ts';
import { salesReportRows } from './iva-operations.ts';
import { fiscalQaRows } from './fiscal-qa-preview.ts';
@@ -636,6 +637,7 @@ export function buildProjectDossierRows(
rows.push(...compositionReportRows(draft));
rows.push(...exclusionReportRows(draft));
rows.push(...valueReviewReportRows(draft));
rows.push(...serviceNormalReportRows(draft));
rows.push(...offerPreparationReportRows(draft));
const operationRows = salesReportRows(draft);
rows.push(...operationRows);
@@ -0,0 +1,56 @@
<?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 IvaServiceNormalDraftTest extends TestCase
{
use DatabaseTransactions;
public function test_service_normals_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-g7i-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:347', $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['serviceNormals']['opportunity:347'];
$this->assertCount(3, $book['rows']);
$this->assertSame(['free_company_service','private_company_asset_use','exempt_sector_asset_use'], array_column($book['rows'], 'treatment'));
$this->assertSame(['500.25','700.50','900.75'], array_column($book['result']['rows'], 'baseAmount'));
$this->assertSame(['determined','determined','determined'], array_column($book['result']['rows'], 'status'));
$this->assertSame([null,null,null], array_column($book['result']['rows'], 'vat'));
$this->assertSame([null,null,null], array_column($book['result']['rows'], 'rate'));
$this->assertArrayNotHasKey('priorDeduction', $book['rows'][0]['conditions']);
$this->assertSame('yes', $book['rows'][1]['conditions']['priorDeduction']);
$this->assertSame('yes', $book['rows'][2]['conditions']['exemptSector']);
$this->assertStringContainsString('bens/elementos A', $book['rows'][1]['assetRef']);
$this->assertStringContainsString('dedução anterior parcial A', $book['rows'][2]['deductionRef']);
$this->assertStringContainsString('finalidade/destinatário', $book['rows'][0]['purposeRef']);
$this->assertStringContainsString('estádio/tempo/lugar', $book['rows'][2]['valuationScope']);
$this->assertStringContainsString('15(2)(c)', $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-g7i-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, ['serviceNormals' => 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-g7i-service-normal-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,35 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import {newServiceNormal,switchServiceTreatment,serviceNormalTreatments,serviceNormalConditions,ownServiceConditions,evaluateServiceNormal,recordServiceNormals,saveServiceNormalBook,serviceNormalBook,serviceNormalCurrent,serviceNormalReportRows} from '../../resources/js/lib/iva-service-normal.ts';
import {valueReviewCurrent} from '../../resources/js/lib/iva-value-review.ts';
import {exclusionCurrent} from '../../resources/js/lib/iva-exclusion-base.ts';
import {createProjectDossierReport} from '../../resources/js/lib/project-dossier-report.ts';
const frozen=JSON.parse(fs.readFileSync(new URL('./fixtures/iva-g7i-preserved-inputs.json',import.meta.url),'utf8'));
const row=(treatment='free_company_service')=>({...newServiceNormal('g7i-'+treatment,'MZN'),treatment,description:'SYNTHETIC QA serviço A',period:'2026-12',operationRef:'SYNTHETIC QA operação A',purposeRef:'SYNTHETIC QA finalidade/destinatário ou sector isento A',assetRef:'SYNTHETIC QA bens/elementos A',deductionRef:'SYNTHETIC QA referência dedução anterior parcial A',documentRef:'SYNTHETIC QA G7I '+treatment,documentDate:'2026-12-12',normalValue:({free_company_service:'500.25',private_company_asset_use:'700.50',exempt_sector_asset_use:'900.75'})[treatment],valuationRef:'SYNTHETIC QA valor normal serviço A15(4)',valuationScope:'SYNTHETIC QA serviço A,estádio/tempo/lugar/concorrência/fornecedor independente,dezembro2026Manica',compositionRef:'SYNTHETIC QA composição15(4)/(5)/(6),sem próprioIVA',evidence:'SYNTHETIC QA pressupostos expressos,não documento real',conditions:Object.fromEntries([...serviceNormalConditions,...ownServiceConditions(treatment)].map(([key])=>[key,'yes']))});
const draft=()=>({...structuredClone(frozen),context_opportunity:'opportunity:347',project_title:'SYNTHETIC QA — G7I / Valor normal de serviços'});
test('P089 three treatments ownconditions and normalvalue references;prior deduction applies only to assetuse',()=>{
for(const t of serviceNormalTreatments){const input=row(t.id);assert.equal(evaluateServiceNormal(input,'MZN').baseAmount,input.normalValue);assert.equal(evaluateServiceNormal(input,'MZN').vat,null);assert.equal(evaluateServiceNormal(input,'MZN').rate,null);
for(const[key]of [...serviceNormalConditions,...ownServiceConditions(t.id)])for(const value of ['','unknown','no']){const r=evaluateServiceNormal({...input,conditions:{...input.conditions,[key]:value}},'MZN');assert.equal(r.status,value==='no'?'not_applicable':'indeterminate');assert.equal(r.baseAmount,null);}
for(const patch of [{description:''},{operationRef:''},{purposeRef:''},{documentRef:''},{documentDate:'2026-02-30'},{valuationRef:''},{valuationScope:''},{compositionRef:''},{evidence:''},{period:'2025-12'},{currency:'USD'},{treatment:''},{normalValue:''}])assert.equal(evaluateServiceNormal({...input,...patch},'MZN').baseAmount,null);
if(t.id!=='free_company_service')for(const patch of [{assetRef:''},{deductionRef:''}])assert.equal(evaluateServiceNormal({...input,...patch},'MZN').baseAmount,null);
}
const free=row();assert.equal(evaluateServiceNormal({...free,assetRef:'',deductionRef:'',conditions:{...free.conditions,priorDeduction:'no'}},'MZN').baseAmount,'500.25');
});
test('normalvalue only,no assetcost/substitution;exactdecimal/zero and switch preserves hiddenassetrefs but clears ownconditions',()=>{
const input=row('private_company_asset_use');for(const normalValue of ['0','1.000002','999999999999999.999999'])assert.equal(evaluateServiceNormal({...input,normalValue},'MZN').baseAmount,normalValue==='0'?'0.00':normalValue);for(const normalValue of ['-1','NaN','0.0000001'])assert.equal(evaluateServiceNormal({...input,normalValue},'MZN').baseAmount,null);
const changed=switchServiceTreatment(input,'free_company_service');assert.equal(changed.conditions.freeByCompany,undefined);assert.equal(changed.conditions.priorDeduction,undefined);for(const field of ['assetRef','deductionRef','normalValue','valuationRef','valuationScope','purposeRef'])assert.equal(changed[field],input[field]);assert.equal(evaluateServiceNormal(changed,'MZN').baseAmount,null);
assert.equal(evaluateServiceNormal({...changed,conditions:{...changed.conditions,freeByCompany:'yes',nonBusinessPurpose:'yes',priorDeduction:'no'}},'MZN').baseAmount,'700.50');
});
test('save/recovery/history/report preserve all86 delivered books/currentkeys including P103/P102/P101/P095',()=>{
const d=draft(),prior=JSON.parse(d.modelo_a_snapshot);assert.deepEqual(serviceNormalBook(d),{rows:[]});assert.deepEqual(serviceNormalReportRows(d),[]);
let book=recordServiceNormals(d,{rows:serviceNormalTreatments.map(t=>({...row(t.id),conditions:{}}))});book=recordServiceNormals(d,{...book,rows:serviceNormalTreatments.map(t=>({...row(t.id),conditions:{...row(t.id).conditions,domesticOperation:'no'}}))});book=recordServiceNormals(d,{...book,rows:serviceNormalTreatments.map(t=>row(t.id))});assert.equal(book.history.length,2);assert.ok(book.history.every(h=>h.rows.every(r=>r.baseAmount===null&&r.vat===null)));assert.ok(serviceNormalCurrent(d,book));
for(const field of ['treatment','normalValue','valuationScope','deductionRef','assetRef','purposeRef'])assert.ok(!serviceNormalCurrent(d,{...book,rows:book.rows.map(r=>({...r,[field]:'changed'}))}));for(const patch of [{context_opportunity:'opportunity:348'},{currency:'USD'},{selection_labels:{province_id:'Maputo'}}])assert.ok(!serviceNormalCurrent({...d,...patch},book));
d.modelo_a_snapshot=saveServiceNormalBook(d,book);assert.deepEqual(serviceNormalBook(JSON.parse(JSON.stringify(d))),book);const next=JSON.parse(d.modelo_a_snapshot);const{serviceNormals,...untouched}=next;assert.deepEqual(untouched,prior);for(const[scope,previous]of Object.entries(prior.valueReviews))assert.ok(valueReviewCurrent({...d,context_opportunity:scope},previous));for(const[scope,previous]of Object.entries(prior.considerationExclusions))assert.ok(exclusionCurrent({...d,context_opportunity:scope},previous));
assert.equal(next.valueReviews['opportunity:346'].result.rows[1].baseAmount,'1400.75');assert.equal(next.considerationExclusions['opportunity:345'].result.rows[0].baseAmount,'799.70');assert.equal(next.regulatedFuelBases['opportunity:342'].result.rows[0].baseAmount,'1400.15');assert.equal(next.books['opportunity:329'].result.periods[0].fields['11'],null);
const report=JSON.stringify(createProjectDossierReport(d,{selectedSections:new Set(['finance']),version:'QA G7I'}));for(const token of ['Base P089','500.25','700.50','900.75','IVA: Indeterminado','opportunity:347','Manica','2026-12','15(2)(c)','4(2)(a)','dedução anterior parcial A','campo11'])assert.ok(report.includes(token),token);assert.deepEqual(serviceNormalReportRows({...d,context_opportunity:'opportunity:348'}),[]);
if(process.env.IVA_G7I_CALCULATED)fs.writeFileSync(process.env.IVA_G7I_CALCULATED,JSON.stringify(d));
});
test('corrupt service rows/store cannot overwrite originals;duplicateoperationids cannotbe recorded',()=>{
const d=draft(),original=d.modelo_a_snapshot;for(const patch of [{normalValue:4},{conditions:[]},{deductionRef:null}])assert.throws(()=>saveServiceNormalBook(d,{rows:[{...row(),...patch}]}),/originais preservados/);assert.equal(d.modelo_a_snapshot,original);assert.throws(()=>recordServiceNormals(d,{rows:[row(),row()]}),/distintos/);assert.throws(()=>recordServiceNormals(d,{rows:[]}),/distintos/);assert.throws(()=>serviceNormalBook({...d,modelo_a_snapshot:JSON.stringify({...JSON.parse(original),serviceNormals:[]})}),/originais preservados/);
});