From 226fd308dd45d2805ef4678b20ca4398d7fef6c6 Mon Sep 17 00:00:00 2001 From: nala Date: Wed, 21 Oct 2026 11:59:50 +0200 Subject: [PATCH] feat: connect published IVA consumer to normal simulator in QA --- .env.example | 5 + .../Api/V1/SimulationController.php | 14 +- .../InvestmentLegalSourceController.php | 1 + app/Http/Requests/StoreSimulationRequest.php | 37 +++ app/Jobs/RunSimulation.php | 12 +- app/Services/SimulationExecution.php | 22 +- app/Services/SimulationFiscalQaPreview.php | 33 +-- app/Services/SimulationFiscalSnapshot.php | 58 +++++ app/Services/SimulationPublishedFiscal.php | 82 +++++++ app/Services/SimulationTaxRuleGovernance.php | 2 + config/simulation-fiscal-consumer.php | 8 + .../ficha-IVA-GERAL-BASE-LIQUIDA-v1.md | 2 + ...-review-continuation-20261020-receipt.json | 104 +++++++++ .../unification/traceability-and-manual.md | 40 ++++ .../v2/auditoria-portal/matriz-requisitos.csv | 2 +- resources/js/components/fiscal-qa-preview.tsx | 4 +- resources/js/components/simulation-form.tsx | 30 +-- .../components/simulation-published-iva.tsx | 68 ++++++ resources/js/lib/fiscal-qa-preview.ts | 11 +- resources/js/lib/project-dossier-report.ts | 10 +- resources/js/lib/simulation-form.ts | 15 +- routes/api.php | 2 + .../Feature/SimulationPublishedFiscalTest.php | 214 ++++++++++++++++++ tests/frontend/fiscal-qa-preview.test.mjs | 45 +++- 24 files changed, 747 insertions(+), 74 deletions(-) create mode 100644 app/Services/SimulationFiscalSnapshot.php create mode 100644 app/Services/SimulationPublishedFiscal.php create mode 100644 config/simulation-fiscal-consumer.php create mode 100644 resources/js/components/simulation-published-iva.tsx create mode 100644 tests/Feature/SimulationPublishedFiscalTest.php diff --git a/.env.example b/.env.example index 60ee2f9d..0eb72f1a 100644 --- a/.env.example +++ b/.env.example @@ -4,6 +4,11 @@ APP_KEY= APP_DEBUG=true APP_URL=http://localhost:8000 +# Separate from publication. Bind only an explicitly authorised published rule version. +INVESTMOZ_IVA_CONSUMER_ENABLED=false +INVESTMOZ_IVA_CONSUMER_RULE_ID= +INVESTMOZ_IVA_CONSUMER_RULE_VERSION=0 + APP_LOCALE=en APP_FALLBACK_LOCALE=en APP_FAKER_LOCALE=en_US diff --git a/app/Http/Controllers/Api/V1/SimulationController.php b/app/Http/Controllers/Api/V1/SimulationController.php index bf0d3e03..3f752588 100644 --- a/app/Http/Controllers/Api/V1/SimulationController.php +++ b/app/Http/Controllers/Api/V1/SimulationController.php @@ -37,11 +37,17 @@ class SimulationController extends Controller $token = $actorId === null ? $request->header('X-Simulation-Token') : null; abort_if($actorId === null && (! is_string($token) || strlen($token) < 32 || strlen($token) > 256), 422, 'X-Simulation-Token opaco gerado pelo cliente é obrigatório para execução anónima.'); $validated = $request->validated(); - $data = ['base' => (string) $validated['base'], 'rate' => (string) $validated['rate'], - 'reduction' => (string) ($validated['reduction'] ?? '0'), 'organization_id' => $validated['organization_id'] ?? null]; - $organizationId = $data['organization_id'] ?? null; + $organizationId = $validated['organization_id'] ?? null; $execution->authorizeOrganization($actorId, $organizationId); - $result = $execution->create($data, $actorId, $organizationId, $token, $key); + if (($validated['scenario_kind'] ?? null) === 'fiscal_rule_published') { + $input = array_intersect_key($validated, array_flip(['base', 'currency', 'reference_date', 'operation', 'scope_declared'])); + $input['scope_declared'] = (bool) $input['scope_declared']; + $result = $execution->createPublishedFiscal($input, $actorId, $organizationId, $token, $key); + } else { + $data = ['base' => (string) $validated['base'], 'rate' => (string) $validated['rate'], + 'reduction' => (string) ($validated['reduction'] ?? '0')]; + $result = $execution->create($data, $actorId, $organizationId, $token, $key); + } $simulation = $result['simulation']; $body = ['data' => $simulation, 'replayed' => $result['replayed']]; if ($actorId === null) { diff --git a/app/Http/Controllers/InvestmentLegalSourceController.php b/app/Http/Controllers/InvestmentLegalSourceController.php index 30e53e38..89f5a433 100644 --- a/app/Http/Controllers/InvestmentLegalSourceController.php +++ b/app/Http/Controllers/InvestmentLegalSourceController.php @@ -40,6 +40,7 @@ class InvestmentLegalSourceController extends Controller $data = $request->validate(['version_no' => ['required', 'integer', 'min:1']]); return DB::transaction(function () use ($request, $id, $action, $data) { + DB::select('select pg_advisory_xact_lock(261022, hashtext(?))', [$id]); $source = InvestmentLegalSource::whereKey($id)->lockForUpdate()->firstOrFail(); abort_if((int) $source->version_no !== (int) $data['version_no'], 409, 'Versão alterada.'); if ($action === 'validate') { diff --git a/app/Http/Requests/StoreSimulationRequest.php b/app/Http/Requests/StoreSimulationRequest.php index ed8df982..736bd3e3 100644 --- a/app/Http/Requests/StoreSimulationRequest.php +++ b/app/Http/Requests/StoreSimulationRequest.php @@ -11,10 +11,47 @@ class StoreSimulationRequest extends FormRequest return true; } + public function messages(): array + { + return [ + 'required' => 'Preencha o campo :attribute. As entradas foram preservadas.', + 'string' => 'Indique :attribute em formato textual.', + 'regex' => 'Indique uma base decimal positiva ou zero, com ponto decimal e sem separadores de milhares.', + 'max' => 'O campo :attribute excede o tamanho permitido.', + 'in' => 'A opção indicada em :attribute não pertence a este cálculo.', + 'date_format' => 'Indique uma data de exigibilidade válida.', + 'boolean' => 'Indique se declara o enquadramento da operação.', + 'prohibited' => 'O campo :attribute não pode ser definido pelo cliente neste cálculo.', + ]; + } + + public function attributes(): array + { + return ['base' => 'base tributável', 'currency' => 'moeda', 'reference_date' => 'data de exigibilidade', + 'operation' => 'operação', 'scope_declared' => 'declaração de enquadramento', 'scenario_kind' => 'tipo de cálculo', + 'rule_id' => 'regra', 'rule_version' => 'versão da regra', 'rate' => 'taxa', 'reduction' => 'redução']; + } + /** @return array> */ public function rules(): array { + if ($this->input('scenario_kind') === 'fiscal_rule_published') { + return [ + 'scenario_kind' => ['required', 'in:fiscal_rule_published'], + 'base' => ['required', 'string', 'max:40', 'regex:/^\d+(?:\.\d+)?$/'], + 'currency' => ['required', 'in:MZN'], + 'reference_date' => ['required', 'date_format:Y-m-d'], + 'operation' => ['required', 'in:internal_general_net'], + 'scope_declared' => ['required', 'boolean'], + 'organization_id' => ['nullable', 'uuid', 'exists:organizations,id'], + 'rate' => ['prohibited'], 'reduction' => ['prohibited'], + 'rule_id' => ['prohibited'], 'rule_version' => ['prohibited'], + 'fiscal_preview' => ['prohibited'], + ]; + } + return [ + 'scenario_kind' => ['sometimes', 'in:custom_planning'], 'base' => ['required', 'numeric', 'min:0'], 'rate' => ['required', 'numeric', 'between:0,100'], 'reduction' => ['required', 'numeric', 'between:0,100'], diff --git a/app/Jobs/RunSimulation.php b/app/Jobs/RunSimulation.php index d3f5dc6e..92fb5f1a 100644 --- a/app/Jobs/RunSimulation.php +++ b/app/Jobs/RunSimulation.php @@ -65,10 +65,12 @@ class RunSimulation implements ShouldQueue $input = json_decode($scenario->parameters, true, flags: JSON_THROW_ON_ERROR); $resultId = (string) Str::uuid(); $engineVersion = 'portal-arithmetic-v1+simulation-v'.$simulation->version_no; - if (($input['scenario_kind'] ?? null) === 'fiscal_rule_qa_preview') { - abort_unless(IsolatedQaDatabase::allowsSchemaChanges(), 404); + if (in_array($input['scenario_kind'] ?? null, ['fiscal_rule_qa_preview', 'fiscal_rule_published'], true)) { + if ($input['scenario_kind'] === 'fiscal_rule_qa_preview') { + abort_unless(IsolatedQaDatabase::allowsSchemaChanges(), 404); + } $result = $input['fiscal_preview']; - $engineVersion = 'fiscal-draft-preview-v1'; + $engineVersion = $result['context']['engine_version']; $result['context']['scenario_id'] = (string) $scenario->id; $result['context']['scenario_version'] = (int) $scenario->version_no; } else { @@ -108,7 +110,7 @@ class RunSimulation implements ShouldQueue $sourceJson = json_encode(isset($result['fiscal_provenance']) ? [ 'base' => $result['base'], 'currency' => $result['currency'], 'reference_date' => $result['context']['reference_date'], - 'context' => $result['fiscal_provenance']['context'], 'scope_status' => 'user_declared', + 'context' => $result['fiscal_provenance']['context'], 'scope_status' => $result['fiscal_provenance']['scope_status'], ] : $input, JSON_THROW_ON_ERROR); $canonicalSource = DB::selectOne('select CAST(? AS jsonb)::text as canonical', [$sourceJson])->canonical; DB::table('simulation_result_sources')->insertOrIgnore(['id' => (string) Str::uuid(), 'result_id' => $resultId, @@ -119,7 +121,7 @@ class RunSimulation implements ShouldQueue $provenanceJson = json_encode($provenance, JSON_THROW_ON_ERROR); $canonicalProvenance = DB::selectOne('select CAST(? AS jsonb)::text as canonical', [$provenanceJson])->canonical; DB::table('simulation_result_sources')->insert([ - 'id' => (string) Str::uuid(), 'result_id' => $resultId, 'source_type' => 'tax_rule_draft', + 'id' => (string) Str::uuid(), 'result_id' => $resultId, 'source_type' => $input['scenario_kind'] === 'fiscal_rule_published' ? 'tax_rule_published' : 'tax_rule_draft', 'source_id' => $provenance['rule']['id'], 'source_version' => (string) $provenance['rule']['rule_version'], 'source_snapshot' => $provenanceJson, 'source_sha256' => hash('sha256', $canonicalProvenance), 'created_at' => now(), ]); diff --git a/app/Services/SimulationExecution.php b/app/Services/SimulationExecution.php index 8d30a3e9..0bea6edb 100644 --- a/app/Services/SimulationExecution.php +++ b/app/Services/SimulationExecution.php @@ -34,7 +34,15 @@ final class SimulationExecution $actorId, null, null, $key); } - private function createPayload(array $payload, ?int $actorId, ?string $organizationId, ?string $token, string $key): array + public function createPublishedFiscal(array $input, ?int $actorId, ?string $organizationId, ?string $token, string $key): array + { + $payload = ['scenario_kind' => 'fiscal_rule_published', 'fiscal_input' => $input, 'organization_id' => $organizationId]; + + return $this->createPayload($payload, $actorId, $organizationId, $token, $key, + fn (): array => app(SimulationPublishedFiscal::class)->admit($input)); + } + + private function createPayload(array $payload, ?int $actorId, ?string $organizationId, ?string $token, string $key, ?callable $admit = null): array { $tokenHash = $actorId === null ? hash_hmac('sha256', (string) $token, (string) config('app.key')) : null; $scopeHash = hash('sha256', $actorId !== null ? (string) $actorId : ($tokenHash ?? 'anonymous')); @@ -42,7 +50,7 @@ final class SimulationExecution ksort($payload); $payloadHash = hash('sha256', json_encode($payload, JSON_THROW_ON_ERROR)); - return DB::transaction(function () use ($actorId, $organizationId, $token, $tokenHash, $scopeHash, $keyHash, $payloadHash, $payload): array { + return DB::transaction(function () use ($actorId, $organizationId, $token, $tokenHash, $scopeHash, $keyHash, $payloadHash, $payload, $admit): array { $this->setContext($actorId, $tokenHash); DB::select('select pg_advisory_xact_lock(261001, hashtext(?))', [$scopeHash.$keyHash]); $receipt = DB::table('simulation_idempotency')->where('scope_hash', $scopeHash)->where('key_hash', $keyHash)->first(); @@ -54,6 +62,10 @@ final class SimulationExecution return ['simulation' => Simulation::with(['scenarios', 'reports'])->whereKey((string) $receipt->simulation_id)->firstOrFail(), 'token' => null, 'replayed' => true]; } + if ($admit !== null) { + $payload['fiscal_preview'] = $admit(); + } + $plainToken = $actorId === null ? $token : null; $tokenHash = $actorId === null ? hash_hmac('sha256', $plainToken, (string) config('app.key')) : null; $this->setContext($actorId, $tokenHash); @@ -61,8 +73,8 @@ final class SimulationExecution $simulation = Simulation::create([ 'id' => $id, 'questionnaire_version_id' => '61000100-0000-4000-8000-000000000001', 'owner_user_id' => $actorId, 'organization_id' => $organizationId, 'anonymous_token_hash' => $tokenHash, 'status' => 'queued', 'version_no' => 1, - 'input_snapshot' => $payload, 'calculation_snapshot' => $payload['scenario_kind'] === 'fiscal_rule_qa_preview' - ? ['engine' => 'fiscal-draft-preview-v1', 'mode' => 'qa_draft_only', 'arithmetic' => 'exact_decimal_no_rounding'] + 'input_snapshot' => $payload, 'calculation_snapshot' => in_array($payload['scenario_kind'], ['fiscal_rule_qa_preview', 'fiscal_rule_published'], true) + ? ['engine' => $payload['fiscal_preview']['context']['engine_version'], 'mode' => $payload['fiscal_preview']['fiscal_provenance']['mode'], 'arithmetic' => 'exact_decimal_no_rounding'] : ['engine' => 'portal-arithmetic-v1', 'formula' => 'base * rate / 100 * (1 - reduction / 100)', 'legal_rate_source' => null], ]); $scenarioId = (string) Str::uuid(); @@ -104,7 +116,7 @@ final class SimulationExecution return DB::transaction(function () use ($id, $scenarioId, $data, $request): Simulation { $this->setContext($request->user()?->id, $this->tokenHash($request)); $simulation = Simulation::whereNull('deleted_at')->lockForUpdate()->findOrFail($id); - abort_if(($simulation->input_snapshot['scenario_kind'] ?? null) === 'fiscal_rule_qa_preview', 422, + abort_if(in_array($simulation->input_snapshot['scenario_kind'] ?? null, ['fiscal_rule_qa_preview', 'fiscal_rule_published'], true), 422, 'Crie uma nova pré-visualização fiscal; este snapshot conserva a regra e as entradas originais.'); $this->requireMatch($request, $simulation); $scenario = DB::table('simulation_scenarios')->where('simulation_id', $id)->where('id', $scenarioId)->lockForUpdate()->first(); diff --git a/app/Services/SimulationFiscalQaPreview.php b/app/Services/SimulationFiscalQaPreview.php index dc9bcdbc..4dbb4e51 100644 --- a/app/Services/SimulationFiscalQaPreview.php +++ b/app/Services/SimulationFiscalQaPreview.php @@ -7,7 +7,6 @@ namespace App\Services; use App\Models\InvestmentLegalSource; use App\Models\User; use App\Support\IsolatedQaDatabase; -use Brick\Math\BigRational; use Illuminate\Support\Facades\DB; /** Exact operation-level IVA calculation against an explicit QA draft; never a payment/return total. */ @@ -43,37 +42,7 @@ final class SimulationFiscalQaPreview ['base_tributavel' => $input['base']], ['iva_general_rate' => $rate['value']]); } } - $exact = ($evaluation['status'] ?? null) === 'determinate' - ? (string) BigRational::of($evaluation['result'])->toBigDecimal() - : null; - if ($exact === null && $missing === []) { - $missing = $evaluation['missing'] ?? ['eligible_parameter']; - } - $snapshot = [ - 'contract_version' => 1, 'scenario_kind' => 'fiscal_rule_qa_preview', - 'base' => $input['base'], 'currency' => $input['currency'], 'indicative_total' => $exact, - 'legal_interpretation' => false, 'formula_version' => 'investmoz.tax.v1', - 'context' => [ - 'reference_date' => $date, 'currency' => $input['currency'], 'currency_fraction_digits' => 2, - 'scenario_id' => null, 'scenario_version' => 1, 'engine_version' => 'fiscal-draft-preview-v1', - 'ruleset_version' => $rule->rule_code.':'.$rule->rule_version, - ], - 'result' => [ - 'status' => $exact === null ? 'indeterminate' : 'calculated', 'value' => $exact, 'unit' => 'MZN', - 'explanation' => $exact === null - ? 'IVA indeterminado: o contexto, a fonte ou o período não permitem resolver o parâmetro deste lote QA.' - : 'IVA calculado com precisão decimal exacta, sem arredondamento. Ensaio QA de regra em rascunho; enquadramento declarado, não validado. Não é saldo de declaração nem valor a pagar.', - 'missing_inputs' => $missing, 'source_ids' => [(string) $source->id], - ], - 'presentation' => ['value' => $exact, 'currency' => $input['currency'], 'rounding_applied' => false, 'purpose' => 'operation_tax_not_payment'], - // Exact rational steps supplement the decimal result; no float conversion. - 'intermediate' => $evaluation, - 'fiscal_provenance' => [ - 'mode' => 'qa_draft_only', 'scope_status' => 'user_declared', 'context' => $context, - 'rounding_policy' => $rule->rounding_policy, 'resolution' => $resolution, - 'rule' => $version, 'source_acceptance' => 'Fonte oficial aceite pela decisão Tempo de 08/10/2026; não constitui aprovação jurídica desta operação ou publicação da regra.', - ], - ]; + $snapshot = app(SimulationFiscalSnapshot::class)->build($version, $input, $resolution, $evaluation, $missing); return app(SimulationExecution::class)->createFiscalQaPreview($snapshot, $actor->id, $key); }); diff --git a/app/Services/SimulationFiscalSnapshot.php b/app/Services/SimulationFiscalSnapshot.php new file mode 100644 index 00000000..5902e42e --- /dev/null +++ b/app/Services/SimulationFiscalSnapshot.php @@ -0,0 +1,58 @@ + $input['operation'], 'currency' => $input['currency']]; + $exact = ($evaluation['status'] ?? null) === 'determinate' + ? (string) BigRational::of($evaluation['result'])->toBigDecimal() + : null; + if ($exact === null && $missing === []) { + $missing = $evaluation['missing'] ?? ['eligible_parameter']; + } + $snapshot = [ + 'contract_version' => 1, 'scenario_kind' => $published ? 'fiscal_rule_published' : 'fiscal_rule_qa_preview', + 'base' => $input['base'], 'currency' => $input['currency'], 'indicative_total' => $exact, + 'legal_interpretation' => false, 'formula_version' => 'investmoz.tax.v1', + 'context' => [ + 'reference_date' => $date, 'currency' => $input['currency'], 'currency_fraction_digits' => 2, + 'scenario_id' => null, 'scenario_version' => 1, 'engine_version' => $published ? 'fiscal-published-v1' : 'fiscal-draft-preview-v1', + 'ruleset_version' => $version['rule_code'].':'.$version['rule_version'], + ], + 'result' => [ + 'status' => $exact === null ? 'indeterminate' : 'calculated', 'value' => $exact, 'unit' => 'MZN', + 'explanation' => $exact === null + ? 'IVA indeterminado: o contexto, a fonte ou o período não permitem resolver o parâmetro deste lote QA.' + : 'IVA calculado com precisão decimal exacta, sem arredondamento. Ensaio QA de regra em rascunho; enquadramento declarado, não validado. Não é saldo de declaração nem valor a pagar.', + 'missing_inputs' => $missing, 'source_ids' => [(string) $version['legal_source_id']], + ], + 'presentation' => ['value' => $exact, 'currency' => $input['currency'], 'rounding_applied' => false, 'purpose' => 'operation_tax_not_payment'], + // Exact rational steps supplement the decimal result; no float conversion. + 'intermediate' => $evaluation, + 'fiscal_provenance' => [ + 'mode' => $published ? 'published_version' : 'qa_draft_only', 'scope_status' => ($input['scope_declared'] ?? true) ? 'user_declared' : 'undetermined', 'context' => $context, + 'rounding_policy' => $version['rounding_policy'], 'resolution' => $resolution, + 'rule' => $version, 'source_acceptance' => 'Fonte oficial aceite pela decisão Tempo de 08/10/2026; não constitui aprovação jurídica desta operação ou publicação da regra.', + ], + ]; + + if ($published) { + $snapshot['result']['explanation'] = $exact === null + ? 'IVA indeterminado: falta contexto, fonte ou parâmetro elegível para esta operação.' + : 'IVA calculado pela versão publicada identificada, sem arredondamento. Enquadramento declarado, não validado. Não é saldo de declaração nem valor a pagar.'; + $snapshot['fiscal_provenance']['admitted_at'] = now()->toISOString(); + $snapshot['fiscal_provenance']['source_acceptance'] = 'Fonte oficial aceite pela Tempo em 08/10/2026; não constitui validação jurídica desta operação.'; + } + + return $snapshot; + } +} diff --git a/app/Services/SimulationPublishedFiscal.php b/app/Services/SimulationPublishedFiscal.php new file mode 100644 index 00000000..6416fbb7 --- /dev/null +++ b/app/Services/SimulationPublishedFiscal.php @@ -0,0 +1,82 @@ +boundRule()->where('status', 'published')->exists(); + + return ['enabled' => $eligible, 'coverage_start' => '2026-01-01', 'coverage_end' => '2026-12-31', + 'message' => $eligible ? 'IVA geral sobre base líquida disponível; enquadramento declarado.' + : 'O cálculo de IVA geral sobre base líquida está indisponível para novas execuções. Os históricos mantêm-se acessíveis.']; + } + + private function boundRule(): Builder + { + if (! Str::isUuid((string) config('simulation-fiscal-consumer.rule_id')) || config('simulation-fiscal-consumer.rule_version') < 1) { + return SimulationTaxRuleVersion::query()->whereRaw('false'); + } + + return SimulationTaxRuleVersion::query()->whereKey(config('simulation-fiscal-consumer.rule_id')) + ->where('rule_version', config('simulation-fiscal-consumer.rule_version')) + ->where('rule_code', 'IVA-GERAL-BASE-LIQUIDA')->where('component', 'IVA') + ->where('scope_key', 'mz.internal-general-net.mzn'); + } + + public function admit(array $input): array + { + // Called inside createPayload, after idempotent replay, never by the worker. + abort_unless(DB::transactionLevel() > 0, 500); + abort_unless(config('simulation-fiscal-consumer.enabled'), 503, + 'O cálculo de IVA está temporariamente indisponível. Os dados e históricos foram preservados.'); + DB::select('select pg_advisory_xact_lock_shared(261021, hashtext(?))', [(string) config('simulation-fiscal-consumer.rule_id')]); + $rule = $this->boundRule()->first(); + abort_unless($rule && $rule->status === 'published', 503, + 'A versão autorizada de IVA não está disponível para novas execuções. Os históricos foram preservados.'); + abort_if($input['reference_date'] < '2026-01-01' || $input['reference_date'] > '2026-12-31', 422, + 'Este cálculo abrange apenas exigibilidade em 2026. As entradas foram preservadas.'); + abort_if(collect($rule->rule_definition['nodes'] ?? [])->contains(fn (array $node): bool => ($node['op'] ?? null) === 'round'), 422, + 'A versão seleccionada não é compatível com o cálculo exacto deste consumidor.'); + DB::select('select pg_advisory_xact_lock_shared(261022, hashtext(?))', [(string) $rule->legal_source_id]); + $source = InvestmentLegalSource::whereKey($rule->legal_source_id)->firstOrFail(); + // Publish calculation provenance, not private governance notes or staff identifiers. + $version = array_intersect_key($rule->attributesToArray(), array_flip([ + 'id', 'rule_code', 'rule_version', 'component', 'scope_key', 'status', 'valid_from', 'valid_until', + 'legal_source_id', 'source_version_no', 'source_sha256', 'rule_definition', 'parameters', 'rounding_policy', + ])); + $version['source'] = array_intersect_key($source->attributesToArray(), array_flip([ + 'id', 'title', 'url', 'diploma', 'article', 'competent_entity', 'valid_from', 'valid_until', 'validation_status', 'version_no', + ])); + $resolution = app(SimulationTaxParameterResolver::class)->resolvePublishedForPeriod('IVA', + $input['reference_date'], $input['reference_date'], ['currency' => $input['currency'], 'operation' => $input['operation']]); + $rate = collect($resolution)->firstWhere('parameter_code', 'IVA_GENERAL_RATE'); + $missing = []; + $evaluation = null; + if (! ($input['scope_declared'] ?? false)) { + $missing[] = 'operation_scope'; + } + if (($rate['status'] ?? null) !== 'resolved' || ($rate['unit'] ?? null) !== 'percent' + || ($rate['rule_id'] ?? null) !== (string) $rule->id + || (int) ($rate['rule_version'] ?? 0) !== (int) $rule->rule_version) { + $missing[] = 'eligible_parameter'; + } + if ($missing === []) { + $evaluation = app(SimulationTaxRuleExpression::class)->evaluate($rule->rule_definition, + ['base_tributavel' => $input['base']], ['iva_general_rate' => $rate['value']]); + } + + return app(SimulationFiscalSnapshot::class)->build($version, $input, $resolution, $evaluation, $missing, true); + } +} diff --git a/app/Services/SimulationTaxRuleGovernance.php b/app/Services/SimulationTaxRuleGovernance.php index 831e60e4..5bc0f4aa 100644 --- a/app/Services/SimulationTaxRuleGovernance.php +++ b/app/Services/SimulationTaxRuleGovernance.php @@ -65,6 +65,8 @@ final class SimulationTaxRuleGovernance return DB::transaction(function () use ($actor, $id, $version, $action, $evidence): SimulationTaxRuleVersion { $this->setContext($actor); + // Serialize public admission with publication/suspension, without widening RLS. + DB::select('select pg_advisory_xact_lock(261021, hashtext(?))', [$id]); $rule = SimulationTaxRuleVersion::query()->lockForUpdate()->findOrFail($id); $this->matchVersion($rule, $version); $next = ['version_no' => $version + 1]; diff --git a/config/simulation-fiscal-consumer.php b/config/simulation-fiscal-consumer.php new file mode 100644 index 00000000..8480badb --- /dev/null +++ b/config/simulation-fiscal-consumer.php @@ -0,0 +1,8 @@ + env('INVESTMOZ_IVA_CONSUMER_ENABLED', false), + 'rule_id' => env('INVESTMOZ_IVA_CONSUMER_RULE_ID'), + 'rule_version' => (int) env('INVESTMOZ_IVA_CONSUMER_RULE_VERSION', 0), +]; diff --git a/docs/investmoz/simulations/ficha-IVA-GERAL-BASE-LIQUIDA-v1.md b/docs/investmoz/simulations/ficha-IVA-GERAL-BASE-LIQUIDA-v1.md index b0ddb433..6883d437 100644 --- a/docs/investmoz/simulations/ficha-IVA-GERAL-BASE-LIQUIDA-v1.md +++ b/docs/investmoz/simulations/ficha-IVA-GERAL-BASE-LIQUIDA-v1.md @@ -87,6 +87,8 @@ As15–26h anteriores eram condicionais e incluíam o trabalho de cadastro e int ## Revisão consolidada de83d2f97d +**Actualização técnica de21/10/2026 — delta2ced7b79 implementado em QA:** o consumidor por versão publicada está ligado ao simulador normal, com activação desligada por omissão, selecção da regra no servidor e gravação/recuperação/relatório pelo histórico existente. O mesmo manual regista a demonstração pelo Edge, indeterminação sem declaração de âmbito, repetição sem duplicação, suspensão antes do processamento da fila, conclusão com versão congelada e recusa de novas execuções. Oito testes dirigidos/75asserções e dois testes frontend novos passaram, além deTypeScript/build/Pint. Mantêm-se integralmente o âmbito2026, exclusões, fontes e precisão desta ficha; não se repete a fundamentação jurídica nem se lhe atribui validação da Tempo. O procedimento único foi actualizado para integração, publicação da versão real e activação, decisões ainda não executadas na homologação. A fixture publicada exclusivamente no clone QA foi suspensa após o ensaio; os históricos foram preservados. As notas abaixo sobre ausência do consumidor descrevem o estado anterior à implementação deste delta. + **Disponibilização no simulador existente:** a proposta única «Consumidor IVA no simulador da homologação», no mesmo manual, delimita o código ainda necessário e as três operações distintas: integração com consumidor desligado, publicação da versão pela governação existente e activação da ligação à versão autorizada. O percurso QA aceite não equivale a esse consumidor público. Mantêm-se âmbito2026, exclusões, precisão exacta e históricos; suspensão impede novas admissões e preserva execuções já admitidas. Esta proposta não executa nenhuma das três operações nem amplia a cobertura fiscal. O [mesmo manual de rastreabilidade](../unification/traceability-and-manual.md) consolida âmbito/período, operações excluídas/indeterminadas e os procedimentos concretos de integração, publicação, activação e desactivação, preservando históricos. Identifica também que83d2f97d não contém um consumidor fiscal público: a publicação torna parâmetros elegíveis na API existente, mas não transforma o percurso QA em cálculo público activado. diff --git a/docs/investmoz/simulations/integrated-review-continuation-20261020-receipt.json b/docs/investmoz/simulations/integrated-review-continuation-20261020-receipt.json index 57ad25f0..e6764dc6 100644 --- a/docs/investmoz/simulations/integrated-review-continuation-20261020-receipt.json +++ b/docs/investmoz/simulations/integrated-review-continuation-20261020-receipt.json @@ -484,5 +484,109 @@ "consumer_activation": false, "user_legal_validation": false, "record": "docs/investmoz/unification/traceability-and-manual.md: Proposta unica - consumidor IVA" + }, + "published_consumer_qa_20261021": { + "proposal_base": "2ced7b79", + "status": "Implemented and demonstrated in normal QA simulator; separate integration/publication/activation decisions pending", + "consumer_default_enabled": false, + "scope": "IVA general net taxable base, declared internal general operation, MZN, exigibility 2026 only", + "feature_tests": { + "passed": 8, + "assertions": 75, + "isolated_database": "simulator_fiscal_checks_20261021_test", + "rls_non_superuser": true, + "separate_connection_lock_probe": true + }, + "frontend_new_link_tests_passed": 2, + "typescript": "passed", + "build": "passed", + "pint": "passed", + "accepted_evidence_reused": [ + "decimal calculation", + "exporters", + "native PDF pagination", + "financial planning and recovery" + ], + "normal_simulator_browser_journey": { + "database": "simulator_fiscal_delta_20261021_test", + "url": "http://127.0.0.1:18580/simulador-fiscal", + "fixture_only": true, + "user_id": 167, + "rule_id": "01a15ddc-b0c5-727f-bb24-d806a69bd1f5", + "rule_status": "suspended", + "suspended_at": "2026-10-21T09:52:16.000000Z", + "executions": [ + { + "id": "53663785-2e5d-4be2-8a05-c0c96a57896d", + "status": "completed", + "base": "1000.03", + "value": null, + "result_status": "indeterminate", + "scope_status": "undetermined", + "admitted_at": "2026-10-21T09:50:53.949756Z", + "frozen_rule_status": "published", + "rule_id": "01a15ddc-b0c5-727f-bb24-d806a69bd1f5", + "rule_version": 2, + "report_id": "63d427ac-e6ae-481c-adad-e5f39fcc3c15", + "report_sha256": "d405705f6ce050518a0b400bed842b2aeaec143af3d78c54088100417d326c64", + "result_report_hash_equal": true + }, + { + "id": "5fa778f1-413e-4643-8c67-1b93ab290da7", + "status": "completed", + "base": "1000.03", + "value": "160.0048", + "result_status": "calculated", + "scope_status": "user_declared", + "admitted_at": "2026-10-21T09:51:11.307106Z", + "frozen_rule_status": "published", + "rule_id": "01a15ddc-b0c5-727f-bb24-d806a69bd1f5", + "rule_version": 2, + "report_id": "533b60df-ce44-40fd-9911-c7ed492dbd7e", + "report_sha256": "2c5cc798c06920aee0707465ab8b7fee517783db24939b3921c76a70999dad77", + "result_report_hash_equal": true + }, + { + "id": "be1e8399-ea95-4aa7-9ac0-7fa392932ac1", + "status": "completed", + "base": "1001.03", + "value": "160.1648", + "result_status": "calculated", + "scope_status": "user_declared", + "admitted_at": "2026-10-21T09:52:11.782758Z", + "frozen_rule_status": "published", + "rule_id": "01a15ddc-b0c5-727f-bb24-d806a69bd1f5", + "rule_version": 2, + "report_id": "5e416a48-db07-449b-8eae-aa51a1538f04", + "report_sha256": "997f026665e586c662ae574fd2ff7f62e0254819adb26aea920575e47e0490a3", + "result_report_hash_equal": true + } + ], + "execution_count": 3, + "pending_queue_records": 0, + "main_integration": false, + "homologation_publication_or_activation": false + }, + "observed": [ + "initial disabled consumer", + "insufficient scope persisted as indeterminate", + "declared scope calculated through published server binding", + "repeat without duplicate", + "database queue admitted before suspension and processed after suspension", + "new input refused with values preserved", + "hard reload and history recovery of calculated and indeterminate snapshots", + "CSV control enabled without rerunning download" + ], + "final_qa_fixture_status": "suspended", + "final_qa_switch_enabled": false, + "historical_records_preserved": true, + "main_local_head": "13f68f1b", + "main_integration": false, + "homologation_rule_publication": false, + "homologation_consumer_activation": false, + "user_legal_validation": false, + "user_local_file_review": false, + "procedure": "Existing manual: Procedimento unico das tres operacoes - agora executavel apos decisao", + "proofs_private": true } } diff --git a/docs/investmoz/unification/traceability-and-manual.md b/docs/investmoz/unification/traceability-and-manual.md index 246355b5..943fa454 100644 --- a/docs/investmoz/unification/traceability-and-manual.md +++ b/docs/investmoz/unification/traceability-and-manual.md @@ -1864,3 +1864,43 @@ Excluem-se preço com IVA incluído, taxas reduzidas, isenções/listas, bases e Históricos não são recalculados, apagados ou renomeados como se tivessem usado a versão nova. A suspensão fica no registo de governação, sem alterar retroactivamente o conteúdo/hash do relatório. Retoma exige versão publicada elegível e ligação/activação autorizada; não existe transição directa de suspended parapublished no fluxo actual. Não eliminar a leitura de snapshots fiscais ao reverter código. **Verificação nova estritamente necessária:** contrato público e permissões existentes; impossibilidade de executar desligado ou com versão não publicada; correspondência temporal/âmbito e precisão na ligação; cenário publicado na interface/histórico/relatório/exportação; suspensão versus admissão concorrente e repetição sem duplicação; preservação da leitura histórica e do modo personalizado. Não repetir as10páginas, valores/CSV ou ensaios QA aceites. Não se cria nova matriz/backlog; AUD-SIM-INT02 conserva o estado parcial do simulador fiscal integral e referencia esta delimitação. As fontes e provas existentes sustentam a proposta; não se atribui ao utilizador conferência local ou validação jurídica. + +### Consumidor publicado implementado no simulador normal — fecho QA de21/10/2026 + +**Autorização:** instrução expressa da Tempo para implementar2ced7b79 em QA, sem nova proposta, integração emmain, publicação de regra real ou activação na homologação. O delta descrito acima está agora implementado. O fecho é da ligação deste único consumidor; mantém âmbito/exclusões da ficha v1.2 e não encerra o simulador fiscal integral. + +**Percurso utilizável:** `/simulador-fiscal` → percurso habitual → etapa «Operação fiscal» → «IVA geral sobre base líquida» → base decimal textual, moeda explicitamente seleccionada, exigibilidade e declaração de contexto → «Calcular IVA e gravar». O resultado aparece no próprio simulador e no histórico existente. Pode recuperar-se sem recalcular; relatório, referência/hash e exportações usam o mesmo snapshot. Não é necessário acesso à administração fiscal para calcular. A consulta de parâmetros e o planeamento personalizado conservam os seus percursos e rótulos próprios. A base de orçamento e a taxa manual não são usadas como entradas implícitas deste IVA. + +#### Implementação e fronteiras efectivas + +- `StoreSimulationRequest` e `SimulationController::store` distinguem `fiscal_rule_published` do planeamento. O servidor recebe apenas entradas declaradas, não taxa/regra/versão/snapshot executável fornecidos pelo cliente; valida formato decimal textual, MZN, data e contexto. Falta de declaração de enquadramento grava indeterminação com valor nulo, nunca zero. Campos inválidos/período fora2026 são recusados com mensagens portuguesas e entradas preservadas. +- `SimulationPublishedFiscal` selecciona o ID/versão configurado no servidor, exige estado publicado e âmbito do lote, reutiliza `SimulationTaxParameterResolver` e `SimulationTaxRuleExpression`, e congela a proveniência antes da fila. `SimulationFiscalSnapshot` partilha a representação decimal com o adaptador QA, cujas guardas não foram removidas. Não há regra fiscal activada por importação ou arranque. +- `SimulationExecution` consulta a chave idempotente antes de verificar nova admissão. Assim, desligamento/suspensão não impedem recuperar o mesmo pedido já admitido. Nova chave exige consumidor ligado e versão publicada elegível; mudar entradas com a mesma chave dá conflito. A interface conserva a chave do pedido fiscal concluído para que repetir o mesmo cálculo não crie uma execução adicional. Novas entradas produzem nova chave/execução. +- Bloqueios consultivos transaccionais partilhados na admissão e exclusivos nas transições de governação/fonte serializam suspensão/revogação e admissão. Não se usa `SELECT FOR SHARE` na regra pública: poderia exigir uma política de alteração, contrariando as permissões existentes. A decisão de usar bloqueios consultivos é escolha de implementação, não nova exigência da Tempo. Não se alteraram políticas/grants nem se acrescentaram papéis de produto. +- `RunSimulation` processa o snapshot fiscal já admitido, sem resolver outra taxa/versão e sem reconsultar a activação. Guarda resultado, relatório e proveniência nos modelos existentes. Notas internas de revisão e identificadores dos funcionários ficam fora da proveniência devolvida pelo novo consumidor. Snapshots QA antigos conservam os seus rótulos/estado originais. +- O leitor de resultados do simulador foi adaptado ao contrato fiscal: o leitor aritmético anterior marcava o novo snapshot como inconsistente e desactivava exportação/conversão. Agora reconhece valor fiscal e indeterminação coerentes, e recusa divergência entre valor calculado e apresentação. CSV e dossier encaminham o novo tipo pelos componentes existentes; não se reescreveram os exportadores nem se repetiram provas aceites de paginação/valores. +- Configuração nova e desligada por omissão: `INVESTMOZ_IVA_CONSUMER_ENABLED=false`, `INVESTMOZ_IVA_CONSUMER_RULE_ID=` e `INVESTMOZ_IVA_CONSUMER_RULE_VERSION=0`, documentadas em `.env.example`. `/api/v1/fiscal-consumer` informa a disponibilidade sem cache; o servidor continua a exigir autorização na execução, independentemente do estado previamente visto no ecrã. A interface actualiza disponibilidade após uma tentativa, preservando entradas quando encontra suspensão. Configuração incompleta não escolhe outra regra automaticamente. + +#### Provas novas, sem reabrir o conjunto aceite + +Oito testes dirigidos deAPI/serviço,75asserções, passaram no clone isolado `simulator_fiscal_checks_20261021_test`, com transacções revertidas: ligação publicada, identidade/propriedade, token anónimo, idempotência/conflito, histórico/relatório/hash, contexto/fonte insuficientes, contrato/cobertura, suspensão/desligamento e trabalhador congelado, planeamento ainda disponível, configuração inválida e bloqueios de admissão. Um papel PostgreSQL temporário sem superutilizador/bypassRLS comprovou execução com a política pública de leitura, sem acesso aos eventos da governação ou alteração da regra; papel/grants de ensaio foram revertidos com a transacção. Uma segunda ligação comprovou que os bloqueios exclusivos da regra/fonte não atravessam a transacção de admissão. Dois testes frontend dirigidos ao novo tipo publicado passaram: encaminhamento coerente para ecrã/CSV/dossier e aceitação da indeterminação/recusa da apresentação divergente. TypeScript, build ePint passaram. Aviso do build sobre optimização opcional de fontes/fontaine não impediu a compilação. + +**Observação no Edge, formulário normal:** porta18580, clone `simulator_fiscal_delta_20261021_test`, conta sintética de utilizador comum167, sem papéis fiscais administrativos. A fixture publicada é a versão sintética2 de `IVA-GERAL-BASE-LIQUIDA`, ID`01a15ddc-b0c5-727f-bb24-d806a69bd1f5`, para não substituir o rascunho QA anterior; fonte/actores sintéticos só neste clone. A governação existente foi usada na preparação da fixture. Não houve publicação na homologação nem declaração de aprovação jurídica da regra real. + +1. Observou-se inicialmente o consumidor desligado e o botão indisponível. Habilitou-se somente a fixture local guardada contra qualquer outra base. +2. Base1000.03MZN, exigibilidade2026-10-21, sem declaração de contexto: execução`53663785-2e5d-4be2-8a05-c0c96a57896d`, resultado indeterminado, entradas conservadas e relatório guardado. A mesma base/data/moeda com declaração criou`5fa778f1-413e-4643-8c67-1b93ab290da7`, valor160.0048. Repetir o botão manteve essas duas execuções, sem terceira duplicada. +3. Reteve-se somente a fila QA dedicada `fiscal-consumer-qa-20261021`. A base1001.03 admitiu`be1e8399-ea95-4aa7-9ac0-7fa392932ac1` às09:52:11.782758UTC. Suspendeu-se a fixture pela governação às09:52:16UTC, ainda com essa execução emqueued e snapshot publicado congelado. O trabalhador real `queue:work database --queue=fiscal-consumer-qa-20261021 --once --tries=1 --timeout=30` concluiu-a depois da suspensão, com160.1648 e a mesma versão2. Nenhuma outra fila foi consumida. +4. Nova entrada1002.03 foi recusada: «A versão autorizada de IVA não está disponível para novas execuções. Os históricos foram preservados.» A base1002.03, data, moeda e declaração continuaram no ecrã; não houve quarta execução. Após recarga completa, recuperaram-se pelo histórico tanto o resultado calculado como o indeterminado. Ambos mostraram o snapshot original, sem alertas de inconsistência, com CSV disponível. O ficheiro não foi descarregado novamente; a implementação e as provas aceites do exportador foram reutilizadas. +5. Conferência por leitura: exactamente3execuções da conta, todascompleted; hashes de resultado/relatório iguais nas três; zero trabalhos pendentes na fila dedicada. O relatório da execução que concluiu após suspensão é`5e416a48-db07-449b-8eae-aa51a1538f04`, SHA256`997f026665e586c662ae574fd2ff7f62e0254819adb26aea920575e47e0490a3`. A fixture permanece suspensa e a configuração local foi reposta emdesligado; os históricos ficam acessíveis. + +Provas privadas em `outputs/simulator-continuation-20261020`: `published-consumer-tests.txt`, `published-consumer-journey-receipt.json`, `published-consumer-calculated-ui.txt/.png`, `published-consumer-suspended-ui.txt`, `published-consumer-recovered-ui.txt/.png` e `published-consumer-indeterminate-recovered-ui.txt`. O primeiro acesso ao separador antecedeu o arranque do servidor local e apresentou ligação recusada; após arranque normal prosseguiu-se. Não é evidência de falha do simulador. Não se repetiram as10páginas do PDF nativo nem o conjunto aceite de83d2f97d/13f68f1b. A aritmética do exemplo demonstra a ligação e preservação do resultado, não a aplicabilidade fiscal a qualquer operação. + +#### Procedimento único das três operações — agora executável após decisão + +1. **Integrar código:** a Tempo identifica a referência exacta desta candidata na branch `codex/simulator-integrated-review`. Integrar emmain apenas após decisão; o envio desencadeia disponibilização automática na homologação. Manter `INVESTMOZ_IVA_CONSUMER_ENABLED=false`; não executar fixture, comandoQA ou cadastro fiscal no deploy. Confirmar build servido e aplicação; aplicar o procedimento habitual de recarga da configuração e reinício de trabalhadores persistentes, se existirem, antes de activar o novo tipo de trabalho. Não há migration neste delta. A leitura dos snapshots deve permanecer em qualquer versão de reversão; desligar o consumidor é preferível a voltar a um build que desconheça os históricos novos. +2. **Publicar versão real:** com autorização de escrita, backup/restauro confirmado e acção autenticada, reutilizar a fonte real aceite e os actores/papéis existentes. Cadastrar a versão fundamentada e usar `POST /api/v2/investment/admin/tax-rules/{id}/{action}`, com `version_no` e `evidence`, em `submit` → `review` → `approve` → `publish`. RegistarID e `rule_version` publicados. Não copiar a fixture/versão2/actoresUUID deste ensaio para homologação. Se a fonte já estiver cadastrada correctamente, reutilizá-la; não exigir nova certificação da credibilidade oficial. A publicação disponibiliza parâmetros naAPI existente, mas o consumidor continua desligado. +3. **Activar consumidor:** por decisão específica da Tempo, preencher `INVESTMOZ_IVA_CONSUMER_RULE_ID=` e `INVESTMOZ_IVA_CONSUMER_RULE_VERSION=`; só então definir `INVESTMOZ_IVA_CONSUMER_ENABLED=true`. Aplicar a configuração pelo procedimento operacional existente (`php artisan config:cache` quando se usar cache de configuração; recarregar processos persistentes conforme o alojamento). Confirmar `/api/v1/fiscal-consumer` e a etapa fiscal do simulador, registando decisão, commit/build, ID/versão e instante de exposição. Não usar `version_no` de concorrência como se fosse `rule_version`. O servidor recusa outra versão em vez de escolher a mais recente silenciosamente. + +**Suspender/desactivar:** repor o interruptor emfalse e aplicar a configuração impede novas admissões; para retirar a elegibilidade da própria regra usar a transição existente `suspend`, com versão corrente/evidência e actor autorizado. Pedidos já admitidos concluem pelo snapshot; repetição idempotente recupera-os. Histórico/relatório/exportação continuam acessíveis sem novo cálculo, publicação ou activação. Retoma segue uma versão publicada elegível e activação explícita, nuncaSQL directo sobre o estado suspenso. A janela2026 mantém-se um limite de cobertura, não data de caducidade legal. + +**Saldo:** ligação funcional do primeiro consumidor fechada em QA, pronta para revisão e decisão sobre as três operações; sem outra preparação genérica identificada. AUD-SIM-INT02 permanece parcial para cobertura fiscal integral. As estimativas anteriores não constituem horas ainda por executar deste delta já concluído. Main local continua em13f68f1b; sem merge/push, publicação real ou activação na homologação. A Tempo não conferiu os ficheiros locais nem validou juridicamente as fontes nesta execução. diff --git a/docs/investmoz/v2/auditoria-portal/matriz-requisitos.csv b/docs/investmoz/v2/auditoria-portal/matriz-requisitos.csv index 74928f67..c30cb7d2 100644 --- a/docs/investmoz/v2/auditoria-portal/matriz-requisitos.csv +++ b/docs/investmoz/v2/auditoria-portal/matriz-requisitos.csv @@ -10652,7 +10652,7 @@ AUD-SEARCH-T10,Consistência transversal da pesquisa e navegação — Bibliotec AUD-SEARCH-T11,Consistência transversal da pesquisa e navegação — Legislação,Instruções expressas da Tempo na conversa de 20/10/2026; AGENTS.md; RF-MN-010 e AUD-GEO-01 preservados.,navegação/pesquisa,/legislacao,portal/services; portal/document — Reutiliza a correcção da biblioteca e preserva filtros jurídicos/documentais pertinentes.,Sem alteração remota nesta execução; revisão apenas na branch codex/opportunity-search.,HTTP/Inertia: resources.total=0; consumidor e query pública partilhados.,QA — delta delimitado; revisão Tempo pendente,"Situação jurídica, consulta e cálculo continuam independentes.",,"Filtros, contagem, resultados e URL coerentes; universos identificados; erro distinto de vazio; permissões e contexto conservados.",P1,,RF-MN-010; AUD-GEO-01,,,,,,,,,,,Mesmo padrão da biblioteca; não deve expor um universo concorrente.,"Situação jurídica, consulta e cálculo continuam independentes.",portal/services; portal/document; SearchState; SearchFeedback; TerritoryFields; portalNavigation,,"Integração em main apenas após revisão; assistência NLP, contratos e P7 separados.",Correcção/simplificação em QA,,HTTP/Inertia: resources.total=0; consumidor e query pública partilhados.,Pesquisa transversal QA 20/10/2026,20/10/2026,,,,,,,,,,"Provas comuns cobrem o componente reutilizado; HTTP vazio não demonstra operações de gestão ou publicação. Situação jurídica, consulta e cálculo continuam independentes.",,falha funcional; navegação; apresentação visual,,Disponível para revisão na aplicação QA local. AUD-GUIDE-CARD-20261020,"Related educational cards: image, source summary, clear actions",Tempo 20/10/2026; independent delta after 0e7d51f18fb4,educational presentation,/sectores/{slug}; /explorar/{group}; articles; /oportunidades and detail,EducationalArticleCard; EducationalGuides; LegacyContent; SectorInvestment,QA only,1 test / 8 assertions; PHP syntax; TypeScript/build; desktop2048/mobile390; keyboard,QA review pending,Normal QA sector and article verified against existing library resource 10 and local image 25; previous dossier proof retained,,Source-derived summary; public images; conditional opportunity action; context preserved,P1,,,,,,,,,,,,,,,,,,presentation,,,,20/10/2026,,,,,,,,,,,,content; navigation; visual presentation,,Ready for QA review AUD-SIM-INT01,Invalid one-off fiscal date can throw instead of returning validation state,Tempo request 20/10/2026; integrated simulator review; same manual,simulator and shared language,,,QA only,Fixed in QA; invalid-date checks; 38 directed frontend tests passed,Previous inline-date proof retained; unchanged delta not repeated,QA continuation verified; precise open inputs and cashflow visual check in same manual; 2-4 h remaining,,,P0,,,,,,,,,,,,,,,,,,diagnosis and targeted corrections,, | [Saldo consolidado21/10/2026] Concluído no recorte. Nenhum delta de validação de data por demonstrar; prova anterior preservada. Ver saldo dos oito itens no manual existente; sem fecho global por esta reconciliação.,,20/10/2026,,,,,,,,,,,,,, -AUD-SIM-INT02,Review actual fiscal parameters sources periods and eligibility,Tempo request 20/10/2026; integrated simulator review; same manual,simulator and shared language,,,QA only,Verification pending; no parameter activation,QA zero rules verified with full visibility; prepared archive and analyses reused; current homologation read awaits SSH authentication,QA continuation verified; precise open inputs and cashflow visual check in same manual; 2-4 h remaining,,,P0,,,,,,,,,,,,,,,,,,diagnosis and targeted corrections,," | [Leitura SSH 21/10/2026 08:17 Maputo] Zero regras em simulation_tax_rule_versions na homologação, incluindo todos os estados por leitura administrativa sem ocultação RLS. Seis referências em investment_legal_sources; não são parâmetros executáveis. QA também tinha zero regras. BEGIN READ ONLY/SELECT/ROLLBACK; zero escritas. Prova homologation-fiscal-readonly.json. O git HEAD registado é do checkout consultado, não certifica a revisão do build servido. A ausência de cadastro não é ausência de fontes oficiais nem reabre a aceitação da Tempo de 08/10. Não implementa a ligação funcional ao motor fiscal. | [Saldo consolidado21/10/2026] Parcial. Leitura remota concluída: zero regras de todos os estados na homologação, tal como QA; seis referências jurídicas não são parâmetros executáveis. Falta cadastro fundamentado por componente/âmbito/período e ligação funcional ao motor fiscal. Fontes e análises existentes reutilizáveis; aceitação oficial da Tempo de08/10 preservada. Não activar taxas nem importar/publicar por esta reconciliação. Ver saldo dos oito itens no manual existente; sem fecho global por esta reconciliação. | [Candidata21/10/2026] Diagnóstico fiscal separado no manual existente: fontes/analises já disponíveis; zero parâmetros executáveis na QA e homologação por leitura de todos os estados; governação/resolvedor temporal/DSL existentes; RunSimulation ainda aritmética personalizada sem consumir regras fiscais. Saldo: cadastro fundamentado, orquestração, contrato de resultado/histórico e ligação ao dossier/relatório, com casos por componente/período. Nenhuma regra criada nem activada; implementação fiscal integral aberta e excluída das1–2h de exportações. | [Proposta21/10/2026] Primeiro lote proposto: uma regra IVA geral sobre base liquida/MZN, cobertura demonstrada2026 sem inventar caducidade legal. Reutilizar fontes/analises/governacao/resolvedor/avaliador existentes; arredondamento e ambito aplicavel explicitamente por fundamentar antes de publicar. Cadastro4-8h e codigo/aceitacao12-20h, total16-28h condicionado ao acervo, separado das1-2h de exportacao. Criterios e exclusoes no manual existente. Nenhuma regra cadastrada/activada; nao e implementacao fiscal integral. | [QA IVA v1.2 21/10/2026] Orientacao posterior Tempo: calcular com precisao decimal exacta sem arredondamento automatico, separando apresentacao monetaria; arredondamento generico deixa de ser bloqueio deste lote. Cadastro draft versionado em clone QA; resolvedor/avaliador existentes; proveniencia, historico e relatorio imutavel ligados. 5 feature (51 assercoes), 7 unit (25), 3 frontend; types/build/Pint aprovados. Contexto declarado; nao e saldo a pagar. Zero regras publicadas; sem main/activacao. Recorte QA autorizado verificado (0h engenharia identificada restante); produto fiscal integral continua parcial. Limites e provas no mesmo manual e ficha v1.2; sem atribuicao de validacao juridica ao utilizador. | [Revisao 83d2f97d] Ambito/periodo/exclusoes e procedimentos de integracao, publicacao, activacao e suspensao consolidados no mesmo manual. Lacuna concreta de ecra QA fechada: entradas 1000.03/MZN/2026-10-21; calculo exacto 160.0048; gravacao fa6d17f2-5f47-4cb6-b8cb-40850c12505f; recarga; recuperacao do mesmo ID; relatorio d13e2ad5-3ba8-4d01-9d30-5815496da774 com mesmo valor/proveniencia e hash igual ao resultado. Uma execucao persistida, draft e zero regras publicadas. 1 teste novo de rota/17 assercoes, TypeScript/build e browser aprovados; restantes provas reutilizadas. Interrupcao inicial da ferramenta resolvida, sem atribuicao de falha ao simulador. Revisao do recorte demonstrada; sem nova preparacao generica identificada, main ou activacao; nao fecha aplicabilidade fiscal universal nem simulador integral. | 21/10/2026: proposta do consumidor IVA no simulador existente no manual: falta ligacao a regra publicada e activacao explicita. Integrar codigo desligado, publicar versao e activar consumidor sao tres operacoes distintas. Reutiliza permissoes, governanca, historico e provas aceites; sem ampliar cobertura ou executar estas operacoes.",,20/10/2026,,,,,,,,,,,,,, +AUD-SIM-INT02,Review actual fiscal parameters sources periods and eligibility,Tempo request 20/10/2026; integrated simulator review; same manual,simulator and shared language,,,QA only,Verification pending; no parameter activation,Parcial: primeiro consumidor IVA funcional no simulador normal em QA; cobertura fiscal integral e disponibilizacao na homologacao permanecem separadas.,QA continuation verified; precise open inputs and cashflow visual check in same manual; 2-4 h remaining,,,P0,,,,,,,,,,,,,,"Ligacao IVA do lote 2026 demonstrada em QA. Decisoes pendentes distintas: integrar candidata, publicar versao real e activar consumidor na homologacao. Demais componentes fiscais fora deste recorte.",,,"Tempo decide integracao do codigo, publicacao da versao real e activacao do consumidor conforme procedimento unico; nenhuma executada nesta passagem.",Ligacao de consumidor publicada concluida em QA; decisoes operacionais separadas.,Sem saldo de implementacao identificado para este delta QA; nao reutilizar estimativas anteriores como horas ainda em falta; nao estima cobertura fiscal integral.," | [Leitura SSH 21/10/2026 08:17 Maputo] Zero regras em simulation_tax_rule_versions na homologação, incluindo todos os estados por leitura administrativa sem ocultação RLS. Seis referências em investment_legal_sources; não são parâmetros executáveis. QA também tinha zero regras. BEGIN READ ONLY/SELECT/ROLLBACK; zero escritas. Prova homologation-fiscal-readonly.json. O git HEAD registado é do checkout consultado, não certifica a revisão do build servido. A ausência de cadastro não é ausência de fontes oficiais nem reabre a aceitação da Tempo de 08/10. Não implementa a ligação funcional ao motor fiscal. | [Saldo consolidado21/10/2026] Parcial. Leitura remota concluída: zero regras de todos os estados na homologação, tal como QA; seis referências jurídicas não são parâmetros executáveis. Falta cadastro fundamentado por componente/âmbito/período e ligação funcional ao motor fiscal. Fontes e análises existentes reutilizáveis; aceitação oficial da Tempo de08/10 preservada. Não activar taxas nem importar/publicar por esta reconciliação. Ver saldo dos oito itens no manual existente; sem fecho global por esta reconciliação. | [Candidata21/10/2026] Diagnóstico fiscal separado no manual existente: fontes/analises já disponíveis; zero parâmetros executáveis na QA e homologação por leitura de todos os estados; governação/resolvedor temporal/DSL existentes; RunSimulation ainda aritmética personalizada sem consumir regras fiscais. Saldo: cadastro fundamentado, orquestração, contrato de resultado/histórico e ligação ao dossier/relatório, com casos por componente/período. Nenhuma regra criada nem activada; implementação fiscal integral aberta e excluída das1–2h de exportações. | [Proposta21/10/2026] Primeiro lote proposto: uma regra IVA geral sobre base liquida/MZN, cobertura demonstrada2026 sem inventar caducidade legal. Reutilizar fontes/analises/governacao/resolvedor/avaliador existentes; arredondamento e ambito aplicavel explicitamente por fundamentar antes de publicar. Cadastro4-8h e codigo/aceitacao12-20h, total16-28h condicionado ao acervo, separado das1-2h de exportacao. Criterios e exclusoes no manual existente. Nenhuma regra cadastrada/activada; nao e implementacao fiscal integral. | [QA IVA v1.2 21/10/2026] Orientacao posterior Tempo: calcular com precisao decimal exacta sem arredondamento automatico, separando apresentacao monetaria; arredondamento generico deixa de ser bloqueio deste lote. Cadastro draft versionado em clone QA; resolvedor/avaliador existentes; proveniencia, historico e relatorio imutavel ligados. 5 feature (51 assercoes), 7 unit (25), 3 frontend; types/build/Pint aprovados. Contexto declarado; nao e saldo a pagar. Zero regras publicadas; sem main/activacao. Recorte QA autorizado verificado (0h engenharia identificada restante); produto fiscal integral continua parcial. Limites e provas no mesmo manual e ficha v1.2; sem atribuicao de validacao juridica ao utilizador. | [Revisao 83d2f97d] Ambito/periodo/exclusoes e procedimentos de integracao, publicacao, activacao e suspensao consolidados no mesmo manual. Lacuna concreta de ecra QA fechada: entradas 1000.03/MZN/2026-10-21; calculo exacto 160.0048; gravacao fa6d17f2-5f47-4cb6-b8cb-40850c12505f; recarga; recuperacao do mesmo ID; relatorio d13e2ad5-3ba8-4d01-9d30-5815496da774 com mesmo valor/proveniencia e hash igual ao resultado. Uma execucao persistida, draft e zero regras publicadas. 1 teste novo de rota/17 assercoes, TypeScript/build e browser aprovados; restantes provas reutilizadas. Interrupcao inicial da ferramenta resolvida, sem atribuicao de falha ao simulador. Revisao do recorte demonstrada; sem nova preparacao generica identificada, main ou activacao; nao fecha aplicabilidade fiscal universal nem simulador integral. | 21/10/2026: proposta do consumidor IVA no simulador existente no manual: falta ligacao a regra publicada e activacao explicita. Integrar codigo desligado, publicar versao e activar consumidor sao tres operacoes distintas. Reutiliza permissoes, governanca, historico e provas aceites; sem ampliar cobertura ou executar estas operacoes. | 21/10/2026: implementado delta de 2ced7b79 no simulador normal QA, activacao desligada por omissao. 8 testes/75 assercoes, 2 testes frontend novos, TypeScript/build/Pint. Edge: indeterminacao, calculo, repeticao sem duplicar, fila admitida antes de suspensao concluida depois, nova execucao recusada e historicos/relatorios preservados. Mesmo manual e recibo consolidam provas e tres operacoes. Fixture isolada suspensa; sem merge ou publicacao/activacao na homologacao.",,20/10/2026,,,,,,,,,,,,,, AUD-SIM-INT03,Review isolation recovery across projects and sessions,Tempo request 20/10/2026; integrated simulator review; same manual,simulator and shared language,,,QA only,Existing controls and accepted proofs to reconcile; no leak established,Two dossiers and new login recovered in browser; cashflow persistence corrected and HTTP-tested; new visual cashflow delta pending,QA continuation verified; precise open inputs and cashflow visual check in same manual; 2-4 h remaining,,,P0,,,,,,,,,,,,,,,,,,diagnosis and targeted corrections,,"[Retoma 21/10/2026] QA 18576, clone simulator_review_delta_20261020_test: sessão sintética renovada por login normal; gravação autenticada e recarga recuperaram saldo 123, receita 900, custos 600, calendário mensal 10/vazio/0, crédito IVA estimado 500. Caixa conhecida 3713 MZN; total indeterminado desde mês 2; crédito estimado excluído da caixa. Prova visual cashflow-recovered-20261021.png e snapshot .txt em outputs/simulator-continuation-20261020. Não certifica recuperação destes novos valores após outro login, CSV/DOCX ou PDF nativo. Ligação de controlo do navegador perdida durante espera de download; não atribuir falha à aplicação. Leitura remota aguarda identificação da ligação SSH; zero alterações no destino. | [Saldo consolidado21/10/2026] Concluído no recorte. A/B, logout/novo login, recuperação e recusa pela aplicação demonstrados anteriormente. A tesouraria tem prova API de nova sessão e prova visual de recuperação por recarga. Não atribuir ao navegador nova sessão dos valores de tesouraria, nem nova certificação RLS; limites expressos conservados. Ver saldo dos oito itens no manual existente; sem fecho global por esta reconciliação.",,20/10/2026,,,,,,,,,,,,,, AUD-SIM-INT04,Review stale and historical results and correct dossier,Tempo request 20/10/2026; integrated simulator review; same manual,simulator and shared language,,,QA only,Existing versions/fingerprints; targeted verification pending,Historical results visible after fix; 12 and 24 recovered; hashes unchanged; synthetic temporal resolver versions verified in memory only,QA continuation verified; precise open inputs and cashflow visual check in same manual; 2-4 h remaining,,,P1,,,,,,,,,,,,,,,,,,diagnosis and targeted corrections,," | [Saldo consolidado21/10/2026] Concluído no recorte demonstrado. Histórico24/12 visível, hashes preservados e versões temporais sintéticas do resolvedor conferidas. Execução fiscal integrada com parâmetros reais pertence ao saldo de02 e não foi demonstrada por estes testes. Ver saldo dos oito itens no manual existente; sem fecho global por esta reconciliação.",,20/10/2026,,,,,,,,,,,,,, AUD-SIM-INT05,Independent financial controls unknown zero units and currencies,Tempo request 20/10/2026; integrated simulator review; same manual,simulator and shared language,,,QA only,Reconciliation pending; no invented tax assumptions,Independent business-plan and cashflow ledgers passed; complete credit recipe included in screen and exports,QA continuation verified; precise open inputs and cashflow visual check in same manual; 2-4 h remaining,,,P1,,,,,,,,,,,,,,,,,,diagnosis and targeted corrections,,"[Retoma 21/10/2026] QA 18576, clone simulator_review_delta_20261020_test: sessão sintética renovada por login normal; gravação autenticada e recarga recuperaram saldo 123, receita 900, custos 600, calendário mensal 10/vazio/0, crédito IVA estimado 500. Caixa conhecida 3713 MZN; total indeterminado desde mês 2; crédito estimado excluído da caixa. Prova visual cashflow-recovered-20261021.png e snapshot .txt em outputs/simulator-continuation-20261020. Não certifica recuperação destes novos valores após outro login, CSV/DOCX ou PDF nativo. Ligação de controlo do navegador perdida durante espera de download; não atribuir falha à aplicação. Leitura remota aguarda identificação da ligação SSH; zero alterações no destino. | [Saldo consolidado21/10/2026] Concluído nos controlos delimitados. Controlos independentes de caixa/plano/crédito, zero/desconhecido e moedas preservados. Não são validação de taxas legais nem certificação de toda a contabilidade. Ver saldo dos oito itens no manual existente; sem fecho global por esta reconciliação.",,20/10/2026,,,,,,,,,,,,,, diff --git a/resources/js/components/fiscal-qa-preview.tsx b/resources/js/components/fiscal-qa-preview.tsx index ab65b3b3..dfdca391 100644 --- a/resources/js/components/fiscal-qa-preview.tsx +++ b/resources/js/components/fiscal-qa-preview.tsx @@ -5,11 +5,11 @@ export default function FiscalQaPreview({ snapshot }: { snapshot: unknown }) { if (rows.length === 0) return null; return (
- Pré-visualização fiscal QA — cálculo exacto sem arredondamento + {rows[0][1]} — cálculo exacto sem arredondamento
{rows.map(([label, value]) => ( diff --git a/resources/js/components/simulation-form.tsx b/resources/js/components/simulation-form.tsx index 018bf85f..5500ae73 100644 --- a/resources/js/components/simulation-form.tsx +++ b/resources/js/components/simulation-form.tsx @@ -1,3 +1,4 @@ +import { SimulationPublishedIva, type PublishedIvaInput } from './simulation-published-iva'; import { PublicDocumentContent, type PublicDocument, @@ -887,7 +888,7 @@ export function SimulationForm() { () => readVersionedArithmeticReportReference(result), [result], ); - const isFiscalQaPreview = arithmeticSnapshot?.scenario_kind === 'fiscal_rule_qa_preview'; + const isFiscalQaPreview = ['fiscal_rule_qa_preview', 'fiscal_rule_published'].includes(String(arithmeticSnapshot?.scenario_kind)); const versionedReportMissing = arithmeticResult.integrity === 'versioned' && arithmeticReportReference === null; @@ -2159,15 +2160,15 @@ export function SimulationForm() { ); } - async function runSimulation() { + async function runSimulation(publishedInput?: PublishedIvaInput) { if (calculationInFlightRef.current) return; - const validation = validateSimulationDraft(draft, 'tax'); - if (draft.fiscal_scenario_mode === 'custom_planning') { + const validation = publishedInput ? {} : validateSimulationDraft(draft, 'tax'); + if (!publishedInput && draft.fiscal_scenario_mode === 'custom_planning') { Object.assign(validation, validateCustomFiscalPlanning(draft)); } setErrors(validation); setServerError(''); - if (draft.fiscal_scenario_mode !== 'custom_planning') return; + if (!publishedInput && draft.fiscal_scenario_mode !== 'custom_planning') return; if (Object.keys(validation).length) { document .getElementById(`field-${Object.keys(validation)[0]}`) @@ -2178,7 +2179,7 @@ export function SimulationForm() { setIsCalculating(true); try { const xsrf = csrfToken(); - const body = { + const body = publishedInput ?? { base: draft.base, rate: draft.rate, reduction: draft.reduction, @@ -2286,7 +2287,7 @@ export function SimulationForm() { } } if ( - JSON.stringify({ + publishedInput || JSON.stringify({ base: draft.base, rate: draft.rate, reduction: draft.reduction, @@ -2306,7 +2307,7 @@ export function SimulationForm() { 'O cálculo não foi concluído. Os dados introduzidos foram preservados.', ); } - sessionStorage.removeItem(idempotencyStorageKey); + if (!publishedInput) sessionStorage.removeItem(idempotencyStorageKey); } catch (cause) { setServerError( cause instanceof Error @@ -3600,6 +3601,7 @@ export function SimulationForm() { } /> )} + {current.id === 'tax' && } {current.id === 'tax' && (

- {isFiscalQaPreview ? 'Pré-visualização fiscal QA seleccionada' : current.id === 'tax' + {isFiscalQaPreview ? (arithmeticSnapshot?.scenario_kind === 'fiscal_rule_published' ? 'IVA pela versão publicada seleccionada' : 'Pré-visualização fiscal QA seleccionada') : current.id === 'tax' ? 'Cenário personalizado de planeamento' : 'Resultado histórico seleccionado'}

@@ -4182,7 +4184,7 @@ export function SimulationForm() { 'não identificada', 'não identificada', )}{' '} - · {source.result_snapshot.scenario_kind === 'fiscal_rule_qa_preview' ? 'IVA calculado exacto em QA' : 'resultado aritmético'}{' '} + · {['fiscal_rule_qa_preview', 'fiscal_rule_published'].includes(String(source.result_snapshot.scenario_kind)) ? 'IVA calculado exacto' : 'resultado aritmético'}{' '} {displayScalar( sourceResult.value ?? (sourceResult.integrity === @@ -4421,7 +4423,9 @@ export function SimulationForm() { e, se já escolhido, o cálculo percentual personalizado. Consultar parâmetros fiscais apresenta fontes e lacunas; não determina imposto - devido. + devido. Na etapa Operação fiscal encontra também + o cálculo de IVA geral sobre base líquida, quando + activado, com âmbito e versão identificados.

+ + {error &&

{error}

} +
; +} diff --git a/resources/js/lib/fiscal-qa-preview.ts b/resources/js/lib/fiscal-qa-preview.ts index eda5b382..50fda83d 100644 --- a/resources/js/lib/fiscal-qa-preview.ts +++ b/resources/js/lib/fiscal-qa-preview.ts @@ -3,7 +3,8 @@ import { isApiSimulationResultSnapshotV1 } from "./simulation-contract.ts"; export function fiscalQaRows(value: unknown): Array<[string, string]> { if (!isApiSimulationResultSnapshotV1(value)) return []; const snapshot = value as unknown as Record; - if (snapshot.scenario_kind !== "fiscal_rule_qa_preview") return []; + if (!["fiscal_rule_qa_preview", "fiscal_rule_published"].includes(String(snapshot.scenario_kind))) return []; + const published = snapshot.scenario_kind === "fiscal_rule_published"; const provenance = snapshot.fiscal_provenance as Record | undefined; const rule = provenance?.rule as Record | undefined; @@ -19,7 +20,7 @@ export function fiscalQaRows(value: unknown): Array<[string, string]> { ? String(item) : "não disponível"; return [ - ["Relatório", "Pré-visualização fiscal QA de rascunho — sem activação"], + ["Relatório", published ? "IVA pela versão publicada; ver enquadramento abaixo" : "Pré-visualização fiscal QA de rascunho — sem activação"], [ "IVA calculado (exacto; não é saldo a pagar)", value.result.value ?? "Indeterminado", @@ -31,11 +32,11 @@ export function fiscalQaRows(value: unknown): Array<[string, string]> { ["Data de exigibilidade declarada", value.context.reference_date], [ "Enquadramento", - "Declarado pelo utilizador; não validado juridicamente", + provenance?.scope_status === "undetermined" ? "Indeterminado: falta declaração do enquadramento" : "Declarado pelo utilizador; não validado juridicamente", ], [ "Regra / versão / estado", - `${text(rule?.rule_code)} / ${text(rule?.rule_version)} / rascunho`, + `${text(rule?.rule_code)} / ${text(rule?.rule_version)} / ${published ? "publicada no momento da admissão" : "rascunho"}`, ], [ "Vigência cadastrada", @@ -53,7 +54,7 @@ export function fiscalQaRows(value: unknown): Array<[string, string]> { ], [ "Limitações", - "Regra não publicada; cobertura demonstrada em 2026; sem deduções, declaração ou saldo a pagar.", + published ? "Cobertura de 2026; operação interna geral em MZN; sem excepções, deduções, declaração ou saldo a pagar." : "Regra não publicada; cobertura demonstrada em 2026; sem deduções, declaração ou saldo a pagar.", ], ]; } diff --git a/resources/js/lib/project-dossier-report.ts b/resources/js/lib/project-dossier-report.ts index 7f3297fd..fa791d37 100644 --- a/resources/js/lib/project-dossier-report.ts +++ b/resources/js/lib/project-dossier-report.ts @@ -345,9 +345,9 @@ function makeSections( } const finance = sections.get('finance'); - if (finance && currentCalculation?.snapshot?.scenario_kind === 'fiscal_rule_qa_preview') { + if (finance && ['fiscal_rule_qa_preview', 'fiscal_rule_published'].includes(String(currentCalculation?.snapshot?.scenario_kind)) && currentCalculation?.snapshot) { for (const [field, value] of fiscalQaRows(currentCalculation.snapshot)) { - finance.rows.push({ field, value, origin: 'Snapshot fiscal QA preservado; sem recálculo', classification: 'Rascunho QA; cálculo exacto sem arredondamento automático; não é saldo a pagar' }); + finance.rows.push({ field, value, origin: 'Snapshot fiscal preservado; sem recálculo', classification: 'Cálculo exacto; ver estado da regra no snapshot; não é saldo a pagar' }); } } if (finance && currentCalculation) { @@ -423,12 +423,12 @@ function makeSections( { field: 'Fórmula e valores usados', value: - snapshot?.scenario_kind === 'fiscal_rule_qa_preview' - ? `Base tributável declarada × taxa cadastrada ÷ 100; cálculo decimal exacto, sem arredondamento. Ver regra e proveniência QA acima.` + ['fiscal_rule_qa_preview', 'fiscal_rule_published'].includes(String(snapshot?.scenario_kind)) + ? `Base tributável declarada × taxa cadastrada ÷ 100; cálculo decimal exacto, sem arredondamento. Ver regra e proveniência acima.` : snapshot && typeof snapshot.formula_version === 'string' ? `Fórmula ${snapshot.formula_version}; base ${displayValue(snapshot.base, 'não disponível')}; taxa ${displayValue(snapshot.rate_percent, 'não disponível')}%; redução ${displayValue(snapshot.reduction_percent, 'não disponível')}%; moeda ${displayValue(snapshot.currency, 'não indicada')}` : 'A fórmula não foi identificada no snapshot; ver explicação da execução', - origin: snapshot?.scenario_kind === 'fiscal_rule_qa_preview' ? 'Snapshot QA de regra em rascunho; sem activação ou validação jurídica da operação' : 'Snapshot guardado; sem aplicação de regra legal', + origin: ['fiscal_rule_qa_preview', 'fiscal_rule_published'].includes(String(snapshot?.scenario_kind)) ? (snapshot?.scenario_kind === 'fiscal_rule_published' ? 'Versão publicada na admissão; sem validação jurídica da operação' : 'Snapshot QA de regra em rascunho; sem activação ou validação jurídica da operação') : 'Snapshot guardado; sem aplicação de regra legal', classification: 'Reutilização do resultado existente', }, { diff --git a/resources/js/lib/simulation-form.ts b/resources/js/lib/simulation-form.ts index f224c010..a64d1327 100644 --- a/resources/js/lib/simulation-form.ts +++ b/resources/js/lib/simulation-form.ts @@ -1675,6 +1675,19 @@ export function readArithmeticSnapshot( } const result = snapshot as Record; const value = result.indicative_total; + if (['fiscal_rule_qa_preview', 'fiscal_rule_published'].includes(String(result.scenario_kind))) { + const presentation = result.presentation as Record | undefined; + if (isApiSimulationResultSnapshotV1(result) && fiscalQaRows(result).length > 0 && + result.result.value === value && presentation?.value === value && + presentation?.rounding_applied === false && result.context.currency === 'MZN' && + result.result.unit === 'MZN' && + ((result.result.status === 'calculated' && typeof value === 'string' && /^\d+(?:\.\d+)?$/.test(value)) || + (result.result.status === 'indeterminate' && value === null && result.result.missing_inputs.length > 0))) { + return { integrity: 'versioned', value: value as string | null, + context: result.context as unknown as Record, explanation: result.result.explanation }; + } + return { integrity: 'invalid', value: null, context: null, explanation: null }; + } if (typeof value !== 'string' || !/^(?:\d+)(?:\.\d+)?$/.test(value)) { return { integrity: 'invalid', @@ -1849,7 +1862,7 @@ export function exportCompletedArithmeticCsv( ); } const result = simulation.result_snapshot as Record; - if (result.scenario_kind === 'fiscal_rule_qa_preview') { + if (['fiscal_rule_qa_preview', 'fiscal_rule_published'].includes(String(result.scenario_kind))) { const rows = fiscalQaRows(result); const report = readVersionedArithmeticReportReference(simulation); if (!rows.length || !report) throw new Error('A pré-visualização fiscal não corresponde ao relatório preservado.'); diff --git a/routes/api.php b/routes/api.php index 7ef162ec..ee9b7010 100644 --- a/routes/api.php +++ b/routes/api.php @@ -13,6 +13,7 @@ use App\Http\Controllers\PortalServicesController; use App\Http\Controllers\PublicBusinessOpportunityController; use App\Http\Middleware\CorrelationId; use App\Http\Middleware\EnsureSimulationRecoveryCookie; +use App\Services\SimulationPublishedFiscal; use Illuminate\Support\Facades\Route; Route::prefix('v1')->middleware(['throttle:api', CorrelationId::class])->group(function () { @@ -38,6 +39,7 @@ Route::prefix('v1')->middleware(['throttle:api', CorrelationId::class])->group(f }); Route::prefix('v1')->middleware(['web', 'throttle:api', CorrelationId::class])->group(function () { + Route::get('/fiscal-consumer', fn (SimulationPublishedFiscal $consumer) => response()->json(['data' => $consumer->availability()])->header('Cache-Control', 'no-store')); Route::post('/tax-parameters/resolve', [SimulationTaxParameterController::class, 'resolve'])->middleware('throttle:20,1'); Route::get('/questionnaires/{code}', [SimulationController::class, 'questionnaire']); Route::middleware(EnsureSimulationRecoveryCookie::class)->group(function () { diff --git a/tests/Feature/SimulationPublishedFiscalTest.php b/tests/Feature/SimulationPublishedFiscalTest.php new file mode 100644 index 00000000..b7df3fc5 --- /dev/null +++ b/tests/Feature/SimulationPublishedFiscalTest.php @@ -0,0 +1,214 @@ +consumer = User::factory()->create(['email_verified_at' => now()]); + $actors = []; + foreach (['editors', 'reviewers', 'approvers', 'publishers'] as $role) { + $actors[$role] = User::factory()->withTwoFactor()->create(); + config(['simulation-tax-governance.'.$role => [$actors[$role]->email]]); + } + $this->publisher = $actors['publishers']; + $this->source = InvestmentLegalSource::create([ + 'title' => 'Synthetic QA fixture, not a homologation publication', 'url' => 'https://www.inm.gov.mz/', + 'diploma' => 'Lei 22/2022', 'article' => 'Artigo 17(1)', 'competent_entity' => 'Assembleia da República', + 'valid_from' => '2023-01-01', 'valid_until' => null, 'validation_status' => 'validated', + 'owner_id' => $actors['editors']->id, 'version_no' => 1, 'validator_id' => $actors['reviewers']->id, 'validated_at' => now(), + ]); + $data = json_decode(file_get_contents(database_path('qa/iva-general-net-v1.json')), true, flags: JSON_THROW_ON_ERROR); + $data['legal_source_id'] = $this->source->id; + $gov = app(SimulationTaxRuleGovernance::class); + $this->rule = $gov->create($actors['editors'], $data); + foreach (['submit' => 'editors', 'review' => 'reviewers', 'approve' => 'approvers', 'publish' => 'publishers'] as $action => $role) { + $this->rule = $gov->transition($actors[$role], $this->rule->id, $this->rule->version_no, $action, + 'Synthetic isolated QA fixture for consumer integration only.'); + } + config(['simulation-fiscal-consumer.enabled' => true, 'simulation-fiscal-consumer.rule_id' => $this->rule->id, + 'simulation-fiscal-consumer.rule_version' => 1]); + } + + private function input(array $overrides = []): array + { + return ['scenario_kind' => 'fiscal_rule_published', 'base' => '1000.03', 'currency' => 'MZN', + 'reference_date' => '2026-10-21', 'operation' => 'internal_general_net', 'scope_declared' => true, ...$overrides]; + } + + private function create(array $overrides = [], ?string $key = null) + { + return $this->actingAs($this->consumer)->postJson('/api/v1/simulations', $this->input($overrides), + ['Idempotency-Key' => $key ?? (string) Str::uuid()]); + } + + private function finish(string $id): Simulation + { + (new RunSimulation($id, $this->consumer->id, null))->handle(app(SimulationExecution::class)); + + return Simulation::with('reports')->findOrFail($id); + } + + public function test_normal_simulator_admits_published_rule_and_reuses_history_and_reports(): void + { + $key = (string) Str::uuid(); + $this->getJson('/api/v1/fiscal-consumer')->assertOk()->assertJsonPath('data.enabled', true); + $id = $this->create([], $key)->assertAccepted()->json('data.id'); + $this->create([], $key)->assertOk()->assertJsonPath('replayed', true)->assertJsonPath('data.id', $id); + $this->create(['base' => '1001'], $key)->assertConflict(); + $simulation = $this->finish($id); + $snapshot = $simulation->result_snapshot; + $this->assertSame('fiscal_rule_published', $snapshot['scenario_kind']); + $this->assertSame('calculated', $snapshot['result']['status']); + $this->assertSame($this->rule->id, $snapshot['fiscal_provenance']['rule']['id']); + $this->assertSame('published', $snapshot['fiscal_provenance']['rule']['status']); + $this->assertArrayNotHasKey('review_evidence', $snapshot['fiscal_provenance']['rule']); + $this->assertArrayNotHasKey('author_id', $snapshot['fiscal_provenance']['rule']); + $this->getJson('/api/v1/simulations/history')->assertOk()->assertJsonPath('meta.total', 1); + $this->getJson('/api/v1/simulations/'.$id)->assertOk()->assertJsonPath('data.result_snapshot', $snapshot); + $report = $simulation->reports->first(); + $this->getJson('/api/v1/simulations/'.$id.'/reports/'.$report->id)->assertOk()->assertJsonPath('data.report_snapshot', $snapshot); + $this->assertSame($report->report_sha256, DB::table('simulation_results')->where('simulation_id', $id)->value('result_sha256')); + $this->assertSame(1, Simulation::where('owner_user_id', $this->consumer->id)->count()); + $this->assertDatabaseHas('simulation_result_sources', ['source_id' => $this->rule->id, 'source_type' => 'tax_rule_published']); + $this->actingAs(User::factory()->create(['email_verified_at' => now()]))->getJson('/api/v1/simulations/'.$id)->assertNotFound(); + $this->getJson('/api/v1/simulations/'.$id.'/reports/'.$report->id)->assertNotFound(); + } + + public function test_missing_scope_or_changed_source_is_indeterminate_not_zero(): void + { + $id = $this->create(['scope_declared' => false])->assertAccepted()->json('data.id'); + $snapshot = $this->finish($id)->result_snapshot; + $this->assertSame('indeterminate', $snapshot['result']['status']); + $this->assertNull($snapshot['result']['value']); + $this->assertContains('operation_scope', $snapshot['result']['missing_inputs']); + $this->assertSame('undetermined', $snapshot['fiscal_provenance']['scope_status']); + $this->source->update(['version_no' => 2]); + $id = $this->create()->assertAccepted()->json('data.id'); + $snapshot = $this->finish($id)->result_snapshot; + $this->assertSame('indeterminate', $snapshot['result']['status']); + $this->assertNull($snapshot['result']['value']); + $this->assertContains('eligible_parameter', $snapshot['result']['missing_inputs']); + } + + public function test_suspension_refuses_new_admissions_but_frozen_queue_and_replays_survive(): void + { + $key = (string) Str::uuid(); + $id = $this->create([], $key)->assertAccepted()->json('data.id'); + $this->assertDatabaseHas('simulations', ['id' => $id, 'status' => 'queued']); + app(SimulationTaxRuleGovernance::class)->transition($this->publisher, $this->rule->id, $this->rule->version_no, + 'suspend', 'QA: suspend after admission and before worker execution.'); + $this->getJson('/api/v1/fiscal-consumer')->assertOk()->assertJsonPath('data.enabled', false); + $this->create()->assertStatus(503); + $this->create([], $key)->assertOk()->assertJsonPath('data.id', $id)->assertJsonPath('replayed', true); + config(['simulation-fiscal-consumer.enabled' => false]); + $this->create()->assertStatus(503); + $simulation = $this->finish($id); + $this->assertSame('completed', $simulation->status); + $this->assertSame('published', $simulation->result_snapshot['fiscal_provenance']['rule']['status']); + $this->assertSame($this->rule->id, $simulation->result_snapshot['fiscal_provenance']['rule']['id']); + $this->assertSame('suspended', $this->rule->fresh()->status); + $this->create([], $key)->assertOk()->assertJsonPath('data.status', 'completed'); + $this->getJson('/api/v1/simulations/'.$id.'/reports/'.$simulation->reports->first()->id)->assertOk() + ->assertJsonPath('data.report_snapshot', $simulation->result_snapshot); + $this->getJson('/api/v1/simulations/history')->assertOk()->assertJsonPath('meta.total', 1); + } + + public function test_server_selection_cannot_be_overridden_and_coverage_is_bounded(): void + { + $this->create(['rate' => '0'])->assertUnprocessable(); + $this->create(['rule_id' => (string) Str::uuid()])->assertUnprocessable(); + $this->create(['reference_date' => '2027-01-01'])->assertUnprocessable(); + $this->create(['currency' => 'USD'])->assertUnprocessable(); + config(['simulation-fiscal-consumer.rule_version' => 2]); + $this->create()->assertStatus(503); + config(['simulation-fiscal-consumer.rule_id' => 'invalid-configuration']); + $this->getJson('/api/v1/fiscal-consumer')->assertOk()->assertJsonPath('data.enabled', false); + $this->assertSame(0, Simulation::where('owner_user_id', $this->consumer->id)->count()); + } + + public function test_anonymous_cookie_token_ownership_is_reused(): void + { + $key = (string) Str::uuid(); + $response = $this->postJson('/api/v1/simulations', $this->input(), ['Idempotency-Key' => $key]); + $id = $response->assertAccepted()->json('data.id'); + $token = $response->json('access_token'); + $this->getJson('/api/v1/simulations/'.$id, ['X-Simulation-Token' => $token])->assertOk(); + $this->getJson('/api/v1/simulations/'.$id, ['X-Simulation-Token' => str_repeat('x', 40)])->assertNotFound(); + $this->getJson('/api/v1/simulations/history')->assertUnauthorized(); + } + + public function test_disabled_consumer_does_not_disable_custom_planning(): void + { + config(['simulation-fiscal-consumer.enabled' => false]); + $this->create()->assertStatus(503); + $this->actingAs($this->consumer)->postJson('/api/v1/simulations', ['base' => '100', 'rate' => '5', 'reduction' => '0'], + ['Idempotency-Key' => (string) Str::uuid()])->assertAccepted()->assertJsonPath('data.input_snapshot.scenario_kind', 'custom_planning'); + } + + public function test_public_consumer_works_under_existing_rls_without_governance_context(): void + { + // Role and grants roll back with the isolated test transaction. No application permission is added. + DB::statement('CREATE ROLE qa_fiscal_consumer_rls_20261021 NOLOGIN NOSUPERUSER NOBYPASSRLS'); + DB::statement('GRANT USAGE ON SCHEMA public TO qa_fiscal_consumer_rls_20261021'); + DB::statement('GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO qa_fiscal_consumer_rls_20261021'); + DB::statement('GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO qa_fiscal_consumer_rls_20261021'); + DB::statement("SELECT set_config('app.simulation_tax_governance', 'off', true)"); + DB::statement('SET LOCAL ROLE qa_fiscal_consumer_rls_20261021'); + try { + $this->assertSame(1, SimulationTaxRuleVersion::whereKey($this->rule->id)->count()); + $id = $this->create()->assertAccepted()->json('data.id'); + $this->assertNotEmpty($id); + $this->assertSame(0, DB::table('simulation_tax_rule_events')->where('rule_version_id', $this->rule->id)->count()); + // An UPDATE scope is not granted by the public SELECT policy. + $this->assertSame(0, SimulationTaxRuleVersion::whereKey($this->rule->id)->update(['status' => 'suspended'])); + } finally { + DB::statement('RESET ROLE'); + } + } + + public function test_admission_holds_shared_locks_until_transaction_end(): void + { + $this->create()->assertAccepted(); + config(['database.connections.fiscal_lock_probe' => config('database.connections.pgsql')]); + $probe = DB::connection('fiscal_lock_probe'); + $probe->beginTransaction(); + try { + // A concurrent suspension/source transition cannot cross an admitted transaction. + $rule = $probe->selectOne('select pg_try_advisory_xact_lock(261021, hashtext(?)) as acquired', [$this->rule->id]); + $source = $probe->selectOne('select pg_try_advisory_xact_lock(261022, hashtext(?)) as acquired', [$this->source->id]); + $this->assertFalse($rule->acquired); + $this->assertFalse($source->acquired); + } finally { + $probe->rollBack(); + DB::purge('fiscal_lock_probe'); + } + } +} diff --git a/tests/frontend/fiscal-qa-preview.test.mjs b/tests/frontend/fiscal-qa-preview.test.mjs index 2a483ef3..364a9dd7 100644 --- a/tests/frontend/fiscal-qa-preview.test.mjs +++ b/tests/frontend/fiscal-qa-preview.test.mjs @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import { fiscalQaRows } from "../../resources/js/lib/fiscal-qa-preview.ts"; -import { exportCompletedArithmeticCsv } from "../../resources/js/lib/simulation-form.ts"; +import { exportCompletedArithmeticCsv, readArithmeticSnapshot } from "../../resources/js/lib/simulation-form.ts"; import { createProjectDossierReport } from "../../resources/js/lib/project-dossier-report.ts"; function fixture() { @@ -131,3 +131,46 @@ test("dossier report uses the same exact snapshot and preserves genuine indeterm "Indeterminado", ); }); + +test("published consumer routes the frozen snapshot through screen CSV and dossier without QA labels", () => { + const simulation = fixture(); + const snapshot = simulation.result_snapshot; + snapshot.scenario_kind = "fiscal_rule_published"; + snapshot.context.engine_version = "fiscal-published-v1"; + snapshot.presentation = { value: snapshot.result.value, rounding_applied: false }; + snapshot.result.explanation = "IVA pela versão publicada; enquadramento declarado."; + snapshot.fiscal_provenance.rule.status = "published"; + snapshot.fiscal_provenance.scope_status = "user_declared"; + snapshot.fiscal_provenance.mode = "published_version"; + simulation.reports[0].report_snapshot = structuredClone(snapshot); + const before = JSON.stringify(simulation); + const rows = fiscalQaRows(snapshot); + assert.equal(readArithmeticSnapshot(snapshot).integrity, "versioned"); + assert.match(rows[0][1], /publicada/); + assert.doesNotMatch(JSON.stringify(rows), /rascunho|não publicada|QA/); + const csv = exportCompletedArithmeticCsv(simulation, "2026-10-21T12:00:00Z"); + for (const [label, value] of rows) { + assert.ok(csv.includes(label)); + assert.ok(csv.includes(value.replaceAll('"', '""'))); + } + const report = createProjectDossierReport({ project_title: "Published IVA QA", currency: "MZN" }, { + selectedSections: new Set(["finance"]), currentCalculation: { status: "completed", version: 2, snapshot }, + }); + const finance = report.sections.find((section) => section.id === "finance"); + for (const [field, value] of rows) assert.ok(finance.rows.some((row) => row.field === field && row.value === value)); + assert.doesNotMatch(JSON.stringify(finance), /rascunho|proveniência QA/); + assert.equal(JSON.stringify(simulation), before); +}); + +test("published consumer indeterminate snapshot is valid and a mismatched presentation is refused", () => { + const snapshot = fixture().result_snapshot; + snapshot.scenario_kind = "fiscal_rule_published"; + snapshot.result.status = "indeterminate"; + snapshot.result.value = null; + snapshot.result.missing_inputs = ["operation_scope"]; + snapshot.indicative_total = null; + snapshot.presentation = { value: null, rounding_applied: false }; + assert.equal(readArithmeticSnapshot(snapshot).integrity, "versioned"); + snapshot.presentation.value = "0"; + assert.equal(readArithmeticSnapshot(snapshot).integrity, "invalid"); +});