Implement Tempo FX selection and offer request decision workflow in QA

This commit is contained in:
2026-10-25 10:54:51 +02:00
parent 8b3ec2b04d
commit 9536eb710b
27 changed files with 406 additions and 21 deletions
@@ -0,0 +1,70 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Jobs\ScanDocumentForMalware;
use App\Services\SimulationFormDrafts;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class SimulationOfferDocumentController extends Controller
{
public function store(Request $request, string $id, SimulationFormDrafts $drafts)
{
abort_unless(Str::isUuid($id), 404);
$drafts->find($id, $request);
// Same private document infrastructure and technical file limit as investment attachments.
$request->validate(['file' => ['required', 'file', 'mimetypes:application/pdf,text/plain,image/png,image/jpeg', 'max:10240']]);
$file = $request->file('file');
$hash = hash_file('sha256', $file->getRealPath());
$existing = DB::table('documents')->where('source_system', 'simulation-fiscal-offer')->where('external_id', $id)->where('sha256', $hash)->whereNull('deleted_at')->first();
if ($existing) {
return response()->json(['data' => $this->metadata($id, $existing)], 200)->header('Cache-Control', 'private, no-store');
}
$documentId = (string) Str::uuid();
$key = $file->storeAs('simulation-offers/'.$id, $documentId, 'local');
abort_unless(is_string($key), 500);
try {
DB::table('documents')->insert(['id' => $documentId, 'source_system' => 'simulation-fiscal-offer', 'external_id' => $id,
'file_name' => basename($file->getClientOriginalName()), 'media_type' => $file->getMimeType(), 'storage_key' => $key,
'sha256' => $hash, 'size_bytes' => $file->getSize(), 'uploaded_by' => $request->user()->id,
'status' => 'quarantined', 'data_classification' => 'confidential']);
} catch (\Throwable $e) {
Storage::disk('local')->delete($key);
throw $e;
}
ScanDocumentForMalware::dispatch($documentId)->afterCommit();
return response()->json(['data' => $this->metadata($id, DB::table('documents')->where('id', $documentId)->first())], 201)->header('Cache-Control', 'private, no-store');
}
public function index(Request $request, string $id, SimulationFormDrafts $drafts)
{
abort_unless(Str::isUuid($id), 404);
$drafts->find($id, $request);
$documents = DB::table('documents')->where('source_system', 'simulation-fiscal-offer')->where('external_id', $id)->whereNull('deleted_at')->get();
return response()->json(['data' => $documents->map(fn ($doc) => $this->metadata($id, $doc))])->header('Cache-Control', 'private, no-store');
}
public function download(Request $request, string $id, string $document, SimulationFormDrafts $drafts)
{
abort_unless(Str::isUuid($id) && Str::isUuid($document), 404);
$drafts->find($id, $request);
$doc = DB::table('documents')->where('id', $document)->where('source_system', 'simulation-fiscal-offer')->where('external_id', $id)->whereNull('deleted_at')->first();
abort_unless($doc, 404);
abort_unless($doc->status === 'active' && $doc->malware_scan_status === 'clean' && hash_equals(trim($doc->sha256), (string) $doc->malware_scanned_sha256), 409, 'Anexo conservado; descarga disponível após análise antimalware concluída.');
$root = realpath(Storage::disk('local')->path('simulation-offers/'.$id));
$path = realpath(Storage::disk('local')->path($doc->storage_key));
abort_unless($root && $path && str_starts_with(strtolower($path), strtolower($root.DIRECTORY_SEPARATOR)) && is_file($path), 404);
abort_unless(hash_equals(trim($doc->sha256), hash_file('sha256', $path)), 409, 'Conteúdo do anexo diverge do hash registado.');
return response()->download($path, basename($doc->file_name), ['Cache-Control' => 'private, no-store', 'X-Content-Type-Options' => 'nosniff']);
}
private function metadata(string $id, object $doc): array
{
return ['id' => $doc->id, 'draftId' => $id, 'name' => $doc->file_name, 'sha256' => trim($doc->sha256),
'scan' => $doc->malware_scan_status, 'url' => '/api/v1/simulation-drafts/'.$id.'/offer-documents/'.$doc->id];
}
}
@@ -53,7 +53,7 @@ final class EnsureSimulationRecoveryCookie
}
}
return $response->withCookie(cookie(
$response->headers->setCookie(cookie(
self::NAME,
$forget ? '' : $token,
$minutes,
@@ -64,6 +64,7 @@ final class EnsureSimulationRecoveryCookie
false,
'lax',
));
return $response;
}
private function isExplicitDraftActivity(Request $request, Response $response): bool
@@ -1,10 +1,11 @@
import {SimulationFxSelection} from './simulation-fx-selection';
import {useState} from 'react';
import {currencyEventBook,currencyEventCurrent,currencyEventReportRows,currencyEventSource,currencyEventTreatments,newCurrencyEvent,recordCurrencyEvents,saveCurrencyEventBook,switchCurrencyEvent,type CurrencyEventBook,type CurrencyEventRow} from '@/lib/iva-currency-events';
export function SimulationCurrencyEvents({draft,onChange}:{draft:Record<string,unknown>;onChange:(field:string,value:string)=>void}){
const[error,setError]=useState('');let book:CurrencyEventBook;try{book=currencyEventBook(draft);}catch(e){return<p role="alert">{String(e)}</p>;}
const save=(next:CurrencyEventBook)=>{try{onChange('modelo_a_snapshot',saveCurrencyEventBook(draft,next));setError('');}catch(e){setError(String(e));}};
const change=(id:string,patch:Partial<CurrencyEventRow>)=>save({...book,rows:book.rows.map(r=>r.id===id?{...r,...patch}:r)});
return <details className="rounded border p-3"><summary>Moeda e datas do facto gerador/exigibilidade</summary><p>Facto gerador e exigibilidade são estados separados. Identifique a operação já preparada,sem repetir os seus cálculos. Conversão P104 é apenas cenário condicionado,enquanto a regra fiscal permanece por corresponder.</p><p>{currencyEventSource}</p>{error&&<p role="alert">{error}</p>}
return <details className="rounded border p-3"><summary>Moeda e datas do facto gerador/exigibilidade</summary><p>Facto gerador e exigibilidade são estados separados. Identifique a operação já preparada,sem repetir os seus cálculos. P104 permite a selecção operacional definida pela Tempo;os cenários antigos permanecem condicionados até selecção explícita,sem recálculo de históricos.</p><p>{currencyEventSource}</p>{error&&<p role="alert">{error}</p>}
<button type="button" onClick={()=>save({...book,rows:[...book.rows,newCurrencyEvent(crypto.randomUUID(),String(draft.currency??''))]})}>Acrescentar moeda/evento</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>
<label>Percurso de moeda/evento<select value={row.treatment} onChange={e=>change(row.id,switchCurrencyEvent(row,e.target.value))}><option value="">Por indicar</option>{currencyEventTreatments.map(t=><option key={t.id} value={t.id}>{t.label}</option>)}</select></label>
@@ -12,8 +13,9 @@ export function SimulationCurrencyEvents({draft,onChange}:{draft:Record<string,u
{([['description','Operação identificada'],['operationRef','Referência da operação existente'],['period','Mês do contexto'],['documentRef','Documento / referência da previsão'],['documentDate','Data do documento'],['scope','Âmbito e parcelas abrangidas'],['evidence','Factos ou pressupostos expressos'],['amount','Montante total do âmbito identificado'],['currency','Moeda do montante / destino da conversão']] as const).map(([field,label])=><label className="block" key={field}>{label}<input value={row[field]} type={field==='period'?'month':field==='documentDate'?'date':'text'} onChange={e=>change(row.id,{[field]:e.target.value})}/></label>)}
<label>Factos próprios correspondem ao percurso,operação e âmbito<select value={row.factsConfirmed} onChange={e=>change(row.id,{factsConfirmed:e.target.value as CurrencyEventRow['factsConfirmed']})}><option value="">Por esclarecer</option><option value="yes">Sim</option><option value="no">Não</option></select></label>
{row.treatment==='foreign_scenario'?<>
<SimulationFxSelection row={row} onChange={patch=>change(row.id,patch)}/>
{([['sourceCurrency','Moeda original'],['fxRate','Taxa de conversão indicada'],['fxDate','Data efectiva da taxa'],['fxSource','Fonte e exemplar da taxa'],['fxRuleRef','Regra/pressuposto da escolha da taxa e data']] as const).map(([field,label])=><label className="block" key={field}>{label}<input value={row[field]} type={field==='fxDate'?'date':'text'} onChange={e=>change(row.id,{[field]:e.target.value})}/></label>)}
<label>Direcção da taxa<select value={row.fxDirection} onChange={e=>change(row.id,{fxDirection:e.target.value as CurrencyEventRow['fxDirection']})}><option value="">Por indicar</option><option value="mzn_per_unit">MZN por unidade da moeda original</option><option value="units_per_mzn">Unidades da moeda original por MZN</option></select></label><p>O valor convertido é uma simulação condicionada. Não substitui a base fiscal,nem altera os valores/moedas dos livros anteriores.</p>
<label>Direcção da taxa<select value={row.fxDirection} onChange={e=>change(row.id,{fxDirection:e.target.value as CurrencyEventRow['fxDirection']})}><option value="">Por indicar</option><option value="mzn_per_unit">MZN por unidade da moeda original</option><option value="units_per_mzn">Unidades da moeda original por MZN</option></select></label><p>{row.fxSelection?.applied?'Conversão operacional após selecção explícita da taxa.':'Cenário histórico condicionado;selecção operacional ainda não aplicada.'} Não activa base fiscal,IVA ou dedução,nem altera os livros anteriores.</p>
</>:row.treatment&&<>
{([['eventDate','Data própria do evento escolhido'],['eventRef','Referência e factos do evento7/8'],['reviewDate','Data até à qual os eventos foram conferidos']] as const).filter(([field])=>field!=='eventDate'||!['advance_payment','consignment_deadline'].includes(row.treatment)).map(([field,label])=><label className="block" key={field}>{label}<input value={row[field]} type={field==='eventRef'?'text':'date'} onChange={e=>change(row.id,{[field]:e.target.value})}/></label>)}
{['consignment_deadline','service_early_charge','contract_effects'].includes(row.treatment)&&<label>{row.treatment==='consignment_deadline'?'Entrega ao destinatário da consignação':row.treatment==='service_early_charge'?'Data de realização do serviço posterior à cobrança/débito':'Disponibilização anterior aos efeitos translativos'}<input type="date" value={row.otherDate} onChange={e=>change(row.id,{otherDate:e.target.value})}/></label>}
@@ -34,6 +36,6 @@ export function SimulationCurrencyEvents({draft,onChange}:{draft:Record<string,u
</>}
<p>Trocar percurso conserva dados ocultos e referências;reveja as confirmações aplicáveis.</p></details>)}
{!!book.rows.length&&<button type="button" onClick={()=>{try{save(recordCurrencyEvents(draft,book));}catch(e){setError(String(e));}}}>Conferir moeda/eventos e guardar versão</button>}
{book.result&&<><p role="status">{currencyEventCurrent(draft,book)?'Resultados correspondentes às entradas guardadas.':'Entradas alteradas;histórico conservado.'} {book.history?.length??0} versões anteriores.P104condicionado,semIVA/base/dedução activados.</p><dl>{currencyEventReportRows(draft).map(([,label,value,origin],i)=><div key={i}><dt>{label}</dt><dd>{value}<small className="block">{origin}</small></dd></div>)}</dl></>}
{book.result&&<><p role="status">{currencyEventCurrent(draft,book)?'Resultados correspondentes às entradas guardadas.':'Entradas alteradas;histórico conservado.'} {book.history?.length??0} versões anteriores.P104 conserva a natureza operacional ou condicionada indicada em cada resultado;semIVA/base/dedução activados.</p><dl>{currencyEventReportRows(draft).map(([,label,value,origin],i)=><div key={i}><dt>{label}</dt><dd>{value}{label.startsWith('P104')?<details><summary>Fonte,âmbito e versão</summary><small className="block">{origin}</small></details>:<small className="block">{origin}</small>}</dd></div>)}</dl></>}
</details>;
}
@@ -0,0 +1,21 @@
import {useState} from 'react';
import type {CurrencyEventRow} from '@/lib/iva-currency-events';
import {emptyFxSelection,selectFx,fxTempoSource,type FxSelection} from '@/lib/iva-fx-selection';
const columnLabels:Record<string,string>={buy:'Compra',sell:'Venda',mean:'Média de referência BM',exception:'Excepção fiscal identificada'};
const directionLabels:Record<string,string>={mzn_per_unit:'MZN por unidade da moeda original',units_per_mzn:'unidades da moeda original por MZN'};
export function SimulationFxSelection({row,onChange}:{row:CurrencyEventRow;onChange:(patch:Partial<CurrencyEventRow>)=>void}){
const[error,setError]=useState(''),[preview,setPreview]=useState<ReturnType<typeof selectFx>|null>(null);const s=row.fxSelection??{...emptyFxSelection(),sourceCurrency:row.sourceCurrency,direction:row.fxDirection};
const edit=(patch:Partial<FxSelection>)=>{setPreview(null);onChange({fxSelection:{...s,...patch}});};
const text=(key:'submissionDate'|'bulletinDate'|'bulletinRef'|'source'|'sourceCurrency'|'bulletinPerspective'|'buy'|'sell'|'mean'|'exceptionRef'|'exceptionScope'|'exceptionDate'|'exceptionRate',label:string)=><label className="block" key={key}>{label}<input value={s[key]} type={key.endsWith('Date')?'date':'text'} onChange={e=>edit({[key]:e.target.value})}/></label>;
return<fieldset className="rounded border p-3"><legend>P104 — selecção da taxa segundo orientação da Tempo</legend><p>{fxTempoSource}</p>
<label>Natureza da operação declarada<select value={s.nature} onChange={e=>edit({nature:e.target.value as FxSelection['nature']})}><option value="">Por indicar</option><option value="purchase">Compra de moeda</option><option value="sale">Venda de moeda</option><option value="commercial">Operação comercial</option></select></label>
{text('submissionDate','Data de submissão indicada')}{text('sourceCurrency','Moeda original já declarada')}{text('bulletinRef','Boletim,edição e série de referência')}{text('source','Fonte do boletim;URL do Banco de Moçambique para operações comerciais')}{text('bulletinDate','Data do boletim')}<label>Emitente identificado do boletim,quando a fonte é um exemplar arquivado<input value={s.issuer??''} onChange={e=>edit({issuer:e.target.value})}/></label>
<label>Sentido da cotação<select value={s.direction} onChange={e=>edit({direction:e.target.value as FxSelection['direction']})}><option value="">Por indicar</option><option value="mzn_per_unit">MZN por unidade da moeda original</option><option value="units_per_mzn">Unidades da moeda original porMZN</option></select></label>
<button type="button" onClick={()=>edit({sourceCurrency:s.sourceCurrency||row.sourceCurrency,direction:s.direction||row.fxDirection,submissionDate:s.submissionDate||row.fxDate,bulletinDate:s.bulletinDate||row.fxDate,source:s.source||row.fxSource})}>Reutilizar moeda,sentido e data já indicados nos campos vazios;conferir a data de submissão</button>
<label>Regra aplicável<select value={s.mode} onChange={e=>edit({mode:e.target.value as FxSelection['mode']})}><option value="general">Procedimento geral da Tempo</option><option value="exception">Excepção fiscal específica identificada</option></select></label>
{s.mode==='exception'?<>{text('exceptionRef','Disposição fiscal que impõe outra data/taxa')}{text('exceptionScope','Âmbito da excepção')}{text('exceptionDate','Data imposta pela disposição')}{text('exceptionRate','Taxa da excepção,com fonte no boletim indicado')}</>:s.nature==='commercial'?text('mean','Coluna Média da série de referênciaBM;sem calcular média própria'):<>{text('bulletinPerspective','Perspectiva de compra/venda indicada no boletim')}
<label>Relação da natureza declarada com a perspectiva do boletim<select value={s.relation} onChange={e=>edit({relation:e.target.value as FxSelection['relation']})}><option value="">Por conferir</option><option value="same">Mesma perspectiva</option><option value="opposite">Perspectiva contrária:compra do cliente corresponde a venda do emitente,ou inversamente</option></select></label>{text('buy','Taxa da coluna Compra do boletim')}{text('sell','Taxa da coluna Venda do boletim')}</>}
{error&&<p role="alert">{error}</p>}<button type="button" onClick={()=>{try{setPreview(selectFx(s));setError('');}catch(e){setError(String(e));}}}>Conferir selecção e impacto</button>
{preview&&<div><p>Coluna:{columnLabels[preview.column]};taxa:{preview.rate};data:{preview.date};sentido:{directionLabels[s.direction]}. Confirmação substitui os campos cambiais desta linha:{row.fxRate||'taxa vazia'} → {preview.rate};{row.fxDate||'data vazia'} → {preview.date};{row.sourceCurrency||'moeda vazia'} → {s.sourceCurrency}. O resultado e os históricos permanecem até usar «Conferir moeda/eventos e guardar versão».</p><button type="button" onClick={()=>{onChange({fxSelection:{...s,applied:preview},sourceCurrency:s.sourceCurrency,fxDirection:s.direction,fxRate:preview.rate,fxDate:preview.date,fxSource:preview.source,fxRuleRef:s.mode==='exception'?s.exceptionRef:fxTempoSource});setPreview(null);}}>Confirmar selecção nesta linha</button></div>}
<p>Não usar taxa de apresentação nem a mais recente como substituto de boletim ausente. Compra/venda são interpretadas pela perspectiva indicada;relação desconhecida fica por esclarecer. Alterações não recalculam versões anteriores.</p></fieldset>;
}
@@ -1,5 +1,6 @@
import { SimulationObjectiveImports } from './simulation-objective-imports';
import { SimulationMiners } from './simulation-miners';
import { SimulationReturnees } from './simulation-returnees';
import { SimulationDigital } from './simulation-digital';
import { SimulationTravel } from './simulation-travel';
import { SimulationTravelEvents } from './simulation-travel-events';
@@ -117,6 +118,7 @@ export function SimulationMonthlyIva({draft, busy, onCalculate, onChange}: {
<SimulationImportExemptions draft={draft} onChange={onChange} />
<SimulationObjectiveImports draft={draft} onChange={onChange} />
<SimulationMiners draft={draft} onChange={onChange} />
<SimulationReturnees draft={draft} onChange={onChange} />
<SimulationPublicAuthorityBase draft={draft} onChange={onChange} />
<SimulationCommissionBase draft={draft} onChange={onChange} />
<SimulationActSaleBase draft={draft} onChange={onChange} />
@@ -1,3 +1,4 @@
import {SimulationOfferWorkflow} from './simulation-offer-workflow';
import { useState } from 'react';
import { newOfferPreparation,offerPreparationBook,offerPreparationSource,offerReusableData,recordOfferPreparations,reuseOfferData,saveOfferPreparationBook,emptyOfferRequest,recordOfferRequest,offerRequestLabels,offerScenarios,type OfferRequest,type OfferPreparation,type OfferPreparationBook } from '@/lib/iva-offer-preparations';
@@ -10,7 +11,7 @@ export function SimulationOfferPreparations({draft,onChange}:{draft:Record<strin
const question=(row:OfferPreparation,key:'nationalInstitution'|'publicSocialInstitution'|'goodsSuitable'|'publicInterestUse'|'mitigation',label:string)=><label className="block" key={key}>{label}<select className="block rounded border p-2" value={row[key]} onChange={e=>change(row.id,{[key]:e.target.value})}><option value="">Por indicar</option><option value="yes">Sim</option><option value="no">Não</option></select></label>;
return <details className="space-y-3 rounded border p-3"><summary>Ofertas a instituições e para calamidades — preparar rascunhos</summary>
<p>Prepare apenas as ofertas relevantes para a oportunidade {String(draft.context_opportunity||'entrada geral')}. Pode guardar dados incompletos e reutilizar referências existentes. A reutilização preenche apenas campos vazios; as suas edições mantêm-se.</p>
<p>O procedimento de aplicação do benefício está em pesquisa. Prepare o pedido e compare o cenário normal com uma hipótese condicionada à isenção. Apresentar o pedido não concede isenção. Estes cenários não alteram a dedução no campo11; os restantes enquadramentos continuam disponíveis.</p>
<p>Procedimento operacional indicado pela Tempo:prepare o pedido escrito,registe a apresentação externa,a decisão com despacho e o formulário aplicável. Antes do deferimento,compare o cenário normal com uma hipótese condicionada. Apresentar o pedido não concede isenção. Estes cenários não alteram a dedução no campo11; os restantes enquadramentos continuam disponíveis.</p>
{error&&<p role="alert">{error}</p>}
<button type="button" className="underline" onClick={()=>run(()=>save({...book,rows:[...book.rows,newOfferPreparation(crypto.randomUUID(),'P084',String(draft.currency??''))]}))}>Preparar oferta a instituição</button>{' '}
<button type="button" className="underline" onClick={()=>run(()=>save({...book,rows:[...book.rows,newOfferPreparation(crypto.randomUUID(),'P085',String(draft.currency??''))]}))}>Preparar oferta para calamidade</button>
@@ -24,7 +25,7 @@ export function SimulationOfferPreparations({draft,onChange}:{draft:Record<strin
{row.branch==='P085'&&<label>Finalidade humanitária da resposta à emergência<textarea className="block w-full rounded border p-2" value={row.humanitarianPurpose??''} onChange={e=>change(row.id,{humanitarianPurpose:e.target.value})}/></label>}
<label>Documentos disponíveis e respectivas referências<textarea className="block w-full rounded border p-2" value={row.availableDocuments??''} onChange={e=>change(row.id,{availableDocuments:e.target.value})}/></label>
{!!row.documentRef&&<button type="button" className="underline" onClick={()=>{const reference=[row.documentRef,row.documentDate].filter(Boolean).join(', ');if(!(row.availableDocuments??'').includes(reference))change(row.id,{availableDocuments:[row.availableDocuments,reference].filter(Boolean).join('\n')});}}>Juntar referência do documento já indicado</button>}
{row.branch==='P084'&&(()=>{const request=row.request??emptyOfferRequest();const edit=(patch:Partial<OfferRequest>)=>change(row.id,{request:{...request,...patch}});return <fieldset className="rounded border p-3"><legend>Pedido e âmbito da decisão</legend>
{(()=>{const request=row.request??emptyOfferRequest();const edit=(patch:Partial<OfferRequest>)=>change(row.id,{request:{...request,...patch}});return <fieldset className="rounded border p-3"><legend>Pedido e âmbito da decisão</legend>
<p>Registe o estado declarado e os elementos disponíveis. Este registo não apresenta um pedido à autoridade nem concede o benefício. Conserve o âmbito exacto de cada decisão, mesmo que cubra apenas parte dos bens ou períodos.</p>
<label>Estado declarado do pedido<select className="block rounded border p-2" value={request.state} onChange={e=>edit({state:e.target.value as OfferRequest['state']})}>{Object.entries(offerRequestLabels).map(([value,label])=><option key={value} value={value}>{label}</option>)}</select></label>
{(['reference','presentedOn','decisionRef','decisionOn','goods','periodFrom','periodTo','conditions'] as const).map((key,index)=><label className="block" key={key}>{['Referência do pedido','Data de apresentação, se disponível','Documento / referência da decisão, se disponível','Data da decisão, se disponível','Bens abrangidos pela decisão','Início do período abrangido','Fim do período abrangido','Condições, limites ou fundamento da decisão'][index]}<input className="block rounded border p-2" type={key==='presentedOn'||key==='decisionOn'?'date':'text'} placeholder={key==='periodFrom'||key==='periodTo'?'AAAA-MM ou AAAA-MM-DD, conforme a decisão':undefined} value={request[key]} onChange={e=>edit({[key]:e.target.value})}/></label>)}
@@ -35,13 +36,14 @@ export function SimulationOfferPreparations({draft,onChange}:{draft:Record<strin
<button type="button" className="rounded border p-2" onClick={()=>run(()=>save({...book,rows:book.rows.map(item=>item.id===row.id?recordOfferRequest(item):item)}))}>Registar estado declarado e âmbito</button>
{(row.requestRecords??[]).map((record,index)=><p key={index}>{offerRequestLabels[record.request.state]} — {record.recordedAt}; bens: {record.request.goods||'por indicar'}; operação: {record.request.operationScope||record.request.operation||'por indicar'}; período: {record.request.periodScope||`${record.request.periodFrom||'por indicar'} a ${record.request.periodTo||'por indicar'}`}; condições: {record.request.conditions||'por indicar'}; decisão: {record.request.decisionRef||'por indicar'}. Este âmbito não se estende automaticamente à restante operação ou oportunidade.</p>)}
</fieldset>;})()}
<SimulationOfferWorkflow row={row} draft={draft} onChange={patch=>change(row.id,patch)}/>
<fieldset className="rounded border p-3"><legend>Cenário normal e cenário condicionado à isenção</legend>
<label>IVA no cenário normal, indicado em documento ou pressuposto<input className="block rounded border p-2" inputMode="decimal" value={row.normalVat??''} onChange={e=>change(row.id,{normalVat:e.target.value})}/></label>
<label>Documento ou pressuposto do IVA normal<input className="block rounded border p-2" value={row.normalVatSource??''} onChange={e=>change(row.id,{normalVatSource:e.target.value})}/></label>
<label>Assumir, apenas para comparar, a hipótese condicionada de isenção<select className="block rounded border p-2" value={row.conditionalAssumption??''} onChange={e=>change(row.id,{conditionalAssumption:e.target.value as ''|'yes'|'no'})}><option value="">Por indicar</option><option value="yes">Sim, como hipótese condicionada</option><option value="no">Não</option></select></label>
{(()=>{const scenarios=offerScenarios(row);return <><table><thead><tr><th>Cenário</th><th>IVA ({row.currency})</th></tr></thead><tbody><tr><td>Normal — valor indicado, sem taxa presumida</td><td>{scenarios.normalVat??'Indeterminado'}</td></tr><tr><td>Condicionado à isenção — hipótese, não concessão</td><td>{scenarios.conditionalVat??'Indeterminado'}</td></tr></tbody></table><p>{scenarios.conditionalReason}</p><p>O cenário condicionado não altera imposto, dedução, totais ou o estado do pedido. O procedimento só será aplicado quando a correspondência regulamentar estiver demonstrada.</p></>;})()}
{(()=>{const scenarios=offerScenarios(row);return <><table><thead><tr><th>Cenário</th><th>IVA ({row.currency})</th></tr></thead><tbody><tr><td>Normal — valor indicado, sem taxa presumida</td><td>{scenarios.normalVat??'Indeterminado'}</td></tr><tr><td>Condicionado à isenção — hipótese, não concessão</td><td>{scenarios.conditionalVat??'Indeterminado'}</td></tr>{scenarios.applied&&<tr><td>Tratamento deferido apenas no âmbito do despacho</td><td>{scenarios.appliedVat}</td></tr>}</tbody></table><p>{scenarios.conditionalReason}</p><p>O cenário condicionado não altera imposto,dedução,totais ou o estado do pedido. O tratamento deferido exige despacho registado e correspondência expressa desta linha ao âmbito;alterar os dados exige nova conferência.</p></>;})()}
</fieldset>
</details>)}
{!!book.rows.length&&<><button type="button" className="rounded border p-2" onClick={()=>run(()=>save(recordOfferPreparations(book)))}>Guardar versão dos rascunhos de ofertas</button><p role="status">{book.saved?'Versão de preparação conservada; alterações seguintes não substituem essa versão.':'Preparação em curso; pode guardar mesmo com informação em falta.'} Benefício não aplicado. {book.history?.length??0} versões anteriores.</p></>}
{!!book.rows.length&&<><button type="button" className="rounded border p-2" onClick={()=>run(()=>save(recordOfferPreparations(book)))}>Guardar versão dos rascunhos de ofertas</button><p role="status">{book.saved?'Versão de preparação conservada; alterações seguintes não substituem essa versão.':'Preparação em curso; pode guardar mesmo com informação em falta.'} Estado e âmbito do tratamento indicados em cada linha. {book.history?.length??0} versões anteriores.</p></>}
</details>;
}
@@ -0,0 +1,35 @@
import {buildOfferPackage} from '@/lib/iva-offer-package';
import {useRef,useState} from 'react';
import {emptyOfferRequest,offerRequestLabels,type OfferPreparation} from '@/lib/iva-offer-preparations';
import {emptyOfferWorkflow,offerWrittenRequest,offerWorkflowSource,offerWorkflowApplied,recordOfferWorkflow,offerAttachmentValid,type OfferAttachment,type OfferWorkflow} from '@/lib/iva-offer-workflow';
export function SimulationOfferWorkflow({row,draft,onChange}:{row:OfferPreparation;draft:Record<string,unknown>;onChange:(patch:Partial<OfferPreparation>)=>void}){
const stageLabels:Record<string,string>={request:'Pedido em preparação',presented:'Pedido apresentado',decision:'Decisão recebida',form:'Formulário preparado'};const latest=useRef(row);latest.current=row;const[notice,setNotice]=useState('');const[error,setError]=useState(''),[busy,setBusy]=useState(false),[documents,setDocuments]=useState<OfferAttachment[]>([]),[draftId,setDraftId]=useState('');const w=row.workflow??emptyOfferWorkflow();
const edit=(patch:Partial<OfferWorkflow>)=>onChange({workflow:{...(latest.current.workflow??emptyOfferWorkflow()),...patch}});
const action=async(fn:()=>void|Promise<void>)=>{setBusy(true);try{await fn();setError('');}catch(e){setError(String(e));}finally{setBusy(false);}};
const current=async()=>{const response=await fetch('/api/v1/simulation-drafts/current',{credentials:'same-origin',cache:'no-store',headers:{Accept:'application/json'}});if(!response.ok)throw Error('Não foi possível localizar a gravação privada.');const data=(await response.json()).data;if(!data?.id)throw Error('Guarde primeiro o rascunho no portal para lhe associar anexos privados.');if(data.economic_snapshot?.context_opportunity!==draft.context_opportunity)throw Error('A gravação actual pertence a outro contexto;guarde este rascunho antes de anexar.');setDraftId(data.id);return data.id as string;};
const list=async()=>{const id=await current(),response=await fetch(`/api/v1/simulation-drafts/${id}/offer-documents`,{credentials:'same-origin',headers:{Accept:'application/json'}});if(response.status===401||response.status===403)throw Error('Autentique-se para anexar ou consultar documentos privados;os dados preparados mantêm-se.');if(!response.ok)throw Error('Não foi possível recuperar anexos privados.');setDocuments((await response.json()).data);};
const attach=async(file:File,field:'requestAttachment'|'decisionAttachment'|'formAttachment')=>{const id=await current(),data=new FormData();data.append('file',file);const csrf=document.querySelector<HTMLMetaElement>('meta[name="csrf-token"]')?.content;const xsrf=document.cookie.split('; ').find(c=>c.startsWith('XSRF-TOKEN='))?.slice(11);const response=await fetch(`/api/v1/simulation-drafts/${id}/offer-documents`,{method:'POST',credentials:'same-origin',headers:{Accept:'application/json',...(csrf?{'X-CSRF-TOKEN':csrf}:{}),...(xsrf?{'X-XSRF-TOKEN':decodeURIComponent(xsrf)}:{})},body:data});if(!response.ok)throw Error(response.status===401?'Autentique-se para guardar anexos privados.':'Anexo não confirmado;rascunho preservado. '+(await response.text()).slice(0,250));const doc=(await response.json()).data;if(!offerAttachmentValid(doc))throw Error('Confirmação do anexo inválida.');edit({[field]:doc});setDocuments(previous=>[...previous.filter(d=>d.id!==doc.id),doc]);};
const download=(blob:Blob,name:string)=>{const url=URL.createObjectURL(blob),a=document.createElement('a');a.href=url;a.download=name;a.click();setTimeout(()=>URL.revokeObjectURL(url),1000);};
const packageForm=async()=>{const result=await buildOfferPackage(latest.current,async(url)=>{const response=await fetch(url,{credentials:'same-origin'});if(!response.ok)throw Error('Anexo conservado;aguarde análise antimalware concluída antes de descarregar o conjunto.');return response.arrayBuffer();});download(new Blob([Uint8Array.from(result.bytes).buffer],{type:'application/zip'}),`${row.branch}-pedido-decisao-formulario.zip`);onChange({workflow:result.row.workflow});setNotice('Conjunto preparado com pedido,elementos do formulário e despacho original conferido porSHA256;descarga iniciada.Submissão à autoridade permanece externa.');};
const text=(field:'recipient'|'writtenRequest'|'decisionIssuer'|'formName'|'formSource'|'formContent'|'submissionReference',label:string)=><label className="block" key={field}>{label}<textarea className="block w-full rounded border p-2" value={w[field]} onChange={e=>edit({[field]:e.target.value})}/></label>;
const attachment=(field:'requestAttachment'|'decisionAttachment'|'formAttachment',label:string)=><fieldset><legend>{label}</legend><label>Reutilizar anexo privado<select value={w[field]?.id??''} onChange={e=>edit({[field]:documents.find(d=>d.id===e.target.value)})}><option value="">Escolher documento existente</option>{documents.map(d=><option key={d.id} value={d.id}>{d.name} — {d.scan}</option>)}</select></label><label>Juntar ficheiro privado<input type="file" accept="application/pdf,text/plain,image/png,image/jpeg" disabled={busy} onChange={e=>{const file=e.target.files?.[0];if(file)void action(()=>attach(file,field));e.target.value='';}}/></label>{w[field]&&<p>{w[field]!.name};SHA256:{w[field]!.sha256};estado da análise na associação:{w[field]!.scan}. Original privado conservado;nenhuma publicação automática.</p>}</fieldset>;
return<fieldset className="rounded border p-3"><legend>Pedido → decisão → formulário com despacho</legend><p>{offerWorkflowSource}</p><p>A submissão ao Ministério das Finanças/AT ocorre fora do InvesteMoz. Pode preparar e guardar informação incompleta. Guardar anexos privados reutiliza a autenticação e a análise antimalware existentes;PDF,texto,PNG/JPEG até10MB é o limite técnico de transferência,não condição do benefício.</p>{error&&<p role="alert">{error}</p>}{notice&&<p role="status">{notice}</p>}
{!row.request&&<button type="button" onClick={()=>onChange({request:emptyOfferRequest(),workflow:w})}>Iniciar acompanhamento do pedido</button>}
{text('recipient','Destinatário do pedido escrito')}{text('writtenRequest','Justificação e elementos adicionais do pedido')}
<button type="button" onClick={()=>download(new Blob([offerWrittenRequest(row,w)],{type:'text/plain;charset=utf-8'}),`${row.branch}-pedido.txt`)}>Preparar pedido escrito com os dados existentes</button>
<button type="button" disabled={busy} onClick={()=>void action(list)}>Recuperar anexos privados desta gravação</button>{draftId&&<p>Anexos associados à gravação privada actual.</p>}{attachment('requestAttachment','Pedido escrito e comprovativo de apresentação,quando disponíveis')}
<button type="button" disabled={busy} onClick={()=>void action(()=>onChange({workflow:recordOfferWorkflow(latest.current,'request').workflow}))}>Registar preparação do pedido</button>
<button type="button" disabled={busy} onClick={()=>void action(()=>onChange({workflow:recordOfferWorkflow(latest.current,'presented').workflow}))}>Registar pedido apresentado fora do portal</button>
{text('decisionIssuer','Entidade emitente do despacho recebido')}{attachment('decisionAttachment','Despacho recebido — original anexo')}
<label>Esta linha está abrangida pelos bens,operação,período e condições do despacho<select value={w.scopeConfirmed} onChange={e=>edit({scopeConfirmed:e.target.value as OfferWorkflow['scopeConfirmed'],scopeKey:undefined})}><option value="">Por conferir</option><option value="yes">Sim,apenas esta linha e âmbito indicado</option><option value="no">Não</option></select></label>
<button type="button" disabled={busy} onClick={()=>void action(()=>onChange({workflow:recordOfferWorkflow(latest.current,'decision').workflow}))}>Registar decisão recebida e âmbito desta linha</button><p role="status">{offerWorkflowApplied(row).reason}</p>
{text('formName','Formulário aplicável:designação/versão')}{text('formSource','Referência que demonstra a aplicabilidade deste formulário')}{text('formContent','Elementos para o formulário,com despacho de isenção anexo')}
<button type="button" onClick={()=>edit({formContent:w.formContent||`${offerWrittenRequest(row,w)}\nDespacho:${row.request?.decisionRef??''};${row.request?.decisionOn??''};emitente:${w.decisionIssuer}\nÂmbito:${row.request?.goods??''};${row.request?.operationScope||row.request?.operation||''};${row.request?.periodScope||`${row.request?.periodFrom??''} a ${row.request?.periodTo??''}`};${row.request?.conditions??''}\nAnexo:${w.decisionAttachment?.name??''};SHA256:${w.decisionAttachment?.sha256??''}`})}>Reutilizar dados e despacho nos campos vazios do formulário</button>
{attachment('formAttachment','Exemplar do formulário preenchido,quando disponível')}
<button type="button" disabled={busy} onClick={()=>void action(()=>onChange({workflow:recordOfferWorkflow(latest.current,'form').workflow}))}>Preparar formulário com despacho associado</button>
<button type="button" disabled={busy} onClick={()=>void action(packageForm)}>Descarregar conjunto do pedido,formulário e despacho</button>
{text('submissionReference','Referência da apresentação externa do formulário,se já ocorreu')}
<p>O ModeloI.1 não é seleccionado automaticamente para estes ramos. O conteúdo preparado só substitui o preenchimento do exemplar quando houver correspondência demonstrada;uma referência em falta não apaga a decisão recebida.</p>
{w.records.map((record,i)=><p key={i}>{record.at} — {stageLabels[record.stage]??record.stage} — {offerRequestLabels[record.request.state]};pedido:{record.request.reference};despacho:{record.request.decisionRef};bens:{record.request.goods};anexo:{record.workflow.decisionAttachment?.name||'não registado'}.</p>)}
</fieldset>;
}
@@ -0,0 +1,26 @@
import {useState} from 'react';
import {returneeBook,saveReturneeBook,newReturnee,returneeTreatments,returneeConditions,recordReturnees,returneeReportRows,returneeCurrent,returneeSource,type ReturneeBook,type ReturneeRow} from '@/lib/iva-returnees';
export function SimulationReturnees({draft,onChange}:{draft:Record<string,unknown>;onChange:(field:string,value:string)=>void}){
const[error,setError]=useState('');let book:ReturneeBook;try{book=returneeBook(draft);}catch(e){return<p role="alert">{String(e)}</p>;}
const save=(b:ReturneeBook)=>{try{onChange('modelo_a_snapshot',saveReturneeBook(draft,b));setError('');}catch(e){setError(String(e));}};
const change=(id:string,patch:Partial<ReturneeRow>)=>save({...book,rows:book.rows.map(r=>r.id===id?{...r,...patch}:r)});
return<details className="rounded border p-3"><summary>Regresso definitivo — veículos (P058, cobertura parcial)</summary>
<p>Prepare o veículo e simule o enquadramento do artigo63.º do Decreto37/2023. Isenção, redução de80% e redução excepcional de50% têm condições distintas. Estas percentagens são benefícios sobre as imposições; não são taxas deIVA. Não se aplicam aqui as condições dos mineirosP059.</p>
<p>Bagagem e outros bens continuam por concluir emP058. A remissão do artigo57(2)b ao próprio n.º2 está incoerente no exemplar e não foi corrigida por presunção. O caso de exactamente180dias de propriedade fica indeterminado. O marco da conferência do tempo de propriedade deve vir do enquadramento/documento indicado;sem escolher a chegada ou o despacho automaticamente. Aquisição interna, direitos,ICE,dedução e alteração de destino não são calculados neste incremento.</p>
{error&&<p role="alert">{error}</p>}<button type="button" onClick={()=>{const next=newReturnee(crypto.randomUUID(),String(draft.currency??''));const candidates=book.rows.filter(r=>r.beneficiary);if(new Set(candidates.map(r=>r.beneficiary)).size===1)next.beneficiary=candidates[0].beneficiary;save({...book,rows:[...book.rows,next]});}}>Acrescentar veículo no regresso</button>
{book.rows.map(row=>{const text=(field:Exclude<keyof ReturneeRow,'conditions'>,label:string,type='text')=><label key={field} className="block">{label}<input type={type} value={row[field]} onChange={e=>change(row.id,{[field]:e.target.value})}/></label>;const choice=(key:keyof ReturneeRow['conditions'],label:string)=><label key={key} className="block">{label}<select value={row.conditions[key]??''} onChange={e=>change(row.id,{conditions:{...row.conditions,[key]:e.target.value}})}><option value="">Por esclarecer</option><option value="yes">Sim,segundo factos ou pressupostos identificados</option><option value="no">Não</option></select></label>;
return<details key={row.id}><summary>{row.description||'Veículo por preparar'}</summary><label>Percurso do veículo<select value={row.treatment} onChange={e=>change(row.id,{treatment:e.target.value,conditions:Object.fromEntries(returneeConditions.map(([k])=>[k,row.conditions[k]]))})}><option value="">Escolher percurso</option>{returneeTreatments.map(t=><option key={t.id} value={t.id}>{t.label}</option>)}</select></label>
{text('description','Veículo identificado')}{text('beneficiary','Beneficiário')}{text('period','Período da importação','month')}{text('importDate','Data da importação ou previsão','date')}
<label>Natureza dos dados<select value={row.origin} onChange={e=>change(row.id,{origin:e.target.value as ReturneeRow['origin']})}><option value="projected">Pressupostos de projecção</option><option value="effective">Factos efectivos declarados</option></select></label>
{text('documentRef','Documentos disponíveis ou previsão')}{text('scope','Âmbito:bens,operação,período e condições')}{text('evidence','Factos ou pressupostos expressos')}{returneeConditions.map(([k,l])=>choice(k,l))}
{text('stayStart','Início da estadia no estrangeiro','date')}{text('stayEnd','Fim da estadia no estrangeiro','date')}{text('arrival','Chegada no regresso definitivo','date')}{text('request','Requerimento do benefício','date')}
{row.treatment!=='separate'&&<>{text('ownedSince','Início da propriedade no país de procedência','date')}{text('ownershipAssessment','Data à qual o documento confere o tempo de propriedade no país de procedência','date')}</>}
{choice('extension','Invoca prorrogação excepcional do prazo de60dias,até30dias adicionais?')}{row.conditions.extension==='yes'&&text('extensionRef','Prorrogação pelo Director-Geral das Alfândegas:referência,âmbito e prazo ou pressuposto expresso')}
{row.treatment==='separate'&&<>{choice('substitution','Substituição excepcional do benefício por veículo separado da bagagem?')}{text('decisionRef','Referência e âmbito da substituição ou pressuposto expresso;pedido não equivale a concessão')}{choice('exception','Invoca excepção por estadia insuficiente no estrangeiro?')}{row.conditions.exception==='yes'&&text('exceptionRef','Autorização do Ministro das Finanças:motivos e âmbito ou pressuposto expresso')}</>}
<label>Benefício anterior de veículo<select value={row.previous} onChange={e=>change(row.id,{previous:e.target.value as ReturneeRow['previous']})}><option value="">Por esclarecer</option><option value="none">Nenhum,segundo histórico identificado</option><option value="known">Sim,data conhecida</option></select></label>{row.previous==='known'&&text('previousDate','Data da numeração do despacho anterior','date')}{text('historyRef','Histórico de benefícios,incluindo fora deste dossier')}
{['recent','separate'].includes(row.treatment)&&<>{text('normalVat','IVA normal desta importação informado emMZN')}{text('normalVatRef','Referência do IVA normal ou pressuposto expresso')}</>}
<p>Mudar percurso conserva os campos ocultos e limpa confirmações específicas. A fonte exige documentos de titularidade e residência e declaração de uso;reutilize os documentos disponíveis. A aplicação não emite despacho nem concede o benefício.</p></details>;})}
{!!book.rows.length&&<button type="button" onClick={()=>{try{save(recordReturnees(draft,book));}catch(e){setError(String(e));}}}>Conferir veículos no regresso e guardar versão</button>}
{book.result&&<><p role="status">{returneeCurrent(draft,book)?'Resultado corresponde às entradas guardadas.':'Entradas alteradas;resultado histórico conservado.'} {book.history?.length??0} versões anteriores.Cobertura parcialP058.</p><dl>{returneeReportRows(draft).map(([,label,value,source],i)=><div key={i}><dt>{label}</dt><dd>{value}<small className="block">{source}</small></dd></div>)}</dl></>}
<details><summary>Fonte e âmbito documental</summary><p>{returneeSource}</p></details></details>;
}
@@ -4,8 +4,8 @@ export function SimulationTravelEvents({draft,onChange}:{draft:Record<string,unk
const[error,setError]=useState('');let book:TravelEventBook;try{book=travelEventBook(draft);}catch(e){return<p role="alert">{String(e)}</p>;}
const save=(next:TravelEventBook)=>{try{onChange('modelo_a_snapshot',saveTravelEventBook(draft,next));setError('');}catch(e){setError(String(e));}};
const change=(id:string,patch:Partial<TravelEventRow>)=>save({...book,rows:book.rows.map(r=>r.id===id?{...r,...patch}:r)});
const fields=(row:TravelEventRow,list:ReadonlyArray<readonly[keyof TravelEventRow,string]>)=>list.map(([key,label])=><label className="block" key={key}>{label}<input type={key==='period'?'month':String(key).endsWith('Date')||key==='legalDeadline'?'date':'text'} value={row[key]} onChange={e=>change(row.id,{[key]:e.target.value})}/></label>);
const select=(row:TravelEventRow,key:keyof TravelEventRow,label:string,options:ReadonlyArray<readonly[string,string]>)=><label>{label}<select value={row[key]} onChange={e=>change(row.id,{[key]:e.target.value})}><option value="">Por esclarecer</option>{options.map(([value,text])=><option key={value} value={value}>{text}</option>)}</select></label>;
const fields=(row:TravelEventRow,list:ReadonlyArray<readonly[Exclude<keyof TravelEventRow,'fxSelection'>,string]>)=>list.map(([key,label])=><label className="block" key={key}>{label}<input type={key==='period'?'month':String(key).endsWith('Date')||key==='legalDeadline'?'date':'text'} value={row[key]} onChange={e=>change(row.id,{[key]:e.target.value})}/></label>);
const select=(row:TravelEventRow,key:Exclude<keyof TravelEventRow,'fxSelection'>,label:string,options:ReadonlyArray<readonly[string,string]>)=><label>{label}<select value={row[key]} onChange={e=>change(row.id,{[key]:e.target.value})}><option value="">Por esclarecer</option>{options.map(([value,text])=><option key={value} value={value}>{text}</option>)}</select></label>;
const yn=[['yes','Sim'],['no','Não']] as const;
let sources:ReturnType<typeof travelEventSources>=[];try{sources=travelEventSources(draft);}catch{/* Origin reader preserves originals. */}
return<details className="rounded border p-3"><summary>Eventos de viagens e cessação de regimes suspensivos</summary><p>Conserve separados facto gerador e exigibilidade. Não inferir pagamento integral por um sinal, início pela emissão do bilhete ou cessação do regime por mudança de armazém. Estes eventos não calculam imposto nem concedem benefício.</p><p>{travelEventSource}</p>{error&&<p role="alert">{error}</p>}
+13 -4
View File
@@ -1,9 +1,10 @@
import {selectFx,fxSelectionKey,fxTempoSource,type FxSelection} from './iva-fx-selection.ts';
import {acquisitionScope,readAcquisitionStore} from './iva-acquisitions.ts';
import {convertCents as convertScaledAmount} from './business-financial-plan.ts';
export const currencyEventVersion='currency-events/1';
export const currencyEventSource='CIVA Lei13/2016,7(1)–(7),PDF8/p1426(266);8,PDF9/p1426(267);15(8),PDF14/p1426(272).Lei22/2022 eLei10/2025 não alteram7/8,15(8)mantido.RIVA21/2008PDF4:regra histórica5dias úteis,não prazo automático vigente;usar prazo e disposição aplicáveis identificados. P104:Lei2/2006 obtida,106(5)conferido;regra taxa/data ainda por demonstrar;conversão apenas cenário condicionado,sem base fiscal confirmada.';
export const currencyEventSource='CIVA Lei13/2016,7(1)–(7),PDF8/p1426(266);8,PDF9/p1426(267);15(8),PDF14/p1426(272).Lei22/2022 eLei10/2025 não alteram7/8,15(8)mantido.RIVA21/2008PDF4:regra histórica5dias úteis,não prazo automático vigente;usar prazo e disposição aplicáveis identificados. P104:orientação operacional da Tempo25/10/2026 compra/venda/médiaBM conforme natureza e perspectiva;data de submissão indicada;excepções fiscais identificadas. Cenários históricos permanecem condicionados;sem base/IVA/dedução automaticamente activados.';
export const currencyEventTreatments=[
{id:'foreign_scenario',branch:'P104',label:'Moeda estrangeira — cenário condicionado'},
{id:'foreign_scenario',branch:'P104',label:'Moeda estrangeira — selecção operacional / cenário histórico'},
{id:'goods_available',branch:'P131',label:'Bens postos à disposição7(1)a'},
{id:'goods_transport',branch:'P131',label:'Início do transporte7(2)'},
{id:'goods_installation',branch:'P131',label:'Instalação/montagem concluída7(2)'},
@@ -20,7 +21,7 @@ export const currencyEventTreatments=[
{id:'advance_payment',branch:'P132',label:'Pagamento anterior à factura8c'},
] as const;
type Choice=''|'yes'|'no';
export type CurrencyEventRow={id:string;treatment:string;description:string;period:string;origin:'projected'|'effective';operationRef:string;documentRef:string;documentDate:string;scope:string;evidence:string;eventDate:string;eventRef:string;otherDate:string;reviewDate:string;factsConfirmed:Choice;invoiceRequired:Choice;invoiceState:''|'issued'|'not_issued';invoiceDate:string;invoiceRef:string;legalDeadline:string;deadlineRef:string;paymentState:''|'none'|'received';paymentDate:string;paymentRef:string;amount:string;paymentAmount:string;currency:string;sourceCurrency:string;fxRate:string;fxDate:string;fxSource:string;fxRuleRef:string;fxDirection:''|'mzn_per_unit'|'units_per_mzn';};
export type CurrencyEventRow={fxSelection?:FxSelection;id:string;treatment:string;description:string;period:string;origin:'projected'|'effective';operationRef:string;documentRef:string;documentDate:string;scope:string;evidence:string;eventDate:string;eventRef:string;otherDate:string;reviewDate:string;factsConfirmed:Choice;invoiceRequired:Choice;invoiceState:''|'issued'|'not_issued';invoiceDate:string;invoiceRef:string;legalDeadline:string;deadlineRef:string;paymentState:''|'none'|'received';paymentDate:string;paymentRef:string;amount:string;paymentAmount:string;currency:string;sourceCurrency:string;fxRate:string;fxDate:string;fxSource:string;fxRuleRef:string;fxDirection:''|'mzn_per_unit'|'units_per_mzn';};
type DueEvent={date:string;amount:string|null;reason:string};
type LineResult={id:string;status:'determined'|'not_applicable'|'indeterminate'|'conditional';eventDate:string|null;dueEvents:DueEvent[];remainingPending:boolean;scenarioAmount:string|null;baseAmount:null;vat:null;rate:null;reason:string};
export type CurrencyEventCalculation={version:string;revision:number;calculatedAt:string;opportunity:string;location:string;sourceKey:string;source:string;inputs:CurrencyEventRow[];rows:LineResult[]};
@@ -47,6 +48,14 @@ export function evaluateCurrencyEvent(row:CurrencyEventRow):LineResult{
const amount=decimal(row.amount),fx=decimal(row.fxRate,8);
if(row.currency!=='MZN'||!['USD','EUR','ZAR','GBP','CNY','BRL','JPY','CHF','AUD','CAD'].includes(row.sourceCurrency)||amount===null||fx===null||fx===0n||date(row.fxDate)===null||![row.fxSource,row.fxRuleRef].every(v=>!!v.trim())||!['mzn_per_unit','units_per_mzn'].includes(row.fxDirection))return stop('Indique moeda original,valor,taxa positiva,direcção,data,fonte e regra/pressuposto expresso.Sem substituir moeda original porMZN ou reutilizar taxa de apresentação como regra fiscal.');
result.scenarioAmount=money(row.fxDirection==='mzn_per_unit'?convertScaledAmount(amount,fx,100000000n):(amount*100000000n+fx/2n)/fx);
if(row.fxSelection){
const converted=result.scenarioAmount;result.scenarioAmount=null;
try{const selected=selectFx(row.fxSelection),applied=row.fxSelection.applied;if(!applied||applied.selectionKey!==fxSelectionKey(row.fxSelection)||applied.rate!==selected.rate||applied.date!==selected.date||row.sourceCurrency!==row.fxSelection.sourceCurrency||row.fxDirection!==row.fxSelection.direction||row.fxRate!==selected.rate||row.fxDate!==selected.date||row.fxSource!==applied.source)return stop('Selecção cambial alterada ou não aplicada explicitamente;histórico preservado,sem recálculo automático.');
result.scenarioAmount=converted;
const columnLabel=({buy:'Compra',sell:'Venda',mean:'Média de referência BM',exception:'Excepção fiscal identificada'} as Record<string,string>)[selected.column];
return stop(`Conversão operacional segundo a orientação da Tempo de25/10/2026:${row.amount} ${row.sourceCurrency} → ${result.scenarioAmount} MZN. Coluna ${columnLabel};taxa ${row.fxRate};${row.fxDirection==='mzn_per_unit'?'MZN por unidade da moeda original':'unidades da moeda original por MZN'};data ${row.fxDate};boletim ${row.fxSelection.bulletinRef}. Sem cálculo de IVA ou dedução e sem alteração dos livros fiscais anteriores.`,'determined');
}catch(error){return stop(String(error));}
}
return stop(`P104:cenário condicionado=${result.scenarioAmount}MZN,origem${row.sourceCurrency},taxa${row.fxRate},${row.fxDirection},data${row.fxDate}. CotejoLei2/2006 concluido:106(5)nao define taxa/data;regra complementar15(8) ainda por demonstrar;referência indicada é pressuposto,não validação da taxa legal.Base fiscal permanece indeterminada.Sem IVA ou activação nos livros anteriores.`,`conditional`);
}
const review=date(row.reviewDate),event=date(row.eventDate);if(review===null||!row.eventRef.trim())return stop('Identifique data de conferência e documento/âmbito do evento específico.');
@@ -91,4 +100,4 @@ export function evaluateCurrencyEvent(row:CurrencyEventRow):LineResult{
function key(draft:Record<string,unknown>,book:CurrencyEventBook):string{return JSON.stringify({version:currencyEventVersion,scope:acquisitionScope(draft),location:draft.selection_labels??{},rows:book.rows});}
export function currencyEventCurrent(draft:Record<string,unknown>,book:CurrencyEventBook):boolean{return!!book.result&&book.result.sourceKey===key(draft,book);}
export function recordCurrencyEvents(draft:Record<string,unknown>,book:CurrencyEventBook):CurrencyEventBook{if(!book.rows.length||new Set(book.rows.map(r=>r.id)).size!==book.rows.length)throw new Error('Indique operações com identidades distintas;originais conservados.');const result:CurrencyEventCalculation={version:currencyEventVersion,revision:(book.result?.revision??0)+1,calculatedAt:new Date().toISOString(),opportunity:acquisitionScope(draft),location:JSON.stringify(draft.selection_labels??{}),sourceKey:key(draft,book),source:currencyEventSource,inputs:structuredClone(book.rows),rows:book.rows.map(evaluateCurrencyEvent)};const next={...book,result,history:[...(book.history??[]),...(book.result?[book.result]:[])]};saveCurrencyEventBook(draft,next);return next;}
export function currencyEventReportRows(draft:Record<string,unknown>):Array<[string,string,string,string]>{try{const book=currencyEventBook(draft),r=book.result;if(!r)return[];return r.inputs.map((row,i)=>['IVA mensal',`${currencyEventTreatments.find(t=>t.id===row.treatment)?.branch??'Evento'} — ${row.period} — ${row.id}`,`${row.description};${r.rows[i].reason};facto gerador:${r.rows[i].eventDate??'Indeterminado/não apurado'};parcelas:${r.rows[i].dueEvents.map(p=>p.date+': '+(p.amount??'Montante indeterminado')+' '+row.currency+' ('+p.reason+')').join('; ')||'Nenhuma apurada'};restante pendente:${r.rows[i].status==='indeterminate'?'Indeterminado':r.rows[i].remainingPending?'Sim':'Não'};cenário condicionado:${r.rows[i].scenarioAmount??'Indeterminado'};base/IVA:Indeterminados`,`${r.opportunity};${r.location};${currencyEventCurrent(draft,book)?'Resultado actual':'Entradas alteradas;resultado histórico conservado'};versão${r.revision};${r.calculatedAt};${r.source};${row.origin};documento ${row.documentRef},${row.documentDate};ambito ${row.scope};${row.treatment==='foreign_scenario'?`origem ${row.amount} ${row.sourceCurrency};taxa ${row.fxRate},${row.fxDirection},${row.fxDate};fonte ${row.fxSource};pressuposto ${row.fxRuleRef}`:`evento ${row.eventDate},${row.eventRef};outra data ${row.otherDate};conferencia ${row.reviewDate};factura ${row.invoiceRequired},${row.invoiceState},${row.invoiceDate},${row.invoiceRef};prazo ${row.legalDeadline},${row.deadlineRef};recebimento ${row.paymentState},${row.paymentDate},${row.paymentAmount},${row.paymentRef};total do ambito ${row.amount} ${row.currency}`};fundamento ${row.evidence};sem nova dedução/campo11/IVA ou activação cambial nos livros anteriores.`]);}catch{return[['IVA mensal','Moeda/eventos','Dados não recuperáveis','Originais preservados;sem substituição porzero.']];}}
export function currencyEventReportRows(draft:Record<string,unknown>):Array<[string,string,string,string]>{try{const book=currencyEventBook(draft),r=book.result;if(!r)return[];return r.inputs.map((row,i):[string,string,string,string]=>{if(row.treatment==='foreign_scenario'&&row.fxSelection)return['IVA mensal',`P104 — ${row.period} — ${row.id}`,`${row.description};${r.rows[i].reason};Original:${row.amount} ${row.sourceCurrency};Convertido:${r.rows[i].scenarioAmount??'Indeterminado'} MZN`,`${r.opportunity};${currencyEventCurrent(draft,book)?'Resultado actual':'Entradas alteradas;resultado histórico conservado'};versão${r.revision};${r.calculatedAt};documento:${row.documentRef},${row.documentDate};âmbito:${row.scope};boletim:${row.fxSelection.bulletinRef};fonte:${row.fxSelection.source};natureza:${row.fxSelection.nature};perspectiva:${row.fxSelection.bulletinPerspective};relação:${row.fxSelection.relation};taxa:${row.fxRate};data:${row.fxDate};sentido:${row.fxDirection};regra/excepção:${row.fxRuleRef};sem base/IVA/dedução activados.`];return['IVA mensal',`${currencyEventTreatments.find(t=>t.id===row.treatment)?.branch??'Evento'} — ${row.period} — ${row.id}`,`${row.description};${r.rows[i].reason};facto gerador:${r.rows[i].eventDate??'Indeterminado/não apurado'};parcelas:${r.rows[i].dueEvents.map(p=>p.date+': '+(p.amount??'Montante indeterminado')+' '+row.currency+' ('+p.reason+')').join('; ')||'Nenhuma apurada'};restante pendente:${r.rows[i].status==='indeterminate'?'Indeterminado':r.rows[i].remainingPending?'Sim':'Não'};${row.treatment==='foreign_scenario'&&r.rows[i].status==='determined'?'conversão operacional':'cenário condicionado'}:${r.rows[i].scenarioAmount??'Indeterminado'};base/IVA:Indeterminados`,`${r.opportunity};${r.location};${currencyEventCurrent(draft,book)?'Resultado actual':'Entradas alteradas;resultado histórico conservado'};versão${r.revision};${r.calculatedAt};${r.source};${row.origin};documento ${row.documentRef},${row.documentDate};ambito ${row.scope};${row.treatment==='foreign_scenario'?`origem ${row.amount} ${row.sourceCurrency};taxa ${row.fxRate},${row.fxDirection},${row.fxDate};fonte ${row.fxSource};pressuposto ${row.fxRuleRef}`:`evento ${row.eventDate},${row.eventRef};outra data ${row.otherDate};conferencia ${row.reviewDate};factura ${row.invoiceRequired},${row.invoiceState},${row.invoiceDate},${row.invoiceRef};prazo ${row.legalDeadline},${row.deadlineRef};recebimento ${row.paymentState},${row.paymentDate},${row.paymentAmount},${row.paymentRef};total do ambito ${row.amount} ${row.currency}`};fundamento ${row.evidence};sem nova dedução/campo11/IVA ou activação cambial nos livros anteriores.`];});}catch{return[['IVA mensal','Moeda/eventos','Dados não recuperáveis','Originais preservados;sem substituição porzero.']];}}
+26
View File
@@ -0,0 +1,26 @@
export const fxTempoSource='Orientação operacional expressa da Tempo,25/10/2026:compra de moeda→taxa de compra;venda→taxa de venda;operações comerciais→taxa média de referência do Banco de Moçambique na data de submissão indicada. Interpretar compra/venda pela perspectiva do boletim. Excepções fiscais específicas conservam disposição,data,taxa e âmbito;sem recálculo automático dos históricos.';
export type FxSelection={issuer?:string;nature:''|'purchase'|'sale'|'commercial';submissionDate:string;bulletinDate:string;bulletinRef:string;source:string;sourceCurrency:string;direction:''|'mzn_per_unit'|'units_per_mzn';bulletinPerspective:string;relation:''|'same'|'opposite';buy:string;sell:string;mean:string;mode:'general'|'exception';exceptionRef:string;exceptionScope:string;exceptionDate:string;exceptionRate:string;applied?:{column:string;rate:string;date:string;source:string;selectedAt:string;selectionKey:string}};
export function emptyFxSelection():FxSelection{return{nature:'',submissionDate:'',bulletinDate:'',bulletinRef:'',source:'',sourceCurrency:'',direction:'',bulletinPerspective:'',relation:'',buy:'',sell:'',mean:'',mode:'general',exceptionRef:'',exceptionScope:'',exceptionDate:'',exceptionRate:''};}
function date(s:string){const d=new Date(s+'T00:00:00Z');return /^\d{4}-\d{2}-\d{2}$/.test(s)&&Number.isFinite(d.getTime())&&d.toISOString().slice(0,10)===s;}
function positive(s:string){return /^\d{1,15}(\.\d{1,8})?$/.test(s)&&Number(s)>0;}
export function fxSelectionKey(s:FxSelection):string{const {applied,...inputs}=s;void applied;return JSON.stringify(inputs);}
export function selectFx(s:FxSelection):NonNullable<FxSelection['applied']>{
if(!['purchase','sale','commercial'].includes(s.nature)||!date(s.submissionDate)||!s.bulletinRef.trim()||!s.source.trim()||!s.sourceCurrency.trim()||!['mzn_per_unit','units_per_mzn'].includes(s.direction))throw Error('Indique natureza,data de submissão,moeda,direcção e boletim/fonte;sem usar taxa actual ouzero por defeito.');
let column='',rate='',day='';
if(s.mode==='exception'){
if(!s.exceptionRef.trim()||!s.exceptionScope.trim()||!date(s.exceptionDate)||!positive(s.exceptionRate))throw Error('Excepção exige disposição fiscal,âmbito,data e taxa identificados;os restantes percursos continuam disponíveis.');
column='exception';rate=s.exceptionRate;day=s.exceptionDate;
}else{
if(s.mode!=='general'||s.bulletinDate!==s.submissionDate||!date(s.bulletinDate))throw Error('Use boletim da data de submissão indicada;ausência de boletim não autoriza usar a taxa mais recente.');
if(s.nature==='commercial'){
let officialUrl=false;try{officialUrl=['bancomoc.mz','www.bancomoc.mz'].includes(new URL(s.source).hostname);}catch{/* An identified official local exemplar does not require another validation. */}if(!officialUrl&&s.issuer?.trim()!=='Banco de Moçambique')throw Error('Operação comercial usa taxa média de referência do Banco de Moçambique;identifique a origemBM do boletim arquivado ou a suaURL,sem exigir nova validação da fonte aceite.');
column='mean';rate=s.mean;
}else{
if(!s.bulletinPerspective.trim()||!['same','opposite'].includes(s.relation))throw Error('Identifique a perspectiva indicada no boletim e a relação com a natureza declarada;sem inverter a operação do cliente por presunção.');
column=(s.nature==='purchase')===(s.relation==='same')?'buy':'sell';rate=column==='buy'?s.buy:s.sell;
}
day=s.submissionDate;
}
if(!positive(rate))throw Error('Taxa da coluna aplicável ausente ou inválida;não calcular média a partir de compra/venda.');
return{column,rate,date:day,source:`${s.bulletinRef};${s.source};perspectiva:${s.bulletinPerspective||'média de referênciaBM'};natureza:${s.nature};relação:${s.relation||'não aplicável à média'};${s.mode==='exception'?s.exceptionRef+';'+s.exceptionScope:fxTempoSource}`,selectedAt:new Date().toISOString(),selectionKey:fxSelectionKey(s)};
}
+10
View File
@@ -0,0 +1,10 @@
import JSZip from 'jszip';
import type {OfferPreparation} from './iva-offer-preparations.ts';
import {recordOfferWorkflow,offerAttachmentValid,offerWrittenRequest} from './iva-offer-workflow.ts';
export async function buildOfferPackage(row:OfferPreparation,load:(url:string)=>Promise<ArrayBuffer>){
const item=recordOfferWorkflow(row,'form'),w=item.workflow!,zip=new JSZip();
for(const [folder,attachment]of [['despacho',w.decisionAttachment],['formulario',w.formAttachment]] as const){if(!attachment)continue;if(!offerAttachmentValid(attachment))throw Error('Referência de anexo inválida.');const bytes=await load(attachment.url),hash=Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256',bytes))).map(b=>b.toString(16).padStart(2,'0')).join('');if(hash!==attachment.sha256)throw Error('O anexo diverge do hash registado;conjunto não emitido.');zip.file(folder+'/'+attachment.name.replace(/[^\p{L}\p{N}._-]/gu,'_'),bytes);}
zip.file('pedido.txt',offerWrittenRequest(item,w));zip.file('preparacao-formulario.txt',`${w.formName}\nFonte/aplicabilidade:${w.formSource}\n${w.formContent}\nDespacho:${item.request?.decisionRef};SHA256:${w.decisionAttachment!.sha256}\nApresentação fora do InvesteMoz.`);
zip.file('manifesto.json',JSON.stringify({branch:item.branch,request:item.request,workflow:w,scope:'Submissão externa;conteúdo de preparação e anexos,não recibo de entrega à autoridade.'},null,2));
return{row:item,bytes:await zip.generateAsync({type:'uint8array'})};
}
+9 -5
View File
@@ -1,11 +1,12 @@
import { acquisitionBook, acquisitionScope } from './iva-acquisitions.ts';
import { importExemptionBook,isImportService } from './iva-import-exemptions.ts';
import {offerWorkflowApplied,offerWorkflowReport,type OfferWorkflow} from './iva-offer-workflow.ts';
type Choice = ''|'yes'|'no';
export const offerPreparationSource='CIVA,Lei13/2016,14(2)(a/b) e14(3),PDF13/p.1426(271). Preparação de dados; procedimento regulamentar de aplicação ainda em pesquisa. Não concede isenção nem preenche dedução/campo11.';
export const offerPreparationSource='CIVA,Lei13/2016,14(2)(a/b) e14(3),PDF13/p.1426(271). Procedimento operacional definido pela Tempo25/10/2026,distinto das fontes legais;correspondência específica do formulário ainda por demonstrar. Não concede isenção nem preenche dedução/campo11.';
export type OfferRequest = {state:'preparation'|'presented'|'granted'|'denied';reference:string;presentedOn:string;decisionRef:string;decisionOn:string;goods:string;operation:''|'acquisition'|'import';periodFrom:string;periodTo:string;conditions:string;operationScope?:string;periodScope?:string};
export const offerRequestLabels = {preparation:'Em preparação',presented:'Apresentado',granted:'Deferido',denied:'Indeferido'};
export type OfferPreparation = {id:string;branch:'P084'|'P085';operation:''|'acquisition'|'import';description:string;period:string;currency:string;amount:string;counterparty:string;documentRef:string;documentDate:string;beneficiary:string;purpose:string;evidence:string;nationalInstitution:Choice;publicSocialInstitution:Choice;goodsSuitable:Choice;publicInterestUse:Choice;disaster:string;disasterKind:string;mitigation:Choice;reusedFrom?:{key:string;copiedAt:string};humanitarianPurpose?:string;availableDocuments?:string;request?:OfferRequest;requestRecords?:Array<{recordedAt:string;request:OfferRequest}>;normalVat?:string;normalVatSource?:string;conditionalAssumption?:Choice};
export type OfferPreparation = {workflow?:OfferWorkflow;id:string;branch:'P084'|'P085';operation:''|'acquisition'|'import';description:string;period:string;currency:string;amount:string;counterparty:string;documentRef:string;documentDate:string;beneficiary:string;purpose:string;evidence:string;nationalInstitution:Choice;publicSocialInstitution:Choice;goodsSuitable:Choice;publicInterestUse:Choice;disaster:string;disasterKind:string;mitigation:Choice;reusedFrom?:{key:string;copiedAt:string};humanitarianPurpose?:string;availableDocuments?:string;request?:OfferRequest;requestRecords?:Array<{recordedAt:string;request:OfferRequest}>;normalVat?:string;normalVatSource?:string;conditionalAssumption?:Choice};
export type OfferPreparationBook={rows:OfferPreparation[];history?:Array<{savedAt:string;rows:OfferPreparation[]}>;saved?:{savedAt:string;rows:OfferPreparation[]}};
type Store={version:1;books:Record<string,unknown>;offerPreparations?:Record<string,OfferPreparationBook>;[key:string]:unknown};
export function newOfferPreparation(id:string,branch:OfferPreparation['branch'],currency:string):OfferPreparation{return {id,branch,operation:branch==='P085'?'acquisition':'',description:'',period:'',currency,amount:'',counterparty:'',documentRef:'',documentDate:'',beneficiary:'',purpose:'',evidence:'',nationalInstitution:'',publicSocialInstitution:'',goodsSuitable:'',publicInterestUse:'',disaster:'',disasterKind:'',mitigation:''};}
@@ -18,6 +19,7 @@ function read(draft:Record<string,unknown>):Store {
for(const row of book.rows){
if(!row||!['P084','P085'].includes(row.branch)||Object.keys(newOfferPreparation('','P084','')).some(key=>typeof row[key as keyof OfferPreparation]!=='string')||(row.branch==='P085'&&row.operation==='import'))throw new Error('Rascunho de oferta não recuperável ou operação fora do ramo; dados conservados.');
if(['humanitarianPurpose','availableDocuments','normalVat','normalVatSource','conditionalAssumption'].some(key=>row[key as keyof OfferPreparation]!==undefined&&typeof row[key as keyof OfferPreparation]!=='string')||(row.requestRecords!==undefined&&!Array.isArray(row.requestRecords)))throw new Error('Elementos do percurso de ofertas não recuperáveis; originais preservados.');
if(row.workflow!==undefined&&(!row.workflow||!Array.isArray(row.workflow.records)||['recipient','writtenRequest','decisionIssuer','scopeConfirmed','formName','formSource','formContent','submission','submissionReference'].some(k=>typeof row.workflow?.[k as keyof OfferWorkflow]!=='string')))throw new Error('Percurso pedido/decisão/formulário não recuperável;originais preservados.');
if(row.request!==undefined&&(!row.request||!Object.hasOwn(offerRequestLabels,row.request.state)||Object.entries(emptyOfferRequest()).some(([key])=>typeof row.request?.[key as keyof OfferRequest]!=='string')))throw new Error('Pedido de oferta não recuperável; decisão e originais conservados.');
}
}
@@ -49,28 +51,30 @@ export function reuseOfferData(row:OfferPreparation,source:OfferReusable):OfferP
}
export function emptyOfferRequest():OfferRequest{return {state:'preparation',reference:'',presentedOn:'',decisionRef:'',decisionOn:'',goods:'',operation:'',periodFrom:'',periodTo:'',conditions:''};}
export function recordOfferRequest(row:OfferPreparation):OfferPreparation{
if(row.branch!=='P084')throw new Error('Os estados deste pedido pertencem ao percurso P084; emergência conservada no percurso próprio.');
const request=row.request??emptyOfferRequest();
const validPeriod=(value:string)=>{if(/^\d{4}-(0[1-9]|1[0-2])$/.test(value))return true;if(!/^\d{4}-\d{2}-\d{2}$/.test(value))return false;const d=new Date(value+'T00:00:00Z');return Number.isFinite(d.getTime())&&d.toISOString().slice(0,10)===value;};
const temporalScope=!!request.periodScope?.trim()||(validPeriod(request.periodFrom)&&validPeriod(request.periodTo)&&request.periodFrom.length===request.periodTo.length&&request.periodTo>=request.periodFrom);
if(['granted','denied'].includes(request.state)&&(!request.goods.trim()||(!['acquisition','import'].includes(request.operation)&&!request.operationScope?.trim())||!temporalScope||!request.conditions.trim()))throw new Error('Conserve o âmbito da decisão: bens, operação, período e condições. Pode usar datas, meses ou o âmbito temporal literal do documento, inclusive sem termo. Pode continuar a guardar o rascunho incompleto.');
return {...row,requestRecords:[...(row.requestRecords??[]),{recordedAt:new Date().toISOString(),request:structuredClone(request)}]};
}
export function offerScenarios(row:OfferPreparation):{normalVat:string|null;conditionalVat:string|null;conditionalReason:string;applied:false}{
export function offerScenarios(row:OfferPreparation):{normalVat:string|null;conditionalVat:string|null;conditionalReason:string;applied:boolean;appliedVat?:string}{
const normalVat=row.normalVatSource?.trim()&&/^\d{1,15}(\.\d{1,6})?$/.test(row.normalVat??'')?row.normalVat!:null;
const result={normalVat,conditionalVat:null as string|null,conditionalReason:'Hipótese de isenção não assumida; benefício não aplicado.',applied:false as const};
const workflow=offerWorkflowApplied(row);if(workflow.applied)return {...result,applied:true,appliedVat:'0.00',conditionalReason:workflow.reason};
if(row.conditionalAssumption!=='yes')return result;
const choices=row.branch==='P084'?[row.nationalInstitution,row.publicSocialInstitution,row.goodsSuitable,row.publicInterestUse]:[row.mitigation];
if(choices.includes('no'))return {...result,conditionalReason:'Condição material contrariada; este cenário de isenção não está demonstrado. Benefício não aplicado.'};
if(choices.some(value=>value!=='yes')||!row.description.trim()||!row.beneficiary.trim()||!row.purpose.trim()||!row.operation||!/^2026-(0[1-9]|1[0-2])$/.test(row.period)||row.currency!=='MZN'||(row.branch==='P085'&&(!row.disaster.trim()||!row.humanitarianPurpose?.trim())))return {...result,conditionalReason:'Pressupostos, contexto ou condições materiais por indicar; sem IVA zero presumido. Benefício não aplicado.'};
if(row.workflow)return {...result,conditionalVat:'0.00',conditionalReason:'Cenário condicionado à isenção antes de despacho favorável e âmbito demonstrado;procedimento operacional fornecido pela Tempo.Não é benefício concedido nem imposto aplicado;apresentar pedido não concede isenção.'};
return {...result,conditionalVat:'0.00',conditionalReason:'Cenário condicionado à isenção: hipótese expressa de cumprimento das condições e aplicação do procedimento, cuja correspondência permanece pendente. Não é benefício concedido nem imposto aplicado; apresentar pedido não concede isenção.'};
}
export function offerPathReport(row:OfferPreparation):string {
const request=row.request??emptyOfferRequest(),scenario=offerScenarios(row);
if(row.workflow)return offerWorkflowReport(row)+`;Cenário normal — IVA ${scenario.normalVat??'Indeterminado'};Cenário condicionado à isenção — IVA ${scenario.conditionalVat??'Indeterminado'};IVA do âmbito deferido:${scenario.appliedVat??'Não aplicado'};${scenario.conditionalReason}`;
const scope=(r:OfferRequest)=>`bens ${r.goods||'por indicar'}; operação ${r.operationScope||r.operation||'por indicar'}; período ${r.periodScope||`${r.periodFrom||'por indicar'} a ${r.periodTo||'por indicar'}`}; condições ${r.conditions||'por indicar'}; documento ${r.decisionRef||'por indicar'},${r.decisionOn||'data por indicar'}`;
return `${row.branch==='P084'?`Pedido ${offerRequestLabels[request.state]} (estado declarado, sem concessão pelo simulador); referência ${request.reference||'por indicar'}, apresentação ${request.presentedOn||'por indicar'}; âmbito em edição ${scope(request)}; ${(row.requestRecords??[]).map(record=>`estado registado ${offerRequestLabels[record.request.state]} em ${record.recordedAt}, ${scope(record.request)}`).join(' | ')||'sem estado/decisão registado'}; não estender decisão aos restantes bens, operações, períodos ou oportunidades; apresentar pedido não concede isenção`:`Emergência ${row.disaster||'por indicar'}; finalidade humanitária ${row.humanitarianPurpose||'por indicar'}; procedimento de emergência/doação por corresponder, sem aplicação automática`}; documentos disponíveis ${row.availableDocuments||row.documentRef||'por indicar'}; Cenário normal — IVA ${scenario.normalVat??'Indeterminado'} ${row.currency}, documento/pressuposto ${row.normalVatSource||'por indicar'}; Cenário condicionado à isenção — IVA ${scenario.conditionalVat??'Indeterminado'} ${row.currency}; ${scenario.conditionalReason}`;
}
export function offerPreparationReportRows(draft:Record<string,unknown>):Array<[string,string,string,string]>{
try{return offerPreparationBook(draft).rows.map(row=>['IVA mensal',`Rascunho de oferta ${row.branch} — ${row.id}`,`${row.description||'Bens por indicar'}; preparação, benefício não aplicado; ${row.amount||'Valor não informado'} ${row.currency}; ${row.period||'Período não informado'}`,`${offerPreparationSource} Oportunidade ${acquisitionScope(draft)}; operação ${row.operation||'por indicar'}; contraparte ${row.counterparty||'por indicar'}; documento ${row.documentRef||'por indicar'},${row.documentDate||'data por indicar'}; beneficiário ${row.beneficiary||'por indicar'}; finalidade ${row.purpose||'por indicar'}; ${row.branch==='P084'?`instituição nacional ${row.nationalInstitution||'por indicar'}, interesse público/relevantes fins sociais ${row.publicSocialInstitution||'por indicar'}, bens adequados ${row.goodsSuitable||'por indicar'}, utilização de interesse público ${row.publicInterestUse||'por indicar'}`:`calamidade ${row.disaster||'por indicar'}, natureza ${row.disasterKind||'por indicar'}, atenuação dos efeitos ${row.mitigation||'por indicar'}`}; fundamento ${row.evidence||'por indicar'}; origem reutilizada ${row.reusedFrom?.key||'entrada directa'}; ${offerPathReport(row)}; sem isenção automática ou dedução.`]);}
try{return offerPreparationBook(draft).rows.map(row=>['IVA mensal',`Rascunho de oferta ${row.branch} — ${row.id}`,`${row.description||'Bens por indicar'}; ${offerWorkflowApplied(row).applied?'tratamento deferido no âmbito do despacho':'preparação, benefício não aplicado'}; ${row.amount||'Valor não informado'} ${row.currency}; ${row.period||'Período não informado'}`,`${offerPreparationSource} Oportunidade ${acquisitionScope(draft)}; operação ${row.operation||'por indicar'}; contraparte ${row.counterparty||'por indicar'}; documento ${row.documentRef||'por indicar'},${row.documentDate||'data por indicar'}; beneficiário ${row.beneficiary||'por indicar'}; finalidade ${row.purpose||'por indicar'}; ${row.branch==='P084'?`instituição nacional ${row.nationalInstitution||'por indicar'}, interesse público/relevantes fins sociais ${row.publicSocialInstitution||'por indicar'}, bens adequados ${row.goodsSuitable||'por indicar'}, utilização de interesse público ${row.publicInterestUse||'por indicar'}`:`calamidade ${row.disaster||'por indicar'}, natureza ${row.disasterKind||'por indicar'}, atenuação dos efeitos ${row.mitigation||'por indicar'}`}; fundamento ${row.evidence||'por indicar'}; origem reutilizada ${row.reusedFrom?.key||'entrada directa'}; ${offerPathReport(row)}; sem isenção automática ou dedução.`]);}
catch{return [['IVA mensal','Rascunhos de ofertas','Dados não recuperáveis','Originais preservados; sem substituição por zero.']];}
}
+29
View File
@@ -0,0 +1,29 @@
import type {OfferPreparation,OfferRequest} from './iva-offer-preparations.ts';
export const offerWorkflowSource='Procedimento operacional fornecido pela Tempo em25/10/2026:pedido escrito ao Ministério das Finanças/AT → pedido apresentado → decisão recebida e despacho anexo → preparação do formulário aplicável com despacho. Origem distinta do CIVA14(2)a/b e14(3) e do cotejo documental do ModeloI.1;sem nova certificação ou aprovação criada pelo portal.';
export type OfferAttachment={id:string;draftId:string;name:string;sha256:string;scan:string;url:string};
export type OfferWorkflow={recipient:string;writtenRequest:string;requestAttachment?:OfferAttachment;decisionIssuer:string;decisionAttachment?:OfferAttachment;scopeConfirmed:''|'yes'|'no';scopeKey?:string;formName:string;formSource:string;formContent:string;formAttachment?:OfferAttachment;formDecisionId?:string;formDecisionHash?:string;formPreparedAt?:string;submission:'outside';submissionReference:string;records:Array<{at:string;stage:string;request:OfferRequest;workflow:Omit<OfferWorkflow,'records'>}>};
export function emptyOfferWorkflow():OfferWorkflow{return{recipient:'Ministério das Finanças / Autoridade Tributária',writtenRequest:'',decisionIssuer:'',scopeConfirmed:'',formName:'',formSource:'',formContent:'',submission:'outside',submissionReference:'',records:[]};}
export function offerScopeKey(row:OfferPreparation):string{return JSON.stringify({branch:row.branch,description:row.description,beneficiary:row.beneficiary,operation:row.operation,period:row.period,currency:row.currency,amount:row.amount,purpose:row.purpose,evidence:row.evidence,national:row.nationalInstitution,social:row.publicSocialInstitution,suitable:row.goodsSuitable,use:row.publicInterestUse,disaster:row.disaster,mitigation:row.mitigation,humanitarian:row.humanitarianPurpose,request:row.request,issuer:row.workflow?.decisionIssuer,document:row.workflow?.decisionAttachment?.id,hash:row.workflow?.decisionAttachment?.sha256});}
export function offerAttachmentValid(a:OfferAttachment|undefined):a is OfferAttachment{return!!a&&/^[0-9a-f-]{36}$/i.test(a.id)&&/^[0-9a-f-]{36}$/i.test(a.draftId)&&!!a.name&&/^[0-9a-f]{64}$/.test(a.sha256)&&a.url===`/api/v1/simulation-drafts/${a.draftId}/offer-documents/${a.id}`;}
export function offerWrittenRequest(row:OfferPreparation,w:OfferWorkflow):string{return`Ao ${w.recipient}\nAssunto:Pedido de isenção — ${row.branch}\nEntidade/destinatário:${row.beneficiary}\nFinalidade:${row.purpose}\n${row.branch==='P084'?'Justificação do interesse público/fins sociais:'+row.evidence:'Calamidade:'+row.disaster+';finalidade humanitária:'+row.humanitarianPurpose}\nBens:${row.description}\nOperação:${row.operation};período:${row.period};valor de referência:${row.amount} ${row.currency}\nDocumentos:${row.availableDocuments||row.documentRef}\n${w.writtenRequest}\nPreparação InvesteMoz;a apresentação ocorre fora do portal. Não constitui concessão.`;}
export function offerWorkflowApplied(row:OfferPreparation):{applied:boolean;reason:string}{const w=row.workflow,r=row.request;if(!w||!r||r.state!=='granted')return{applied:false,reason:'Sem despacho de deferimento registado;mantém-se hipótese condicionada.'};if(!r.decisionRef.trim()||!validDate(r.decisionOn)||!w.decisionIssuer.trim()||!offerAttachmentValid(w.decisionAttachment))return{applied:false,reason:'Despacho recebido incompleto:número,data,emitente e anexo por registar.'};if(w.scopeConfirmed!=='yes'||w.scopeKey!==offerScopeKey(row))return{applied:false,reason:'Correspondência desta linha ao âmbito do despacho não confirmada ou dados alterados;sem extensão automática.'};if((!r.operationScope?.trim()&&r.operation&&r.operation!==row.operation)||(!r.periodScope?.trim()&&/^\d{4}-\d{2}$/.test(row.period)&&(row.period<r.periodFrom.slice(0,7)||row.period>r.periodTo.slice(0,7))))return{applied:false,reason:'A operação ou o período desta linha está fora do âmbito expresso do despacho;sem extensão automática.'};const conditions=row.branch==='P084'?[row.nationalInstitution,row.publicSocialInstitution,row.goodsSuitable,row.publicInterestUse]:[row.mitigation];if(conditions.some(v=>v!=='yes')||!row.description||!row.beneficiary||!row.purpose||!row.operation||(row.branch==='P085'&&(row.operation!=='acquisition'||!row.disaster||!row.humanitarianPurpose)))return{applied:false,reason:'Condições próprias do tratamento por demonstrar;despacho não alarga o fundamento legal.'};return{applied:true,reason:`Tratamento deferido apenas nesta linha:${r.goods};${r.operationScope||r.operation};${r.periodScope||r.periodFrom+' a '+r.periodTo};condições:${r.conditions}. Despacho${r.decisionRef},${r.decisionOn},${w.decisionIssuer}. Sem extensão às restantes linhas,períodos ou oportunidades;sem dedução automática.`};}
function validDate(v:string){const d=new Date(v+'T00:00:00Z');return /^\d{4}-\d{2}-\d{2}$/.test(v)&&Number.isFinite(d.getTime())&&d.toISOString().slice(0,10)===v;}
export function recordOfferWorkflow(row:OfferPreparation,stage:'request'|'presented'|'decision'|'form'):OfferPreparation{
const w=structuredClone(row.workflow??emptyOfferWorkflow()),r=structuredClone(row.request);if(!r)throw Error('Prepare primeiro o pedido.');
if(!w.recipient.trim()||!row.beneficiary.trim()||!row.description.trim()||!row.purpose.trim()||!row.operation)throw Error('Indique entidade,finalidade,bens,operação e destinatário do pedido;rascunho incompleto pode ser guardado.');
if(stage==='presented'&&(!r.reference.trim()||!validDate(r.presentedOn)||r.state!=='presented'))throw Error('Registe referência,data e estado apresentado;submissão fora do InvesteMoz.');
if(stage==='decision'||stage==='form'){
if(!['granted','denied'].includes(r.state)||!r.decisionRef.trim()||!validDate(r.decisionOn)||!w.decisionIssuer.trim()||!offerAttachmentValid(w.decisionAttachment))throw Error('Decisão recebida exige número,data,emitente e despacho anexo.');
const dates=validDate(r.periodFrom)&&validDate(r.periodTo)&&r.periodFrom<=r.periodTo;const months=/^\d{4}-(0[1-9]|1[0-2])$/.test(r.periodFrom)&&/^\d{4}-(0[1-9]|1[0-2])$/.test(r.periodTo)&&r.periodFrom<=r.periodTo;
if(!r.goods.trim()||(!r.operation&&!r.operationScope?.trim())||(!r.periodScope?.trim()&&!dates&&!months)||!r.conditions.trim())throw Error('Conserve o âmbito literal:bens,operação,período e condições da decisão.');
if(stage==='decision')w.scopeKey=w.scopeConfirmed==='yes'?offerScopeKey(row):undefined;
}
if(stage==='form'){
if(r.state!=='granted')throw Error('Não preparar formulário de isenção como deferido após indeferimento.');
if(!offerWorkflowApplied({...row,workflow:w}).applied)throw Error('Confira explicitamente a correspondência desta linha ao despacho antes de preparar o formulário;alterações não renovam a confirmação automaticamente.');
if(!w.formName.trim()||!w.formSource.trim()||(!w.formContent.trim()&&!offerAttachmentValid(w.formAttachment)))throw Error('Identifique formulário aplicável e referência da sua aplicabilidade;prepare conteúdo ou anexe o exemplar preenchido.Sem presumir que ModeloI.1 abrange todos os casos.');
w.formDecisionId=w.decisionAttachment!.id;w.formDecisionHash=w.decisionAttachment!.sha256;w.formPreparedAt=new Date().toISOString();
}
const {records,...snapshot}=w;w.records=[...records,{at:new Date().toISOString(),stage,request:r,workflow:snapshot}];return{...row,workflow:w};
}
export function offerWorkflowReport(row:OfferPreparation):string{const w=row.workflow;if(!w)return'';const state=offerWorkflowApplied(row);return`${offerWorkflowSource} Pedido:${row.request?.reference||'por indicar'};apresentado em:${row.request?.presentedOn||'por indicar'};estado:${row.request?.state||'preparation'};decisão:${row.request?.decisionRef||'por indicar'},${row.request?.decisionOn||'por indicar'};Pedido escrito:${offerWrittenRequest(row,w)};decisão emitente:${w.decisionIssuer||'por indicar'};anexo:${w.decisionAttachment?.name||'por anexar'},SHA256:${w.decisionAttachment?.sha256||'por indicar'};${state.reason};formulário:${w.formName||'por identificar'},fonte/aplicabilidade:${w.formSource||'por indicar'};conteúdo:${w.formContent||'por preparar'};despacho ligado ao formulário:${w.formDecisionId||'por preparar'}/${w.formDecisionHash||''};submissão fora do InvesteMoz:${w.submissionReference||'não registada'};histórico:${w.records.map(e=>e.stage+'@'+e.at+':'+e.request.state+':'+e.request.decisionRef).join(' | ')}`;}
+49
View File
@@ -0,0 +1,49 @@
import {acquisitionScope,readAcquisitionStore} from './iva-acquisitions.ts';
export const returneeSource='P058 — CIVA12(2)a:IVA proporcional à redução de direitos;Decreto37/2023,art63(3)–(5),PDF11/p1475;art28,PDF5/p1469. Decreto27/2026,BR123-I,1.ºsuplemento01/07,PDF1–3/p1012(1–3):altera14/58 eQuadrosV/VI,não63. Veículos no regresso;sem extensão das condições dos mineirosP059.';
export const returneeTreatments=[{id:'owned',label:'Veículo próprio há mais de 180 dias — isenção',article:'63(3)a,primeira parte',remaining:0},{id:'recent',label:'Veículo próprio há menos de 180 dias — redução de 80%',article:'63(3)a,segunda parte',remaining:20},{id:'separate',label:'Substituição por veículo separado da bagagem — redução de 50%',article:'63(3)b/e',remaining:50}] as const;
export const returneeConditions=[['national','Cidadão nacional maior de 18 anos'],['definitive','Regresso definitivo ao País'],['single','Um único veículo abrangido;outros veículos pagam a totalidade'],['ownership','Documentos disponíveis comprovam titularidade e residência no país de procedência'],['use','Uso exclusivo pelo beneficiário ou cônjuge e fim declarado;art28'],['undertaking','Declaração em modelo próprio de não alteração de uso,art28(3),identificada ou prevista'],['history','Histórico identificado inclui benefícios fora deste dossier']] as const;
type Choice=''|'yes'|'no';
export type ReturneeRow={id:string;treatment:string;description:string;beneficiary:string;period:string;currency:string;origin:'projected'|'effective';scope:string;evidence:string;documentRef:string;arrival:string;request:string;stayStart:string;stayEnd:string;ownedSince:string;ownershipAssessment?:string;importDate:string;previous:''|'none'|'known';previousDate:string;historyRef:string;extensionRef:string;exceptionRef:string;decisionRef:string;normalVat:string;normalVatRef:string;conditions:Partial<Record<typeof returneeConditions[number][0]|'extension'|'exception'|'substitution',Choice>>};
type Line={id:string;status:'exempt'|'reduced'|'not_applicable'|'indeterminate';importVat:string|null;deduction:null;reason:string;source:string};
type Result={revision:number;calculatedAt:string;sourceKey:string;inputs:ReturneeRow[];rows:Line[]};
export type ReturneeBook={rows:ReturneeRow[];result?:Result;history?:Result[]};
export function newReturnee(id:string,currency:string):ReturneeRow{return{id,currency,treatment:'',description:'',beneficiary:'',period:'',origin:'projected',scope:'',evidence:'',documentRef:'',arrival:'',request:'',stayStart:'',stayEnd:'',ownedSince:'',importDate:'',previous:'',previousDate:'',historyRef:'',extensionRef:'',exceptionRef:'',decisionRef:'',normalVat:'',normalVatRef:'',conditions:{}};}
function store(draft:Record<string,unknown>){const s=readAcquisitionStore(draft.modelo_a_snapshot) as ReturnType<typeof readAcquisitionStore>&{returneeImports?:Record<string,ReturneeBook>};const fields=Object.keys(newReturnee('','')).filter(k=>k!=='conditions');if(s.returneeImports!==undefined&&(!s.returneeImports||typeof s.returneeImports!=='object'||Array.isArray(s.returneeImports)))throw Error('Rascunhos do regresso não recuperáveis;originais preservados.');for(const b of Object.values(s.returneeImports??{}))if(!b||!Array.isArray(b.rows)||(b.history!==undefined&&!Array.isArray(b.history))||b.rows.some(r=>!r||fields.some(k=>typeof r[k as keyof ReturneeRow]!=='string')||!r.conditions||typeof r.conditions!=='object'||Array.isArray(r.conditions)))throw Error('Linhas do regresso não recuperáveis;sem substituição por lista vazia.');return s;}
export function returneeBook(draft:Record<string,unknown>):ReturneeBook{return store(draft).returneeImports?.[acquisitionScope(draft)]??{rows:[]};}
export function saveReturneeBook(draft:Record<string,unknown>,book:ReturneeBook):string{const s=store(draft),value=JSON.stringify({...s,returneeImports:{...s.returneeImports,[acquisitionScope(draft)]:book}});store({...draft,modelo_a_snapshot:value});return value;}
function date(v:string):Date|null{const d=new Date(v+'T00:00:00Z');return /^\d{4}-\d{2}-\d{2}$/.test(v)&&Number.isFinite(d.getTime())&&d.toISOString().slice(0,10)===v?d:null;}
function years(v:string,n:number){const d=date(v)!;const month=d.getUTCMonth();d.setUTCFullYear(d.getUTCFullYear()+n);if(d.getUTCMonth()!==month)d.setUTCDate(0);return d.toISOString().slice(0,10);}
function days(a:string,b:string):number{return(date(b)!.getTime()-date(a)!.getTime())/86400000;}
export function evaluateReturnee(r:ReturneeRow,currency:unknown):Line{
const rule=returneeTreatments.find(t=>t.id===r.treatment),base:Line={id:r.id,status:'indeterminate',importVat:null,deduction:null,reason:'',source:returneeSource+(rule?';'+rule.article:'')};const stop=(reason:string,status:Line['status']='indeterminate')=>({...base,status,reason});
if(!rule)return stop('Escolha percurso de veículo documentado;bagagem e outros bens permanecem por enquadrar separadamente.');
if(currency!=='MZN'||r.currency!==currency||!/^2026-(0[1-9]|1[0-2])$/.test(r.period)||!date(r.importDate)||r.importDate.slice(0,7)!==r.period)return stop('Indique importação2026 emMZN,com data/período coerentes;sem conversão presumida.');
if(!['projected','effective'].includes(r.origin)||![r.description,r.beneficiary,r.scope,r.evidence,r.documentRef,r.historyRef].every(v=>v.trim()))return stop('Identifique bens,beneficiário,documentos,âmbito,factos/pressupostos e histórico completo;sem exigir novo certificado.');
if(returneeConditions.some(([k])=>r.conditions[k]==='no'))return stop('Condição deste percurso não satisfeita;outros regimes não são excluídos.','not_applicable');
if(returneeConditions.some(([k])=>r.conditions[k]!=='yes'))return stop('Condições por esclarecer;desconhecido não equivale a isenção.');
if(![r.arrival,r.request,r.stayStart,r.stayEnd].every(v=>date(v))||r.stayEnd<r.stayStart||r.stayEnd>r.arrival||r.request<r.arrival||r.importDate<r.arrival)return stop('Indique estadia,chegada,requerimento e importação coerentes.');
if(r.stayEnd<=years(r.stayStart,1)&&!(r.treatment==='separate'&&r.conditions.exception==='yes'&&r.exceptionRef.trim()))return stop('Exigida estadia superior a um ano;excepção63(3)e apenas no separado,com autorização do Ministro das Finanças e âmbito ou pressuposto expresso.');
const elapsed=days(r.arrival,r.request);if(elapsed>60&&!(elapsed<=90&&r.conditions.extension==='yes'&&r.extensionRef.trim()))return stop('Pedido além de60dias:prorrogação excepcional pelo Director-Geral das Alfândegas até30dias deve estar identificada;sem extensão automática.');
if(!['none','known'].includes(r.previous))return stop('Identifique existência ou ausência de benefício anterior pelo histórico,sem inferir do dossier vazio.');
if(r.previous==='known'&&(!date(r.previousDate)||r.importDate<years(r.previousDate,5)))return stop('Não demonstrado intervalo de cinco anos desde a numeração do despacho do benefício anterior.');
if(r.treatment==='separate'){
if(r.conditions.substitution!=='yes'||!r.decisionRef.trim())return stop('Tratamento excepcional de substituição63(3)b deve ter referência e âmbito ou pressuposto expresso;pedido não equivale a concessão.');
}else{
if(!date(r.ownedSince)||!date(r.ownershipAssessment??'')||r.ownedSince>r.ownershipAssessment!||r.ownershipAssessment!>r.arrival)return stop('Indique início da propriedade e data à qual o documento confere a sua duração no país de procedência,coerentes com o regresso;sem escolher automaticamente o marco de contagem.');
const ownershipDays=days(r.ownedSince,r.ownershipAssessment!);if(ownershipDays===180)return stop('A fonte distingue mais/menos de180dias;exactamente180 não recebe benefício presumido.');
if((r.treatment==='owned'&&ownershipDays<180)||(r.treatment==='recent'&&ownershipDays>180))return stop('Tempo de propriedade não corresponde ao percurso seleccionado.','not_applicable');
}
const context=`${rule.article};${r.origin==='projected'?'cenário condicionado aos pressupostos expressos':'factos declarados no âmbito identificado'};não constitui concessão administrativa. Restrições de uso/alienação e eventual alteração de destino seguem art28;sem cálculo de direitos,ICE,dedução/campo11 ou IVA de aquisição interna.`;
if(rule.remaining===0)return{...base,status:'exempt',importVat:'0.00',reason:'Isenção integral no âmbito demonstrado do veículo.'+context};
if(!/^\d{1,15}(\.\d{1,6})?$/.test(r.normalVat)||!r.normalVatRef.trim())return stop('Indique IVA normal da importação e respectiva referência/pressuposto;sem presumir base ou taxa.');
const [w,f='']=r.normalVat.split('.');const amount=(BigInt(w)*1000000n+BigInt(f.padEnd(6,'0')))*BigInt(rule.remaining);const value=`${amount/100000000n}.${(amount%100000000n).toString().padStart(8,'0').replace(/0+$/,'').padEnd(2,'0')}`;
return{...base,status:'reduced',importVat:value,reason:`Redução${100-rule.remaining}% das imposições;IVA normal informado×${rule.remaining}%=${value}MZN,sem arredondamento fiscal presumido.Não é taxa deIVA.`+context};
}
function key(draft:Record<string,unknown>,b:ReturneeBook){return JSON.stringify({version:1,scope:acquisitionScope(draft),currency:draft.currency,location:draft.selection_labels??{},rows:b.rows});}
export function returneeCurrent(draft:Record<string,unknown>,b:ReturneeBook){return !!b.result&&b.result.sourceKey===key(draft,b);}
export function recordReturnees(draft:Record<string,unknown>,b:ReturneeBook):ReturneeBook{
if(!b.rows.length||new Set(b.rows.map(r=>r.id)).size!==b.rows.length)throw Error('Identifique linhas distintas;originais preservados.');
const rows=b.rows.map(r=>evaluateReturnee(r,draft.currency));for(let i=0;i<b.rows.length;i++){const r=b.rows[i];if(!['exempt','reduced'].includes(rows[i].status))continue;if(b.rows.some((peer,j)=>j!==i&&peer.beneficiary===r.beneficiary&&peer.origin===r.origin&&date(peer.importDate)&&evaluateReturnee(peer,draft.currency).status!=='not_applicable'&&(peer.importDate<=r.importDate?r.importDate<years(peer.importDate,5):peer.importDate<years(r.importDate,5))))rows[i]={...rows[i],status:'indeterminate',importVat:null,reason:'Mais de uma linha usa o mesmo direito de veículo em cinco anos;conferir o conjunto e histórico externo,sem dupla utilização.'};}
const result:Result={revision:(b.result?.revision??0)+1,calculatedAt:new Date().toISOString(),sourceKey:key(draft,b),inputs:structuredClone(b.rows),rows};const next={...b,result,history:[...(b.history??[]),...(b.result?[b.result]:[])]};saveReturneeBook(draft,next);return next;
}
export function returneeReportRows(draft:Record<string,unknown>):Array<[string,string,string,string]>{try{const b=returneeBook(draft),result=b.result;if(!result)return[];return result.inputs.map((r,i)=>['IVA mensal',`P058 — ${r.period} — ${r.id}`,`${r.description};${result.rows[i].reason};IVA:${result.rows[i].importVat??'Indeterminado'};dedução:Não apurada`,`${result.rows[i].source};${returneeCurrent(draft,b)?'Resultado actual':'Entradas alteradas;resultado histórico conservado'};versão${result.revision};${result.calculatedAt};${r.documentRef};${r.scope};${r.evidence};${r.historyRef};${r.extensionRef};${r.exceptionRef};${r.decisionRef};${r.normalVatRef}`]);}catch(e){return[['IVA mensal','P058 — recuperação',String(e),'Originais preservados']];}}
+2
View File
@@ -1,6 +1,7 @@
import { digitalReportRows } from './iva-digital.ts';
import { objectiveImportReportRows } from './iva-objective-imports.ts';
import { minerReportRows } from './iva-miners.ts';
import { returneeReportRows } from './iva-returnees.ts';
import { travelReportRows } from './iva-travel.ts';
import { travelEventReportRows } from './iva-travel-events.ts';
import { foreignOperationReportRows } from './iva-foreign-operations.ts';
@@ -668,6 +669,7 @@ export function buildProjectDossierRows(
rows.push(...digitalReportRows(draft));
rows.push(...objectiveImportReportRows(draft));
rows.push(...minerReportRows(draft));
rows.push(...returneeReportRows(draft));
rows.push(...travelReportRows(draft));
rows.push(...travelEventReportRows(draft));
rows.push(...offerPreparationReportRows(draft));
+4
View File
@@ -8,6 +8,7 @@ use App\Http\Controllers\Api\V1\ReferenceCatalogController;
use App\Http\Controllers\Api\V1\SectorController;
use App\Http\Controllers\Api\V1\SimulationController;
use App\Http\Controllers\Api\V1\SimulationFormDraftController;
use App\Http\Controllers\Api\V1\SimulationOfferDocumentController;
use App\Http\Controllers\Api\V1\SimulationTaxParameterController;
use App\Http\Controllers\PortalServicesController;
use App\Http\Controllers\PublicBusinessOpportunityController;
@@ -54,6 +55,9 @@ Route::prefix('v1')->middleware(['web', 'throttle:api', CorrelationId::class])->
Route::post('/simulation-drafts/{id}/business-link', [SimulationFormDraftController::class, 'linkBusiness'])->middleware(['auth', 'verified', 'throttle:simulation-draft-writes']);
Route::delete('/simulation-drafts/{id}/business-link', [SimulationFormDraftController::class, 'unlinkBusiness'])->middleware(['auth', 'verified', 'throttle:simulation-draft-writes']);
Route::get('/simulation-drafts/{id}', [SimulationFormDraftController::class, 'show']);
Route::get('/simulation-drafts/{id}/offer-documents', [SimulationOfferDocumentController::class, 'index'])->middleware('auth');
Route::post('/simulation-drafts/{id}/offer-documents', [SimulationOfferDocumentController::class, 'store'])->middleware(['auth', 'throttle:simulation-draft-writes']);
Route::get('/simulation-drafts/{id}/offer-documents/{document}', [SimulationOfferDocumentController::class, 'download'])->middleware('auth');
Route::put('/simulation-drafts/{id}', [SimulationFormDraftController::class, 'update'])->middleware('throttle:simulation-draft-writes');
Route::post('/simulation-drafts/{id}/touch', [SimulationFormDraftController::class, 'renew'])->middleware('throttle:simulation-draft-writes');
Route::post('/simulation-drafts/{id}/conversions', [SimulationFormDraftController::class, 'attachSimulation'])->middleware('throttle:simulation-draft-writes');
@@ -0,0 +1,45 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use App\Jobs\ScanDocumentForMalware;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Storage;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Str;
use Tests\TestCase;
final class IvaOfferWorkflowDraftTest extends TestCase
{
use DatabaseTransactions;
public function test_P104_operational_selection_recovers_without_recalculating_history():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-fx-selection.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']);$s=json_decode($recovered['modelo_a_snapshot'],true,flags:JSON_THROW_ON_ERROR);$b=$s['currencyEvents']['opportunity:365'];$this->assertCount(1,$b['history']);$this->assertSame('6406.9775',$b['history'][0]['rows'][0]['scenarioAmount']);$this->assertSame('12782.00',$b['result']['rows'][0]['scenarioAmount']);$this->assertSame('mean',$b['rows'][0]['fxSelection']['applied']['column']);$this->assertNull($b['result']['rows'][0]['baseAmount']);$this->assertNull($b['result']['rows'][0]['vat']);
$prior=json_decode(json_decode(file_get_contents(base_path('tests/frontend/fixtures/iva-offer-workflow.json')),true)['modelo_a_snapshot'],true);$this->assertSame(array_diff_key($prior,['currencyEvents'=>true]),array_diff_key($s,['currencyEvents'=>true]));foreach($prior['currencyEvents'] as $k=>$v)$this->assertSame($v,$s['currencyEvents'][$k]);
file_put_contents(base_path('outputs/iva-l1-returnees-20261025/fx-recovered.json'),json_encode($recovered,JSON_THROW_ON_ERROR|JSON_UNESCAPED_UNICODE));
}
public function test_private_decision_attachment_and_recovery_preserve_prior_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]);Queue::fake();Storage::fake('local');$owner=User::factory()->create(['email_verified_at'=>now()]);$this->actingAs($owner);
$draft=json_decode(file_get_contents(base_path('tests/frontend/fixtures/iva-offer-workflow.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');
$file=UploadedFile::fake()->createWithContent('SYNTHETIC-QA-despacho.txt','SYNTHETIC QA despacho privado para teste de continuidade.');
$url='/api/v1/simulation-drafts/'.$id.'/offer-documents';$doc=$this->post($url,['file'=>$file],['Accept'=>'application/json'])->assertCreated()->json('data');$this->assertSame(hash('sha256','SYNTHETIC QA despacho privado para teste de continuidade.'),$doc['sha256']);Queue::assertPushed(ScanDocumentForMalware::class);
$this->post($url,['file'=>UploadedFile::fake()->createWithContent('copy.txt','SYNTHETIC QA despacho privado para teste de continuidade.')],['Accept'=>'application/json'])->assertOk()->assertJsonPath('data.id',$doc['id']);
$this->getJson($doc['url'])->assertStatus(409);$this->getJson($url)->assertOk()->assertJsonCount(1,'data');
$this->actingAs(User::factory()->create(['email_verified_at'=>now()]));$this->getJson($doc['url'])->assertNotFound();$this->getJson($url)->assertNotFound();$this->post($url,['file'=>UploadedFile::fake()->createWithContent('other.txt','SYNTHETIC QA other')],['Accept'=>'application/json'])->assertNotFound();
$this->actingAs($owner);DB::table('documents')->where('id',$doc['id'])->update(['status'=>'active','malware_scan_status'=>'clean','malware_scanned_sha256'=>$doc['sha256']]);$this->get($doc['url'])->assertOk()->assertHeader('X-Content-Type-Options','nosniff');
$recovered=$this->getJson('/api/v1/simulation-drafts/'.$id)->assertOk()->json('data.economic_snapshot');$this->assertSame($draft['modelo_a_snapshot'],$recovered['modelo_a_snapshot']);$snapshot=json_decode($recovered['modelo_a_snapshot'],true,flags:JSON_THROW_ON_ERROR);$this->assertCount(2,$snapshot['offerPreparations']['opportunity:364']['rows']);
$prior=json_decode(json_decode(file_get_contents(base_path('tests/frontend/fixtures/iva-returnees.json')),true)['modelo_a_snapshot'],true);$this->assertSame(array_diff_key($prior,['offerPreparations'=>true]),array_diff_key($snapshot,['offerPreparations'=>true]));foreach($prior['offerPreparations'] as $k=>$v)$this->assertSame($v,$snapshot['offerPreparations'][$k]);
// The UI seed contains the real uploaded private decision identity, not a synthetic URL passed as an upload.
foreach($snapshot['offerPreparations']['opportunity:364']['rows'] as &$row){$row['workflow']['decisionAttachment']=$doc;}
unset($row);$recovered['modelo_a_snapshot']=json_encode($snapshot,JSON_THROW_ON_ERROR|JSON_UNESCAPED_UNICODE);
file_put_contents(base_path('outputs/iva-l1-returnees-20261025/recovered.json'),json_encode($recovered,JSON_THROW_ON_ERROR|JSON_UNESCAPED_UNICODE));
$stored=DB::table('documents')->where('id',$doc['id'])->first();Storage::disk('local')->put($stored->storage_key,'changed');$this->getJson($doc['url'])->assertStatus(409);
}
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+10
View File
@@ -0,0 +1,10 @@
import test from 'node:test';import assert from 'node:assert/strict';import fs from 'node:fs';
import {emptyFxSelection,selectFx,fxTempoSource} from '../../resources/js/lib/iva-fx-selection.ts';
import {newCurrencyEvent,evaluateCurrencyEvent,recordCurrencyEvents,saveCurrencyEventBook,currencyEventBook,currencyEventCurrent} from '../../resources/js/lib/iva-currency-events.ts';
const selection=()=>({...emptyFxSelection(),nature:'commercial',submissionDate:'2026-10-23',bulletinDate:'2026-10-23',bulletinRef:'SYNTHETIC QA boletimBM de referência,23/10/2026',source:'https://www.bancomoc.mz/pt/areas-de-actuacao/mercados/mercado-cambial/',sourceCurrency:'USD',direction:'mzn_per_unit',bulletinPerspective:'SYNTHETIC QA bancos perante público',relation:'same',buy:'63.27',sell:'64.54',mean:'63.91'});
for(const[nature,relation,column,rate]of[['purchase','same','buy','63.27'],['sale','same','sell','64.54'],['purchase','opposite','sell','64.54'],['sale','opposite','buy','63.27'],['commercial','','mean','63.91']])test(`P104 ${nature}/${relation||'médiaBM'} escolhe ${column} sem inverter cliente`,()=>{const result=selectFx({...selection(),nature,relation});assert.equal(result.column,column);assert.equal(result.rate,rate);assert.equal(result.date,'2026-10-23');});
test('P104 falta de boletim/data/perspectiva não autoriza taxa actual,média própria ou coluna oposta presumida',()=>{for(const patch of [{bulletinDate:'2026-10-22'},{mean:''},{source:'https://example.com/cambio'},{submissionDate:'2026-02-30'},{nature:''},{nature:'purchase',relation:''},{nature:'sale',bulletinPerspective:''}])assert.throws(()=>selectFx({...selection(),...patch}));assert.equal(selectFx({...selection(),source:'SYNTHETIC QA exemplar oficial arquivado',issuer:'Banco de Moçambique'}).column,'mean');const exception={...selection(),mode:'exception',exceptionRef:'SYNTHETIC QA disposição fiscal específica',exceptionScope:'SYNTHETIC QA apenas operaçãoA',exceptionDate:'2026-10-20',exceptionRate:'62.12345678'};assert.equal(selectFx(exception).rate,'62.12345678');assert.equal(selectFx(exception).date,'2026-10-20');assert.throws(()=>selectFx({...exception,exceptionRef:''}));});
const frozen=JSON.parse(fs.readFileSync('tests/frontend/fixtures/iva-offer-workflow.json','utf8')),prior=JSON.parse(frozen.modelo_a_snapshot),draft={...frozen,context_opportunity:'opportunity:365',project_title:'SYNTHETIC QA — P104 operacional / Manica'};
function row(){const s=selection(),applied=selectFx(s);return{...newCurrencyEvent('commercial','MZN'),treatment:'foreign_scenario',description:'SYNTHETIC QA operação comercial',period:'2026-10',origin:'effective',operationRef:'SYNTHETIC QA operação já declarada',documentRef:'SYNTHETIC QA factura',documentDate:'2026-10-23',scope:'SYNTHETIC QA operaçãoA',evidence:'SYNTHETIC QA factos',factsConfirmed:'yes',amount:'100.25',sourceCurrency:'USD',fxRate:applied.rate,fxDate:applied.date,fxSource:applied.source,fxRuleRef:fxTempoSource,fxDirection:s.direction,fxSelection:{...s,applied}};}
test('P104 original/convertido,direcção e taxa guardados;semIVA,nova dedução ou alteração automática',()=>{const r=row(),v=evaluateCurrencyEvent(r);assert.equal(v.status,'determined');assert.equal(v.scenarioAmount,'6406.9775');assert.equal(v.baseAmount,null);assert.equal(v.vat,null);const reverse={...r,fxDirection:'units_per_mzn',fxSelection:{...r.fxSelection,direction:'units_per_mzn'}};const applied=selectFx(reverse.fxSelection);reverse.fxSelection.applied=applied;reverse.fxSource=applied.source;assert.equal(evaluateCurrencyEvent(reverse).scenarioAmount,'1.568612');assert.equal(evaluateCurrencyEvent({...r,fxRate:'999'}).status,'indeterminate');assert.equal(evaluateCurrencyEvent({...r,fxSelection:{...r.fxSelection,submissionDate:'2026-10-24'}}).status,'indeterminate');});
test('P104 versões antigas permanecem condicionadas e127fechos/P058/ofertas permanecem iguais',()=>{const r=row(),legacy={...r};delete legacy.fxSelection;assert.equal(evaluateCurrencyEvent(legacy).status,'conditional');let b=recordCurrencyEvents(draft,{rows:[r]});const saved=structuredClone(b.result);const changed={...b,rows:[{...r,amount:'200'}]};assert.equal(currencyEventCurrent(draft,changed),false);assert.deepEqual(b.result,saved);b=recordCurrencyEvents(draft,changed);assert.deepEqual(b.history[0],saved);draft.modelo_a_snapshot=saveCurrencyEventBook(draft,b);assert.deepEqual(currencyEventBook(draft),b);const next=JSON.parse(draft.modelo_a_snapshot);assert.deepEqual(Object.fromEntries(Object.entries(prior).filter(([k])=>k!=='currencyEvents')),Object.fromEntries(Object.entries(next).filter(([k])=>k!=='currencyEvents')));for(const[k,v]of Object.entries(prior.currencyEvents??{}))assert.deepEqual(next.currencyEvents[k],v);if(process.env.IVA_FX_SELECTION_FIXTURE)fs.writeFileSync('tests/frontend/fixtures/iva-fx-selection.json',JSON.stringify(draft));});
@@ -0,0 +1,8 @@
import test from 'node:test';import assert from 'node:assert/strict';import fs from 'node:fs';import JSZip from 'jszip';
import {buildOfferPackage} from '../../resources/js/lib/iva-offer-package.ts';
import {recordOfferWorkflow} from '../../resources/js/lib/iva-offer-workflow.ts';
const seed=JSON.parse(fs.readFileSync('tests/frontend/fixtures/iva-offer-workflow.json','utf8')),rows=JSON.parse(seed.modelo_a_snapshot).offerPreparations['opportunity:364'].rows,bytes=Buffer.from('SYNTHETIC QA despacho privado para teste de continuidade.');
const hash=Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256',bytes))).map(b=>b.toString(16).padStart(2,'0')).join('');for(const row of rows)row.workflow.decisionAttachment.sha256=hash;
const buffer=()=>Uint8Array.from(bytes).buffer;
for(const row of rows)test(row.branch+' conjunto inclui original,pedido,formulário e manifesto com âmbito',async()=>{const ready=recordOfferWorkflow(row,'decision'),result=await buildOfferPackage(ready,async()=>buffer()),zip=await JSZip.loadAsync(result.bytes);assert.deepEqual(Buffer.from(await zip.file('despacho/SYNTHETIC-QA-despacho.txt').async('uint8array')),bytes);assert.match(await zip.file('pedido.txt').async('string'),/apresentação ocorre fora/);const manifest=JSON.parse(await zip.file('manifesto.json').async('string'));assert.equal(manifest.workflow.formDecisionHash,row.workflow.decisionAttachment.sha256);assert.match(await zip.file('preparacao-formulario.txt').async('string'),/Apresentação fora/);assert.equal(manifest.request.goods,'SYNTHETIC QA apenas bensA');if(process.env.IVA_OFFER_PACKAGE_PROOF)fs.writeFileSync(`outputs/iva-l1-returnees-20261025/${row.branch}-pedido-decisao-formulario.zip`,result.bytes);});
test('anexo alterado e indeferimento nunca geram conjunto de isenção',async()=>{await assert.rejects(buildOfferPackage(recordOfferWorkflow(rows[0],'decision'),async()=>new ArrayBuffer(1)),/hash/);await assert.rejects(buildOfferPackage({...rows[0],request:{...rows[0].request,state:'denied'}},async()=>buffer()),/indeferimento/);});
+2 -2
View File
@@ -22,9 +22,9 @@ test('P084 recorded decisions preserve goods/operation/period/conditions and pre
const literal=recordOfferRequest({...row,request:{...row.request,operation:'',operationScope:'SYNTHETIC QA aquisição e importação dos bens A',periodFrom:'',periodTo:'',periodScope:'SYNTHETIC QA desde a decisão até revogação, sem termo'}});assert.match(offerPathReport(literal),/até revogação, sem termo/);assert.equal(literal.requestRecords.at(-1).request.operationScope,'SYNTHETIC QA aquisição e importação dos bens A');
assert.equal(row.requestRecords[0].request.operation,'import');assert.equal(row.operation,'acquisition');
});
test('P085 humanitarian purpose/emergency/documents are distinct;missing inputs stay indeterminate and no P084 state is imposed',()=>{
test('P085 humanitarian purpose/emergency/documents are distinct;missing inputs stay indeterminate and operational states may now be recorded under Tempo25/10',()=>{
const base={...offerPreparationBook(draft()).rows[1],period:'2026-12',beneficiary:'SYNTHETIC QA destinatário',purpose:'SYNTHETIC QA atenuar cheias',humanitarianPurpose:'SYNTHETIC QA água e alimentação para deslocados',availableDocuments:'SYNTHETIC QA lista de bens\nSYNTHETIC QA guia disponível',normalVat:'160',normalVatSource:'SYNTHETIC QA previsão explícita',conditionalAssumption:'yes'};
assert.equal(offerScenarios(base).conditionalVat,'0.00');assert.equal(offerScenarios({...base,humanitarianPurpose:''}).conditionalVat,null);assert.equal(offerScenarios({...base,mitigation:'no'}).conditionalVat,null);assert.throws(()=>recordOfferRequest(base),/P084/);assert.match(offerPathReport(base),/finalidade humanitária/);assert.match(offerPathReport(base),/procedimento de emergência\/doação por corresponder/);assert.equal(base.request,undefined);
assert.equal(offerScenarios(base).conditionalVat,'0.00');assert.equal(offerScenarios({...base,humanitarianPurpose:''}).conditionalVat,null);assert.equal(offerScenarios({...base,mitigation:'no'}).conditionalVat,null);assert.equal(recordOfferRequest(base).requestRecords.at(-1).request.state,'preparation');assert.match(offerPathReport(base),/finalidade humanitária/);assert.match(offerPathReport(base),/procedimento de emergência\/doação por corresponder/);assert.equal(base.request,undefined);
});
test('save/recover/report retain old drafts/histories and all27fiscalbooks;normal and conditional are labelled and field11 stays unchanged',()=>{
const d=draft(),prior=JSON.parse(frozen.modelo_a_snapshot),old=offerPreparationBook(d);const priorDecision=recordOfferRequest({...row84(),request:{...emptyOfferRequest(),state:'granted',decisionRef:'SYNTHETIC QA decisão parcial anterior',goods:'SYNTHETIC QA apenas bens A',operation:'import',periodFrom:'2026-11',periodTo:'2026-11',conditions:'SYNTHETIC QA só a operação anterior A'}});const p84=recordOfferRequest({...priorDecision,request:{...emptyOfferRequest(),state:'presented',reference:'SYNTHETIC QA pedido em apreciação'}});const p85={...old.rows[1],period:'2026-12',beneficiary:'SYNTHETIC QA destinatário',purpose:'SYNTHETIC QA resposta humanitária',humanitarianPurpose:'SYNTHETIC QA assistência imediata às vítimas das cheias',availableDocuments:'SYNTHETIC QA bens e destinatário',normalVat:'160',normalVatSource:'SYNTHETIC QA previsão sem taxa inferida',conditionalAssumption:'yes'};
@@ -0,0 +1,15 @@
import test from 'node:test';import assert from 'node:assert/strict';import fs from 'node:fs';
import {newOfferPreparation,emptyOfferRequest,saveOfferPreparationBook,offerPreparationBook,offerScenarios,offerPathReport} from '../../resources/js/lib/iva-offer-preparations.ts';
import {emptyOfferWorkflow,recordOfferWorkflow,offerWorkflowApplied,offerWrittenRequest} from '../../resources/js/lib/iva-offer-workflow.ts';
const frozen=JSON.parse(fs.readFileSync('tests/frontend/fixtures/iva-returnees.json','utf8')),draft={...frozen,context_opportunity:'opportunity:364',project_title:'SYNTHETIC QA — Pedidos ofertas / Manica'},prior=JSON.parse(frozen.modelo_a_snapshot);
const attachment={id:'00000000-0000-4000-8000-000000000001',draftId:'00000000-0000-4000-8000-000000000002',name:'SYNTHETIC-QA-despacho.txt',sha256:'a'.repeat(64),scan:'pending',url:'/api/v1/simulation-drafts/00000000-0000-4000-8000-000000000002/offer-documents/00000000-0000-4000-8000-000000000001'};
function initial(branch){return{...newOfferPreparation(branch,branch,'MZN'),operation:'acquisition',description:'SYNTHETIC QA bensA',period:'2026-10',amount:'1000',beneficiary:'SYNTHETIC QA entidade',purpose:branch==='P084'?'SYNTHETIC QA interesse público/fins sociais':'SYNTHETIC QA atenuar cheias',evidence:'SYNTHETIC QA justificação',nationalInstitution:'yes',publicSocialInstitution:'yes',goodsSuitable:'yes',publicInterestUse:'yes',mitigation:'yes',disaster:'SYNTHETIC QA cheias',humanitarianPurpose:'SYNTHETIC QA assistência humanitária',normalVat:'160',normalVatSource:'SYNTHETIC QA hipótese',conditionalAssumption:'yes',request:emptyOfferRequest(),workflow:emptyOfferWorkflow()};}
const completed=[];
for(const branch of ['P084','P085'])test(branch+' pedido → apresentação externa → decisão anexa → formulário com despacho',()=>{
let row=recordOfferWorkflow(initial(branch),'request');assert.equal(offerWorkflowApplied(row).applied,false);assert.match(offerWrittenRequest(row,row.workflow),/apresentação ocorre fora/);row={...row,request:{...row.request,state:'presented',reference:'SYNTHETIC QA pedido1',presentedOn:'2026-10-01'}};row=recordOfferWorkflow(row,'presented');assert.equal(offerScenarios(row).applied,false);assert.equal(offerScenarios(row).conditionalVat,'0.00');
row={...row,request:{...row.request,state:'granted',decisionRef:'SYNTHETIC QA despacho1',decisionOn:'2026-10-03',goods:'SYNTHETIC QA apenas bensA',operation:'acquisition',periodFrom:'2026-10',periodTo:'2026-10',conditions:'SYNTHETIC QA só operaçãoA'},workflow:{...row.workflow,decisionIssuer:'SYNTHETIC QA MF/AT',decisionAttachment:attachment,scopeConfirmed:'yes'}};
row=recordOfferWorkflow(row,'decision');assert.equal(offerScenarios(row).applied,true);assert.throws(()=>recordOfferWorkflow({...row,description:'outros bens'},'form'),/explicitamente/);const outside=recordOfferWorkflow({...row,request:{...row.request,periodFrom:'2027-01',periodTo:'2027-01'}},'decision');assert.equal(offerWorkflowApplied(outside).applied,false);assert.equal(offerScenarios(row).appliedVat,'0.00');assert.equal(offerWorkflowApplied({...row,description:'outros bens'}).applied,false);assert.equal(offerWorkflowApplied({...row,period:'2026-11'}).applied,false);assert.equal(offerWorkflowApplied({...row,beneficiary:'outra entidade'}).applied,false);assert.equal(offerWorkflowApplied({...row,request:{...row.request,state:'denied'}}).applied,false);
row={...row,workflow:{...row.workflow,formName:'SYNTHETIC QA formulário aplicável',formSource:'SYNTHETIC QA correspondência documentada apenas operaçãoA',formContent:offerWrittenRequest(row,row.workflow)}};row=recordOfferWorkflow(row,'form');assert.equal(row.workflow.formDecisionId,attachment.id);assert.equal(row.workflow.formDecisionHash,attachment.sha256);assert.deepEqual(row.workflow.records.map(r=>r.stage),['request','presented','decision','form']);assert.match(offerPathReport(row),/Procedimento operacional fornecido pela Tempo/);assert.match(offerPathReport(row),/submissão fora/);assert.match(offerPathReport(row),/SYNTHETIC QA pedido1/);assert.match(offerPathReport(row),/2026-10-01/);completed.push(row);
});
test('decisão e formulário incompletos não bloqueiam guardar rascunho nem inventam concessão',()=>{const row=initial('P085');assert.throws(()=>recordOfferWorkflow({...row,request:{...row.request,state:'presented'}},'presented'),/referência/);assert.throws(()=>recordOfferWorkflow({...row,request:{...row.request,state:'granted'}},'decision'),/número/);const denied={...completed[0],request:{...completed[0].request,state:'denied'}};assert.throws(()=>recordOfferWorkflow(denied,'form'),/indeferimento/);for(const patch of [{decisionIssuer:''},{decisionAttachment:undefined},{scopeConfirmed:'no'}])assert.equal(offerWorkflowApplied({...completed[0],workflow:{...completed[0].workflow,...patch}}).applied,false);const value=saveOfferPreparationBook(draft,{rows:[row]});assert.equal(offerPreparationBook({...draft,modelo_a_snapshot:value}).rows[0].workflow.records.length,0);});
test('127 fechos e P058 parcial conservados;histórico e ambos percursos recuperáveis',()=>{const old=prior.offerPreparations??{};const value=saveOfferPreparationBook(draft,{rows:completed});draft.modelo_a_snapshot=value;const next=JSON.parse(value);assert.deepEqual(Object.fromEntries(Object.entries(next).filter(([k])=>k!=='offerPreparations')),Object.fromEntries(Object.entries(prior).filter(([k])=>k!=='offerPreparations')));for(const[k,v]of Object.entries(old))assert.deepEqual(next.offerPreparations[k],v);assert.equal(offerPreparationBook(draft).rows.length,2);assert.deepEqual(next.returneeImports,prior.returneeImports);if(process.env.IVA_OFFER_WORKFLOW_FIXTURE)fs.writeFileSync('tests/frontend/fixtures/iva-offer-workflow.json',JSON.stringify(draft));});
+11
View File
@@ -0,0 +1,11 @@
import test from 'node:test';import assert from 'node:assert/strict';import fs from 'node:fs';
import {newReturnee,returneeConditions,returneeTreatments,evaluateReturnee,recordReturnees,saveReturneeBook,returneeBook,returneeCurrent,returneeReportRows} from '../../resources/js/lib/iva-returnees.ts';
const frozen=JSON.parse(fs.readFileSync('tests/frontend/fixtures/iva-returnees-preserved.json','utf8')),prior=JSON.parse(frozen.modelo_a_snapshot),draft={...frozen,context_opportunity:'opportunity:363',project_title:'SYNTHETIC QA — Regresso definitivo / Manica'};
function row(treatment,id=treatment){return{...newReturnee(id,'MZN'),treatment,description:'SYNTHETIC QA veículo '+id,beneficiary:'SYNTHETIC QA beneficiário '+id,period:'2026-10',origin:'projected',scope:'SYNTHETIC QA veículo/operação/período',evidence:'SYNTHETIC QA pressupostos expressos',documentRef:'SYNTHETIC QA propriedade/residência/declaração uso',arrival:'2026-09-01',request:'2026-09-20',stayStart:'2024-09-01',stayEnd:'2026-08-31',ownershipAssessment:'2026-09-01',ownedSince:treatment==='owned'?'2025-01-01':'2026-08-01',importDate:'2026-10-10',previous:'none',historyRef:'SYNTHETIC QA histórico completo incluindo outros dossiers',decisionRef:'SYNTHETIC QA pressuposto de substituição63(3)b',normalVat:'160.123456',normalVatRef:'SYNTHETIC QA IVA normal informado',conditions:{...Object.fromEntries(returneeConditions.map(([k])=>[k,'yes'])),substitution:'yes'}};}
const rows=returneeTreatments.map(t=>row(t.id));
for(const r of rows)test('P058 veículo '+r.treatment+' sem dedução nem taxa presumida',()=>{const v=evaluateReturnee(r,'MZN');assert.equal(v.status,r.treatment==='owned'?'exempt':'reduced');assert.equal(v.importVat,{owned:'0.00',recent:'32.0246912',separate:'80.061728'}[r.treatment]);assert.equal(v.deduction,null);});
test('P058 desconhecido,âmbito e condições recusadas conservam distinção',()=>{const r=rows[0];for(const patch of [{period:'2025-10'},{currency:'USD'},{importDate:'2026-02-30'},{description:''},{documentRef:''},{historyRef:''},{scope:''},{evidence:''},{previous:''},{conditions:{...r.conditions,national:''}}])assert.equal(evaluateReturnee({...r,...patch},'MZN').status,'indeterminate');for(const[k]of returneeConditions)assert.equal(evaluateReturnee({...r,conditions:{...r.conditions,[k]:'no'}},'MZN').status,'not_applicable');});
test('P058 prazo60/90dias e excepções referenciadas distintas do regime mineiros',()=>{const r=rows[0];assert.equal(evaluateReturnee({...r,request:'2026-10-31'},'MZN').status,'exempt');assert.equal(evaluateReturnee({...r,request:'2026-11-01'},'MZN').status,'indeterminate');const extended={...r,request:'2026-11-30',extensionRef:'SYNTHETIC QA prazo concedido',conditions:{...r.conditions,extension:'yes'}};assert.equal(evaluateReturnee(extended,'MZN').status,'exempt');assert.equal(evaluateReturnee({...extended,request:'2026-12-01'},'MZN').status,'indeterminate');assert.equal(evaluateReturnee({...extended,extensionRef:''},'MZN').status,'indeterminate');const sep=rows[2];const exceptional={...sep,stayStart:'2026-02-01',exceptionRef:'SYNTHETIC QA Ministro/âmbito',conditions:{...sep.conditions,exception:'yes'}};assert.equal(evaluateReturnee(exceptional,'MZN').status,'reduced');assert.equal(evaluateReturnee({...exceptional,exceptionRef:''},'MZN').status,'indeterminate');assert.equal(evaluateReturnee({...sep,decisionRef:''},'MZN').status,'indeterminate');assert.equal(evaluateReturnee({...r,stayStart:'2025-08-31'},'MZN').status,'indeterminate');});
test('P058 propriedade180dias sem preencher lacuna e redução80% não é taxa deIVA',()=>{const r=rows[0];const d=new Date(r.arrival+'T00:00:00Z');d.setUTCDate(d.getUTCDate()-180);assert.equal(evaluateReturnee({...r,ownedSince:d.toISOString().slice(0,10)},'MZN').status,'indeterminate');assert.equal(evaluateReturnee({...r,ownedSince:'2026-08-01'},'MZN').status,'not_applicable');assert.equal(evaluateReturnee({...rows[1],normalVat:'0.000001'},'MZN').importVat,'0.0000002');assert.equal(evaluateReturnee({...rows[1],normalVatRef:''},'MZN').status,'indeterminate');});
test('P058 histórico global e um veículo em cinco anos;sem dupla utilização',()=>{const r=rows[0];assert.equal(evaluateReturnee({...r,previous:'known',previousDate:'2021-10-10'},'MZN').status,'exempt');assert.equal(evaluateReturnee({...r,previous:'known',previousDate:'2021-10-11'},'MZN').status,'indeterminate');const b=recordReturnees(draft,{rows:[r,{...r,id:'duplicate',treatment:'separate'}]});assert.deepEqual(b.result.rows.map(r=>r.status),['indeterminate','indeterminate']);assert.equal(b.rows.length,2);});
test('P058 gravação/histórico/recuperação/relatório conservam127fechos e limitações',()=>{let book=recordReturnees(draft,{rows});assert.equal(returneeCurrent(draft,book),true);const changed={...book,rows:book.rows.map((r,i)=>i?r:{...r,scope:'SYNTHETIC QA âmbito revisto'})};assert.equal(returneeCurrent(draft,changed),false);book=recordReturnees(draft,changed);draft.modelo_a_snapshot=saveReturneeBook(draft,book);assert.deepEqual(returneeBook(draft),book);assert.equal(book.history.length,1);assert.equal(returneeBook({...draft,context_opportunity:'opportunity:999'}).rows.length,0);assert.deepEqual(prior,Object.fromEntries(Object.entries(JSON.parse(draft.modelo_a_snapshot)).filter(([k])=>k!=='returneeImports')));assert.throws(()=>returneeBook({...draft,modelo_a_snapshot:'{"version":1,"books":{},"returneeImports":[]}'}));assert.match(returneeReportRows(draft)[0][3],/versão2/);if(process.env.IVA_RETURNEE_FIXTURE)fs.writeFileSync('tests/frontend/fixtures/iva-returnees.json',JSON.stringify(draft));});