This commit is contained in:
2026-10-21 12:42:05 +02:00
33 changed files with 2153 additions and 52 deletions
+5
View File
@@ -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
@@ -0,0 +1,50 @@
<?php
namespace App\Console\Commands;
use App\Models\InvestmentLegalSource;
use App\Models\User;
use App\Services\SimulationTaxParameterResolver;
use App\Services\SimulationTaxRuleExpression;
use App\Services\SimulationTaxRuleGovernance;
use App\Support\IsolatedQaDatabase;
use Illuminate\Console\Command;
/** Explicit registration: never run during migration, deployment or document import. */
class RegisterFiscalQaDraft extends Command
{
protected $signature = 'fiscal:qa-register-iva {actor : Existing QA editor ID} {source : Existing accepted legal source UUID}';
protected $description = 'Cadastrar apenas o rascunho IVA do primeiro lote numa base QA isolada, sem publicação';
public function handle(SimulationTaxRuleGovernance $governance): int
{
abort_unless(IsolatedQaDatabase::allowsSchemaChanges(), 403, 'O cadastro deste lote só é permitido na base QA isolada.');
$actor = User::findOrFail($this->argument('actor'));
$source = InvestmentLegalSource::findOrFail($this->argument('source'));
abort_unless($source->validation_status === 'validated' && $source->diploma === 'Lei 22/2022'
&& $source->article === 'Artigo 17(1)', 422, 'Seleccione a referência aceite da taxa geral, Lei 22/2022, artigo 17(1).');
$payload = json_decode(file_get_contents(database_path('qa/iva-general-net-v1.json')), true, flags: JSON_THROW_ON_ERROR);
$actualHash = hash_file('sha256', base_path('docs/investmoz/simulations/source-archive/Lei-22-2022-IVA-republicada-BR-250-2-suplemento.pdf'));
abort_unless(hash_equals($payload['source_sha256'], $actualHash), 422, 'O exemplar não corresponde ao hash da ficha.');
$payload['legal_source_id'] = (string) $source->id;
app(SimulationTaxRuleExpression::class)->validate($payload['rule_definition']);
app(SimulationTaxParameterResolver::class)->validateParameters($payload['parameters']);
$existing = $governance->listing($actor, ['rule_code' => $payload['rule_code']])->getCollection()
->firstWhere('rule_version', 1);
if ($existing) {
foreach ($payload as $field => $value) {
$stored = $existing->attributesToArray()[$field] ?? null;
abort_unless($stored == $value, 409, 'O cadastro existente difere desta ficha; não será sobrescrito.');
}
abort_unless($existing->status === 'draft', 409, 'A versão existente já não é um rascunho.');
$this->line(json_encode(['id' => $existing->id, 'status' => 'draft', 'reused' => true], JSON_THROW_ON_ERROR));
return self::SUCCESS;
}
$rule = $governance->create($actor, $payload);
$this->line(json_encode(['id' => $rule->id, 'status' => $rule->status, 'reused' => false], JSON_THROW_ON_ERROR));
return self::SUCCESS;
}
}
@@ -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) {
@@ -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') {
@@ -2,17 +2,51 @@
namespace App\Http\Controllers;
use App\Services\SimulationFiscalQaPreview;
use App\Services\SimulationTaxParameterResolver;
use App\Services\SimulationTaxRuleExpression;
use App\Services\SimulationTaxRuleGovernance;
use App\Support\IsolatedQaDatabase;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
use Inertia\Response;
use InvalidArgumentException;
class SimulationTaxRuleGovernanceController extends Controller
{
public function qaReview(Request $request, string $id, SimulationTaxRuleGovernance $governance): Response
{
abort_unless(IsolatedQaDatabase::allowsSchemaChanges(), 404);
$rule = $governance->show($request->user(), $id);
abort_unless($rule->status === 'draft' && $rule->rule_code === 'IVA-GERAL-BASE-LIQUIDA', 422,
'Seleccione o rascunho do primeiro lote fiscal.');
return Inertia::render('management/fiscal-qa-review', ['rule' => [
'id' => $rule->id, 'code' => $rule->rule_code, 'version' => $rule->rule_version,
]]);
}
public function qaPreview(Request $request, string $id, SimulationFiscalQaPreview $preview): JsonResponse
{
abort_unless(IsolatedQaDatabase::allowsSchemaChanges(), 404);
$key = $request->header('Idempotency-Key');
abort_unless(is_string($key) && strlen($key) >= 16 && strlen($key) <= 128, 428, 'Idempotency-Key é obrigatório.');
$input = $request->validate([
'base' => ['required', 'string', 'max:40', 'regex:/^(?:0|[1-9][0-9]*)(?:\.[0-9]+)?$/D'],
'reference_date' => ['required', 'date_format:Y-m-d'],
'currency' => ['required', 'in:MZN'],
'operation' => ['required', 'in:internal_general_net'],
]);
$result = $preview->create($request->user(), $id, $input, $key);
$simulation = $result['simulation'];
return response()->json(['data' => $simulation, 'replayed' => $result['replayed']], $result['replayed'] ? 200 : 202)
->header('Location', url('/api/v1/simulations/'.$simulation->id));
}
public function index(Request $request, SimulationTaxRuleGovernance $governance): JsonResponse
{
$filters = $request->validate([
@@ -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<string, list<mixed>> */
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'],
+47 -22
View File
@@ -4,6 +4,7 @@ namespace App\Jobs;
use App\Models\Simulation;
use App\Services\SimulationExecution;
use App\Support\IsolatedQaDatabase;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
@@ -62,29 +63,39 @@ class RunSimulation implements ShouldQueue
throw new \RuntimeException('Simulation has no scenario.');
}
$input = json_decode($scenario->parameters, true, flags: JSON_THROW_ON_ERROR);
$row = DB::selectOne('select (CAST(? AS numeric) * CAST(? AS numeric) / 100 * (1 - CAST(? AS numeric) / 100))::text as value', [$input['base'], $input['rate'], $input['reduction']]);
$resultId = (string) Str::uuid();
$engineVersion = 'portal-arithmetic-v1+simulation-v'.$simulation->version_no;
$result = ['base' => $input['base'], 'rate_percent' => $input['rate'], 'reduction_percent' => $input['reduction'], 'scenario_kind' => 'custom_planning', 'indicative_total' => $row->value,
'currency' => null, 'legal_interpretation' => false, 'formula_version' => 'portal-arithmetic-v1',
'contract_version' => 1,
'context' => [
'reference_date' => substr((string) $scenario->created_at, 0, 10),
'currency' => null,
'currency_fraction_digits' => null,
'scenario_id' => (string) $scenario->id,
'scenario_version' => (int) $scenario->version_no,
'engine_version' => $engineVersion,
'ruleset_version' => null,
],
'result' => [
'status' => 'calculated',
'value' => (string) $row->value,
'unit' => null,
'explanation' => 'Resultado aritmético determinístico; não constitui cálculo nem conclusão fiscal.',
'missing_inputs' => [],
'source_ids' => [],
]];
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 = $result['context']['engine_version'];
$result['context']['scenario_id'] = (string) $scenario->id;
$result['context']['scenario_version'] = (int) $scenario->version_no;
} else {
$row = DB::selectOne('select (CAST(? AS numeric) * CAST(? AS numeric) / 100 * (1 - CAST(? AS numeric) / 100))::text as value', [$input['base'], $input['rate'], $input['reduction']]);
$result = ['base' => $input['base'], 'rate_percent' => $input['rate'], 'reduction_percent' => $input['reduction'], 'scenario_kind' => 'custom_planning', 'indicative_total' => $row->value,
'currency' => null, 'legal_interpretation' => false, 'formula_version' => 'portal-arithmetic-v1',
'contract_version' => 1,
'context' => [
'reference_date' => substr((string) $scenario->created_at, 0, 10),
'currency' => null,
'currency_fraction_digits' => null,
'scenario_id' => (string) $scenario->id,
'scenario_version' => (int) $scenario->version_no,
'engine_version' => $engineVersion,
'ruleset_version' => null,
],
'result' => [
'status' => 'calculated',
'value' => (string) $row->value,
'unit' => null,
'explanation' => 'Resultado aritmético determinístico; não constitui cálculo nem conclusão fiscal.',
'missing_inputs' => [],
'source_ids' => [],
]];
}
$json = json_encode($result, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES);
$canonicalResult = DB::selectOne('select CAST(? AS jsonb)::text as canonical', [$json])->canonical;
$resultHash = hash('sha256', $canonicalResult);
@@ -96,11 +107,25 @@ class RunSimulation implements ShouldQueue
DB::table('simulation_jobs')->where('id', $job->id)->update(['status' => 'succeeded', 'finished_at' => now(), 'updated_at' => now()]);
DB::table('simulation_reports')->insert(['id' => (string) Str::uuid(), 'simulation_id' => $simulation->id, 'version_no' => $scenario->version_no,
'status' => 'ready', 'report_snapshot' => $json, 'report_sha256' => $resultHash, 'created_at' => now(), 'updated_at' => now()]);
$sourceJson = json_encode($input, JSON_THROW_ON_ERROR);
$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' => $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,
'source_type' => 'user_input', 'source_id' => $scenario->id, 'source_version' => (string) $scenario->version_no,
'source_snapshot' => $sourceJson, 'source_sha256' => hash('sha256', $canonicalSource), 'created_at' => now()]);
if (isset($result['fiscal_provenance'])) {
$provenance = $result['fiscal_provenance'];
$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' => $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(),
]);
}
DB::table('simulation_audit_events')->insert(['simulation_id' => $simulation->id, 'actor_user_id' => $simulation->owner_user_id,
'action' => 'simulation.completed', 'correlation_id' => $simulation->id, 'data' => json_encode(['result_sha256' => hash('sha256', $json), 'attempt' => $job->attempts + 1], JSON_THROW_ON_ERROR), 'occurred_at' => now()]);
}, 3);
+39 -6
View File
@@ -4,6 +4,7 @@ namespace App\Services;
use App\Jobs\RunSimulation;
use App\Models\Simulation;
use App\Support\IsolatedQaDatabase;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
@@ -17,15 +18,39 @@ final class SimulationExecution
*/
public function create(array $data, ?int $actorId, ?string $organizationId, ?string $token, string $key): array
{
$tokenHash = $actorId === null ? hash_hmac('sha256', (string) $token, (string) config('app.key')) : null;
$scope = $actorId !== null ? (string) $actorId : ($tokenHash ?? 'anonymous');
$scopeHash = hash('sha256', $scope);
$keyHash = hash('sha256', strtolower($key));
$payload = ['base' => (string) $data['base'], 'rate' => (string) $data['rate'], 'reduction' => (string) $data['reduction'], 'scenario_kind' => 'custom_planning', 'organization_id' => $organizationId];
return $this->createPayload($payload, $actorId, $organizationId, $token, $key);
}
public function createFiscalQaPreview(array $snapshot, int $actorId, string $key): array
{
abort_unless(IsolatedQaDatabase::allowsSchemaChanges(), 404);
abort_unless(($snapshot['scenario_kind'] ?? null) === 'fiscal_rule_qa_preview'
&& in_array($snapshot['result']['status'] ?? null, ['calculated', 'indeterminate'], true)
&& ($snapshot['presentation']['rounding_applied'] ?? null) === false, 422);
return $this->createPayload(['scenario_kind' => 'fiscal_rule_qa_preview', 'fiscal_preview' => $snapshot, 'organization_id' => null],
$actorId, null, null, $key);
}
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'));
$keyHash = hash('sha256', strtolower($key));
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();
@@ -37,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);
@@ -44,7 +73,9 @@ 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' => ['engine' => 'portal-arithmetic-v1', 'formula' => 'base * rate / 100 * (1 - reduction / 100)', 'legal_rate_source' => null],
'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();
DB::table('simulation_scenarios')->insert([
@@ -85,6 +116,8 @@ 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(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();
abort_if($scenario === null, 404);
@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\InvestmentLegalSource;
use App\Models\User;
use App\Support\IsolatedQaDatabase;
use Illuminate\Support\Facades\DB;
/** Exact operation-level IVA calculation against an explicit QA draft; never a payment/return total. */
final class SimulationFiscalQaPreview
{
public function create(User $actor, string $ruleId, array $input, string $key): array
{
abort_unless(IsolatedQaDatabase::allowsSchemaChanges(), 404);
return DB::transaction(function () use ($actor, $ruleId, $input, $key): array {
$rule = app(SimulationTaxRuleGovernance::class)->show($actor, $ruleId);
abort_unless($rule->status === 'draft' && $rule->rule_code === 'IVA-GERAL-BASE-LIQUIDA', 422,
'Seleccione o rascunho do primeiro lote fiscal.');
abort_if(collect($rule->rule_definition['nodes'] ?? [])->contains(fn (array $node): bool => ($node['op'] ?? null) === 'round'), 422,
'Este percurso preserva o valor exacto e não aplica arredondamento automático.');
$source = InvestmentLegalSource::findOrFail($rule->legal_source_id);
$version = $rule->attributesToArray();
$version['source'] = $source->attributesToArray();
$date = $input['reference_date'];
$context = ['operation' => $input['operation'], 'currency' => $input['currency']];
$missing = [];
$resolution = [];
$evaluation = null;
if ($date < '2026-01-01' || $date > '2026-12-31') {
$missing[] = 'demonstrated_coverage';
} else {
$resolution = app(SimulationTaxParameterResolver::class)->resolveDraftForQa([$version], 'IVA', $date, $date, $context);
$rate = collect($resolution)->firstWhere('parameter_code', 'IVA_GENERAL_RATE');
if (($rate['status'] ?? null) !== 'resolved' || ($rate['unit'] ?? null) !== 'percent') {
$missing[] = 'eligible_parameter';
} else {
$evaluation = app(SimulationTaxRuleExpression::class)->evaluate($rule->rule_definition,
['base_tributavel' => $input['base']], ['iva_general_rate' => $rate['value']]);
}
}
$snapshot = app(SimulationFiscalSnapshot::class)->build($version, $input, $resolution, $evaluation, $missing);
return app(SimulationExecution::class)->createFiscalQaPreview($snapshot, $actor->id, $key);
});
}
}
+58
View File
@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace App\Services;
use Brick\Math\BigRational;
/** Shared snapshot representation; eligibility is resolved by each admission adapter. */
final class SimulationFiscalSnapshot
{
public function build(array $version, array $input, array $resolution, ?array $evaluation, array $missing, bool $published = false): array
{
$date = $input['reference_date'];
$context = ['operation' => $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;
}
}
@@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\InvestmentLegalSource;
use App\Models\SimulationTaxRuleVersion;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
/** Admission freezes eligibility without granting the public consumer governance write access. */
final class SimulationPublishedFiscal
{
public function availability(): array
{
$enabled = (bool) config('simulation-fiscal-consumer.enabled');
$eligible = $enabled && $this->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);
}
}
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Services;
use App\Support\IsolatedQaDatabase;
use Brick\Math\BigRational;
use DateTimeImmutable;
use Illuminate\Support\Facades\DB;
@@ -126,14 +127,30 @@ final class SimulationTaxParameterResolver
* @return list<array<string,mixed>>
*/
public function resolveAvailableParameters(array $versions, string $component, string $periodStart, string $periodEnd, array $context): array
{
return $this->resolveWithStatus($versions, $component, $periodStart, $periodEnd, $context, 'published');
}
/** Explicit draft preview. Never used by the published-parameter API. */
public function resolveDraftForQa(array $versions, string $component, string $periodStart, string $periodEnd, array $context): array
{
abort_unless(IsolatedQaDatabase::allowsSchemaChanges(), 404);
return $this->resolveWithStatus($versions, $component, $periodStart, $periodEnd, $context, 'draft');
}
private function resolveWithStatus(array $versions, string $component, string $periodStart, string $periodEnd, array $context, string $requiredStatus): array
{
$start = $this->date($periodStart);
$end = $this->date($periodEnd);
if ($end < $start || $start->diff($end)->days > 365) {
throw new InvalidArgumentException('Reveja o período de aplicação indicado.');
}
$groups = [];
foreach ($versions as $version) {
if (($version['component'] ?? null) !== $component
|| ($version['status'] ?? null) !== 'published'
|| ($version['status'] ?? null) !== $requiredStatus
|| ($version['source']['validation_status'] ?? null) !== 'validated'
|| (int) ($version['source_version_no'] ?? 0) !== (int) ($version['source']['version_no'] ?? -1)
|| ! $this->overlaps($version, $start, $end)
@@ -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];
+8
View File
@@ -0,0 +1,8 @@
<?php
return [
// Publication and consumer exposure are separate Tempo decisions.
'enabled' => env('INVESTMOZ_IVA_CONSUMER_ENABLED', false),
'rule_id' => env('INVESTMOZ_IVA_CONSUMER_RULE_ID'),
'rule_version' => (int) env('INVESTMOZ_IVA_CONSUMER_RULE_VERSION', 0),
];
+38
View File
@@ -0,0 +1,38 @@
{
"rule_code": "IVA-GERAL-BASE-LIQUIDA",
"rule_version": 1,
"component": "IVA",
"scope_key": "mz.internal-general-net.mzn",
"valid_from": "2023-01-01",
"valid_until": null,
"source_sha256": "ea1b0dc6f5cf6e595f6d9a19e7042f398999e0087caef1785f6e8fac571b5f53",
"rule_definition": {
"language": "investmoz.tax.v1",
"nodes": {
"base": {"op": "input", "key": "base_tributavel"},
"rate": {"op": "parameter", "key": "iva_general_rate"},
"hundred": {"op": "literal", "value": "100"},
"product": {"op": "multiply", "args": ["base", "rate"]},
"intermediate": {"op": "divide", "args": ["product", "hundred"]}
},
"result": "intermediate"
},
"parameters": {
"IVA_GENERAL_RATE": {
"value": "16",
"unit": "percent",
"conditions": {"operation": "internal_general_net", "currency": "MZN"}
}
},
"rounding_policy": {
"status": "not_applied",
"precision": "exact_decimal",
"method": "none",
"ties": "not_applicable_without_rounding",
"aggregation": "single_declared_taxable_base",
"purpose": "operation_tax_not_payment",
"evidence": "Orientação expressa Tempo: calcular este lote IVA sem arredondamento automático; ficha v1.2",
"coverage_start": "2026-01-01",
"coverage_end": "2026-12-31"
}
}
@@ -0,0 +1,98 @@
# IVA-GERAL-BASE-LIQUIDA — ficha documental v1.2
21/10/2026. AUD-SIM-INT02 na matriz existente. Proposta inicial `0fafd348`; v1 em `325d58b6`, v1.1 em `37085b40`. Essas versões ficam preservadas em Git. Esta versão aplica a orientação posterior expressa da Tempo: **prosseguir neste lote IVA com precisão decimal exacta, sem introduzir arredondamento automático; separar valor calculado de apresentação monetária**. Retira a falta de uma política genérica de arredondamento como condição bloqueante do cálculo. Não estende a decisão a todos os impostos.
**Estado:** cadastro e cálculo experimental em QA, regra em rascunho, sem publicação/activação ou integração em `main`. Não equivale a conclusão do simulador fiscal, emissão de factura, declaração ou determinação de saldo a pagar. A Tempo declarou não ter conferido a ficha local nem validado juridicamente as conclusões transcritas; não se lhe atribui essa validação.
## Âmbito e contrato
Um único percurso: base tributável líquida já identificada × taxa geral /100, em MZN explícito, para operação interna abrangida pela taxa geral. Janela demonstrada do lote:01/01/2026–31/12/2026, delimitação técnica que não cria caducidade legal em31/12. Não decide automaticamente incidência, sujeito passivo, localização, isenção ou regime especial. Contexto declarado é rotulado como tal; uma rubrica orçamental não se transforma automaticamente em base fiscal.
Excluídos: extracção de preço com IVA incluído, taxas reduzidas, isenções/listas, importações, digital/não residentes, dedução/crédito/reembolso, incentivos e outros impostos. Permanecem no inventário documental existente, sem omissão nem alegação de cobertura deste lote.
| Campo | Estado actual |
|---|---|
| Regra | `IVA-GERAL-BASE-LIQUIDA`, versão1, estado `draft`; cadastro explícito apenas em QA. |
| Taxa / unidade | `16` / `percent`, vínculo Lei22/2022, art.17(1), exemplar/hash abaixo. |
| Vigência cadastrada | Desde01/01/2023, sem termo artificial; cobertura ensaiada2026 separada da vigência. |
| Entradas | Base decimal como texto, MZN explícito, data de exigibilidade declarada, contexto `internal_general_net`. Zero explícito é válido; ausência não é zero. |
| Valor calculado | Decimal exacto obtido pelo avaliador racional existente; sem conversão para float, truncagem ou redução para duas casas. |
| Apresentação | Valor decimal preservado como texto e moeda separados. Duas casas da unidade monetária não impõem arredondamento ao cálculo. Ecrã/CSV/dossier reproduzem o valor guardado. |
| Arredondamento | `not_applied`; nenhum nó `round` neste percurso. Não se inventa precisão, empate ou método fiscal. |
| Indeterminação efectiva | Fonte/versão/contexto incompatível, parâmetro ausente ou pedido fora da cobertura demonstrada. A falta de arredondamento não torna indeterminado o resultado deste percurso. |
| Limites do resultado | Cálculo QA sobre regra em rascunho e enquadramento declarado; não é obrigação validada nem saldo a pagar. |
## Fontes reutilizadas e disposições documentadas
Fontes oficiais aceites pela decisão Tempo de08/10/2026; sem nova certificação genérica de proveniência. A identificação de âmbito, período e dispositivo é aplicação da fonte aceite. Reutilizar [análise primária](primary-text-review-2025-2026.md), [registo de fontes](official-source-register.md) e [reforma fiscal](fiscal-reform-2026-review.md). A aceitação da fonte não activa regra nem valida juridicamente uma operação concreta.
| Exemplar | Identificação e integridade |
|---|---|
| [Lei13/2016 / CIVA republicado](source-archive/Lei-13-2016-CIVA-BR-156-10-suplemento.pdf) | BR156,10.º suplemento,30/12/2016;32páginas,2.127.554bytes; SHA256 `B90DC7C14ECEA2E378C4A62E8E5A47D54A5644C457BAEB8B8053B92FD2A5710B`. Leitura visual dirigida PDF6–8,13–14,17–18; entrada em vigor no art.4/PDF5. Não se afirma análise integral de anexos. |
| [Lei22/2022 republicada](source-archive/Lei-22-2022-IVA-republicada-BR-250-2-suplemento.pdf) | BR250,2.º suplemento;18páginas,1.834.284bytes; SHA256 `EA1B0DC6F5CF6E595F6D9A19E7042F398999E0087CAEF1785F6E8FAC571B5F53`. Art.17(1),PDF3/BR2252-(27), taxa16%; art.6 da Lei,PDF4, eficácia01/01/2023. |
| [BR248, suplemento1/2025](source-archive/BR-248-I-Suplemento-1-2025.pdf) |21páginas,440.018bytes; SHA256 `CCF2A8DEECA820F2DD076B4B2141B1EFE3F6A806568C3A587CA59A8E92F4C251`. Lei10/2025,PDF12–15; eficácia01/01/2026. |
| [BR16/2008 / Decreto7/2008](source-archive/BR-16-I-Serie-2008-04-16.pdf) |16/04/2008;28páginas,2.267.865bytes; SHA256 `FC373FF5637EDF75EC6A9595DEC8763A8503D4EE786E3B7D8F1C742EAE9D80EE`. Regulamento IVA,PDF1–2; página2 conferida visualmente. O BR inclui outros impostos, que não são fundamento deste lote. |
| [BR89/2017 / rectificação CIVA](source-archive/BR-89-I-Serie-2017-06-08.pdf) | Aviso relativo a art.6(5)c/d,14(2)c e anexos de isenção; análise anterior reutilizada. Não encerra consolidação das listas de isenção. |
| Disposição exacta | Conteúdo documentado / consequência |
|---|---|
| CIVA art.1(1)(a), PDF6, BR1426-(264) | Transmissões de bens e prestações de serviços onerosas no território nacional, nos termos do art.6, por sujeito passivo nessa qualidade. Exigir identificação desse contexto; importações ficam fora. |
| Art.2(1)(a), PDF6 | Qualidade do sujeito passivo ligada às condições de residência/estabelecimento e actividade independente previstas. Perfil de investidor não prova sujeição. |
| Arts.3(1),4(1), PDF6–7 | Transferência onerosa de bens corpóreos e noção geral de serviço. Casos assimilados/especiais não entram implicitamente na fórmula. |
| Art.6(1), PDF7, BR1426-(265) | Para bens, localização no início do transporte/expedição ou quando postos à disposição, sem transporte. Serviços seguem art.6(3) e excepções subsequentes; não basta localização do prestador para todos os serviços. |
| Art.15(1), PDF13, BR1426-(271) | Base geral: contraprestação obtida ou a obter, sem prejuízo das bases especiais do n.º2. Não equivale a qualquer receita de planeamento. |
| Art.15(5)(a)/(b), PDF14, BR1426-(272) | Inclusão de outros impostos/direitos/taxas, excepto o próprio IVA, e despesas acessórias debitadas, nas condições previstas. |
| Art.15(6)(a)–(d), PDF14 | Exclusões condicionadas: indemnizações judiciais indicadas, descontos/abatimentos/bónus, pagamentos em nome/conta de terceiros registados adequadamente e embalagens retornáveis. Não classificar só pelo nome da rubrica. |
| Art.17(2), PDF14; art.7(1)(a)/(b), PDF8 | Taxa vigente na exigibilidade; facto gerador/exigibilidade diferem por operação e condições. A criação da simulação não substitui a data fiscal. Adiantamentos e prestações continuadas requerem enquadramento próprio. |
| Art.27(5)(c)/(d), PDF17, BR1426-(275) | Separação do preço líquido/elementos da base, taxa e montante do imposto. Lei10/2025 preserva essas alíneas com «[…]», alterando o n.º10. |
| Regulamento, arts.4/5, Decreto7/2008,PDF1–2 | Remete matéria colectável para arts.15/16 e apuramento devido para arts.18–22 do Código. IVA da operação não equivale ao saldo após deduções. |
Cadeia temporal documentada: Lei13/2016 efectiva em01/01/2017; a taxa histórica17% do exemplar não é usada para2026. Lei22/2022 altera art.17(1) para16% desde01/01/2023 e acrescenta art.15(10) para taxa reduzida, mantendo os números gerais consultados. Lei10/2025, efectiva01/01/2026, não lista arts.15/17 entre os alterados. Esta conclusão documental não é atribuída como validação jurídica ao utilizador e não certifica todos os regimes/excepções.
## Precisão do cálculo e exigências contextuais
Art.34 do CIVA, Lei10/2025/PDF14, regula extracção de base de valores com imposto incluído, com divisor116 e arredondamento para unidade mais próxima. Art.9(1)(b) do regulamento/Decreto7/2008,PDF2/p.100, trata do valor do meio de pagamento por cheque ou vale de correio e arredondamento por excesso para meticais nesse exemplar histórico. **Nenhum é aplicado como regra geral de arredondamento deste cálculo sobre base líquida.** A vigência consolidada e exigências de formatos de pagamento/declaração serão determinadas quando esses consumidores forem abrangidos.
A [comunicação oficial de30/09/2026](https://portaldogoverno.gov.mz/governo-introduz-alteracoes-nos-regulamentos-fiscais-para-reforcar-eficiencia-tributaria/) anuncia alterações regulamentares; não fornece articulado publicado e eficácia por disposição. A pesquisa dirigida em fontes oficiais não identificou uma exigência geral que obrigue a arredondar este percurso. Ausência de correspondência não prova inexistência de instruções específicas. Não há questão pendente à Tempo como pré-condição deste cálculo. Se um futuro formato exigir precisão, apresentar uma única questão contextual, identificando o formato, precisão, método, empates e agregação; não generalizar a outros cálculos.
Nota de rastreabilidade, sem reabrir pontos: o exemplar BR54 de06/04/2017 consultado anteriormente não continha a alteração regulamentar procurada. O [catálogo INM](https://www.inm.gov.mz/pt-br/produtos?page=124) identifica Decreto8/2017 no BR50 de30/03/2017. O articulado e alterações pertinentes continuam a ser correspondências documentais a completar quando necessárias ao consumidor; não são bloqueio genérico do cálculo exacto deste lote.
## Exemplos e provas dirigidas
Pressupostos explícitos: operação interna onerosa à taxa geral, base já apurada, sem isenção/redução/base especial, data coberta2026 e MZN. Não são classificações jurídicas de operações reais.
| Base | IVA calculado exacto | Apresentação / significado |
|---|---|---|
|100|16|`16` e moeda `MZN`, separados; não é saldo a pagar. |
|0,03|0,0048|Conservar todas as casas no armazenamento, ecrã e exportação. Não substituir por0,00. |
|0|0|Zero explicitamente declarado, distinto de entrada ausente. |
|9007199254740993,0003|1441151880758558,880048|Preservar sem converter para float JavaScript/PHP. |
|100 + transporte10 − desconto5|16,8 sobre base105|Apenas se verificadas as condições de inclusão/exclusão do art.15. |
Os testes do novo consumidor cobrem registo repetido sem duplicação, rascunho excluído da API publicada, resultado exacto, snapshot/relatório conservados após edição da regra e recuperação noutra sessão de autenticação, acesso negado a outra conta, moeda obrigatória, incompatibilidade da fonte, cobertura e recusa fora de QA. A nova sessão provada é API/autenticação, não alegação de novo ensaio browser. A conta PostgreSQL QA ultrapassa RLS: recusas demonstram a política da aplicação, não nova prova de RLS.
## Implementação, limites e integração
Reutilizados `SimulationTaxRuleGovernance`, `SimulationTaxParameterResolver`, `SimulationTaxRuleExpression` e o armazenamento existente de simulações, fontes, histórico e relatórios. Não há motor novo, migration ou alteração da base activa. `fiscal:qa-register-iva` exige alvo QA isolado, editor e referência de fonte já existente; confere hash e cadastra apenas rascunho, de forma idempotente. Não corre em deploy/importação. O artefacto versionado é `database/qa/iva-general-net-v1.json`.
Pré-visualização administrativa `POST /api/v2/investment/admin/tax-rules/{id}/qa-preview`, com `Idempotency-Key`, exige os controlos administrativos existentes e alvo QA isolado. Não muda a API de parâmetros publicados para aceitar rascunhos. O snapshot congela regra, fonte/versão/hash, contexto declarado, data, passos racionais, decimal calculado, apresentação sem arredondamento e limitações. Alteração da regra não reescreve o histórico. Uma nova entrada produz nova execução; um snapshot fiscal não é convertido por edição numa simulação de planeamento.
O cadastro persistido usa clone `simulator_fiscal_delta_20261021_test` e referência/actores sintéticos explicitamente identificados; não representa revisão jurídica por uma pessoa real nem atribuição persistente de papéis da Tempo. O recibo privado regista IDs, estado draft, zero regras publicadas e hash do relatório. A conta técnica e os ficheiros de prova privados não são material de deploy.
Planeamento personalizado mantém identificação própria. O resultado deste lote é apresentado como cálculo QA com regra em rascunho, não estimativa percentual introduzida pelo utilizador nem regra fiscal activada. As provas aceites de13f68f1b/valores/CSV/paginação não são repetidas. A entrega anterior permanece independente e concluída; não integra esta branch automaticamente.
## Estimativa e revisão
As15–26h anteriores eram condicionais e incluíam o trabalho de cadastro e integração que agora avançou em QA. O esclarecimento de uma política genérica de arredondamento deixa de integrar o caminho crítico deste lote. O saldo deve usar os resultados técnicos registados na adenda do manual existente, distinguindo trabalho já executado, revisão e qualquer consumidor futuro de pagamento/declaração. Não são horas medidas, promessa de activação nem estimativa do simulador fiscal integral. Publicação, activação e integração em `main` continuam separadas e não foram executadas.
## 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.
A única lacuna adicional encontrada foi o percurso completo pela interface. Acrescentou-se a páginaQA protegida `/management/investment/tax-rules/{id}/qa-review`, ligada às APIs existentes de cálculo/gravação, recuperação e relatório imutável. **Exemplo observado no Edge:** base1000,03MZN e exigibilidade declarada21/10/2026 → IVA calculado160,0048 → execução`fa6d17f2-5f47-4cb6-b8cb-40850c12505f` gravada → recarga da página → recuperação do mesmoID no histórico → relatório`d13e2ad5-3ba8-4d01-9d30-5815496da774`, mesmo valor/contexto/proveniência e hash igual ao resultado guardado. Uma execução persistida; regra continua emdraft, zero publicadas. Não prova aplicabilidade fiscal a qualquer operação nem saldo a pagar.
A ferramenta perdeu inicialmente a ligação ao navegador e permaneceu sem inventário apesar das reactivações; essa limitação ficou registada, sem a atribuir ao simulador. A ligação foi depois restabelecida e concluiu-se apenas a parte não observada, com autenticação/segundo factor normais, sem contornar guardas. As provas aceites de83d2f97d são reutilizadas;1teste novo de rota/17asserções,TypeScript/build e a observação browser cobrem o delta. Não se atribui ao utilizador conferência local ou validação jurídica. Não há nova preparação genérica identificada para este recorte; integração e activação continuam separadas.
@@ -319,5 +319,274 @@
"AUD-SIM-INT07": "demonstrated within bounded scope; see existing manual for limits",
"AUD-SIM-INT08": "demonstrated within bounded scope; see existing manual for limits"
}
},
"integration_review_20261021": {
"functional_candidate": "13f68f1b",
"delivery_label": "melhorias de planeamento financeiro, recuperacao, exportacao e idioma",
"attachment_limit_bytes_before_and_after": 15728640,
"refused_fixture_bytes": 16777216,
"lower_limit_introduced": false,
"user_read_local_record": false,
"main_integration": false,
"fiscal_first_batch_proposal": {
"rule": "IVA general rate on net taxable base; provisional identifier only",
"demonstrated_coverage": "2026-01-01 through 2026-12-31; not invented legal expiry",
"data_work_hours": [
4,
8
],
"code_and_acceptance_hours": [
12,
20
],
"total_hours": [
16,
28
],
"scope_and_rounding_evidence_required": true,
"rules_created": 0,
"rules_activated": 0
}
},
"fiscal_qa_v12_20261021": {
"instruction": "Tempo: exact decimal IVA calculation without automatic rounding; presentation distinct; no activation or main integration",
"supersedes": "v1.1 rounding-as-blocker and remaining estimate; no reopening accepted evidence",
"status": "QA scoped implementation verified; fiscal product remains partial AUD-SIM-INT02",
"proof": {
"synthetic_qa": true,
"database": "simulator_fiscal_delta_20261021_test",
"rule_id": "01a15d4e-c69b-7264-bdee-1e56ee88bf94",
"rule_status": "draft",
"registration_reused": true,
"simulation_id": "842e05f9-2b6e-4853-8ff5-81650633f6c4",
"report_id": "49cac0bd-f49c-4613-a8f0-1ee5c285cf81",
"exact_value": "0.0048",
"rounding_applied": false,
"report_sha256": "8ed8155a56fdf98ad26e2f01435644e062f27ca6b067ce3553415e974261c687",
"result_report_equal": true,
"previous_report_unchanged": true,
"published_rules": 0,
"source_reference_is_qa_fixture": true,
"human_legal_approval": false
},
"tests": {
"feature": 5,
"feature_assertions": 51,
"resolver_unit": 7,
"resolver_assertions": 25,
"frontend": 3,
"typescript": "passed",
"build": "passed",
"pint": "passed"
},
"browser_observation": "Actual component SSR and native report HTML fiscal delta observed in Edge; not a newly saved native PDF or complete browser history flow",
"limits": [
"synthetic source reference and QA actors; not human legal approval",
"QA PostgreSQL role bypasses RLS; application denials only",
"no payment or return balance; scope declared",
"no activation, migration, main merge or deployment"
],
"remaining_hours_scoped_qa_engineering": 0,
"previous_15_26_hours": "Historical conditional estimate includes work now performed, not measured hours; review/activation and fiscal product scope separate",
"record": "docs/investmoz/simulations/ficha-IVA-GERAL-BASE-LIQUIDA-v1.md"
},
"review_83d2f97d_20261021": {
"scope": "Consolidated review; accepted evidence reused; only missing full interface journey targeted",
"legal_scope": "Internal general-rate IVA on previously determined net taxable base; context declared, not automatic legal incidence",
"period": "Rate chain since 2023-01-01; demonstrated coverage 2026, no artificial legal expiry",
"excluded": [
"gross-price extraction",
"reduced rates/exemptions",
"special bases",
"imports/digital/nonresidents",
"deduction/credit/refund",
"incentives",
"other taxes"
],
"concrete_gap": "83d2f97d had QA API and rendering but no input/recovery review screen",
"delta": "Protected QA-only Inertia review page calling existing calculation/history/immutable-report APIs",
"new_checks": {
"route_test": 1,
"assertions": 17,
"typescript": "passed",
"build": "passed"
},
"interface_journey": {
"observed_at_utc": "2026-10-21T07:39:15+00:00",
"scope": "synthetic QA interface journey only",
"database": "simulator_fiscal_delta_20261021_test",
"simulation_id": "fa6d17f2-5f47-4cb6-b8cb-40850c12505f",
"report_id": "d13e2ad5-3ba8-4d01-9d30-5815496da774",
"report_sha256": "2e5e260f47ee5c849a34453fad248009e607ef8679dc56b7a1c58730f13e5c3b",
"input": {
"base": "1000.03",
"currency": "MZN",
"reference_date": "2026-10-21",
"scope_status": "user_declared"
},
"calculated_value": "160.0048",
"status": "calculated",
"rounding_applied": false,
"browser_observed": [
"normal_login_and_mfa",
"explicit_inputs",
"calculate_and_save",
"hard_reload",
"load_history",
"recover_same_id",
"read_immutable_report_same_value_and_provenance"
],
"personal_simulation_count": 1,
"result_report_hash_equal": true,
"rule_status": "draft",
"published_rules": 0,
"tool_interruption": "Browser connection unavailable during initial authentication; later restored; no simulator failure attributed",
"initial_invalid_attempt": "Date editing was not committed initially; history checked empty before retry; one completed execution persisted",
"not_legal_applicability_validation": true,
"main_integration": false,
"rule_publication_or_activation": false,
"proof_files": [
"fiscal-ui-complete-20261021.txt",
"fiscal-ui-complete-20261021.jpg",
"fiscal-ui-report-20261021.jpg"
]
},
"integration": "Separate Tempo decision for exact candidate including reviewed UI delta; origin/main read as 13f68f1b; auto deployment on push; no migration or rule transition",
"publication": "Existing submit/review/approve/publish with version/evidence; published parameter becomes visible to resolver API",
"activation": "No public fiscal consumer switch in 83d2f97d; QA draft preview must not be activated by removing guards or changing active APP_ENV",
"deactivation": "Existing suspend excludes published version from new resolution; snapshots/reports/history remain; QA draft preview stopped by closing QA exposure",
"status": "Consolidated scoped QA review demonstrated, including complete interface journey; no further generic preparation identified",
"user_legal_validation": false,
"main_merge": false,
"rule_publication_or_activation": false,
"tool_interruption_resolved": true
},
"public_consumer_delta_proposal_20261021": {
"reviewed_head": "d186f942",
"status": "Bounded product code delta required; proposal only",
"existing_reused": [
"permissions and anonymous token/organization ownership",
"rule governance",
"temporal resolver and expression evaluator",
"exact decimal arithmetic",
"execution idempotency and immutable history/reports"
],
"operations": [
"integrate compatible code with consumer disabled",
"publish exact rule version through existing governance",
"activate explicit consumer binding to authorized published version"
],
"coverage": "IVA general net taxable base, MZN, declared internal operation, 2026 only",
"suspension": "No new admissions; previously admitted frozen executions may complete; historical reads and exports preserved",
"accepted_evidence_repeated": false,
"main_integration": false,
"rule_publication": false,
"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
}
}
@@ -1714,3 +1714,193 @@ A aceitação das fontes oficiais determinada pela Tempo em08/10 prevalece sobre
Esforço restante do recorte de engenharia de exportações:0h; revisão consolidada e eventual integração continuam pendentes. As1–2h anteriores foram estimativa deste recorte; não eram estimativa da implementação fiscal integral. Não fechar a revisão integral do simulador nem AUD-SIM-INT02 por este incremento. Word/PDF/ZIP foram ensaiados no componente real com falhas sintéticas de geração de um invólucro QA privado; ficheiros de67bytes e16MiB seleccionados manualmente pelo utilizador, estados observados directamente pelo Codex. A primeira recusa encontrou lista vazia e não foi usada como prova de preservação; a sequência posterior demonstrou lista válida→recusa16MB mantendo1→re-selecção válida mantendo1. ZIP real verificado por bytes, entrada única, título e secção. Leitura transitória falhada e concorrência demonstradas nos testes dirigidos, não por avaria real do sistema de ficheiros. A limitação anterior da selecção automática não foi contornada: a selecção normal foi manual. Os detalhes técnicos permanecem em diagnóstico local; não foi criado serviço de recolha remoto.
Provas actuais em outputs/simulator-continuation-20261020/: export-word-error-20261021.*, export-pdf-error-20261021.txt, export-pdf-retry-20261021.txt, export-zip-error-20261021.*, export-zip-retry-20261021.txt, attachment-limit-preserved-20261021.*, attachment-duplicate-retry-20261021.*, export-zip-content-proof-20261021.json e export-recovery-candidate-20261021.json. O procedimento de integração e diagnóstico fiscal imediatamente anteriores integram esta mesma candidata e o mesmo registo; sem matriz/backlog adicional.
### Identificação da entrega e conferência do limite — 21/10/2026
A entrega funcional `13f68f1b` identifica-se como **melhorias de planeamento financeiro, recuperação, exportação e idioma**. Está pronta para revisão de integração, não representa a conclusão do simulador fiscal. Não houve integração em `main` nem disponibilização activa. A Tempo confirmou o fecho em QA segundo o relatório e declarou não ter conferido o registo local; não atribuir essa conferência ao utilizador.
Conferência somente por leitura, sem repetir ensaios: em `c22003a3`, anterior à candidata, `project-dossier-report.ts` já definia `MAX_DOSSIER_ATTACHMENT_BYTES = 15 * 1024 * 1024` (15.728.640 bytes), quantidade10 e total50MiB. A candidata conserva esses valores. O ficheiro recusado tem16.777.216 bytes (16MiB), superior ao limite preexistente de15MiB; não foi introduzido limite inferior. A interface histórica escreve «MB», mas a implementação usa unidades binárias. A origem é uma escolha de implementação já existente; esta leitura não inventa uma aprovação organizacional específica para esse número. O limite2MiB de incorporação de texto é separado do limite por anexo: conteúdos maiores podem seguir como originais no ZIP, dentro dos15MiB. A nova preparação/deduplicação chama a mesma validação por anexo e não transforma2MiB num bloqueio de selecção.
### Proposta do primeiro lote fiscal delimitado — 21/10/2026
**Proposta para revisão, sem cadastro, publicação ou activação nesta passagem.** Um único percurso inicial de IVA geral sobre base líquida já identificada, em MZN, para operações internas abrangidas pela taxa geral. Janela de cobertura/ensaio do lote:01/01/2026–31/12/2026. Esta janela não cria uma data de entrada em vigor ou caducidade legal: os metadados das regras devem reproduzir as datas dos diplomas aplicáveis. Não criar artificialmente uma expiração em31/12/2026. A cobertura do primeiro lote é escolha de implementação proposta, não uma nova restrição organizacional da Tempo.
**Regra candidata, ainda não cadastrada:** `IVA-GERAL-BASE-LIQUIDA`, identificador provisório para a fórmula base tributável × taxa geral/100. A análise primária já existente identifica16% no art.17(1) da republicação da Lei22/2022, com efeitos desde01/01/2023, e documenta que a Lei10/2025 não altera esse artigo. Reutilizar essa conclusão e as citações/versões existentes, sem nova validação genérica da credibilidade da fonte. O lote não decide automaticamente se uma pessoa/operação está sujeita, isenta, reduzida ou excluída. A aplicação à operação exige um âmbito identificado e evidência/declaração de entrada rotulada; se o âmbito não estiver determinado, a saída é indeterminada. Um cálculo por fórmula com fonte verificada e âmbito apenas declarado deve ser identificado dessa forma, sem o apresentar como obrigação fiscal validada.
| Elemento | Existente e reutilizável | Trabalho do lote |
|---|---|---|
| Fonte da taxa | `source-archive/Lei-22-2022-IVA-republicada-BR-250-2-suplemento.pdf`, art.17(1); análise em `primary-text-review-2025-2026.md` e adenda de taxa reconciliada em `fiscal-reform-2026-review.md`. | Recuperar exemplar/hash/edição/citação já registados e ligar ao cadastro. A taxa identificada não está tecnicamente cadastrada como parâmetro executável. |
| Cadeia para2026 | `source-archive/BR-248-I-Suplemento-1-2025.pdf`, alterações IVA/Lei10/2025 e vigência documentadas nas análises; aviso BR89/2017 e referências da consolidação disponíveis. | Aplicar as correspondências de âmbito/período que afectem esta regra. Não declarar que todos os binários do acervo foram analisados. Os diplomas/anexos sem correspondência continuam no inventário existente. Não reconstruir uma consolidação completa de todos os regimes para implementar apenas esta regra. |
| Parâmetros e entradas | Valor nominal16% já fundamentado no acervo; modelo aceita valor/unidade/condições; resolução temporal existente. | Cadastrar código/versão, unidade`percent`, condições de operação e vínculo documental. Determinar base tributável, âmbito concreto, período e moeda de entrada. Não presumir MZN nem base fiscal pela simples existência de um valor de planeamento. |
| Arredondamento e unidade de apuramento | O avaliador já suporta operações racionais e arredondamentos explícitos. | Mapear a política aplicável à liquidação sobre base líquida, a precisão e se o arredondamento incide por linha/documento/apuramento. Não transportar automaticamente a regra do art.34 para este percurso, nem assumir HALF_UP/duas casas como regra fiscal. Até esse ponto estar fundamentado, não publicar a regra nem apresentar um total fiscal final arredondado; conservar resultado exacto/intermédio e lacuna explícita. |
| Períodos/versões | O resolvedor verifica período, âmbito e compatibilidade da versão da fonte. | Preservar a vigência efectiva do diploma; limitar a cobertura demonstrada deste lote a2026. Pedido fora dessa cobertura identifica «fora da cobertura demonstrada do lote», sem afirmar que a lei deixou de vigorar ou usar zero como substituto. |
**Fora deste lote:** extracção de IVA de preço já incluído (art.34), taxas reduzidas, isenções/listas de bens, importações/serviços digitais/não residentes, dedução/crédito/reembolso, incentivos territoriais, ISPC, IRPS, IRPC, ICE, INSS, retenções e direitos aduaneiros. Estes domínios permanecem no acervo/registo existente e não são omitidos por ficarem fora desta primeira implementação. Não confundir consulta documental disponível com disponibilidade de cálculo.
**Cadastro e aplicação documental — estimativa4–8h.** Reutilizar as análises/fontes, preparar a ficha nominal da regra e parâmetro, transcrever vínculos/versionamento/condições e política de arredondamento aplicável, rever fronteiras temporais e casos de referência e cadastrar como rascunho apenas em QA pelo mecanismo existente quando a execução do lote for autorizada. O intervalo inclui a aplicação documental ao percurso delimitado; se a política ou o âmbito permanecerem ambíguos no acervo, registar a lacuna e parar apenas esse resultado, sem criar um valor. A espera por um elemento documental concreto não está incluída. As fontes oficiais continuam aceites pela decisão da Tempo; não se introduz novo fiscalista ou nova certificação genérica como condição.
**Desenvolvimento de código e aceitação técnica — estimativa12–20h.** Reutilizar `SimulationTaxRuleGovernance`, `SimulationTaxParameterResolver` e `SimulationTaxRuleExpression`; não reconstruir cadastro, transições, DSL ou resolvedor. Acrescentar a orquestração explícita do percurso fiscal: componente/período/âmbito→resolução existente→parâmetros e entradas→avaliação existente→resultado com explicação e proveniência. Separar o cenário fiscal do `custom_planning` de `RunSimulation`. Ligar o resultado ao dossier, histórico e exportadores existentes, com regra/versão/fonte/período/moeda/entradas/passos/arredondamento/estado de validação do âmbito. Acrescentar tratamento de lacunas e casos de referência dirigidos. Não se prevê um novo motor ou uma migration por princípio; confirmar necessidade a partir do contrato do resultado, sem alargar o schema preventivamente.
**Total de engenharia proposto16–28h para este lote**, sem dupla contagem entre cadastro e código e sem incluir espera, revisão consolidada, activação no destino ou implementação fiscal integral. Pressupõe que o acervo disponível permita fundamentar âmbito e arredondamento deste percurso e que os mecanismos existentes continuem compatíveis. Divergência concreta é relatada antes de alargar o lote. As1–2h anteriores pertenciam exclusivamente ao recorte de exportação já concluído.
Critérios de aceitação deste primeiro lote:
1. Ficha e parâmetro correspondem ao exemplar/artigo/versão/hash e vigência aplicáveis; condições e arredondamento têm fundamento identificado. Não publicar enquanto faltarem estes elementos. O cadastro em rascunho não é activação.
2. No clone QA isolado, resolver a regra para contexto/período cobertos e recusar aplicação ambígua ou fora de cobertura, com lacuna expressa, sem zero/taxa de outra versão como substituto. Transição dentro de um período e fonte incompatível mantêm o comportamento temporal já existente.
3. Casos independentes sobre bases que gerem resultados exactos e casos fraccionários/fronteiras da política de arredondamento correspondem à regra cadastrada; unidades/moeda não são inferidas. Zero explícito e ausência de valor continuam distintos.
4. O resultado fiscal identifica regra/versão/fonte/artigo/período, entradas, passos, unidade/moeda, arredondamento e âmbito comprovado ou apenas declarado. A expressão «verificado» não se estende a uma operação cuja sujeição foi apenas declarada. Planeamento manual permanece disponível e claramente distinto.
5. Guardar/recuperar o resultado no dossier e numa nova sessão mantém o snapshot original; mudar regra/entrada não reescreve o histórico. Outra conta não obtém entradas ou resultados privados. Reutilizar os mecanismos e provas de isolamento, ensaiando apenas o novo consumidor fiscal.
6. Ecrã e relatório apresentam o mesmo resultado/proveniência/lacunas e distinguem o cenário fiscal de estimativas do utilizador. Reutilizar o exportador e a prova aceite de paginação; conferir só as novas linhas fiscais e os deltas de layout se existirem, sem repetir os cálculos de planeamento/CSV aceites.
7. Publicação de regra, exposição do percurso e eventual disponibilização no destino são acções separadas, pelos mecanismos e papéis existentes definidos pela Tempo. Nenhum parâmetro é activado por importação de documento ou integração deste documento de proposta em Git.
Esta proposta fica ligada a AUD-SIM-INT02 na matriz existente. Não cria uma matriz ou backlog alternativos. A apresentação da proposta não autoriza, por si, a integração de13f68f1b em`main`.
### Actualização posterior — integração autorizada e ficha fiscal v1
A Tempo autorizou posteriormente a integração de `13f68f1b`. O commit foi integrado em `main` e a revisão servida na homologação confirmou esse identificador em21/10/2026. As menções anteriores a integração pendente descrevem o estado histórico da candidata. A entrega continua identificada como melhorias de planeamento financeiro, recuperação, exportação e idioma, sem encerrar a implementação fiscal.
A [ficha documental IVA-GERAL-BASE-LIQUIDA v1](../simulations/ficha-IVA-GERAL-BASE-LIQUIDA-v1.md) desenvolve separadamente a proposta `0fafd348`: fundamento da taxa e período, limites do art.34, exemplar digitalizado recuperado do inventário, parâmetros por cadastrar, lacunas de enquadramento/arredondamento, integração e critérios de aceitação. É rascunho documental sem cadastro ou activação. AUD-SIM-INT02 mantém-se parcial; os outros sete saldos aceites não são reabertos. Não foram repetidas provas aceites nem alterados cálculos, dados ou regras. A branch fiscal permanece separada de `main`.
**Adenda fiscal v1.1 — 21/10/2026:** a mesma ficha regista a leitura visual dirigida do CIVA/Lei13/2016, arts.1–4,6–7,15,17 e27, e a consulta do regulamento/Decreto7/2008. Enquadramento geral da base documentado, com composição, exclusões, localização e data de exigibilidade; não constitui classificação automática de operações. O arredondamento do art.34 refere-se à extracção de base com IVA incluído e o art.9(1)(b) do regulamento ao meio de pagamento cheque/vale; nenhum foi convertido em regra geral de arredondamento sobre base líquida. Permanecem por esclarecer precisão/modo/empates/agregação e a correspondência regulamentar pertinente para2026. A ficha identifica a questão documental concreta e exemplos aritméticos, sem inventar norma. Estimativa restante condicional: cadastro/aplicação documental3–6h + código/aceitação12–20h =15–26h, excluindo espera, revisão e activação. Fontes locais arquivadas com hash coincidente; não se atribui conferência ao utilizador. Nenhuma regra activada; AUD-SIM-INT02 continua parcial e a entrega13f68f1b mantém-se independente.
### Decisão posterior da Tempo e execução fiscal QA v1.2 — 21/10/2026
**Estado actual; substitui a condição bloqueante e a estimativa da adenda v1.1.** A Tempo determinou prosseguir neste lote sem arredondamento automático, preservando precisão decimal e distinguindo valor calculado de apresentação monetária. A ausência de uma política genérica de arredondamento deixa de bloquear o cálculo do IVA sobre base líquida. Isto não é regra universal para todos os impostos. Exigências de precisão de um formato de pagamento/declaração serão identificadas nesse consumidor, sem as generalizar. A ficha existente foi consolidada emv1.2; versões anteriores permanecem emGit. Não há questão adicional à Tempo como pré-condição deste cálculo nem validação jurídica atribuída ao utilizador.
**Cadastro:** artefacto `database/qa/iva-general-net-v1.json`; comando explícito `fiscal:qa-register-iva {actor} {source}`, com guarda da base QA isolada, papel existente de editor e vínculo a fonte cadastrada. Confere SHA256 do exemplar e usa a governação existente para criar apenas `draft`. Repetição conserva o mesmoID/versão; divergência não sobrescreve o cadastro. Não corre em deploy, importação ou migration. O clone persistido é `simulator_fiscal_delta_20261021_test`,127.0.0.1:55441. A referência de fonte e os actores usados no ensaio são fixtures sintéticas identificadas; não constituem revisão jurídica por pessoa real nem atribuição persistente de papéis organizacionais.
**Cálculo e proveniência:** `SimulationFiscalQaPreview` usa o resolvedor temporal e avaliador existentes, com percurso explícito de rascunho QA que não altera a elegibilidade da API de parâmetros publicados. O resultado guarda contexto declarado, fonte/versão/hash, regra/versão/DSL, passos racionais e decimal exacto; apresentação guarda valor e moeda separadamente, com `rounding_applied=false`. Não há float, truncagem, `toFixed` ou nó`round` neste percurso. Exemplos dirigidos:0,03×16%=0,0048;9007199254740993,0003×16%=1441151880758558,880048; zero explícito conserva0. Fonte/versão incompatível e fora da cobertura2026 continuam indeterminados, sem taxa substituta/zero. A data declarada de exigibilidade alimenta a resolução, sem expiração legal artificial em31/12/2026.
**Histórico e relatórios:** armazenamento existente de simulações, fontes de resultado e relatórios; sem schema novo. Entradas declaradas e proveniência da regra são fontes separadas. Snapshot/relatório não são reavaliados quando a regra é editada. Nova execução usa novo registo; a mesma chave e parâmetros reutiliza a execução. O ensaio persistido actual tem simulação`842e05f9-2b6e-4853-8ff5-81650633f6c4`, relatório`49cac0bd-f49c-4613-a8f0-1ee5c285cf81`, hash`8ed8155a56fdf98ad26e2f01435644e062f27ca6b067ce3553415e974261c687`; resultado/relatório iguais e relatório anterior conservado. Zero regras publicadas. Ecrã e linhas do dossier/CSV distinguem cálculo exacto QA, contexto declarado, estimativas do utilizador e saldo a pagar, que não é calculado neste lote.
**Verificação deste incremento:**5testes feature/51asserções;7testes do resolvedor/25asserções;3testes frontend;TypeScript,build ePint aprovados. Os testes feature correm com transacções no clone dedicado `simulator_fiscal_checks_20261021_test`, separado do cadastro persistido. A primeira tentativa de base vazia encontrou uma migration antiga que exige instalação nominal; não se contornou essa guarda nem se alterou o activo: usou-se cópia do schema QA existente e transacções, sem novas migrations. Cobertura inclui não publicação, indeterminação efectiva, idempotência, precisão fraccionária/grande, moeda obrigatória, recusa de nó de arredondamento, recusa foraQA, snapshot após edição, recuperação por nova autenticação API e recusa por outra conta.
Conferência visual dirigida no Edge do componente React real renderizado emSSR e do HTML produzido por `createProjectDossierPrintHtml` apenas para as novas linhas fiscais:0.0048, base0.03, taxa16, MZN e rótulos legíveis, sem corte visível. O invólucro de inspecção suprimiu apenas a abertura automática da janela de impressão numa cópia privada do HTML; não alterou o exportador. Não é prova de novo PDF nativo guardado nem novo percurso browser de histórico no formulário completo. API/histórico/relatório imutável e os modelos usados pelo ecrã/CSV/dossier têm provas dirigidas. A conta técnica QA ultrapassaRLS; recusas demonstram política da aplicação, não nova certificaçãoRLS. Provas aceites das10páginas, valores, CSV e recuperação/exportações de13f68f1b reutilizadas, sem repetição.
Provas privadas em`outputs/simulator-continuation-20261020/`: `fiscal-qa-receipt-v2.json`, `fiscal-qa-tests.json`, `fiscal-qa-build.log`, `fiscal-qa-snapshot.json`, `fiscal-component-review.html` e `fiscal-report-review.html`. Página/servidor temporários de inspecção encerrados. O recibo versionado desta continuação recebe a mesma reconciliação; a matriz existente recebe apenas adenda emAUD-SIM-INT02. Não se cria backlog paralelo.
**Saldo e estimativa:** o recorte agora autorizado — cadastro versionado, precisão/proveniência, resolução temporal QA, histórico e dados de relatório — está implementado e verificado nos limites acima;0h de engenharia identificada restante nesse recorte, revisão não incluída. As15–26h eram estimativa condicional anterior que incluía trabalho agora executado, não horas medidas nem estimativa ainda integralmente por consumir. AUD-SIM-INT02 permanece parcial para disponibilização fiscal de produto e cobertura integral; os outros sete itens não são reabertos. Pagamento, declaração, classificação automática e outros impostos exigem âmbito/estimativa próprios. Não declarar o simulador fiscal concluído.
**Integração:** commit desta candidata fica apenas na branch `codex/simulator-integrated-review`, descendente de0fafd348, sem push/merge emmain. A autorização anterior de integração de13f68f1b não é reutilizada para esta candidata fiscal. Após revisão consolidada, uma decisão específica de integração continua separada de cadastro/publicação de regras. Esta preparação não muda o destino, papéis activos ou dados activos. Não retirar a guarda QA ou mudar o ambiente do activo para contornar a separação; preparar o consumidor de produto e a sua disponibilização apenas no âmbito que vier a ser autorizado.
### Revisão consolidada de83d2f97d — âmbito, demonstração e operação
**Pedido da Tempo:** consolidar a revisão, reutilizando provas aceites e demonstrando apenas o percurso completo pela interface que faltava. O exemplo aritmético não prova incidência nem aplicabilidade a qualquer operação; a Tempo não conferiu o ficheiro local nem validou juridicamente as conclusões transcritas. Esta revisão mantém essa distinção.
| Elemento da regra | Âmbito documentado / fronteira |
|---|---|
| Operação e base | Operação interna onerosa por sujeito passivo nessa qualidade, localização aplicável e base líquida já apurada: CIVA arts.1(1)(a),2,6 e15. Condições gerais e composição da base estão na ficha v1.2, com PDF/página/artigo. O código não classifica automaticamente uma pessoa ou operação. |
| Taxa e período |16% no art.17(1) da Lei22/2022 republicada, eficácia01/01/2023; data de exigibilidade relevante nos arts.7 e17(2). Lei10/2025 efectiva01/01/2026 não altera art.17 na cadeia lida. A janela demonstrada do lote é2026; não é caducidade legal em31/12/2026. Isto é conclusão documental nos limites da cadeia consultada, não validação jurídica atribuída ao utilizador. |
| Excluído do primeiro lote | Preço já com imposto incluído/art.34, taxas reduzidas, isenções/listas, bases especiais, importações, digital/não residentes, deduções, crédito/reembolso, incentivos e outros impostos. Não se transforma base orçamental em base tributável. |
| Indeterminado | Operação sem enquadramento identificado; fonte/versão/parâmetro/contexto incompatível ou período fora da cobertura demonstrada. O enquadramento apenas declarado permanece declarado. Ausência de arredondamento automático não torna indeterminado o valor exacto deste percurso. |
| Valor versus apresentação | Decimal exacto guardado e reproduzido como texto, sem float, truncagem ou arredondamento implícito; moeda separada. Não é valor a pagar ou saldo de declaração. Precisão de um formato de pagamento/declaração será determinada nesse contexto, sem regra universal. |
**Lacuna concreta encontrada na revisão:**83d2f97d disponibilizava API QA e componente/relatório, mas não um ecrã de introdução das entradas que permitisse observar a sequência inteira. Acrescentou-se apenas a página protegida `/management/investment/tax-rules/{id}/qa-review`, exclusiva do alvo QA isolado, sob os mesmos controlos administrativos, autenticação/MFA e consulta de governação. Chama a API QA já existente, grava no histórico existente, recupera sem recalcular e lê o relatório guardado pelo seuID/hash. Não cria motor, schema, fonte ou regra nova e não expõe um cálculo fiscal público.
**Exemplo completo efectivamente observado pela interface:** base`1000.03`, moedaMZN seleccionada explicitamente, exigibilidade declarada`2026-10-21` e declaração de operação interna à taxa geral sem excepção. Valor calculado`160.0048`, sem arredondamento; não prova de sujeição. Sequência observada: preencher → «Calcular e gravar no histórico QA» → execução`fa6d17f2-5f47-4cb6-b8cb-40850c12505f` concluída → recarregar a página, limpando o estado local/entradas → «Carregar histórico» → «Recuperar [mesmoID]» → «Consultar relatório guardado» → mesmo valor, base, moeda, data, regra/versão, fonte e contexto declarado. Relatório`d13e2ad5-3ba8-4d01-9d30-5815496da774`, SHA256`2e5e260f47ee5c849a34453fad248009e607ef8679dc56b7a1c58730f13e5c3b`, igual ao hash do resultado guardado. Cálculo e gravação pertencem à mesma operação transaccional existente; não se inventa um segundo botão de gravação. A conta de ensaio ficou com uma única execução persistida.
O ensaio correu no Edge, no clone`simulator_fiscal_delta_20261021_test`, porta local18579, com conta sintética própria e sessão/cookie separados. Durante a primeira autenticação a ligação da ferramenta perdeu-se. As duas reactivações comunicadas pelo utilizador não repuseram imediatamente navegadores no inventário, mesmo após reiniciar a sessão da ferramenta. **Limitação da ferramenta; não falha do simulador.** Mais tarde o inventário voltou a expor o Edge e retomou-se apenas o que faltava, por interacção DOM documentada. Foram observados início de sessão e segundo factor normais, entradas, cálculo/gravação, recarga, recuperação e relatório acima. A primeira edição de data não estava consolidada no controlo; a recusa não gerou execução no histórico. Conferiu-se histórico vazio antes da retoma, concluiu-se a edição da data por teclado e submeteu-se uma única execução concluída. Nenhuma guarda ou autenticação foi contornada. As provas anteriores de83d2f97d são reutilizadas; esta demonstração fecha somente a lacuna browser que faltava.
O novo ecrã passouTypeScript/build e1teste dirigido de rota/17asserções: redireccionamento para autenticação, página/props correctos para perfil autorizado, recusa de outro perfil e recusa foraQA. Mensagens de falha são portuguesas e o detalhe técnico fica na consola interna. O primeiro ensaio de rota antecedeu o build da página nova e encontrou ausência da entrada no manifest; após o build o teste passou. Não se reabriram os15testes aceites de83d2f97d, nem valores/CSV/paginação anteriores. **A única lacuna adicional desta revisão ficou demonstrada.** Provas privadas: `fiscal-ui-journey-receipt-20261021.json`, `fiscal-ui-complete-20261021.txt`, `fiscal-ui-complete-20261021.jpg` e `fiscal-ui-report-20261021.jpg`, na mesma pasta deoutputs. Conferência da BD somente por leitura para o novo exemplo: uma execução da conta, igualdade de hashes, regra emdraft, zero regras publicadas. Esta prova é de relatório guardado na interface; não se alega nova impressão/PDF nativo ou nova sessão browser, que não foram pedidos/repetidos.
#### Procedimento de integração — decisão de código
1. A Tempo revê83d2f97d e o delta desta revisão, identificando o commit exacto que autoriza integrar. A autorização anterior de13f68f1b não cobre estes commits. Esta consolidação inclui a demonstração browser concluída e as limitações expressas, sem a converter em validação de aplicabilidade fiscal.
2. Conferir a ancestralidade e o delta contra`origin/main`; a consulta remota desta revisão confirmoumain em13f68f1b. Se continuar ancestral da candidata, integrar por avanço directo após autorização; se houver alterações posteriores, resolver apenas os conflitos reais e verificar os deltas afectados. Não reexecutar o conjunto aceite sem motivo concreto.
3. Enviar a referência autorizada a`main` desencadeia disponibilização automática na homologação. Conferir a revisão efectivamente servida e a disponibilidade do portal. Não executar cadastro fiscal, transição de regra ou migration por fazer merge do código. Este delta não tem migration.
4. Guardar os identificadores da decisão, commit, build servido e resultado da disponibilização no mesmo registo. Para reversão de código, preservar o contrato de leitura dos snapshots e históricos; não eliminar tabelas/registos nem reescrever resultados antigos. As guardasQA permanecem activas neste consumidor.
#### Publicação e activação — decisões distintas da integração
1. Cadastro no destino exige autorização específica para escrita, backup/restauro confirmado e acção autenticada. Usar a API de governação existente com a referência real da fonte aceite e actores designados; não copiar as fixtures sintéticas QA para o destino nem mudar o ambiente do activo para executar o comandoQA.
2. A governação existente usa `POST /api/v2/investment/admin/tax-rules/{id}/{action}`, com`version_no` actual e`evidence`: `submit`→`review`→`approve`→`publish`, pelos papéis já configurados. As verificações actuais de autoria/revisão/aprovação/publicação, versão da fonte, vigência e sobreposição conservam-se; não se introduz nesta revisão um novo papel ou uma certificação genérica da fonte. Corrigir uma versão após revisão faz-se por nova versão e histórico, sem edição retroactiva de uma publicada.
3. **Efeito real da publicação:** a versão passa a ser elegível no resolvedor/API de parâmetros publicados. Isso pode disponibilizar o parâmetro aos consumidores dessa API; não é um simples rótulo documental. Ainda assim, não liga por si este cálculo QA ao motor público nem valida incidência de uma operação.
4. **Limite concreto de83d2f97d:** o consumidor novo é QA, exige regra em`draft` e guarda de base isolada. O job público de planeamento não consome automaticamente uma regra publicada como cálculo fiscal. Portanto, não há neste commit um interruptor de activação do lote em produção. Uma decisão futura de activação deve identificar o consumidor de produto, regra/versão, âmbito/período, comportamento de indeterminação e data de exposição. Não remover a guardaQA nem publicar a regra para simular que esse consumidor já existe. A publicação de documento, o cadastro, o merge e a activação não se confundem.
#### Desactivação e preservação histórica
- Para uma regra já publicada, a transição autenticada`.../{id}/suspend`, com versão corrente e evidência, altera o estado para`suspended`. O resolvedor existente selecciona apenas`published`, pelo que deixa de escolher essa versão para novas resoluções. Arquivo posterior usa`archive` quando aplicável. Não se ensaiaram estas transições sobre a regra real deste lote, que continua emrascunho; reutiliza-se o comportamento documentado/testado da governação existente.
- Suspender não apaga snapshots, relatórios, fontes ou eventos. A consulta de uma execução antiga lê o snapshot preservado, não volta a resolver com a taxa actual. Uma correcção ou substituição gera nova versão/execução com proveniência própria; nunca substitui silenciosamente o resultado antigo.
- Não existe transição genérica de`suspended` para`published` no fluxo actual. Uma retoma deve seguir uma versão elegível e as transições previstas, sem actualização directa de estado porSQL.
- Para parar este ensaio QA de rascunho basta encerrar o servidor de ensaio ou retirar a sua exposição no ambienteQA; não se publica/suspende artificialmente o rascunho para o desligar. Os registos persistidos mantêm-se para consulta. Qualquer futuro consumidor público deve definir a sua desactivação de novas execuções preservando essa leitura histórica.
**Saldo:**83d2f97d conserva o fecho técnico QA aceite; revisão consolidada com âmbito, exclusões, procedimento e percurso de interface demonstrados no recorte definido. Não há preparação técnica adicional identificada para fechar esta revisão; decisões de integração e publicação/activação permanecem distintas. AUD-SIM-INT02 continua parcial para produto fiscal integral; não declarar o simulador fiscal concluído. Sem merge, publicação, activação ou alteração do activo.13f68f1b mantém-se independente.
### Proposta única — consumidor IVA no simulador da homologação (21/10/2026)
**Conclusão da leitura do código em d186f942:** falta um delta de produto delimitado; integrar apenas83d2f97d/d186f942 e publicar uma regra não disponibiliza o percurso fiscal ao utilizador do simulador. A revisão QA anterior permanece concluída. Esta proposta identifica trabalho novo de ligação ao produto, sem reabrir as provas aceites, executar integração ou activar regras.
**Funcionalidade proposta:** na etapa fiscal do simulador existente, escolher «IVA geral sobre base líquida», indicar base tributável decimal, MZN, data de exigibilidade e declarar o contexto de operação interna à taxa geral sem excepções. Calcular e gravar pela execução existente; recuperar a execução e consultar/exportar o seu relatório preservado. Mostrar versão/fonte, contexto declarado e valor exacto separado da apresentação monetária. Não usar automaticamente a base orçamental nem a taxa introduzida no planeamento. A consulta do histórico mantém os requisitos actuais de conta e correio verificado; a execução/consulta individual mantém os controlos actuais de proprietário, organização ou token anónimo, conforme o percurso já suportado, sem prometer histórico de conta ao anónimo.
#### Delta exacto de código a implementar na branch separada
| Ponto actual verificado | Alteração delimitada / reutilização |
|---|---|
| `simulation-form.tsx::runSimulation` termina quando o modo não é `custom_planning`; a consulta de parâmetros não calcula. | Ligar o consumidor IVA à mesma etapa, validação, recuperação de falhas, idempotência, acompanhamento e histórico. Expor disponibilidade vinda do servidor; manter o planeamento personalizado identificado separadamente. |
| `StoreSimulationRequest`/`SimulationController::store` recebem base/taxa/redução. | Acrescentar contrato discriminado para o consumidor, com base decimal textual, moeda, data e contexto declarado. Reutilizar autorização de organização, identidade/token e limites existentes. O cliente não escolhe taxa, definição ou fonte executável; pedidos fora do contrato são recusados com mensagem clara e entradas preservadas. |
| `SimulationFiscalQaPreview`, `createFiscalQaPreview` e ramo de `RunSimulation` exigem QA isolado e regra emdraft. | Criar adaptador para regra publicada, reutilizando o resolvedor temporal e avaliador existentes e o núcleo decimal exacto. Conservar as guardas e o percurso QA. Resolver no servidor, conferir a versão autorizada e congelar regra/definição/fonte/parâmetro/contexto no snapshot; gravar através da mesma transacção, fila, idempotência e histórico. Não aceitar snapshot fiscal fornecido pelo cliente. |
| `resolvePublishedForPeriod` existe, mas a resposta de parâmetro não fornece um snapshot executável completo. | Recuperar no servidor a versão seleccionada pelo resolvedor e a definição correspondente, verificando elegibilidade publicada e correspondência temporal no mesmo limite transaccional da admissão. Não reconstruir o resolvedor nem inferir a regra pela taxa mais recente. |
| Não foi identificado interruptor deste consumidor nos serviços/configuração fiscal. | Acrescentar configuração específica, desligada por omissão, com consumidor, ID/versão autorizada e cobertura2026. Aplicar a verificação no servidor e expor o estado ao formulário. É escolha técnica proposta para executar as decisões separadas da Tempo, não novo papel organizacional. Não reutilizar o interruptor de outro módulo nem mudarAPP_ENV/guardasQA. |
| `fiscal-qa-preview.ts`, CSV e dossier reconhecem apenas `fiscal_rule_qa_preview`; rótulos dizemQA/draft. | Suportar explicitamente o novo tipo de snapshot publicado nos mesmos componentes/relatórios/exportações, preservando rótulos e leitura dos snapshots QA antigos. Garantir valor exacto textual e proveniência; nenhuma migração de resultados históricos ou arredondamento monetário silencioso. |
Modelos, políticas, governação, tabelas de execução/relatório e contrato genérico de resultados já existentes são reutilizados. Não se identifica necessidade de nova tabela fiscal ou migration para esta ligação; configuração e novo discriminador de cenário são o delta proposto. Novas entradas geram nova execução, sem converter uma execução fiscal antiga num cenário percentual editável.
#### As três operações da mesma proposta
1. **Integração do código.** Implementar o delta acima na branch fiscal, incluindo leitura histórica compatível e controlo desligado. A candidata a integrar terá de incluir esse delta: d186f942, isoladamente, ainda é QA. Após revisão da referência exacta pela Tempo, integrar emmain e confirmar a revisão servida pela disponibilização automática da homologação. Não cadastrar/publicar regra no arranque nem nas migrations. Resultado observável: código instalado, consumidor indisponível para novas execuções enquanto desligado; restantes percursos continuam disponíveis. Reversão por código compatível com os snapshots existentes, sem apagar dados.
2. **Publicação da versão da regra.** Com autorização de escrita no destino, backup/restauro confirmado e actores autenticados já designados, cadastrar a versão com a fonte real aceite, citações e metadados da ficha; não copiarUUIDs, utilizadores ou estados artificiais do QA. Usar os endpoints existentes de cadastro e `.../tax-rules/{id}/{action}`, com `version_no` e `evidence`, na sequência `submit` → `review` → `approve` → `publish`, pelos papéis existentes. Registar o ID/versão real publicado. Publicar torna o parâmetro elegível na API existente, inclusive para quem já a consulte; não habilita o novo consumidor. Não exigir nova certificação genérica da fonte oficial.
3. **Activação do consumidor.** Por decisão específica da Tempo, configurar a ligação ao ID/versão publicado autorizado e cobertura2026, habilitar o consumidor na homologação e aplicar a configuração efectiva do servidor. Confirmar estado disponível na etapa fiscal e executar somente a prova da ligação nova: entrada no simulador normal → versão publicada correcta → gravação → recuperação/relatório, mais recusa fora do âmbito e desligamento. Reutilizar as provas aceites de aritmética decimal, CSV, recuperação e paginação. Registar decisão, commit/build, regra/versão, configuração e momento da activação. Resultado entregue à Tempo: funcionalidade utilizável no portal, com esses limites; não apenas código instalado.
Estas operações são autorizáveis separadamente dentro desta única proposta. A autorização de13f68f1b não autoriza esta integração fiscal, publicação ou activação. Não foram executadas nesta análise. A sequência é código compatível desligado → versão publicada → consumidor ligado; não se atribui activação à mera publicação.
#### Âmbito, indeterminação e suspensão
Reutiliza-se integralmente o âmbito fundamentado da ficha v1.2: IVA geral16% sobre base líquida já identificada de operação interna, MZN, exigibilidade na cobertura demonstrada01/01/2026–31/12/2026. A taxa tem eficácia documental desde01/01/2023; o fim da cobertura2026 é limite do primeiro consumidor, não revogação/caducidade da norma. Enquadramento declarado é mostrado como declarado; o cálculo não certifica incidência. Fonte/versão/parâmetro/contexto insuficiente, conflito temporal ou falta de enquadramento produzem resultado indeterminado, nunca zero/taxa actual como substituto. Fora da cobertura ou consumidor desligado, explicar a indisponibilidade para nova execução e preservar as entradas.
Excluem-se preço com IVA incluído, taxas reduzidas, isenções/listas, bases especiais, importações, digital/não residentes, deduções/crédito/reembolso, incentivos, apuramento/declaração/pagamento e outros impostos. Não ampliar cobertura nem presumir arredondamento. Valor calculado exacto e apresentação monetária permanecem distintos.
**Suspensão operacional proposta:** desligar a configuração impede novas admissões pelo servidor e informa o formulário, sem bloquear recuperação, relatórios ou exportação de execuções já guardadas. **Suspensão da regra existente:** acção autenticada `suspend`, versão corrente e evidência; o resolvedor deixa de a seleccionar. Sem troca silenciosa por outra versão. A admissão deve fixar atomicamente a elegibilidade e o snapshot; execuções já admitidas conservam essa versão e podem concluir, identificadas pelo momento de admissão, mesmo se a fila terminar depois da suspensão. Novos pedidos ficam impedidos; repetição idempotente de pedido já admitido recupera a mesma execução. Este contrato de concorrência é escolha técnica explícita desta proposta, a verificar no delta, não comportamento já demonstrado pelo código QA.
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=<ID real autorizado>` e `INVESTMOZ_IVA_CONSUMER_RULE_VERSION=<rule_version autorizada>`; 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.
File diff suppressed because one or more lines are too long
@@ -0,0 +1,24 @@
import { fiscalQaRows } from "@/lib/fiscal-qa-preview";
export default function FiscalQaPreview({ snapshot }: { snapshot: unknown }) {
const rows = fiscalQaRows(snapshot);
if (rows.length === 0) return null;
return (
<section
aria-label="Resultado fiscal preservado"
className="my-3 rounded border border-amber-700 bg-amber-50 p-3"
>
<h5 className="font-semibold">
{rows[0][1]} — cálculo exacto sem arredondamento
</h5>
<dl className="mt-2 space-y-2 text-sm">
{rows.map(([label, value]) => (
<div key={label}>
<dt className="font-medium">{label}</dt>
<dd className="break-all">{value}</dd>
</div>
))}
</dl>
</section>
);
}
+21 -15
View File
@@ -1,9 +1,11 @@
import { SimulationPublishedIva, type PublishedIvaInput } from './simulation-published-iva';
import {
PublicDocumentContent,
type PublicDocument,
} from '@/components/public-document-content';
import { DossierPrefill } from '@/components/dossier-prefill';
import { createOperationKey } from '@/lib/operation-key';
import FiscalQaPreview from './fiscal-qa-preview';
import { displayValue } from '@/lib/display-value';
import {
useCallback,
@@ -886,6 +888,7 @@ export function SimulationForm() {
() => readVersionedArithmeticReportReference(result),
[result],
);
const isFiscalQaPreview = ['fiscal_rule_qa_preview', 'fiscal_rule_published'].includes(String(arithmeticSnapshot?.scenario_kind));
const versionedReportMissing =
arithmeticResult.integrity === 'versioned' &&
arithmeticReportReference === null;
@@ -2157,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]}`)
@@ -2176,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,
@@ -2284,7 +2287,7 @@ export function SimulationForm() {
}
}
if (
JSON.stringify({
publishedInput || JSON.stringify({
base: draft.base,
rate: draft.rate,
reduction: draft.reduction,
@@ -2304,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
@@ -3598,6 +3601,7 @@ export function SimulationForm() {
}
/>
)}
{current.id === 'tax' && <SimulationPublishedIva busy={isCalculating} onCalculate={runSimulation} />}
{current.id === 'tax' && (
<div
id="draft-tool-fiscal"
@@ -3695,8 +3699,8 @@ export function SimulationForm() {
O período e as respostas servem para encaminhar uma
exploração informativa. A residência é auto-indicada ou
fica por determinar; não é inferida por nacionalidade ou
dias de presença. Regras de 2026 continuam pendentes de
validação e este formulário não determina cumprimento
dias de presença. Cada cálculo identifica a regra, versão, cobertura e
limitações; este formulário não determina cumprimento
fiscal. Um crédito estimado de IVA não significa pedido
aprovado nem dinheiro disponível. Não carregue
documentos nesta simulação exploratória.
@@ -3705,7 +3709,7 @@ export function SimulationForm() {
{(current.id === 'tax' || result) && (
<div className="space-y-3 border-t pt-4">
<h4 className="font-semibold">
{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'}
</h4>
@@ -3769,7 +3773,7 @@ export function SimulationForm() {
submetido.
</p>
)}
{result.status === 'completed' && (
{result.status === 'completed' && !isFiscalQaPreview && (
<p>
Resultado aritmético indicativo:{' '}
<strong className="break-all">
@@ -3806,9 +3810,10 @@ export function SimulationForm() {
taxas legais, elegibilidade ou obrigação
fiscal.
</p>
{isFiscalQaPreview && <FiscalQaPreview snapshot={result.result_snapshot} />}
{result.status === 'completed' && (
<div className="mt-3 space-y-2 border-t pt-3">
{arithmeticResult.integrity !==
{!isFiscalQaPreview && arithmeticResult.integrity !==
'invalid' && (
<details className="rounded border p-3">
<summary className="cursor-pointer font-semibold focus-visible:outline-2">
@@ -4179,8 +4184,7 @@ export function SimulationForm() {
'não identificada',
'não identificada',
)}{' '}
· 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 ===
@@ -4419,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.
</p>
<button
type="button"
@@ -0,0 +1,68 @@
import { useEffect, useState } from 'react';
export type PublishedIvaInput = {
scenario_kind: 'fiscal_rule_published';
base: string;
currency: 'MZN';
reference_date: string;
operation: 'internal_general_net';
scope_declared: boolean;
};
export function SimulationPublishedIva({ busy, onCalculate }: {
busy: boolean;
onCalculate: (input: PublishedIvaInput) => Promise<void>;
}) {
const [available, setAvailable] = useState(false);
const [message, setMessage] = useState('A consultar disponibilidade do cálculo de IVA…');
const [base, setBase] = useState('');
const [date, setDate] = useState('');
const [currency, setCurrency] = useState('');
const [declared, setDeclared] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
let active = true;
fetch('/api/v1/fiscal-consumer', { credentials: 'same-origin', headers: { Accept: 'application/json' } })
.then(async (response) => {
if (!response.ok) throw new Error();
const payload = await response.json();
if (!active) return;
setAvailable(payload?.data?.enabled === true);
setMessage(typeof payload?.data?.message === 'string' ? payload.data.message : 'Cálculo de IVA indisponível.');
}).catch(() => { if (active) setMessage('Não foi possível consultar a disponibilidade do IVA. As entradas e históricos foram preservados.'); });
return () => { active = false; };
}, []);
async function calculate() {
if (!/^\d+(?:\.\d+)?$/.test(base) || base.length > 40 || !date || currency !== 'MZN') {
setError('Indique uma base líquida decimal válida, a moeda MZN e a data de exigibilidade. As entradas foram preservadas.');
return;
}
setError('');
await onCalculate({ scenario_kind: 'fiscal_rule_published', base, currency: 'MZN', reference_date: date,
operation: 'internal_general_net', scope_declared: declared });
// Refresh after admission/refusal so a suspension is reflected without discarding inputs.
try {
const response = await fetch('/api/v1/fiscal-consumer', { credentials: 'same-origin', headers: { Accept: 'application/json' } });
if (!response.ok) return;
const payload = await response.json();
setAvailable(payload?.data?.enabled === true);
if (typeof payload?.data?.message === 'string') setMessage(payload.data.message);
} catch { /* The execution flow already preserves and reports connection errors. */ }
}
return <section aria-label="IVA geral sobre base líquida" className="space-y-3 rounded border border-emerald-700 p-4">
<h4 className="font-semibold">IVA geral sobre base líquida</h4>
<p role="status">{message}</p>
<p className="text-sm">Cobertura: exigibilidade em 2026, operação interna à taxa geral. A base tributável é indicada por si; não é copiada do orçamento. Sem isenções, taxas reduzidas, bases especiais, importações, deduções, declaração ou pagamento. O cálculo não valida o enquadramento jurídico.</p>
<fieldset disabled={busy} className="space-y-3">
<label className="block">Base tributável líquida (decimal, com ponto)<input aria-label="Base tributável líquida" inputMode="decimal" value={base} onChange={(e) => setBase(e.target.value)} className="block rounded border p-2" /></label>
<label className="block">Moeda da base<select aria-label="Moeda da base IVA" value={currency} onChange={(e) => setCurrency(e.target.value)} className="block rounded border p-2"><option value="">Seleccione</option><option value="MZN">MZN</option></select></label>
<label className="block">Data de exigibilidade declarada<input aria-label="Data de exigibilidade IVA" type="date" min="2026-01-01" max="2026-12-31" value={date} onChange={(e) => setDate(e.target.value)} className="block rounded border p-2" /></label>
<label className="block"><input type="checkbox" checked={declared} onChange={(e) => setDeclared(e.target.checked)} /> Declaro operação interna à taxa geral, base líquida já apurada e ausência das excepções acima.</label>
<p className="text-sm">Sem esta declaração, o resultado fica indeterminado. O valor calculado conserva a precisão decimal; não é saldo a pagar.</p>
<button type="button" disabled={!available || busy} onClick={() => void calculate()} className="rounded bg-emerald-800 px-4 py-2 text-white disabled:opacity-50">{busy ? 'A calcular…' : 'Calcular IVA e gravar'}</button>
</fieldset>
{error && <p role="alert">{error}</p>}
</section>;
}
+60
View File
@@ -0,0 +1,60 @@
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<string, unknown>;
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<string, unknown> | undefined;
const rule = provenance?.rule as Record<string, unknown> | undefined;
const source = rule?.source as Record<string, unknown> | undefined;
const parameters = rule?.parameters as
Record<string, Record<string, unknown>> | undefined;
const intermediate = snapshot.intermediate as Record<
string,
unknown
> | null;
const text = (item: unknown) =>
typeof item === "string" || typeof item === "number"
? String(item)
: "não disponível";
return [
["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",
],
["Explicação", value.result.explanation],
["Base tributável declarada", text(snapshot.base)],
["Taxa cadastrada (%)", text(parameters?.IVA_GENERAL_RATE?.value)],
["Moeda explícita", text(value.context.currency)],
["Data de exigibilidade declarada", value.context.reference_date],
[
"Enquadramento",
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)} / ${published ? "publicada no momento da admissão" : "rascunho"}`,
],
[
"Vigência cadastrada",
`${text(rule?.valid_from)} até ${rule?.valid_until === null ? "sem termo indicado" : text(rule?.valid_until)}`,
],
[
"Fonte da taxa",
`${text(source?.diploma)} — ${text(source?.article)}`,
],
["Hash do exemplar da taxa", text(rule?.source_sha256)],
["Representação racional exacta", text(intermediate?.result)],
[
"Arredondamento",
"Não aplicado. Valor calculado preservado sem truncar ou arredondar; apresentação monetária separada.",
],
[
"Limitações",
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.",
],
];
}
+10 -2
View File
@@ -1,4 +1,5 @@
import { displayValue } from './display-value.ts';
import { fiscalQaRows } from './fiscal-qa-preview.ts';
import {
buildProjectDossierRows,
type ProjectDossierRow,
@@ -344,6 +345,11 @@ function makeSections(
}
const finance = sections.get('finance');
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 preservado; sem recálculo', classification: 'Cálculo exacto; ver estado da regra no snapshot; não é saldo a pagar' });
}
}
if (finance && currentCalculation) {
const snapshot = currentCalculation.snapshot;
const result =
@@ -417,10 +423,12 @@ function makeSections(
{
field: 'Fórmula e valores usados',
value:
snapshot && typeof snapshot.formula_version === 'string'
['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 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',
},
{
+21
View File
@@ -1,4 +1,5 @@
import { csvCell } from './csv.js';
import { fiscalQaRows } from './fiscal-qa-preview.ts';
import { isApiSimulationResultSnapshotV1 } from './simulation-contract.ts';
export type SimulationDraft = Record<string, string | string[]>;
@@ -1674,6 +1675,19 @@ export function readArithmeticSnapshot(
}
const result = snapshot as Record<string, unknown>;
const value = result.indicative_total;
if (['fiscal_rule_qa_preview', 'fiscal_rule_published'].includes(String(result.scenario_kind))) {
const presentation = result.presentation as Record<string, unknown> | 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<string, unknown>, explanation: result.result.explanation };
}
return { integrity: 'invalid', value: null, context: null, explanation: null };
}
if (typeof value !== 'string' || !/^(?:\d+)(?:\.\d+)?$/.test(value)) {
return {
integrity: 'invalid',
@@ -1848,6 +1862,13 @@ export function exportCompletedArithmeticCsv(
);
}
const result = simulation.result_snapshot as Record<string, unknown>;
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.');
return `\uFEFF${[...rows, ['ID do relatório', report.id], ['Hash do relatório', report.report_sha256], ['Data de exportação', exportedAt]]
.map((field) => field.map(csvCell).join(',')).join('\r\n')}`;
}
const decimal = (value: unknown, label: string) => {
if (typeof value !== 'string' || !/^(?:\d+)(?:\.\d+)?$/.test(value))
throw new Error(
@@ -0,0 +1,356 @@
import { Head } from "@inertiajs/react";
import { useRef, useState, type FormEvent } from "react";
import FiscalQaPreview from "@/components/fiscal-qa-preview";
import { readVersionedArithmeticReportReference } from "@/lib/simulation-form";
import {
isApiSimulationCreateResponseV1,
isApiSimulationReadResponseV1,
isApiSimulationHistoryResponseV1,
isApiSimulationResultSnapshotV1,
type ApiSimulationExecutionV1,
type ApiSimulationHistoryEntryV1,
} from "@/lib/simulation-contract";
class ReviewOperationError extends Error {}
/** Review-only UI: it calls the existing authenticated QA API and immutable report reader. */
export default function FiscalQaReview({
rule,
}: {
rule: { id: string; code: string; version: number };
}) {
const [base, setBase] = useState("");
const [date, setDate] = useState("");
const [currency, setCurrency] = useState("");
const [declared, setDeclared] = useState(false);
const [selected, setSelected] = useState<ApiSimulationExecutionV1 | null>(
null,
);
const [history, setHistory] = useState<ApiSimulationHistoryEntryV1[]>([]);
const [report, setReport] = useState<{
id: string;
hash: string;
snapshot: unknown;
} | null>(null);
const [busy, setBusy] = useState(false);
const [message, setMessage] = useState("");
const key = useRef<string | null>(null);
const gate = useRef(false);
const changed = () => {
key.current = null;
};
const statusLabel = (status: string) =>
({
queued: "na fila",
running: "em processamento",
completed: "concluída",
failed: "não concluída",
cancelled: "cancelada",
})[status] ?? "recebida";
async function request(
url: string,
body?: Record<string, string>,
): Promise<unknown> {
const xsrf = decodeURIComponent(
document.cookie
.split("; ")
.find((part) => part.startsWith("XSRF-TOKEN="))
?.slice(11) ?? "",
);
const response = await fetch(url, {
method: body ? "POST" : "GET",
credentials: "same-origin",
headers: {
Accept: "application/json",
...(body
? {
"Content-Type": "application/json",
"X-XSRF-TOKEN": xsrf,
"Idempotency-Key": key.current!,
}
: {}),
},
...(body ? { body: JSON.stringify(body) } : {}),
});
if (!response.ok)
throw new ReviewOperationError(
response.status === 401 || response.status === 419
? "A sessão expirou. Inicie sessão e consulte o histórico antes de voltar a calcular."
: "Não foi possível concluir a operação. Reveja as entradas e consulte o histórico antes de tentar novamente.",
);
return response.json();
}
async function action(work: () => Promise<void>) {
if (gate.current) return;
gate.current = true;
setBusy(true);
setMessage("");
try {
await work();
} catch (cause) {
console.error("[fiscal-qa-review]", cause);
setMessage(
cause instanceof ReviewOperationError
? cause.message
: "Não foi possível concluir a ligação ao servidor. Consulte o histórico antes de tentar novamente.",
);
} finally {
gate.current = false;
setBusy(false);
}
}
async function recover(id: string) {
const payload = await request(`/api/v1/simulations/${id}`);
if (!isApiSimulationReadResponseV1(payload))
throw new ReviewOperationError(
"O resultado guardado tem formato incompatível.",
);
setSelected(payload.data);
setReport(null);
return payload.data;
}
async function calculate(event: FormEvent) {
event.preventDefault();
await action(async () => {
key.current ??= crypto.randomUUID();
const payload = await request(
`/api/v2/investment/admin/tax-rules/${rule.id}/qa-preview`,
{
base,
currency,
reference_date: date,
operation: "internal_general_net",
},
);
if (!isApiSimulationCreateResponseV1(payload))
throw new ReviewOperationError(
"A execução devolveu um formato incompatível.",
);
let simulation = await recover(payload.data.id);
for (
let attempt = 0;
attempt < 10 &&
["queued", "running"].includes(simulation.status);
attempt++
) {
await new Promise((resolve) => setTimeout(resolve, 500));
simulation = await recover(payload.data.id);
}
setMessage(
simulation.status === "completed"
? "Execução concluída e gravada no histórico. Pode recuperar esta execução e consultar o relatório guardado."
: "A execução foi gravada e continua pendente. Consulte o histórico para a recuperar.",
);
});
}
async function loadHistory() {
const payload = await request(
"/api/v1/simulations/history?per_page=10",
);
if (!isApiSimulationHistoryResponseV1(payload))
throw new ReviewOperationError(
"O histórico devolveu um formato incompatível.",
);
setHistory(payload.data);
}
async function loadReport() {
const reference = readVersionedArithmeticReportReference(selected);
if (!selected || !reference)
throw new ReviewOperationError(
"O relatório ainda não está disponível para esta execução.",
);
const payload = (await request(
`/api/v1/simulations/${selected.id}/reports/${reference.id}`,
)) as {
data?: {
id?: string;
report_sha256?: string;
report_snapshot?: unknown;
};
};
if (
payload.data?.id !== reference.id ||
payload.data.report_sha256 !== reference.report_sha256 ||
!isApiSimulationResultSnapshotV1(payload.data.report_snapshot)
) {
throw new ReviewOperationError(
"O relatório guardado não corresponde à referência desta execução.",
);
}
setReport({
id: reference.id,
hash: reference.report_sha256,
snapshot: payload.data.report_snapshot,
});
}
const field = "mt-1 block w-full rounded border border-slate-400 p-2";
const button =
"rounded border border-emerald-800 px-4 py-2 font-semibold disabled:opacity-50";
return (
<main className="mx-auto max-w-4xl space-y-5 p-6">
<Head title="Revisão do lote IVA em QA" />
<h1 className="text-2xl font-semibold">
Revisão do lote IVA em QA
</h1>
<p>
{rule.code} · versão {rule.version} · rascunho, sem activação.
Cobertura demonstrada: 2026.
</p>
<p>
Este percurso calcula IVA sobre uma base já apurada, em contexto
declarado. Não determina elegibilidade, isenção, saldo de
declaração ou valor a pagar. O cálculo exacto não aplica
arredondamento automático.
</p>
<form
onSubmit={(event) => void calculate(event)}
className="space-y-4 rounded border p-4"
>
<fieldset disabled={busy} className="space-y-4">
<label className="block">
Base tributável líquida declarada (decimal com ponto)
<input
className={field}
required
inputMode="decimal"
pattern="[0-9]+(\.[0-9]+)?"
value={base}
onChange={(event) => {
setBase(event.target.value);
changed();
}}
/>
</label>
<label className="block">
Moeda explícita
<select
className={field}
required
value={currency}
onChange={(event) => {
setCurrency(event.target.value);
changed();
}}
>
<option value="">Seleccione</option>
<option value="MZN">MZN — Metical</option>
</select>
</label>
<label className="block">
Data de exigibilidade declarada
<input
className={field}
type="date"
required
value={date}
onChange={(event) => {
setDate(event.target.value);
changed();
}}
/>
</label>
<label className="flex gap-2">
<input
type="checkbox"
required
checked={declared}
onChange={(event) => {
setDeclared(event.target.checked);
changed();
}}
/>
Declaro para este ensaio uma operação interna à taxa
geral, sem isenção, taxa reduzida ou base especial. Esta
declaração não é validação jurídica.
</label>
<button className={button} type="submit">
Calcular e gravar no histórico QA
</button>
</fieldset>
</form>
{message && (
<p role="status" className="rounded bg-slate-100 p-3">
{message}
</p>
)}
{selected && (
<section
aria-label="Execução seleccionada"
className="space-y-2"
>
<h2 className="font-semibold">Execução seleccionada</h2>
<p className="break-all">
ID: {selected.id} · estado:{" "}
{statusLabel(selected.status)}
</p>
<FiscalQaPreview snapshot={selected.result_snapshot} />
<button
className={button}
disabled={busy || selected.status !== "completed"}
onClick={() => void action(loadReport)}
>
Consultar relatório guardado
</button>
</section>
)}
<section aria-label="Histórico da conta" className="space-y-3">
<h2 className="font-semibold">Histórico da conta</h2>
<button
className={button}
disabled={busy}
onClick={() => void action(loadHistory)}
>
Carregar histórico
</button>
<ul className="space-y-2">
{history.map((entry) => (
<li
key={entry.id}
className="break-all rounded border p-3"
>
{entry.id} · {statusLabel(entry.status)}{" "}
<button
className={button}
disabled={busy}
onClick={() =>
void action(async () => {
await recover(entry.id);
setMessage(
"Execução recuperada do histórico, sem recalcular.",
);
})
}
>
Recuperar {entry.id}
</button>
</li>
))}
</ul>
</section>
{report && (
<section
aria-label="Relatório guardado"
className="space-y-2 rounded border p-4"
>
<h2 className="text-xl font-semibold">
Relatório guardado
</h2>
<p className="break-all">ID do relatório: {report.id}</p>
<p className="break-all">SHA256: {report.hash}</p>
<p>
Conteúdo recuperado do relatório imutável, sem novo
cálculo.
</p>
<FiscalQaPreview snapshot={report.snapshot} />
</section>
)}
</main>
);
}
+2
View File
@@ -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 () {
+2
View File
@@ -27,6 +27,8 @@ Route::middleware([CorrelationId::class, 'auth', 'verified', \App\Http\Middlewar
Route::post('/api/v2/investment/admin/tax-rules', [SimulationTaxRuleGovernanceController::class, 'store']);
Route::get('/api/v2/investment/admin/tax-rules/{id}', [SimulationTaxRuleGovernanceController::class, 'show'])->whereUuid('id');
Route::put('/api/v2/investment/admin/tax-rules/{id}', [SimulationTaxRuleGovernanceController::class, 'update'])->whereUuid('id');
Route::post('/api/v2/investment/admin/tax-rules/{id}/qa-preview', [SimulationTaxRuleGovernanceController::class, 'qaPreview'])->whereUuid('id');
Route::get('/management/investment/tax-rules/{id}/qa-review', [SimulationTaxRuleGovernanceController::class, 'qaReview'])->whereUuid('id');
Route::post('/api/v2/investment/admin/tax-rules/{id}/{action}', [SimulationTaxRuleGovernanceController::class, 'transition'])
->whereUuid('id')->whereIn('action', ['submit', 'review', 'reject', 'approve', 'publish', 'suspend', 'archive']);
Route::get('/management/investment/zones/operational-install',function(\Illuminate\Http\Request $r,\App\Services\InvestmentZoneOperationalDetailsInstallation $s){return response()->json(['plan'=>$s->preview($r->user())]);});
@@ -0,0 +1,161 @@
<?php
namespace Tests\Feature;
use App\Jobs\RunSimulation;
use App\Models\InvestmentLegalSource;
use App\Models\Simulation;
use App\Models\SimulationTaxRuleVersion;
use App\Models\User;
use App\Services\SimulationExecution;
use App\Services\SimulationTaxParameterResolver;
use App\Services\SimulationTaxRuleGovernance;
use Brick\Math\BigRational;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Str;
use Tests\TestCase;
class SimulationFiscalQaPreviewTest extends TestCase
{
use DatabaseTransactions;
private User $editor;
private InvestmentLegalSource $source;
private string $ruleId;
protected function setUp(): void
{
parent::setUp();
Queue::fake();
$this->editor = User::factory()->withTwoFactor()->create();
config(['portal.managers' => [$this->editor->email], 'simulation-tax-governance.editors' => [$this->editor->email],
'simulation-tax-governance.publishers' => [$this->editor->email]]);
$this->source = InvestmentLegalSource::create([
'title' => 'QA fixture: Lei22/2022 republicada; sem activação',
'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' => $this->editor->id, 'version_no' => 1,
'validator_id' => User::factory()->create()->id, 'validated_at' => now(),
]);
$this->artisan('fiscal:qa-register-iva', ['actor' => $this->editor->id, 'source' => $this->source->id])->assertSuccessful();
$this->ruleId = SimulationTaxRuleVersion::where('rule_code', 'IVA-GERAL-BASE-LIQUIDA')->value('id');
}
private function preview(array $overrides = [], ?string $key = null)
{
return $this->actingAs($this->editor)->postJson('/api/v2/investment/admin/tax-rules/'.$this->ruleId.'/qa-preview', [
'base' => '0.03', 'currency' => 'MZN', 'reference_date' => '2026-10-21', 'operation' => 'internal_general_net', ...$overrides,
], ['Idempotency-Key' => $key ?? (string) Str::uuid()]);
}
private function finish(string $id): array
{
(new RunSimulation($id, $this->editor->id, null))->handle(app(SimulationExecution::class));
return Simulation::findOrFail($id)->result_snapshot;
}
public function test_draft_registration_reuses_version_and_never_exposes_a_published_parameter(): void
{
$this->artisan('fiscal:qa-register-iva', ['actor' => $this->editor->id, 'source' => $this->source->id])->assertSuccessful();
$this->assertSame(1, SimulationTaxRuleVersion::where('rule_code', 'IVA-GERAL-BASE-LIQUIDA')->count());
$this->assertDatabaseHas('simulation_tax_rule_versions', ['id' => $this->ruleId, 'status' => 'draft']);
$resolved = app(SimulationTaxParameterResolver::class)->resolvePublishedForPeriod('IVA', '2026-10-21', '2026-10-21',
['operation' => 'internal_general_net', 'currency' => 'MZN']);
$this->assertSame([], $resolved);
$this->actingAs($this->editor)->postJson('/api/v2/investment/admin/tax-rules/'.$this->ruleId.'/publish', ['version_no' => 1, 'evidence' => 'QA attempt must not publish an incomplete fiscal rule.'])->assertUnprocessable();
}
public function test_fractional_result_is_exact_and_snapshot_survives_rule_edits_and_new_session(): void
{
$key = (string) Str::uuid();
$id = $this->preview([], $key)->assertAccepted()->json('data.id');
$this->preview([], $key)->assertOk()->assertJsonPath('replayed', true)->assertJsonPath('data.id', $id);
$this->preview(['base' => '1'], $key)->assertConflict();
$snapshot = $this->finish($id);
$this->assertSame('0.0048', $snapshot['result']['value']);
$this->assertSame('calculated', $snapshot['result']['status']);
$this->assertSame('0.0048', $snapshot['presentation']['value']);
$this->assertFalse($snapshot['presentation']['rounding_applied']);
$this->assertEquals(0, BigRational::of($snapshot['intermediate']['result'])->compareTo('0.0048'));
$this->assertSame('draft', $snapshot['fiscal_provenance']['rule']['status']);
$this->assertSame('user_declared', $snapshot['fiscal_provenance']['scope_status']);
$this->assertSame([], $snapshot['result']['missing_inputs']);
$rule = SimulationTaxRuleVersion::findOrFail($this->ruleId);
$params = $rule->parameters;
$params['IVA_GENERAL_RATE']['value'] = '17';
app(SimulationTaxRuleGovernance::class)->update($this->editor, $this->ruleId, 1, [
'rule_code' => $rule->rule_code, 'rule_version' => 1, 'component' => 'IVA', 'scope_key' => $rule->scope_key,
'valid_from' => '2023-01-01', 'valid_until' => null, 'legal_source_id' => $this->source->id,
'source_sha256' => $rule->source_sha256, 'rule_definition' => $rule->rule_definition, 'parameters' => $params,
'rounding_policy' => $rule->rounding_policy,
]);
auth()->logout();
$this->actingAs(User::findOrFail($this->editor->id))->getJson('/api/v1/simulations/'.$id)->assertOk()
->assertJsonPath('data.result_snapshot.fiscal_provenance.rule.parameters.IVA_GENERAL_RATE.value', '16');
$reportId = DB::table('simulation_reports')->where('simulation_id', $id)->value('id');
$this->getJson('/api/v1/simulations/'.$id.'/reports/'.$reportId)->assertOk()->assertJsonPath('data.report_snapshot', $snapshot);
$this->getJson('/api/v1/simulations/history')->assertOk()->assertJsonPath('meta.total', 1);
$this->assertDatabaseHas('simulation_result_sources', ['source_type' => 'tax_rule_draft', 'source_id' => $this->ruleId]);
$resultId = DB::table('simulation_results')->where('simulation_id', $id)->value('id');
$declared = json_decode(DB::table('simulation_result_sources')->where('result_id', $resultId)->where('source_type', 'user_input')->value('source_snapshot'), true);
$this->assertSame('0.03', $declared['base']);
$this->assertArrayNotHasKey('fiscal_preview', $declared);
$other = User::factory()->create(['email_verified_at' => now()]);
$this->actingAs($other)->getJson('/api/v1/simulations/'.$id)->assertNotFound();
$this->getJson('/api/v1/simulations/'.$id.'/reports/'.$reportId)->assertNotFound();
$this->getJson('/api/v1/simulations/history')->assertOk()->assertJsonPath('meta.total', 0);
}
public function test_coverage_and_source_version_mismatch_stay_indeterminate_without_an_intermediate(): void
{
$id = $this->preview(['reference_date' => '2025-12-31'])->assertAccepted()->json('data.id');
$snapshot = $this->finish($id);
$this->assertContains('demonstrated_coverage', $snapshot['result']['missing_inputs']);
$this->assertNull($snapshot['intermediate']);
$this->source->update(['version_no' => 2]);
$id = $this->preview()->assertAccepted()->json('data.id');
$snapshot = $this->finish($id);
$this->assertContains('eligible_parameter', $snapshot['result']['missing_inputs']);
$this->assertNull($snapshot['intermediate']);
}
public function test_zero_is_an_explicit_input_missing_currency_is_rejected_and_non_qa_preview_is_unavailable(): void
{
$id = $this->preview(['base' => '0'])->assertAccepted()->json('data.id');
$snapshot = $this->finish($id);
$this->assertEquals(0, BigRational::of($snapshot['intermediate']['result'])->compareTo(0));
$this->assertSame('0', $snapshot['result']['value']);
$largeId = $this->preview(['base' => '9007199254740993.0003'])->assertAccepted()->json('data.id');
$this->assertSame('1441151880758558.880048', $this->finish($largeId)['result']['value']);
$this->preview(['currency' => null])->assertUnprocessable();
config(['database.connections.pgsql.url' => 'not-an-isolated-qa-target']);
$this->preview()->assertNotFound();
}
public function test_this_consumer_refuses_a_round_node_instead_of_silently_applying_it(): void
{
$rule = SimulationTaxRuleVersion::findOrFail($this->ruleId);
$definition = $rule->rule_definition;
$definition['nodes']['rounded'] = ['op' => 'round', 'args' => ['intermediate'], 'scale' => 2, 'mode' => 'HALF_UP'];
$definition['result'] = 'rounded';
// Deliberately altered QA draft; no rule is published by this test.
$rule->update(['rule_definition' => $definition]);
$this->preview()->assertUnprocessable()->assertJsonPath('message', 'Este percurso preserva o valor exacto e não aplica arredondamento automático.');
}
public function test_qa_review_page_uses_existing_administration_and_environment_guards(): void
{
$path = '/management/investment/tax-rules/'.$this->ruleId.'/qa-review';
$this->get($path)->assertRedirect('/login');
$this->actingAs($this->editor)->get($path)->assertOk()->assertInertia(fn ($page) => $page
->component('management/fiscal-qa-review')->where('rule.id', $this->ruleId)->where('rule.code', 'IVA-GERAL-BASE-LIQUIDA'));
$this->actingAs(User::factory()->withTwoFactor()->create())->get($path)->assertForbidden();
config(['database.connections.pgsql.url' => 'not-an-isolated-qa-target']);
$this->actingAs($this->editor)->get($path)->assertNotFound();
}
}
@@ -0,0 +1,214 @@
<?php
namespace Tests\Feature;
use App\Jobs\RunSimulation;
use App\Models\InvestmentLegalSource;
use App\Models\Simulation;
use App\Models\SimulationTaxRuleVersion;
use App\Models\User;
use App\Services\SimulationExecution;
use App\Services\SimulationTaxRuleGovernance;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Str;
use Tests\TestCase;
/** Published fixtures exist only in an isolated QA transaction, never in homologation. */
class SimulationPublishedFiscalTest extends TestCase
{
use DatabaseTransactions;
private User $consumer;
private User $publisher;
private SimulationTaxRuleVersion $rule;
private InvestmentLegalSource $source;
protected function setUp(): void
{
parent::setUp();
Queue::fake();
$this->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');
}
}
}
+176
View File
@@ -0,0 +1,176 @@
import assert from "node:assert/strict";
import test from "node:test";
import { fiscalQaRows } from "../../resources/js/lib/fiscal-qa-preview.ts";
import { exportCompletedArithmeticCsv, readArithmeticSnapshot } from "../../resources/js/lib/simulation-form.ts";
import { createProjectDossierReport } from "../../resources/js/lib/project-dossier-report.ts";
function fixture() {
const snapshot = {
contract_version: 1,
scenario_kind: "fiscal_rule_qa_preview",
base: "0.03",
currency: "MZN",
indicative_total: "0.0048",
legal_interpretation: false,
formula_version: "investmoz.tax.v1",
context: {
reference_date: "2026-10-21",
currency: "MZN",
currency_fraction_digits: 2,
scenario_id: "61000100-0000-4000-8000-000000000099",
scenario_version: 1,
engine_version: "fiscal-draft-preview-v1",
ruleset_version: "IVA-GERAL-BASE-LIQUIDA:1",
},
result: {
status: "calculated",
value: "0.0048",
unit: "MZN",
explanation:
"Cálculo exacto QA; enquadramento declarado; não é saldo a pagar.",
missing_inputs: [],
source_ids: ["source-fixture"],
},
intermediate: { result: "48/10000" },
fiscal_provenance: {
rule: {
rule_code: "IVA-GERAL-BASE-LIQUIDA",
rule_version: 1,
valid_from: "2023-01-01",
valid_until: null,
source_sha256: "a".repeat(64),
source: { diploma: "Lei 22/2022", article: "Artigo 17(1)" },
},
},
};
return {
id: "61000100-0000-4000-8000-000000000088",
status: "completed",
version_no: 2,
result_snapshot: snapshot,
reports: [
{
id: "61000100-0000-4000-8000-000000000077",
version_no: 1,
status: "ready",
report_snapshot: structuredClone(snapshot),
report_sha256: "b".repeat(64),
},
],
};
}
test("screen rows preserve sub-cent IVA as a string and distinguish calculation from payment", () => {
const simulation = fixture();
const before = JSON.stringify(simulation);
const rows = fiscalQaRows(simulation.result_snapshot);
assert.equal(
rows.find(([label]) => label.startsWith("IVA calculado"))[1],
"0.0048",
);
assert.match(
rows.find(([label]) => label === "Arredondamento")[1],
/Não aplicado/,
);
assert.match(
rows.find(([label]) => label === "Enquadramento")[1],
/não validado juridicamente/,
);
assert.equal(JSON.stringify(simulation), before);
});
test("CSV preserves the exact amount and refuses a mismatched immutable report", () => {
const simulation = fixture();
const csv = exportCompletedArithmeticCsv(
simulation,
"2026-10-21T09:00:00Z",
);
assert.match(csv, /0\.0048/);
assert.doesNotMatch(csv, /Resultado fiscal final,Indeterminado/);
simulation.reports[0].report_snapshot.result.value = "0.00";
assert.throws(
() => exportCompletedArithmeticCsv(simulation),
/relatório preservado/,
);
});
test("dossier report uses the same exact snapshot and preserves genuine indetermination", () => {
const simulation = fixture();
function report() {
return createProjectDossierReport(
{ project_title: "QA fiscal", currency: "MZN" },
{
selectedSections: new Set(["finance"]),
currentCalculation: {
status: "completed",
version: 2,
snapshot: simulation.result_snapshot,
},
},
);
}
const rows = report().sections.find(
(section) => section.id === "finance",
).rows;
assert.equal(
rows.find((row) => row.field.startsWith("IVA calculado")).value,
"0.0048",
);
assert.equal(
rows.find((row) => row.field === "Fonte da taxa").value,
"Lei 22/2022 — Artigo 17(1)",
);
simulation.result_snapshot.result.status = "indeterminate";
simulation.result_snapshot.result.value = null;
simulation.result_snapshot.indicative_total = null;
simulation.result_snapshot.result.missing_inputs = ["eligible_parameter"];
assert.equal(
report()
.sections.find((section) => section.id === "finance")
.rows.find((row) => row.field.startsWith("IVA calculado")).value,
"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");
});