Compare commits

..
12 changed files with 531 additions and 41 deletions
@@ -2089,5 +2089,291 @@
"AUD-SIM-INT07",
"AUD-SIM-INT08"
]
},
"closure_qa_20261024": {
"status": "S1_S2_S3_passed_QA_ready_for_integration",
"authorization": "Tempo execute S1/S2 jointly in QA, then S3; no fiscal rule changes; candidate for existing Git integration",
"functional_candidate": "65b87819a0629f79f415788a6aa1a16fe031e37e",
"branch": "codex/simulator-closure-qa-20261024",
"preserved_reference": "276d833a4b4f9071db328b653f48529b2dfac4f9",
"integrated_main": false,
"homologation_deployed": false,
"S1": {
"status": "corrected_and_demonstrated_QA",
"snapshot_version": 2,
"currency_origin_preserved": true,
"original_USD_to_project_MZN": "guarded; report/panel retain USD, IVA refuses reuse as MZN",
"explicit_resolution": "Restore original currency on project; unknown legacy origin requires explicit currency confirmation only",
"legacy_v1_inputs_preserved": true,
"private_draft_roundtrip_passed": true
},
"S2": {
"status": "corrected_and_demonstrated_QA",
"method": "GET same admitted execution ID, credentials same-origin; wrong response ID rejected",
"new_admissions_disabled": true,
"visual_read_requests": 1,
"visual_create_requests": 0,
"real_API_owner_read_and_wrong_owner_denial": true,
"execution_count_preserved": true,
"rule_version_fingerprint_unchanged": true
},
"S3": {
"status": "final_CSV_Word_and_Edge_PDF_verified",
"artifact_inspection": {
"files": {
"monthly-final.csv": {
"bytes": 10639,
"sha256": "0b5fc670b0c252391aa99c0bff4e21af1e7a7b9c3a0e4ac6e2e2359a647d8a0e"
},
"monthly-final.docx": {
"bytes": 14101,
"sha256": "f83629e6ed3090a0d99d4843b11d4388048a6c993d2e1f7fabd87282bf0c2b38"
},
"monthly-final.html": {
"bytes": 11530,
"sha256": "09d40095550cae9bfdd29fcb5c6c7e0ad825a26170997a23347a8320a352b3a7"
}
},
"same_frozen_monthly_fields_verified": [
"160.0048",
"320.0096",
"1160.0348",
"2320.0696",
"MZN",
"opportunity:309",
"Manica",
"2026-01-01",
"2026-04-01",
"Fonte sintética QA",
"IVA-GERAL-BASE-LIQUIDA"
],
"csv_rows": 73,
"first_opportunity_not_included": true,
"fiscal_calculation_repeated": false,
"native_pdf_status": "awaiting_saved_file; request already sent"
},
"native_pdf_expected_path": "C:/laragon/www/portal-investidor/outputs/simulator-closure-qa-20261024/monthly-final.pdf",
"user_response": "Will save at requested path",
"HTML_is_not_native_pdf_proof": true,
"fixture": "Explicit synthetic QA, frozen accepted monthly numbers; not legal validation or real-rule publication",
"pdf_generation": {
"renderer": "Installed Microsoft Edge, isolated headless print engine",
"manual_print_dialog_used": false,
"source": "C:\\laragon\\www\\portal-investidor\\outputs\\simulator-closure-qa-20261024\\ui\\monthly-review.html",
"source_sha256": "b44ee5fc573f8deb8d5427f9f3b6a667c1d60fc23de4509d383b23acb9df2779",
"pdf": "C:\\laragon\\www\\portal-investidor\\outputs\\simulator-closure-qa-20261024\\monthly-final.pdf",
"bytes": 111910,
"sha256": "7719a05136f6dba668b621f551626060d10db5fcbb84815cdad112a1eab9079b",
"exit_code": 0
},
"pdf_inspection": {
"status": "native_file_read_and_rendered",
"exact_path": "C:\\laragon\\www\\portal-investidor\\outputs\\simulator-closure-qa-20261024\\monthly-final.pdf",
"sha256": "7719a05136f6dba668b621f551626060d10db5fcbb84815cdad112a1eab9079b",
"pages": [
{
"page": 1,
"size": [
594.9599609375,
841.9199829101562
],
"characters": 1633,
"image": "C:\\laragon\\www\\portal-investidor\\outputs\\simulator-closure-qa-20261024\\monthly-pdf-pages\\page-01.png",
"footer_boxes": [
[
48.0,
807.8525390625,
257.5710144042969,
817.9072265625
]
],
"footer_separated": true
},
{
"page": 2,
"size": [
594.9599609375,
841.9199829101562
],
"characters": 2299,
"image": "C:\\laragon\\www\\portal-investidor\\outputs\\simulator-closure-qa-20261024\\monthly-pdf-pages\\page-02.png",
"footer_boxes": [
[
48.0,
807.8525390625,
257.5710144042969,
817.9072265625
]
],
"footer_separated": true
},
{
"page": 3,
"size": [
594.9599609375,
841.9199829101562
],
"characters": 2047,
"image": "C:\\laragon\\www\\portal-investidor\\outputs\\simulator-closure-qa-20261024\\monthly-pdf-pages\\page-03.png",
"footer_boxes": [
[
48.0,
807.8525390625,
257.5710144042969,
817.9072265625
]
],
"footer_separated": true
},
{
"page": 4,
"size": [
594.9599609375,
841.9199829101562
],
"characters": 1832,
"image": "C:\\laragon\\www\\portal-investidor\\outputs\\simulator-closure-qa-20261024\\monthly-pdf-pages\\page-04.png",
"footer_boxes": [
[
48.0,
807.8525390625,
257.5710144042969,
817.9072265625
]
],
"footer_separated": true
}
],
"checks": {
"160.0048": true,
"320.0096": true,
"1160.0348": true,
"2320.0696": true,
"MZN": true,
"opportunity:309": true,
"Manica": true,
"2026-01-01": true,
"2026-04-01": true,
"Fonte sintética QA": true,
"IVA-GERAL-BASE-LIQUIDA": true,
"first_opportunity_absent": true,
"rule_version_4": true,
"zero_and_missing": true
},
"content_checks_passed": true,
"visual_review": "passed: all four rendered pages inspected; repeated headers, intact rows, no clipping/overlap, footers separated, numbering 1/4-4/4",
"regenerated": false,
"render_method": "Installed Microsoft Edge headless print engine, isolated profile; not manual print-dialog save",
"generation_authorized_by_user": true
},
"manual_print_dialog_used": false,
"regeneration_authorization": "User explicitly requested generating the file again to complete this step"
},
"checks": {
"frontend_new_cases": 4,
"frontend_total_direct_dependencies": 12,
"frontend_passed": 12,
"API_tests": 2,
"API_assertions": 23,
"QA_database": "simulator_fiscal_delta_20261021_test",
"QA_port": 55441,
"QA_transactions_rolled_back": true,
"typescript": "passed",
"build": "passed",
"diff_check": "passed"
},
"unchanged": [
"Fiscal server/evaluator/rules/sources",
"Schema/configuration/permissions/routes",
"Active business data",
"Accepted stabilization and opportunity-context closures"
],
"remaining": [],
"ready_for_integration": true,
"new_taxes_added": false,
"integration_note": "Candidate only; existing Git process and installed-delta verification still to execute after integration authorization; no homologation delivery claimed"
},
"closure_homologation_20261024": {
"status": "installed_delta_verified",
"authorization": "Tempo current conversation: integrate S1-S3 through existing Git process, install homologation and verify only delta",
"served_commit": "cd10cccbf45f53836d8bf7b2bd90794d99602cb9",
"functional_candidate": "65b87819",
"QA_closure_record": "cd10cccb",
"previous_main": "276d833a4b4f9071db328b653f48529b2dfac4f9",
"stabilization_tag": "reference/stabilization-20261024",
"Git_integration": "fast-forward after fetch; no conflicting/concurrent main changes found; no force",
"installed_delta": {
"served_revision": {
"commit": "cd10cccbf45f53836d8bf7b2bd90794d99602cb9",
"branch": "main",
"built_at": "2026-10-24T09:39:42.304316+00:00",
"manifest_sha256": "7957c776ab2c2eab516bcc6caedcdeb9943184645b4a21a89a4f709a4ba974f6"
},
"manifest_hash_matches": true,
"compiled_delta_markers": {
"currency_origin_guard": "assets/project-dossier-DNSA8_QR.js",
"original_currency_report": "assets/project-dossier-DNSA8_QR.js",
"same_id_recovery": "assets/services-BXcNtigY.js",
"explicit_currency_resolution": "assets/services-BXcNtigY.js"
},
"assets": [
{
"file": "assets/project-dossier-DNSA8_QR.js",
"status": 200,
"content_type": "application/javascript",
"sha256": "8cfe6743dddc5f35ddfe7ccdd6bb4d859719c1e263c6e51d9ac94b30a5fe1b76"
},
{
"file": "assets/simulation-form-ClLDsvMf.js",
"status": 200,
"content_type": "application/javascript",
"sha256": "fc829b447dcb1eec1cc550edcbfaa28f4db7b8973b4f86dab8a065cbbd8f0421"
},
{
"file": "assets/project-dossier-document-CJ_IVxDn.js",
"status": 200,
"content_type": "application/javascript",
"sha256": "ac4013924c4a78427c3e134654f331bf6fce313733532e7084ed85cf344106b2"
},
{
"file": "assets/services-BXcNtigY.js",
"status": 200,
"content_type": "application/javascript",
"sha256": "66d8af6670347a19e4006323f6c8b84e625a2dbaad6b4de777b4d979faa67093"
}
],
"simulator_public_status": 200,
"simulator_uses_installed_app_entry": "assets/app-Ds1F0RnS.js",
"existing_fiscal_availability": {
"data": {
"enabled": true,
"coverage_start": "2026-01-01",
"coverage_end": "2026-12-31",
"message": "IVA geral sobre base líquida disponível; enquadramento declarado."
}
},
"synthetic_dossiers_requests_or_calculations_created": 0,
"QA_acceptance_tests_repeated": false,
"method": "Read-only public installed delta: revision, manifest, compiled changed features, simulator page and existing availability; no authenticated DB audit claimed",
"shared_guard_asset_matches_QA_bytes": true,
"reference_276d833a_is_ancestor": true,
"fiscal_server_schema_config_routes_deploy_diff": [],
"cross_environment_asset_validation": "Served build checked against its own revision manifest and compiled changed features; not a claim of identical complete bundles across Windows QA and Linux production"
},
"functional_files": [
"resources/js/components/simulation-cashflow-planner.tsx",
"resources/js/components/simulation-monthly-iva.tsx",
"resources/js/lib/monthly-cashflow.ts",
"resources/js/lib/monthly-iva.ts",
"resources/js/lib/project-dossier.ts"
],
"preservation": "Stabilization ancestry and accepted proofs preserved; no fiscal server/schema/config/routes/deploy diff. No new authenticated database count audit claimed. No destination business-data mutation or new synthetic calculation requested.",
"accepted_QA_tests_repeated": false,
"PDF_regenerated_for_delivery": false,
"remaining_in_S1_S2_S3": [],
"legacy_snapshot_limit": "Original currency absent in v1 requires explicit confirmation; amounts preserved, MZN not presumed",
"not_full_all_tax_completion": true,
"next_fiscal_batch": "Scope from attached models; no automatic new tax/rule activation",
"statistics_and_AI": "remain deferred"
}
}
@@ -1,5 +1,31 @@
# Rastreabilidade, utilização e formação
## Simulador S1–S3 — entregue na homologação em `cd10cccb`, 24/10/2026
Por autorização expressa da Tempo após o fecho QA, integrado pelo Git existente, de `276d833a` para **`cd10cccbf45f53836d8bf7b2bd90794d99602cb9`**, que contém a correcção funcional `65b87819`. `git fetch` não encontrou alterações concorrentes em main; avanço normal, sem force. A referência `276d833a`/`reference/stabilization-20261024` permanece ancestral. Não houve edição da pasta servida nem migração/importação solicitada.
**Delta instalado conferido por leitura pública:** `/build/revision.json` identifica `cd10cccb`; SHA256 do manifesto servido coincide com o da revisão (`7957c776ab2c2eab516bcc6caedcdeb9943184645b4a21a89a4f709a4ba974f6`). Quatro assets do simulador/relatório responderam 200; os bundles contêm a guarda da moeda original, a confirmação explícita e a recuperação por mesmo ID. O asset comum da guarda de moeda coincide por bytes com o build QA. O simulador responde 200 e referencia a entrada app do manifesto instalado. O consumidor IVA mantém disponibilidade `enabled=true` e cobertura de 2026. Não se exige identidade dos bundles completos entre Windows QA e Linux; a conferência usa o manifesto do próprio build servido, os assets e o delta de código.
O diff de `app`, `database`, `config`, `routes` e `deploy` contra `276d833a` é vazio: sem alteração de motor, regras/fontes fiscais, schema, permissões, configuração ou processo de entrega. Cinco fontes frontend constituem o delta funcional de moeda/recuperação/relatório. Não foram criados dossiers, pedidos ou cálculos sintéticos na homologação, nem repetidos os ensaios aceites. Provas QA 12 frontend, 2 API/23 asserções, TypeScript/build e PDF de quatro páginas são reutilizadas, sem nova impressão. Não se apresenta esta leitura pública como auditoria autenticada nova dos dados protegidos.
**Saldo S1–S3: entregue, sem pendências deste lote.** Nos snapshots antigos sem moeda, a confirmação explícita da origem continua necessária, preservando os montantes; não se presume MZN nem se convertem valores. Este fecho abrange moeda da tesouraria, recuperação do IVA mensal e exportações; **não conclui todos os impostos**. Os modelos anexados delimitam o próximo lote fiscal, sem activação implícita. As pendências documentais anteriores mantêm-se no seu âmbito. Recibo integrado existente: chave `closure_homologation_20261024`; prova do delta em `outputs/simulator-delivery-20261024/installed-delta.json`. Os estados QA/pendentes abaixo são histórico superado por esta entrega.
## Simulador S1/S2/S3 — candidata QA `65b87819`, 24/10/2026
**Actualização de fecho S3:** a Tempo pediu expressamente voltar a gerar o ficheiro. O PDF foi gravado directamente em `outputs/simulator-closure-qa-20261024/monthly-final.pdf` pelo motor de impressão do Microsoft Edge em modo headless, com perfil isolado, a partir da mesma saída HTML final; não foi gravação manual pelo diálogo de impressão. Ficheiro de 111 910 bytes, SHA256 `7719a05136f6dba668b621f551626060d10db5fcbb84815cdad112a1eab9079b`. As **quatro páginas A4** foram extraídas, renderizadas e inspeccionadas visualmente: valores exactos, MZN, oportunidade `309`/Manica, Janeiro–Abril/2026, fonte sintética QA e versão 4 conservados; zero distinto de falta, cabeçalhos repetidos, linhas intactas, rodapés separados e numeração 1/4–4/4. CSV e Word previamente conferidos mantidos, sem recálculo fiscal. **S1, S2 e S3 concluídos em QA; candidata pronta para integração pelo Git.** Saldo QA deste lote: zero; integração, disponibilização e conferência do delta instalado ainda não executadas. Os estados de espera abaixo são histórico anterior a esta geração/conferência e não reabrem S3. Recibo integrado e mesmas oito linhas da matriz actualizados; referência `276d833a` e regras fiscais preservadas.
Execução autorizada pela Tempo após `be62783b`, preservando `276d833a` e a etiqueta `reference/stabilization-20261024`. Ramo `codex/simulator-closure-qa-20261024`; **não integrado em main nem entregue na homologação**. Cinco fontes frontend alteradas; sem alterações em motor/regras/fontes fiscais, schema, configuração, permissões, rotas ou dados activos. Sem impostos adicionais.
| Recorte | Correcção e resultado dirigido |
| --- | --- |
| **S1 — corrigido e demonstrado em QA** | Novos snapshots de tesouraria v2 conservam moeda de origem. Painel, CSV próprio e relatório usam essa moeda; o fallback de vendas para IVA recusa divergência. USD → MZN conserva `123 USD`, explica a diferença e permite restaurar USD no projecto por acção explícita. Os montantes não são convertidos nem reinterpretados. Snapshots v1 continuam legíveis; moeda sem origem recuperável exige confirmação única, conservando valores/modo/meses vazios. Não se presume MZN. A observação visual detectou um rótulo residual de saldo na moeda actual; corrigido para a moeda de origem. O rótulo do selector passou a «Moeda do projecto», distinguindo-o dos pressupostos guardados. |
| **S2 — corrigido e demonstrado em QA** | Recuperação mensal usa GET no mesmo ID, com credenciais e verificação do identificador devolvido; não depende da disponibilidade para novas admissões. Botão específico continua disponível com consumidor desligado. Resposta não substitui snapshot alterado entretanto ou componente desmontado. Interface isolada: consumidor indisponível, uma consulta GET, zero criações POST, mesma execução concluída. API real de QA: execução admitida → consumidor desligado só na configuração do teste → mesmo ID em fila/concluído; outra conta continua recusada e contagem de execuções não aumenta. Regras existentes reutilizadas, sem criar/publicar/suspender regra; fingerprint integral das versões igual. |
| **S3 — CSV/Word e fonte de impressão conferidos; PDF nativo ainda pendente** | Mesmo bloco mensal sintético congelado, sem recalcular: Janeiro `160.0048`, Fevereiro `320.0096`, Março zero, Abril sem informação; MZN, oportunidade `309`/Manica, período Janeiro–Abril/2026, fonte identificada como sintética QA e versão 4. CSV e Word finais lidos e comparados, incluindo ausência da primeira oportunidade/Sofala. A fonte HTML conserva os mesmos dados. O relatório está aberto no Edge e a Tempo indicou que guardará o PDF em `outputs/simulator-closure-qa-20261024/monthly-final.pdf`; falta ler e renderizar esse ficheiro para conferir apenas a paginação do bloco. HTML não substitui essa prova. |
Provas: quatro testes novos em `simulator-closure-scope.test.mjs`, mais oito casos das dependências directamente afectadas, **12/12 aprovados**; `SimulatorClosureRecoveryTest`, **2 casos/23 asserções**, transacções revertidas no clone nominal `simulator_fiscal_delta_20261021_test:55441`. TypeScript e build aprovados. As suites de aceitação não afectadas, ZIP, cálculos fiscais, PDFs/rodapé anteriores e contextos por oportunidade são reutilizados; não se exigiu à Tempo repetir essas provas. O ensaio visual usa componentes reais com transporte simulado explicitamente identificado, complementado pelo teste API de permissões; não é apresentado como servidor real ou processo na homologação.
Artefactos novos: `outputs/simulator-closure-qa-20261024/` — CSV/Word/HTML finais, snapshot congelado, inspecção/hash dos ficheiros, captura de recuperação e resultados dos testes. O estado executado fica no recibo integrado existente, chave `closure_qa_20261024`, e nas mesmas oito linhas da matriz. **Limite corrente:** S1/S2 em QA; S3 aguarda somente o PDF nativo guardado. A candidata não é declarada pronta para integração enquanto faltar essa conferência. Os seis pares legados e os dois originais incompletos não têm dependência efectiva neste lote.
## Simulador — âmbito de fecho reconciliado e lote proposto, 24/10/2026
Pedido expresso da Tempo após a referência `276d833a`/`reference/stabilization-20261024`, confirmada segundo o relatório, sem lhe atribuir leitura do recibo local. Esta passagem é **análise e proposta**, sem correcção funcional, integração, nova publicação/activação fiscal ou alteração de dados. A ordem de referência mantém-se. Não se acrescentam impostos nem se reabre o lote de estabilização.
File diff suppressed because one or more lines are too long
@@ -5,6 +5,7 @@ import {
defaultCashflowDraftInputs,
restoreCashflowDraftSnapshot,
serializeCashflowDraftSnapshot,
cashflowCurrency,
type CashflowDraftSnapshot,
} from '@/lib/monthly-cashflow';
import {
@@ -24,6 +25,9 @@ export function SimulationCashflowPlanner({
onSnapshotChange?: (snapshot: string) => void;
}) {
const restored = restoreCashflowDraftSnapshot(snapshot);
const [confirmedCurrency, setConfirmedCurrency] = useState('');
const originCurrency = restored?.version === 2 ? restored.currency : null;
const calculationCurrency = snapshot ? originCurrency ?? '' : currency;
const [taxMode, setTaxMode] = useState<'fixed' | 'schedule'>(
restored?.taxMode ?? 'fixed',
);
@@ -41,7 +45,7 @@ export function SimulationCashflowPlanner({
) {
setInputs(next);
setTaxMode(mode);
onSnapshotChange?.(serializeCashflowDraftSnapshot(mode, next));
onSnapshotChange?.(serializeCashflowDraftSnapshot(mode, next, snapshot ? originCurrency ?? null : currency || null));
}
const set = (
key: Exclude<keyof typeof inputs, 'monthlyTaxSchedule'>,
@@ -63,7 +67,8 @@ export function SimulationCashflowPlanner({
throw new Error(
'Não foi possível recuperar os pressupostos guardados de tesouraria. Reveja os campos antes de voltar a guardar; nenhum resultado foi calculado com dados alternativos.',
);
projection = projectMonthlyCashflow({ ...cashflowInput, currency });
if (restored) cashflowCurrency(restored, currency);
projection = projectMonthlyCashflow({ ...cashflowInput, currency: calculationCurrency });
} catch (cause) {
error =
cause instanceof Error
@@ -76,7 +81,7 @@ export function SimulationCashflowPlanner({
aria-labelledby="cashflow-title"
>
<label className="block max-w-xs text-sm font-medium">
Moeda dos valores (código ISO)
Moeda do projecto (código ISO)
<input
list="cashflow-currency-options"
inputMode="text"
@@ -103,6 +108,16 @@ export function SimulationCashflowPlanner({
<h3 id="cashflow-title" className="text-lg font-semibold">
Projecção de tesouraria — 12 meses
</h3>
{restored && !originCurrency && <div className="space-y-2 rounded border border-amber-600 p-3">
<p>A moeda de origem dos montantes guardados não foi registada. Os valores foram conservados. Confirme apenas essa moeda; não é necessário preencher os montantes novamente.</p>
<label>Moeda de origem da tesouraria guardada<select className="ml-2 rounded border p-2" value={confirmedCurrency} onChange={event => setConfirmedCurrency(event.target.value)}><option value="">Por confirmar</option>{simulationCurrencyOptions.map(code => <option key={code} value={code}>{code}</option>)}</select></label>
<button type="button" className="block underline" disabled={!isSimulationCurrency(confirmedCurrency)} onClick={() => onSnapshotChange?.(serializeCashflowDraftSnapshot(taxMode, inputs, confirmedCurrency))}>Confirmar moeda de origem e conservar montantes</button>
</div>}
{originCurrency && originCurrency !== currency && <div role="alert" className="rounded border border-amber-600 p-3">
<p>{cashflowCurrency(restored!, currency).notice}</p>
<button type="button" className="underline" onClick={() => onCurrencyChange(originCurrency)}>Usar {originCurrency} no projecto e conservar montantes</button>
</div>}
{originCurrency && <p>Moeda de origem dos pressupostos: {originCurrency}.</p>}
<p className="text-sm text-slate-700">
Introduza pressupostos na mesma moeda. O imposto indicado é um
pressuposto do utilizador; esta ferramenta não calcula nem
@@ -257,12 +272,12 @@ export function SimulationCashflowPlanner({
{error}
</p>
)}
{projection && isSimulationCurrency(currency) && (
{projection && isSimulationCurrency(calculationCurrency) && (
<>
<p aria-live="polite" className="font-semibold">
{projection.endingCash === null
? `Caixa conhecida antes de imposto em falta, ao fim de 12 meses (${currency}):`
: `Saldo projectado ao fim de 12 meses (${currency}):`}{' '}
? `Caixa conhecida antes de imposto em falta, ao fim de 12 meses (${calculationCurrency}):`
: `Saldo projectado ao fim de 12 meses (${calculationCurrency}):`}{' '}
{projection.endingCash ??
projection.endingCashBeforeUnassessedTax}
</p>
@@ -274,7 +289,7 @@ export function SimulationCashflowPlanner({
[
exportMonthlyCashflowCsv({
...cashflowInput,
currency,
currency: calculationCurrency,
periods: 12,
}),
],
@@ -1,8 +1,8 @@
import { InvestmentContextAssessment } from './investment-context-assessment';
import { useEffect, useRef, useState } from 'react';
import { monthlyIvaInput, monthlyIvaKey, monthlySalesFromDraft, readMonthlyIvaRecord, readMonthlyIvaSettings, monthlyIvaCashTreatment, type MonthlyIvaInput, type MonthlyIvaSettings } from '@/lib/monthly-iva';
import { monthlyIvaInput, monthlyIvaKey, monthlySalesFromDraft, readMonthlyIvaRecord, readMonthlyIvaSettings, recoverMonthlyIvaRecord, monthlyIvaCashTreatment, type MonthlyIvaInput, type MonthlyIvaSettings } from '@/lib/monthly-iva';
import { fiscalQaRows } from '@/lib/fiscal-qa-preview';
import { isApiSimulationReadResponseV1, type ApiSimulationExecutionV1 } from '@/lib/simulation-contract';
import { type ApiSimulationExecutionV1 } from '@/lib/simulation-contract';
import { SimulationModeloA } from './simulation-modelo-a';
export function SimulationMonthlyIva({draft, busy, onCalculate, onChange}: {
@@ -16,6 +16,9 @@ export function SimulationMonthlyIva({draft, busy, onCalculate, onChange}: {
const [error, setError] = useState('');
const [available, setAvailable] = useState<boolean | null>(null);
const attempt = useRef('');
const mounted = useRef(true);
const latestSnapshot = useRef(draft.monthly_iva_snapshot); latestSnapshot.current = draft.monthly_iva_snapshot;
useEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []);
const callbacks = useRef({onCalculate, onChange}); callbacks.current = {onCalculate, onChange};
let input: MonthlyIvaInput | null = null; let inputError = ''; let source = '';
try { source = monthlySalesFromDraft(draft).source; input = monthlyIvaInput(draft, settings); }
@@ -23,6 +26,8 @@ export function SimulationMonthlyIva({draft, busy, onCalculate, onChange}: {
const key = input ? monthlyIvaKey(input) : '';
const current = !!(input && record && monthlyIvaKey(record.input) === key);
const completed = current && record?.execution.status === 'completed';
const pendingRecord = record && ['queued','running'].includes(record.execution.status) ? record : null;
const actionKey = pendingRecord ? `recover:${pendingRecord.execution.id}` : key;
const saveSettings = (patch: Partial<MonthlyIvaSettings>) => onChange('monthly_iva_settings', JSON.stringify({...settings, ...patch}));
useEffect(() => {
let active = true;
@@ -32,27 +37,26 @@ export function SimulationMonthlyIva({draft, busy, onCalculate, onChange}: {
return () => { active = false; };
}, [retry]);
useEffect(() => {
if (!settings.auto || !input || input.currency !== 'MZN' || busy || !available || completed || attempt.current === key) return;
const request = input;
const pending = current && record && ['queued','running'].includes(record.execution.status) ? record.execution.id : null;
if (!settings.auto || busy || attempt.current === actionKey) return;
if (!pendingRecord && (!input || input.currency !== 'MZN' || !available || completed)) return;
const request = pendingRecord?.input ?? input!;
const savedSnapshot = draft.monthly_iva_snapshot;
const timer = window.setTimeout(() => {
attempt.current = key; setError('');
attempt.current = actionKey; setError('');
void (async () => {
try {
let execution;
if (pending) {
const response = await fetch(`/api/v1/simulations/${pending}`, {credentials:'same-origin', headers:{Accept:'application/json'}});
const value: unknown = await response.json();
if (!response.ok || !isApiSimulationReadResponseV1(value)) throw new Error('Não foi possível recuperar a execução mensal guardada. Não foi criada uma execução alternativa.');
execution = value.data;
if (pendingRecord) {
execution = (await recoverMonthlyIvaRecord(pendingRecord)).execution;
} else execution = await callbacks.current.onCalculate(request);
if (!execution) throw new Error('O cálculo mensal não foi concluído. As vendas e a versão anterior foram preservadas; tente novamente.');
if (!mounted.current || (pendingRecord && latestSnapshot.current !== savedSnapshot)) return;
callbacks.current.onChange('monthly_iva_snapshot', JSON.stringify({input:request, execution}));
} catch (cause) {setError(cause instanceof Error ? cause.message : 'Não foi possível calcular o IVA mensal.');}
} catch (cause) {if (mounted.current) setError(cause instanceof Error ? cause.message : 'Não foi possível calcular o IVA mensal.');}
})();
}, 700);
return () => window.clearTimeout(timer);
}, [key, settings.auto, busy, available, completed, retry]);
}, [actionKey, key, settings.auto, busy, available, completed, retry]);
const snapshot = record?.execution.result_snapshot;
const projection = snapshot?.monthly_projection as {rows?: Array<{date:string; sales_declared:string|null; net_sales:string|null; rate:string|null; vat:string|null; gross_sales:string|null; status:string}>} | undefined;
@@ -73,7 +77,8 @@ export function SimulationMonthlyIva({draft, busy, onCalculate, onChange}: {
{settings.auto && !completed && current && <p role="status">Execução mensal guardada: {record?.execution.status === 'queued' ? 'em fila' : record?.execution.status === 'running' ? 'em processamento' : 'não concluída'}. Pode voltar a consultar sem duplicar.</p>}
{busy && <p role="status">A actualizar os resultados dependentes das vendas…</p>}
{error && <p role="alert">{error}</p>}
<button type="button" disabled={busy || !available || !input || input.currency !== 'MZN'} onClick={() => {attempt.current=''; setRetry((value) => value+1); saveSettings({auto:true});}} className="rounded bg-emerald-800 p-2 text-white disabled:opacity-50">Calcular / actualizar IVA mensal e guardar versão</button>
<button type="button" disabled={busy || (!pendingRecord && (!available || !input || input.currency !== 'MZN'))} onClick={() => {attempt.current=''; setRetry((value) => value+1); saveSettings({auto:true});}} className="rounded bg-emerald-800 p-2 text-white disabled:opacity-50">{pendingRecord ? 'Recuperar execução mensal admitida pelo mesmo ID' : 'Calcular / actualizar IVA mensal e guardar versão'}</button>
{pendingRecord && <p>Consulta da execução {pendingRecord.execution.id}. A suspensão de novos cálculos não impede esta recuperação; não é criado outro pedido.</p>}
{settings.auto && <button type="button" className="ml-3 underline" onClick={() => saveSettings({auto:false})}>Pausar actualização automática</button>}
{input && <p className="text-sm">{input.monthly_sales.length} meses; moeda {input.currency || 'por indicar'}. Campo vazio significa informação em falta; zero declarado permanece zero. Depois da primeira execução, alterações das vendas actualizam o cálculo enquanto a actualização automática estiver ligada.</p>}
{projection?.rows && <div className="overflow-x-auto" tabIndex={0}><table className="min-w-full text-sm"><caption>IVA mensal — {current ? 'versão correspondente às entradas' : 'versão histórica'}</caption><thead><tr>{['Mês','Vendas declaradas','Vendas tributáveis sem IVA','Taxa aplicada (%)','IVA calculado','Total com IVA','Estado'].map((label) => <th key={label} className="p-2 text-left">{label}</th>)}</tr></thead><tbody>{projection.rows.map((row) => <tr key={row.date}><th scope="row" className="p-2">{row.date.slice(0,7)}</th><td>{row.sales_declared ?? 'Sem informação'}</td><td>{row.net_sales ?? 'Indeterminado'}</td><td>{row.rate ?? 'Indeterminada'}</td><td>{row.vat ?? 'Indeterminado'}</td><td>{row.gross_sales ?? 'Indeterminado'}</td><td>{{calculated:'Calculado',missing_sales:'Sem informação de vendas',unsupported_basis:'Base líquida não determinada',indeterminate:'Enquadramento ou regra indeterminados'}[row.status] ?? 'Indeterminado'}</td></tr>)}</tbody></table></div>}
+15 -4
View File
@@ -17,7 +17,9 @@ export function defaultCashflowDraftInputs() {
}
export type CashflowDraftSnapshot = {
version: 1;
version: 1 | 2;
/** Missing in legacy v1: never infer it from the current project. */
currency?: string | null;
taxMode: 'fixed' | 'schedule';
inputs: ReturnType<typeof defaultCashflowDraftInputs>;
};
@@ -31,7 +33,8 @@ export function restoreCashflowDraftSnapshot(
const parsed = JSON.parse(snapshot);
if (
!parsed ||
parsed.version !== 1 ||
![1, 2].includes(parsed.version) ||
(parsed.version === 2 && parsed.currency !== null && (typeof parsed.currency !== 'string' || !/^[A-Z]{3}$/.test(parsed.currency))) ||
!['fixed', 'schedule'].includes(parsed.taxMode) ||
!parsed.inputs ||
typeof parsed.inputs !== 'object' ||
@@ -55,7 +58,7 @@ export function restoreCashflowDraftSnapshot(
} else if (typeof value !== 'string' || value.length > 160)
return null;
}
return { version: 1, taxMode: parsed.taxMode, inputs: parsed.inputs };
return { version: parsed.version, ...(parsed.version === 2 ? {currency: parsed.currency} : {}), taxMode: parsed.taxMode, inputs: parsed.inputs };
} catch {
return null;
}
@@ -64,8 +67,16 @@ export function restoreCashflowDraftSnapshot(
export function serializeCashflowDraftSnapshot(
taxMode: CashflowDraftSnapshot['taxMode'],
inputs: CashflowDraftSnapshot['inputs'],
currency?: string | null,
): string {
return JSON.stringify({ version: 1, taxMode, inputs });
return JSON.stringify({ version: currency === undefined ? 1 : 2, ...(currency !== undefined ? {currency} : {}), taxMode, inputs });
}
export function cashflowCurrency(saved: CashflowDraftSnapshot, projectCurrency: string): {currency: string; matchesProject: boolean; notice: string} {
const currency = saved.version === 2 ? saved.currency : null;
if (!currency) throw new Error('A moeda de origem da tesouraria guardada está por confirmar. Os montantes foram preservados; confirme a moeda na ferramenta de tesouraria antes de calcular ou reutilizar as vendas.');
const matchesProject = currency === projectCurrency;
return {currency, matchesProject, notice: matchesProject ? '' : `A tesouraria foi guardada em ${currency}, mas o projecto está em ${projectCurrency || 'moeda por indicar'}. Os montantes continuam em ${currency}; não foram convertidos nem reinterpretados. Para os reutilizar no projecto, restaure explicitamente a moeda de origem. Uma projecção noutra moeda exige pressupostos próprios.`};
}
export type MonthlyCashflowInput = {
+10 -1
View File
@@ -1,5 +1,5 @@
import { monthlyProjectedSales, restoreBusinessPlanSnapshot } from './business-financial-plan.ts';
import { restoreCashflowDraftSnapshot } from './monthly-cashflow.ts';
import { cashflowCurrency, restoreCashflowDraftSnapshot } from './monthly-cashflow.ts';
import { isApiSimulationReadResponseV1, type ApiSimulationExecutionV1 } from './simulation-contract.ts';
export type MonthlyIvaInput = {
@@ -27,6 +27,8 @@ export function monthlySalesFromDraft(draft: Record<string, unknown>): { amounts
if (draft.cashflow_snapshot) {
const saved = restoreCashflowDraftSnapshot(String(draft.cashflow_snapshot));
if (!saved) throw new Error('Não foi possível recuperar a receita projectada.');
const origin = cashflowCurrency(saved, String(draft.currency ?? ''));
if (!origin.matchesProject) throw new Error(origin.notice);
return {amounts: Array.from({length: 12}, () => saved.inputs.monthlyRevenue.trim() === '' ? null : saved.inputs.monthlyRevenue), source: 'Receita mensal constante já declarada na projecção de tesouraria; confirme que corresponde a vendas tributáveis.'};
}
return {amounts: Array.from({length: 12}, () => null), source: 'Sem projecção de vendas guardada. Prepare as vendas no plano empresarial; não é necessário voltar a introduzi-las aqui.'};
@@ -48,4 +50,11 @@ export function readMonthlyIvaRecord(raw?: string): MonthlyIvaRecord | null {
} catch { /* Keep the stored value intact; no fallback fiscal result. */ }
return null;
}
/** Read an admitted execution through the existing ownership-protected endpoint; never create one. */
export async function recoverMonthlyIvaRecord(record: MonthlyIvaRecord, request: typeof fetch = fetch): Promise<MonthlyIvaRecord> {
const response = await request(`/api/v1/simulations/${encodeURIComponent(record.execution.id)}`, {credentials:'same-origin', headers:{Accept:'application/json'}});
const value: unknown = await response.json();
if (!response.ok || !isApiSimulationReadResponseV1(value) || value.data.id !== record.execution.id) throw new Error('Não foi possível recuperar a execução mensal guardada. Os dados foram preservados e não foi criada uma execução alternativa.');
return {input: record.input, execution: value.data};
}
export const monthlyIvaCashTreatment = 'IVA apresentado separadamente: não é somado novamente aos recebimentos declarados nem deduzido como custo ou imposto final a pagar. A tesouraria conserva os recebimentos e pagamentos introduzidos, antes de uma liquidação fiscal não apurada; não se presume cobrança integral das vendas no mesmo mês.';
+4 -1
View File
@@ -7,6 +7,7 @@ import { fiscalQaRows } from './fiscal-qa-preview.ts';
import { csvCellWithDelimiter } from './csv.js';
import {
restoreCashflowDraftSnapshot,
cashflowCurrency,
projectMonthlyCashflow,
} from './monthly-cashflow.ts';
import {
@@ -637,7 +638,9 @@ export function buildProjectDossierRows(
throw new Error(
'Pressupostos de tesouraria não recuperáveis; não foram substituídos por valores alternativos.',
);
const currency = String(draft.currency ?? '');
const originCurrency = cashflowCurrency(saved, String(draft.currency ?? ''));
const currency = originCurrency.currency;
if (originCurrency.notice) rows.push(['Tesouraria', 'Divergência de moeda', originCurrency.notice, 'Moeda original preservada; sem conversão']);
if (!currency)
throw new Error(
'Indique uma moeda para interpretar os montantes de tesouraria.',
@@ -0,0 +1,67 @@
<?php
namespace Tests\Feature;
use App\Jobs\RunSimulation;
use App\Models\Simulation;
use App\Models\SimulationTaxRuleVersion;
use App\Models\User;
use App\Services\SimulationExecution;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Str;
use Tests\TestCase;
final class SimulatorClosureRecoveryTest extends TestCase
{
use DatabaseTransactions;
public function test_existing_monthly_admission_can_be_read_and_finished_when_new_admissions_are_off(): void
{
$identity = DB::selectOne('select current_database() as name,inet_server_port() as port');
$this->assertSame('simulator_fiscal_delta_20261021_test', $identity->name);
$this->assertSame(55441, (int) $identity->port);
Queue::fake();
// Reuse the existing isolated synthetic rule. No create/publish/suspend of any rule.
$rule = SimulationTaxRuleVersion::findOrFail('01a15e50-7972-72e0-8991-82ca28df9b49');
$before = $this->ruleFingerprint();
config(['simulation-fiscal-consumer.enabled' => true, 'simulation-fiscal-consumer.rule_id' => $rule->id, 'simulation-fiscal-consumer.rule_version' => $rule->rule_version]);
$actor = User::factory()->create(['email_verified_at' => now()]);
$input = ['scenario_kind' => 'fiscal_rule_published', 'currency' => 'MZN', 'scope_declared' => true, 'sales_vat_basis' => 'net', 'monthly_sales' => [['date' => '2026-01-01', 'amount' => '1000.03']]];
$id = $this->actingAs($actor)->postJson('/api/v1/simulations', $input, ['Idempotency-Key' => (string) Str::uuid()])->assertAccepted()->json('data.id');
$count = Simulation::count();
config(['simulation-fiscal-consumer.enabled' => false]);
$this->getJson('/api/v1/fiscal-consumer')->assertOk()->assertJsonPath('data.enabled', false);
$this->getJson('/api/v1/simulations/'.$id)->assertOk()->assertJsonPath('data.id', $id)->assertJsonPath('data.status', 'queued');
$this->postJson('/api/v1/simulations', $input, ['Idempotency-Key' => (string) Str::uuid()])->assertStatus(503);
(new RunSimulation($id, $actor->id, null))->handle(app(SimulationExecution::class));
$this->getJson('/api/v1/simulations/'.$id)->assertOk()->assertJsonPath('data.id', $id)->assertJsonPath('data.status', 'completed');
$this->assertSame($count, Simulation::count());
$this->actingAs(User::factory()->create(['email_verified_at' => now()]))->getJson('/api/v1/simulations/'.$id)->assertNotFound();
$this->assertSame($before, $this->ruleFingerprint());
}
private function ruleFingerprint(): string
{
return DB::selectOne("select md5(string_agg(row_to_json(r)::text,E'\\n' order by id)) as hash from simulation_tax_rule_versions r")->hash;
}
public function test_cashflow_original_currency_and_amounts_round_trip_in_the_existing_private_draft(): void
{
$identity = DB::selectOne('select current_database() as name,inet_server_port() as port');
$this->assertSame('simulator_fiscal_delta_20261021_test', $identity->name);
$this->assertSame(55441, (int) $identity->port);
config(['investor-path.enabled' => true, 'suitecrm.enabled' => false]);
$actor = User::factory()->create(['email_verified_at' => now()]);
$this->actingAs($actor);
$id = $this->postJson('/investimentos', ['title' => 'SYNTHETIC QA — moeda de tesouraria', 'client_key' => (string) Str::uuid(), 'reuse_consent' => true])->assertCreated()->json('id');
$snapshot = json_encode(['version' => 2, 'currency' => 'USD', 'taxMode' => 'fixed', 'inputs' => ['monthlyRevenue' => '123', 'monthlyTaxSchedule' => ['0', '', '0']]], JSON_THROW_ON_ERROR);
$this->postJson('/api/v1/simulation-drafts', ['context' => $id, 'personal_snapshot' => [], 'economic_snapshot' => ['purpose' => 'simulate_only', 'project_title' => 'SYNTHETIC QA — moeda', 'currency' => 'MZN', 'cashflow_snapshot' => $snapshot]], ['Idempotency-Key' => (string) Str::uuid()])->assertCreated();
$this->actingAs($actor->fresh());
$saved = $this->getJson('/api/v1/simulation-drafts/current?context='.$id)->assertOk()->json('data.economic_snapshot');
$this->assertSame('MZN', $saved['currency']);
$this->assertSame($snapshot, $saved['cashflow_snapshot']);
$this->assertSame('USD', json_decode($saved['cashflow_snapshot'], true)['currency']);
}
}
@@ -0,0 +1,13 @@
import {serializeBusinessPlanSnapshot} from '../../../resources/js/lib/business-financial-plan.ts';
export function closureFixture(status='completed') {
const fields=['openingCash','monthlyCollections','monthlyVariableCostPayments','monthlyInventoryPurchases','monthlyOperatingExpenses','monthlyOperatingPayments','monthlyPayrollExpense','monthlyPayrollPayments','assetInvestment','inventory','receivables','payables','equity','shareholderLoans','bankLoanProceeds','grants','assetDepreciation','monthlyInterestExpense','monthlyInterestPayment','monthlyPrincipalRepayment'];
const plan={...Object.fromEntries(fields.map(k=>[k,'0'])),currency:'MZN',periods:4,depreciationLifeMonths:60,products:[{label:'SYNTHETIC QA',monthlyUnits:'1',unitPrice:'1000.03',unitVariableCost:'0',monthlyUnitsByPeriod:['1','2','0','']}]};
const input={scenario_kind:'fiscal_rule_published',monthly_sales:[{date:'2026-01-01',amount:'1000.03'},{date:'2026-02-01',amount:'2000.06'},{date:'2026-03-01',amount:'0.00'},{date:'2026-04-01',amount:null}],sales_vat_basis:'net',currency:'MZN',scope_declared:true};
// Frozen numbers already accepted in the monthly QA receipt; no tax calculation here.
const rows=[{date:'2026-01-01',sales_declared:'1000.03',net_sales:'1000.03',rate:'16',vat:'160.0048',gross_sales:'1160.0348',status:'calculated'},{date:'2026-02-01',sales_declared:'2000.06',net_sales:'2000.06',rate:'16',vat:'320.0096',gross_sales:'2320.0696',status:'calculated'},{date:'2026-03-01',sales_declared:'0.00',net_sales:'0.00',rate:'16',vat:'0',gross_sales:'0',status:'calculated'},{date:'2026-04-01',sales_declared:null,net_sales:null,rate:null,vat:null,gross_sales:null,status:'missing_sales'}];
const snapshot={contract_version:1,scenario_kind:'fiscal_rule_published',base:null,currency:'MZN',indicative_total:null,legal_interpretation:false,formula_version:'investmoz.tax.v1',context:{reference_date:'2026-01-01',currency:'MZN',currency_fraction_digits:2,scenario_id:'61000100-0000-4000-8000-000000000099',scenario_version:1,engine_version:'fiscal-published-v1',ruleset_version:'IVA-GERAL-BASE-LIQUIDA:4'},result:{status:'indeterminate',value:null,unit:'MZN',explanation:'IVA das vendas projectadas; sem apuramento final.',missing_inputs:['monthly_sales'],source_ids:['synthetic-qa-source']},presentation:{value:null,rounding_applied:false},fiscal_provenance:{rule:{rule_code:'IVA-GERAL-BASE-LIQUIDA',rule_version:4,valid_from:'2023-01-01',valid_until:null,source_sha256:'synthetic-qa-source-hash',source:{diploma:'Fonte sintética QA — regra já ensaiada',article:'Cópia do snapshot aceite'}},scope_status:'user_declared'},monthly_projection:{input,rows,cash_treatment:'IVA separado da tesouraria; não é imposto final a pagar.'}};
const execution={id:'06566910-9672-4176-804d-aad8350417cb',status,version_no:2,result_snapshot:status==='completed'?snapshot:null};
const context={version:1,context_revision:13,consumer_opportunity:'opportunity:309',opportunity_choices:[{opportunity:{key:'opportunity:309',title:'SYNTHETIC QA — segunda oportunidade Manica'}}],path:{steps:[]}};
return {input,snapshot,execution,draft:{project_title:'SYNTHETIC QA — fecho do simulador S1 S2 S3',currency:'MZN',context_opportunity:'opportunity:309',context_applicability_snapshot:JSON.stringify(context),business_plan_snapshot:serializeBusinessPlanSnapshot(plan),monthly_iva_settings:JSON.stringify({start:'2026-01',basis:'net',declared:true,auto:false}),monthly_iva_snapshot:JSON.stringify({input,execution})}};
}
@@ -0,0 +1,55 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {mkdir,writeFile} from 'node:fs/promises';
import JSZip from 'jszip';
import {defaultCashflowDraftInputs,serializeCashflowDraftSnapshot,restoreCashflowDraftSnapshot,cashflowCurrency} from '../../resources/js/lib/monthly-cashflow.ts';
import {monthlyIvaInput,recoverMonthlyIvaRecord} from '../../resources/js/lib/monthly-iva.ts';
import {exportProjectDossierCsv,buildProjectDossierRows} from '../../resources/js/lib/project-dossier.ts';
import {createProjectDossierReport,generateProjectDossierDocx,createProjectDossierPrintHtml} from '../../resources/js/lib/project-dossier-report.ts';
import {closureFixture} from './fixtures/simulator-closure-fixture.mjs';
test('S1 saved USD stays USD in report and cannot be reused as MZN for IVA',()=>{
const inputs={...defaultCashflowDraftInputs(),monthlyRevenue:'123',monthlyTaxAssessed:'0'};
const snapshot=serializeCashflowDraftSnapshot('fixed',inputs,'USD');
const draft={currency:'MZN',cashflow_snapshot:snapshot};
const saved=restoreCashflowDraftSnapshot(snapshot);
assert.equal(saved.currency,'USD');assert.deepEqual(saved.inputs,inputs);
assert.equal(cashflowCurrency(saved,'MZN').matchesProject,false);
assert.throws(()=>monthlyIvaInput(draft,{start:'2026-01',basis:'net',declared:true,auto:false}),/continuam em USD/);
const rows=buildProjectDossierRows(draft).filter(r=>r[0]==='Tesouraria');
assert.match(rows.find(r=>r[1]==='Pressupostos e período')[2],/moeda USD/);
assert.match(rows.find(r=>r[1]==='Divergência de moeda')[2],/não foram convertidos/);
const restoredProject={...draft,currency:'USD'};
assert.equal(cashflowCurrency(restoreCashflowDraftSnapshot(restoredProject.cashflow_snapshot),restoredProject.currency).matchesProject,true);
assert.equal(restoredProject.cashflow_snapshot,snapshot);
});
test('S1 legacy amounts remain readable but require explicit origin currency before reuse',()=>{
const inputs={...defaultCashflowDraftInputs(),monthlyRevenue:'123'};
const legacy=serializeCashflowDraftSnapshot('fixed',inputs);const saved=restoreCashflowDraftSnapshot(legacy);
assert.equal(saved.version,1);assert.throws(()=>cashflowCurrency(saved,'MZN'),/por confirmar/);
const confirmed=restoreCashflowDraftSnapshot(serializeCashflowDraftSnapshot(saved.taxMode,saved.inputs,'USD'));
assert.deepEqual(confirmed.inputs,saved.inputs);assert.equal(confirmed.currency,'USD');
assert.equal(restoreCashflowDraftSnapshot(legacy).version,1);
});
test('S2 recovery is a same-ID authenticated GET without new admission and preserves denied records',async()=>{
const pending=closureFixture('queued');const completed=closureFixture();const calls=[];
const record={input:pending.input,execution:pending.execution};const before=JSON.stringify(record);
const request=async(url,options)=>{calls.push({url,options});return new Response(JSON.stringify({data:completed.execution}),{status:200});};
const recovered=await recoverMonthlyIvaRecord(record,request);
assert.equal(recovered.execution.id,pending.execution.id);assert.equal(recovered.execution.status,'completed');assert.deepEqual(recovered.input,pending.input);
assert.equal(calls.length,1);assert.equal(calls[0].options.credentials,'same-origin');assert.equal(calls[0].options.method,undefined);assert.match(calls[0].url,/simulations\/06566910/);
await assert.rejects(()=>recoverMonthlyIvaRecord(record,async()=>new Response('{}',{status:403})),/preservados/);
await assert.rejects(()=>recoverMonthlyIvaRecord(record,async()=>new Response(JSON.stringify({data:{...completed.execution,id:'00000000-0000-4000-8000-000000000000'}}),{status:200})),/preservados/);
assert.equal(JSON.stringify(record),before);
});
test('S3 final CSV Word and print source retain frozen monthly values context source and version',async()=>{
const {draft}=closureFixture();
const report=createProjectDossierReport(draft,{selectedSections:new Set(['sources','finance']),version:'SYNTHETIC QA S3'});
const csv=exportProjectDossierCsv(draft);const html=await createProjectDossierPrintHtml(report,[],false);
const docx=await generateProjectDossierDocx(report,[],true);const bytes=new Uint8Array(await docx.arrayBuffer());const word=await JSZip.loadAsync(bytes);const xml=await word.file('word/document.xml').async('string');
for(const text of [csv,html,xml]) for(const expected of ['160.0048','320.0096','1160.0348','MZN','2026-01','2026-04','Fonte sintética QA','IVA-GERAL-BASE-LIQUIDA','segunda oportunidade Manica','opportunity:309']) assert.ok(text.includes(expected),expected);
for(const text of [csv,html,xml]) assert.match(text,/IVA-GERAL-BASE-LIQUIDA[^\n<]{0,30}4/);
assert.match(html,/segunda oportunidade Manica/);assert.match(xml,/segunda oportunidade Manica/);
const out='outputs/simulator-closure-qa-20261024';await mkdir(out,{recursive:true});
await Promise.all([writeFile(`${out}/monthly-final.csv`,csv),writeFile(`${out}/monthly-final.docx`,bytes),writeFile(`${out}/monthly-final.html`,html),writeFile(`${out}/frozen-fixture.json`,JSON.stringify(draft,null,2))]);
});
@@ -17,7 +17,7 @@ test('cashflow draft recovery preserves unanswered months and invalid editable v
openingCash: '77.01',
monthlyTaxSchedule: ['10', '', '20', ...Array(9).fill('0')],
};
const stored = serializeCashflowDraftSnapshot('schedule', inputs);
const stored = serializeCashflowDraftSnapshot('schedule', inputs, 'MZN');
const restored = restoreCashflowDraftSnapshot(stored);
assert.equal(restored.taxMode, 'schedule');
assert.deepEqual(restored.inputs, inputs);
@@ -65,7 +65,7 @@ test('cashflow draft recovery preserves unanswered months and invalid editable v
assert.equal(restoreCashflowDraftSnapshot('{"version":2}'), null);
assert.equal(
restoreCashflowDraftSnapshot(
stored.replace('"version":1', '"version":2'),
stored.replace('"version":2', '"version":3'),
),
null,
);