Compare commits

..
Author SHA1 Message Date
nala bd5166bd11 docs: record authorised fiscal rule publication and consumer activation 2026-10-21 12:57:40 +02:00
nala 4a4d121275 fix: allow authorised fiscal maintenance by the same responsible person
tests / ci (push) Canceled after 0s
2026-10-21 12:39:07 +02:00
nala 226fd308dd feat: connect published IVA consumer to normal simulator in QA
tests / ci (push) Canceled after 0s
2026-10-21 11:59:50 +02:00
nala 2ced7b790c docs: delimit published IVA consumer and three rollout operations 2026-10-21 11:39:17 +02:00
nala d186f94206 feat: demonstrar percurso IVA QA e consolidar revisao 2026-10-21 09:40:49 +02:00
nala 83d2f97d82 feat: preparar lote IVA exacto em QA sem activacao 2026-10-21 09:22:11 +02:00
nala 37085b40c1 docs: fundamentar base IVA e delimitar lacuna de arredondamento 2026-10-21 09:03:01 +02:00
nala 325d58b694 docs: preparar ficha fiscal IVA v1 sem activacao 2026-10-21 08:57:56 +02:00
nala 0fafd348e7 docs: delimitar entrega de planeamento e propor primeiro lote fiscal 2026-10-21 08:47:07 +02:00
nala 13f68f1bd8 fix: concluir recuperacao de exportacoes e consolidar candidata QA
tests / ci (push) Canceled after 0s
2026-10-21 08:43:12 +02:00
nala c22003a3da fix: recover simulator cashflow and verify QA continuity and report assumptions 2026-10-20 22:00:48 +02:00
nala a690c665fb fix: validate simulator dates and reconcile QA calculations reports and language 2026-10-20 21:00:52 +02:00
nala 59ab4fe956 fix: validar datas fiscais e iniciar revisao integrada do simulador 2026-10-20 20:03:40 +02:00
nala e69ab1499f docs: comprovar guias na biblioteca QA existente
tests / ci (push) Canceled after 0s
2026-10-20 19:49:55 +02:00
nala 3258a25f76 feat: apresentar guias educativos com imagem resumo e contexto 2026-10-20 19:45:45 +02:00
105 changed files with 5786 additions and 530 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) {
@@ -27,6 +27,7 @@ class InvestmentDiscoveryController extends Controller
return Inertia::render('investment/catalogue', ['filters' => $filters, 'catalogueUrl' => $request->routeIs('investment.education') ? '/educacao' : '/oportunidades', 'references' => $catalogue->references(false, $filters),
'territorySelection' => app(InvestmentGeography::class)->selection($filters),
'guides' => app(\App\Services\SectorInvestment::class)->guides(\App\Models\Sector::where('slug', $filters['sector'] ?? '')->where('is_active', true)->whereNotNull('published_at')->first()),
'results' => $catalogue->query($filters)->paginate(12)->withQueryString(),
'favourites' => $request->user() ? DB::table('opportunity_favourites')->where('user_id', $request->user()->id)->pluck('opportunity_id') : [],
'territorial' => $this->territorial($filters)]);
@@ -106,6 +107,7 @@ class InvestmentDiscoveryController extends Controller
unset($returnFilters['zone_id'], $returnFilters['from_dossier']);
return Inertia::render('investment/detail', ['record' => $record, 'context' => $catalogue->context($record->id), 'zone_origin' => $origin,
'guides' => app(\App\Services\SectorInvestment::class)->guides($record->sector),
'dossierOrigin' => $dossierOrigin ? ['id' => $dossierOrigin->id, 'title' => $dossierOrigin->title] : null,
'returnUrl' => $returnFilters === [] ? '/oportunidades' : '/oportunidades?'.http_build_query($returnFilters)]);
}
@@ -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') {
@@ -0,0 +1,46 @@
<?php
namespace App\Http\Controllers;
use App\Models\InvestmentLegalSource;
use App\Services\SimulationTaxGovernanceAccess;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
/** Explicit fiscal curation of an identified official source; not an independent legal opinion. */
final class SimulationFiscalSourceController extends Controller
{
public function store(Request $request): JsonResponse
{
SimulationTaxGovernanceAccess::requireRole($request->user(), 'editors');
$data = $request->validate([
'title' => ['required', 'string', 'max:255'], 'url' => ['required', 'url:https', 'max:2000'],
'diploma' => ['required', 'string', 'max:255'], 'article' => ['required', 'string', 'max:255'],
'competent_entity' => ['required', 'string', 'max:255'], 'valid_from' => ['required', 'date_format:Y-m-d'],
'valid_until' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:valid_from'],
'sha256' => ['required', 'regex:/^[a-f0-9]{64}$/D'],
'citation' => ['required', 'string', 'min:10', 'max:2000'],
'official_origin' => ['required', 'string', 'min:10', 'max:2000'],
]);
return DB::transaction(function () use ($request, $data): JsonResponse {
$actor = $request->user();
$acceptance = ['scope' => 'fiscal_parameters', 'basis' => 'Tempo-official-sources-20261008',
'responsible_user_id' => $actor->id, 'sha256' => $data['sha256'], 'citation' => $data['citation'],
'official_origin' => $data['official_origin'], 'accepted_at' => now()->toISOString(),
'legal_opinion' => false, 'independent_review' => false];
unset($data['sha256'], $data['citation'], $data['official_origin']);
$source = InvestmentLegalSource::create([...$data, 'owner_id' => $actor->id, 'validator_id' => null,
'validation_status' => 'pending', 'validated_at' => null, 'version_no' => 1, 'fiscal_acceptance' => $acceptance]);
DB::table('audit_events')->insert(['source_system' => 'simulation-fiscal-maintenance',
'entity_type' => 'investment_legal_sources', 'entity_id' => $source->id, 'actor_user_id' => $actor->id,
'action' => 'official_source_acceptance', 'after_data' => json_encode([
'source' => $source->attributesToArray(), 'basis' => $acceptance,
'sha256' => hash('sha256', $source->toJson()),
], JSON_THROW_ON_ERROR)]);
return response()->json($source->fresh(), 201);
});
}
}
@@ -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([
@@ -1,31 +1,39 @@
<?php
namespace App\Http\Middleware;
use App\Services\PortalProfileAccess;
use App\Services\ScopedPortalAdministration;
use App\Services\SimulationTaxGovernanceAccess;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
use App\Services\ScopedPortalAdministration;
final class EnsureScopedPortalAdministration
{
public function handle(Request $request, Closure $next): Response
{
$actor=$request->user();abort_unless($actor,403);
$scope=app(ScopedPortalAdministration::class);
$path=$request->path();
$interests=$request->is('management/interests','management/interests/*','api/v1/investment/admin/interests/*');
// Exact areas of P1; unrelated management, fiscal rules and private records keep their gate.
$catalogue=$request->is('management/economic-references/lot-01','management/economic-references/lot-01/*',
'management/education','management/education/*','management/investment/references','management/investment/references/*','api/v1/investment/admin/references','api/v1/investment/admin/references/*',
'management/investment/zones','management/investment/zones/*','api/v2/investment/admin/zones','api/v2/investment/admin/zones/*',
'management/balcao/atribuicoes','management/balcao/atribuicoes/*','management/balcao/servicos',
'management/balcao/fontes') || ($request->isMethod('GET') && $path==='management/balcao-install');
$allowed=$interests ? $scope->interestOperator($actor) : ($catalogue && $scope->administrative($actor));
abort_unless($allowed || ($actor->can('manage-portal') && !app(\App\Services\PortalProfileAccess::class)->has($actor,'auditor')),403,'Acesso reservado ao âmbito já designado.');
$response=$next($request);
if (!$request->isMethod('GET') && $response->getStatusCode()<400) {
app(\App\Services\PortalProfileAccess::class)->audit($actor,'scoped_administration',[
'method'=>$request->method(),'route'=>$request->route()?->uri(),'scope'=>$interests?'assigned-interest':'catalogue-administration']);
$actor = $request->user();
abort_unless($actor, 403);
$scope = app(ScopedPortalAdministration::class);
$path = $request->path();
$interests = $request->is('management/interests', 'management/interests/*', 'api/v1/investment/admin/interests/*');
// Fiscal maintenance reuses authorised administration; unrelated gates remain unchanged.
$catalogue = $request->is('management/economic-references/lot-01', 'management/economic-references/lot-01/*',
'management/education', 'management/education/*', 'management/investment/references', 'management/investment/references/*', 'api/v1/investment/admin/references', 'api/v1/investment/admin/references/*',
'management/investment/zones', 'management/investment/zones/*', 'api/v2/investment/admin/zones', 'api/v2/investment/admin/zones/*',
'management/balcao/atribuicoes', 'management/balcao/atribuicoes/*', 'management/balcao/servicos',
'management/balcao/fontes') || ($request->isMethod('GET') && $path === 'management/balcao-install');
$fiscal = $request->is('api/v2/investment/admin/tax-rules', 'api/v2/investment/admin/tax-rules/*',
'api/v2/investment/admin/tax-rule-sources', 'management/investment/tax-rules/*');
$allowed = ($fiscal && SimulationTaxGovernanceAccess::maintainer($actor)) || ($interests ? $scope->interestOperator($actor) : ($catalogue && $scope->administrative($actor)));
abort_unless($allowed || ($actor->can('manage-portal') && ! app(PortalProfileAccess::class)->has($actor, 'auditor')), 403, 'Acesso reservado ao âmbito já designado.');
$response = $next($request);
if (! $request->isMethod('GET') && $response->getStatusCode() < 400) {
app(PortalProfileAccess::class)->audit($actor, 'scoped_administration', [
'method' => $request->method(), 'route' => $request->route()?->uri(), 'scope' => $fiscal ? 'fiscal-maintenance' : ($interests ? 'assigned-interest' : 'catalogue-administration')]);
}
return $response;
}
}
@@ -37,7 +37,7 @@ class StoreSimulationFormDraftRequest extends FormRequest
'contact_province', 'contact_district', 'preferred_channel',
];
$economic = [
'journey_type', 'detail_level', 'business_plan_snapshot', 'financial_tools_snapshot', 'project_team_snapshot', 'project_execution_snapshot', 'purpose', 'manifestation_type', 'preferred_language', 'project_title', 'project_summary', 'project_problem', 'target_customers', 'value_proposition', 'market_assumption', 'market_source', 'project_milestones', 'selection_labels', 'project_type',
'journey_type', 'detail_level', 'business_plan_snapshot', 'financial_tools_snapshot', 'cashflow_snapshot', 'project_team_snapshot', 'project_execution_snapshot', 'purpose', 'manifestation_type', 'preferred_language', 'project_title', 'project_summary', 'project_problem', 'target_customers', 'value_proposition', 'market_assumption', 'market_source', 'project_milestones', 'selection_labels', 'project_type',
'competitor_assumption', 'sales_channel_assumption', 'marketing_approach', 'location_rationale', 'project_risk_responses_snapshot', 'project_questionnaire_version',
'customer_validation_status', 'cost_estimate_status', 'location_assessment_status', 'funding_status', 'receivables_timing_status',
'project_type_other', 'sector_id', 'activity_id', 'products_services', 'maturity_stage', 'expected_start_date',
@@ -71,6 +71,7 @@ class StoreSimulationFormDraftRequest extends FormRequest
'economic_snapshot.detail_level' => ['nullable', 'in:simple,advanced'],
'economic_snapshot.business_plan_snapshot' => ['nullable', 'string', 'max:60000'],
'economic_snapshot.financial_tools_snapshot' => ['nullable', 'string', 'max:30000'],
'economic_snapshot.cashflow_snapshot' => ['nullable', 'string', 'max:20000'],
'economic_snapshot.project_team_snapshot' => ['nullable', 'string', 'max:30000'],
'economic_snapshot.project_execution_snapshot' => ['nullable', 'string', 'max:30000'],
'economic_snapshot.project_risk_responses_snapshot' => ['nullable', 'string', 'max:30000'],
@@ -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);
+5
View File
@@ -16,4 +16,9 @@ class InvestmentLegalSource extends Model
use HasUuids;
protected $guarded = ['id'];
protected function casts(): array
{
return ['fiscal_acceptance' => 'array'];
}
}
+32 -3
View File
@@ -35,7 +35,7 @@ final class LegacyContent
}
/** @return array<string, mixed> */
public function article(string $slug = 'agronegocio'): array
public function article(string $slug = 'agronegocio', bool $includeRelated = true): array
{
abort_unless($this->available(), 404);
$identity = config('legacy_content.articles.'.$slug);
@@ -80,13 +80,26 @@ final class LegacyContent
}
preg_match_all('~href="([^"]+)"~i', $localHtml, $existingLinks);
$alreadyLinked = array_map(fn ($href) => html_entity_decode($href, ENT_QUOTES | ENT_HTML5, 'UTF-8'), $existingLinks[1]);
$relatedArticles = array_values(array_filter($this->related($identity['group'], $slug), fn ($item) => !in_array($item['href'], $alreadyLinked, true)));
$relatedArticles = [];
if ($includeRelated) {
foreach ($this->related($identity['group'], $slug) as $item) {
if (in_array($item['href'], $alreadyLinked, true)) continue;
try {
$relatedArticles[] = $this->article($item['slug'], false);
} catch (\Symfony\Component\HttpKernel\Exception\HttpException $error) {
if ($error->getStatusCode() !== 404) throw $error;
}
}
}
return ['id' => (int) $row->resource_id, 'document_id' => $row->id, 'version' => (int) $row->metadata_version,
'sha256' => trim($row->sha256), 'title' => $source['title'], 'author' => $source['author'],
'legacy_id' => $source['legacy_id'], 'legacy_category' => $source['legacy_category'],
'published_at' => $source['published_at'], 'modified_at' => $source['modified_at'], 'source_url' => $source['source_url'],
'href' => '/'.$slug, 'html' => $localHtml,
'href' => '/'.$slug, 'html' => $localHtml, 'summary' => self::excerpt($localHtml),
'opportunities_href' => $sector ? '/oportunidades?sector='.rawurlencode($sector->slug) : null,
'opportunities_label' => $sector ? 'Ver oportunidades de '.$sector->name : null,
'has_opportunities' => count($opportunities) > 0,
'landing_href' => '/explorar/'.$identity['group'], 'start_href' => '/investimentos/a-partir-de/'.$slug,
'group' => config('legacy_content.groups.'.$identity['group']),
'related_articles' => $relatedArticles,
@@ -101,6 +114,22 @@ final class LegacyContent
'consultation_state' => 'public', 'legal_state' => 'not_applicable', 'calculation_use' => false];
}
/** An excerpt of the displayed edition, never a claim about returns or availability. */
public static function excerpt(string $html): string
{
$plain = html_entity_decode(strip_tags($html), ENT_QUOTES | ENT_HTML5, 'UTF-8');
if (str_contains($plain, 'Hotelaria e resorts') && str_contains($plain, 'Ecoturismo')
&& str_contains($plain, 'Infraestruturas turísticas') && str_contains($plain, 'Oportunidades Regionais')) {
return 'Conheça os temas de turismo e hospitalidade abordados no guia: hotelaria, ecoturismo, infraestruturas de apoio e referências regionais. Use este enquadramento para preparar a sua ideia de investimento.';
}
preg_match_all('~<p\b[^>]*>(.*?)</p>~isu', $html, $paragraphs);
foreach ([...($paragraphs[1] ?? []), $html] as $paragraph) {
$text = trim(preg_replace('/\s+/u', ' ', html_entity_decode(strip_tags($paragraph), ENT_QUOTES | ENT_HTML5, 'UTF-8')));
if (mb_strlen($text) >= 60) return \Illuminate\Support\Str::limit($text, 320);
}
return trim(html_entity_decode(strip_tags($html), ENT_QUOTES | ENT_HTML5, 'UTF-8'));
}
/** Resolve a library identity, preserving the already stored source URLs and versions. */
/** @return array<string, mixed> */
public function articleForResource(int $resourceId): array
+21 -13
View File
@@ -37,19 +37,7 @@ final class SectorInvestment
public function publicView(Sector $sector): array
{
$query = SectorOrganization::opportunities($sector);
$articles = [];
if (app(LegacyContent::class)->available()) {
foreach (self::CONTENT[$sector->slug] ?? [] as $slug) {
try {
$a = app(LegacyContent::class)->article($slug);
$articles[] = ['title' => $a['title'], 'href' => $a['href'], 'image' => $a['image'], 'sha256' => $a['sha256'], 'scope' => $slug === 'tech-fintech' && $sector->slug !== 'fintech' ? 'Especialização Fintech; não representa todo o setor.' : 'Conteúdo educativo relacionado; não é uma oportunidade concreta.'];
} catch (HttpException $e) {
if ($e->getStatusCode() !== 404) {
throw $e;
}
}
}
}
$articles = $this->guides($sector);
$relations = [];
foreach (['projects' => 'project_id', 'clusters' => 'cluster_id', 'corridors' => 'corridor_id'] as $table => $foreign) {
$ids = DB::table('opportunity_'.$table)->whereIn('opportunity_id', (clone $query)->select('id'))->pluck($foreign);
@@ -74,6 +62,26 @@ final class SectorInvestment
'content_notice' => $sector->slug === 'mercado-financeiro' ? 'O artigo geral de Mercado Financeiro permanece vazio e não foi publicado. A preparação de negócios neste setor está disponível.' : null];
}
/** Published guides for an explicit sector; never inferred from proximity. */
public function guides(?Sector $sector): array
{
if (! $sector) return [];
$articles = [];
if (app(LegacyContent::class)->available()) {
foreach (self::CONTENT[$sector->slug] ?? [] as $slug) {
try {
$a = app(LegacyContent::class)->article($slug, false);
$articles[] = \Illuminate\Support\Arr::only($a, ['title', 'href', 'image', 'sha256', 'summary', 'author', 'opportunities_href', 'opportunities_label', 'has_opportunities']);
} catch (HttpException $e) {
if ($e->getStatusCode() !== 404) {
throw $e;
}
}
}
}
return $articles;
}
public function attach(User $actor, string $id, array $input): void
{
$data = Validator::make($input, ['version' => 'required|integer|min:1', 'sector_slug' => 'required|string|max:150'])->validate();
+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,20 @@
<?php
namespace App\Services;
final class SimulationFiscalSourceAcceptance
{
public static function eligible(array $source, ?string $hash = null): bool
{
if (($source['validation_status'] ?? null) === 'validated') {
return true;
}
$basis = $source['fiscal_acceptance'] ?? null;
return ($source['validation_status'] ?? null) === 'pending' && is_array($basis)
&& ($basis['scope'] ?? null) === 'fiscal_parameters'
&& ($basis['basis'] ?? null) === 'Tempo-official-sources-20261008'
&& preg_match('/^[a-f0-9]{64}$/D', (string) ($basis['sha256'] ?? '')) === 1
&& ($hash === null || $basis['sha256'] === $hash);
}
}
@@ -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);
}
}
+10 -1
View File
@@ -8,15 +8,24 @@ final class SimulationTaxGovernanceAccess
{
private const ROLES = ['editors', 'reviewers', 'approvers', 'publishers'];
public static function maintainer(User $user): bool
{
return $user->hasVerifiedEmail() && ! app(PortalProfileAccess::class)->has($user, 'auditor') && ($user->can('manage-portal')
|| app(ScopedPortalAdministration::class)->administrative($user));
}
public static function requireRole(User $user, string $role): void
{
abort_unless(in_array($role, self::ROLES, true), 500, 'Função de governação desconhecida.');
abort_unless(in_array(strtolower($user->email), config('simulation-tax-governance.'.$role, []), true),
abort_unless(self::maintainer($user) || in_array(strtolower($user->email), config('simulation-tax-governance.'.$role, []), true),
403, 'O utilizador não está designado para esta função de governação fiscal.');
}
public static function requireAnyRole(User $user): void
{
if (self::maintainer($user)) {
return;
}
foreach (self::ROLES as $role) {
if (in_array(strtolower($user->email), config('simulation-tax-governance.'.$role, []), true)) {
return;
+33 -13
View File
@@ -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;
@@ -16,32 +17,32 @@ final class SimulationTaxParameterResolver
public function validateParameters(array $parameters): void
{
if ($parameters === [] || count($parameters) > 100) {
throw new InvalidArgumentException('Provide between 1 and 100 versioned parameters.');
throw new InvalidArgumentException('Indique entre 1 e 100 parâmetros versionados.');
}
foreach ($parameters as $code => $parameter) {
if (! is_string($code) || ! preg_match('/^[A-Z][A-Z0-9._-]{1,63}$/', $code) || ! is_array($parameter)
|| array_diff(array_keys($parameter), ['value', 'unit', 'conditions']) !== []
|| array_diff(['value', 'unit', 'conditions'], array_keys($parameter)) !== []) {
throw new InvalidArgumentException('Each parameter requires only value, unit, and conditions.');
throw new InvalidArgumentException('Cada parâmetro deve conter apenas valor, unidade e condições.');
}
if ((! is_string($parameter['value']) && ! is_int($parameter['value'])) || (string) $parameter['value'] === ''
|| ! is_string($parameter['unit']) || ! preg_match('/^[a-z][a-z0-9._-]{0,31}$/', $parameter['unit'])
|| ! is_array($parameter['conditions'])) {
throw new InvalidArgumentException('Invalid parameter value, unit, or conditions.');
throw new InvalidArgumentException('Reveja o valor, a unidade e as condições do parâmetro.');
}
$isNumericValue = preg_match('/^-?\d+(?:\.\d+)?$/', (string) $parameter['value']) === 1;
if (in_array($parameter['unit'], ['percent', 'mzn', 'amount', 'days', 'count', 'mzn_per_unit', 'per_unit'], true) && ! $isNumericValue) {
throw new InvalidArgumentException('Numeric parameter units require a plain decimal value.');
throw new InvalidArgumentException('Esta unidade requer um valor numérico decimal.');
}
if ($isNumericValue) {
$value = BigRational::of((string) $parameter['value']);
if ($value->isNegative() || ($parameter['unit'] === 'percent' && $value->compareTo(100) > 0)) {
throw new InvalidArgumentException('Numeric parameters cannot be negative; percentages must be between 0 and 100.');
throw new InvalidArgumentException('Os valores não podem ser negativos; as percentagens devem estar entre 0 e 100.');
}
}
foreach ($parameter['conditions'] as $condition => $expected) {
if (! is_string($condition) || ! is_string($expected)) {
throw new InvalidArgumentException('Conditions must be a flat map of string assumptions.');
throw new InvalidArgumentException('Indique as condições numa lista simples de nomes e valores em texto.');
}
}
}
@@ -59,7 +60,7 @@ final class SimulationTaxParameterResolver
$start = $this->date($periodStart);
$end = $this->date($periodEnd);
if ($end < $start || $start->diff($end)->days > 365) {
throw new InvalidArgumentException('Invalid effective period.');
throw new InvalidArgumentException('Reveja o período de aplicação indicado.');
}
$rows = DB::table('simulation_tax_rule_versions as rule')
@@ -68,7 +69,9 @@ final class SimulationTaxParameterResolver
->where('rule.status', 'published')
->whereDate('rule.valid_from', '<=', $periodEnd)
->where(fn ($query) => $query->whereNull('rule.valid_until')->orWhereDate('rule.valid_until', '>=', $periodStart))
->where('source.validation_status', 'validated')
->where(fn ($query) => $query->where('source.validation_status', 'validated')->orWhere(fn ($fiscal) => $fiscal->where('source.validation_status', 'pending')->where('source.fiscal_acceptance->scope', 'fiscal_parameters')
->where('source.fiscal_acceptance->basis', 'Tempo-official-sources-20261008')
->whereRaw("source.fiscal_acceptance->>'sha256'=rule.source_sha256")))
->whereColumn('source.version_no', 'rule.source_version_no')
->whereDate('source.valid_from', '<=', $periodStart)
->where(fn ($query) => $query->whereNull('source.valid_until')->orWhereDate('source.valid_until', '>=', $periodEnd))
@@ -77,7 +80,7 @@ final class SimulationTaxParameterResolver
'rule.valid_from', 'rule.valid_until', 'rule.parameters', 'rule.rule_definition',
'rule.source_version_no', 'rule.source_sha256',
'source.title as source_title', 'source.diploma as source_diploma', 'source.article as source_article',
'source.url as source_url', 'source.validation_status as source_validation_status',
'source.url as source_url', 'source.validation_status as source_validation_status', 'source.fiscal_acceptance',
'source.version_no as source_current_version', 'source.valid_from as source_valid_from',
'source.valid_until as source_valid_until',
])
@@ -110,6 +113,7 @@ final class SimulationTaxParameterResolver
'article' => $row->source_article,
'url' => $row->source_url,
'validation_status' => $row->source_validation_status,
'fiscal_acceptance' => is_array($row->fiscal_acceptance) ? $row->fiscal_acceptance : json_decode($row->fiscal_acceptance ?? 'null', true),
'version_no' => (int) $row->source_current_version,
'valid_from' => (string) $row->source_valid_from,
'valid_until' => $row->source_valid_until === null ? null : (string) $row->source_valid_until,
@@ -126,15 +130,31 @@ 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['source']['validation_status'] ?? null) !== 'validated'
|| ($version['status'] ?? null) !== $requiredStatus
|| ! SimulationFiscalSourceAcceptance::eligible($version['source'] ?? [], $version['source_sha256'] ?? null)
|| (int) ($version['source_version_no'] ?? 0) !== (int) ($version['source']['version_no'] ?? -1)
|| ! $this->overlaps($version, $start, $end)
|| ! $this->covers($version['source'], $start) || ! $this->covers($version['source'], $end)
@@ -232,7 +252,7 @@ final class SimulationTaxParameterResolver
|| ($version['component'] ?? null) !== $component
|| ($version['scope_key'] ?? null) !== $scopeKey
|| ! $this->covers($version, $date)
|| ($version['source']['validation_status'] ?? null) !== 'validated'
|| ! SimulationFiscalSourceAcceptance::eligible($version['source'] ?? [], $version['source_sha256'] ?? null)
|| (int) ($version['source_version_no'] ?? 0) !== (int) ($version['source']['version_no'] ?? -1)
|| ! $this->covers($version['source'], $date)
|| ! preg_match('/^[a-f0-9]{64}$/D', (string) ($version['source_sha256'] ?? ''))) {
@@ -315,7 +335,7 @@ final class SimulationTaxParameterResolver
{
$parsed = DateTimeImmutable::createFromFormat('!Y-m-d', $date);
if ($parsed === false || $parsed->format('Y-m-d') !== $date) {
throw new InvalidArgumentException('Effective date must use YYYY-MM-DD.');
throw new InvalidArgumentException('Indique uma data de aplicação válida no formato ano-mês-dia.');
}
return $parsed;
+14 -14
View File
@@ -95,16 +95,16 @@ final class SimulationTaxRuleExpression
private function topologicalOrder(array $definition): array
{
if (($definition['language'] ?? null) !== self::LANGUAGE || ! is_array($definition['nodes'] ?? null) || ! is_string($definition['result'] ?? null)) {
throw new InvalidArgumentException('Unsupported expression definition.');
throw new InvalidArgumentException('O formato da expressão não é suportado.');
}
$nodes = $definition['nodes'];
if ($nodes === [] || count($nodes) > 100) {
throw new InvalidArgumentException('Expression must contain between 1 and 100 nodes.');
throw new InvalidArgumentException('A expressão deve conter entre 1 e 100 elementos.');
}
foreach ($nodes as $id => $node) {
if (! is_string($id) || ! preg_match('/^[a-z][a-z0-9_]{0,47}$/', $id) || ! is_array($node) || ! is_string($node['op'] ?? null)) {
throw new InvalidArgumentException('Invalid expression node.');
throw new InvalidArgumentException('Reveja o elemento indicado na expressão.');
}
$op = $node['op'];
$allowedFields = match ($op) {
@@ -112,40 +112,40 @@ final class SimulationTaxRuleExpression
'literal' => ['op', 'value'],
'add', 'subtract', 'multiply', 'divide', 'min', 'max' => ['op', 'args'],
'round' => ['op', 'args', 'scale', 'mode'],
default => throw new InvalidArgumentException('Expression operation is not allowed.'),
default => throw new InvalidArgumentException('Esta operação não é suportada na expressão.'),
};
if (array_diff(array_keys($node), $allowedFields) !== [] || array_diff($allowedFields, array_keys($node)) !== []) {
throw new InvalidArgumentException('Expression node has missing or unsupported fields.');
throw new InvalidArgumentException('Há campos em falta ou não suportados num elemento da expressão.');
}
if (in_array($op, ['input', 'parameter'], true) && (! is_string($node['key']) || ! preg_match('/^[a-z][a-z0-9_.-]{0,79}$/', $node['key']))) {
throw new InvalidArgumentException('Invalid input or parameter key.');
throw new InvalidArgumentException('Reveja o identificador da entrada ou do parâmetro.');
}
if ($op === 'literal') {
$this->number($node['value']);
}
if (isset($node['args'])) {
if (! is_array($node['args'])) {
throw new InvalidArgumentException('Invalid expression arguments.');
throw new InvalidArgumentException('Reveja os argumentos da expressão.');
}
$count = count($node['args']);
$validCount = $op === 'round' ? $count === 1 : ($op === 'subtract' || $op === 'divide' ? $count === 2 : $count >= 2 && $count <= 20);
if (! $validCount || count(array_filter($node['args'], 'is_string')) !== $count) {
throw new InvalidArgumentException('Invalid expression arguments.');
throw new InvalidArgumentException('Reveja os argumentos da expressão.');
}
}
if ($op === 'round' && (! is_int($node['scale']) || $node['scale'] < 0 || $node['scale'] > 12 || ! isset(self::ROUNDING[$node['mode']]))) {
throw new InvalidArgumentException('Invalid explicit rounding configuration.');
throw new InvalidArgumentException('Reveja a configuração de arredondamento.');
}
}
if (! array_key_exists($definition['result'], $nodes)) {
throw new InvalidArgumentException('Expression result references an unknown node.');
throw new InvalidArgumentException('O resultado da expressão refere um elemento inexistente.');
}
$state = [];
$order = [];
$visit = function (string $id) use (&$visit, &$state, &$order, $nodes): void {
if (($state[$id] ?? 0) === 1) {
throw new InvalidArgumentException('Expression dependency graph contains a cycle.');
throw new InvalidArgumentException('A expressão contém uma dependência circular.');
}
if (($state[$id] ?? 0) === 2) {
return;
@@ -153,7 +153,7 @@ final class SimulationTaxRuleExpression
$state[$id] = 1;
foreach ($nodes[$id]['args'] ?? [] as $dependency) {
if (! is_string($dependency) || ! array_key_exists($dependency, $nodes)) {
throw new InvalidArgumentException('Expression references an unknown node.');
throw new InvalidArgumentException('A expressão refere um elemento inexistente.');
}
$visit($dependency);
}
@@ -191,13 +191,13 @@ final class SimulationTaxRuleExpression
if ($op === 'min' || $op === 'max') {
return array_reduce(array_slice($args, 1), static fn (BigRational $carry, BigRational $value): BigRational => ($op === 'min' ? $carry->compareTo($value) <= 0 : $carry->compareTo($value) >= 0) ? $carry : $value, $args[0]);
}
throw new InvalidArgumentException('Expression operation is not allowed.');
throw new InvalidArgumentException('Esta operação não é suportada na expressão.');
}
private function number(mixed $value): BigRational
{
if ((! is_string($value) && ! is_int($value)) || ! preg_match('/^-?\d+(?:\.\d+)?$/', (string) $value)) {
throw new InvalidArgumentException('Numbers must be plain decimal strings or integers.');
throw new InvalidArgumentException('Introduza valores numéricos decimais ou inteiros.');
}
return BigRational::of((string) $value);
+9 -8
View File
@@ -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];
@@ -78,15 +80,12 @@ final class SimulationTaxRuleGovernance
break;
case 'review':
case 'reject':
abort_unless((int) $rule->author_id !== $actor->id, 403, 'O autor não pode rever a própria regra.');
abort_unless($rule->status === 'submitted', 422, 'A regra tem de estar submetida para revisão.');
$this->sourceStillValid($rule);
$next += ['status' => $action === 'review' ? 'reviewed' : 'rejected', 'reviewer_id' => $actor->id,
'review_evidence' => $evidence, 'reviewed_at' => now()];
break;
case 'approve':
abort_unless((int) $rule->author_id !== $actor->id && (int) $rule->reviewer_id !== $actor->id, 403,
'Autor e revisor não podem aprovar a mesma regra.');
abort_unless($rule->status === 'reviewed', 422, 'A regra tem de estar revista antes da aprovação.');
$this->sourceStillValid($rule);
$next += ['status' => 'approved', 'approver_id' => $actor->id,
@@ -94,9 +93,6 @@ final class SimulationTaxRuleGovernance
break;
case 'publish':
abort_unless($rule->status === 'approved', 422, 'A regra tem de estar aprovada antes da publicação.');
abort_unless((int) $rule->author_id !== $actor->id && (int) $rule->reviewer_id !== $actor->id
&& (int) $rule->approver_id !== $actor->id, 403,
'Autor, revisor e aprovador não podem publicar a mesma regra.');
$this->sourceStillValid($rule);
$this->assertNoPublishedOverlap($rule);
$next += ['status' => 'published', 'publisher_id' => $actor->id,
@@ -155,7 +151,11 @@ final class SimulationTaxRuleGovernance
$source = InvestmentLegalSource::query()->whereKey($data['legal_source_id'])->firstOrFail();
abort_unless(preg_match('/^[a-f0-9]{64}$/D', (string) $data['source_sha256']) === 1, 422,
'Indique o SHA-256 do exemplar oficial efectivamente revisto.');
abort_unless($source->validation_status === 'validated', 422, 'A fonte jurídica ainda não foi validada.');
abort_unless(SimulationFiscalSourceAcceptance::eligible($source->attributesToArray(), $data['source_sha256']), 422,
'A fonte ainda não está aceite para este cadastro fiscal.');
abort_if(is_array($source->fiscal_acceptance)
&& ($source->fiscal_acceptance['sha256'] ?? null) !== $data['source_sha256'], 422,
'O hash da regra deve corresponder ao exemplar registado na aceitação da fonte fiscal.');
abort_unless($source->valid_from <= $data['valid_from']
&& ($source->valid_until === null || ($data['valid_until'] !== null && $source->valid_until >= $data['valid_until']))
&& ($data['valid_until'] !== null || $source->valid_until === null), 422,
@@ -167,7 +167,8 @@ final class SimulationTaxRuleGovernance
private function sourceStillValid(SimulationTaxRuleVersion $rule): void
{
$source = InvestmentLegalSource::query()->lockForUpdate()->find($rule->legal_source_id);
abort_unless($source !== null && $source->validation_status === 'validated'
abort_unless($source !== null && SimulationFiscalSourceAcceptance::eligible($source->attributesToArray(), $rule->source_sha256)
&& (! is_array($source->fiscal_acceptance) || ($source->fiscal_acceptance['sha256'] ?? null) === $rule->source_sha256)
&& (int) $source->version_no === (int) $rule->source_version_no
&& $source->valid_from <= $rule->valid_from->format('Y-m-d')
&& ($source->valid_until === null || ($rule->valid_until !== null && $source->valid_until >= $rule->valid_until->format('Y-m-d')))
+24 -1
View File
@@ -1,6 +1,7 @@
<?php
use App\Http\Middleware\AuditProfileDecision;
use App\Http\Middleware\EnsureHomologationAccess;
use App\Http\Middleware\EnsureSimulationRecoveryCookie;
use App\Http\Middleware\HandleAppearance;
use App\Http\Middleware\HandleInertiaRequests;
@@ -30,7 +31,7 @@ return Application::configure(basePath: dirname(__DIR__))
$middleware->encryptCookies(except: ['appearance', 'sidebar_state', EnsureSimulationRecoveryCookie::NAME]);
$middleware->web(append: [
\App\Http\Middleware\EnsureHomologationAccess::class,
EnsureHomologationAccess::class,
PortalProfileSession::class,
ProtectPrivilegedIdentity::class,
AuditProfileDecision::class,
@@ -68,6 +69,28 @@ return Application::configure(basePath: dirname(__DIR__))
fn (Request $request) => $request->is('api/*') || $request->expectsJson(),
);
$exceptions->respond(function (Response $response, Throwable $exception, Request $request): Response {
if (($request->is('api/*') || $request->expectsJson()) && $response->getStatusCode() >= 400) {
$body = json_decode((string) $response->getContent(), true);
$genericMessages = [
'Not Found' => 'O registo não foi encontrado ou não está acessível nesta conta.',
'Not found.' => 'O registo não foi encontrado ou não está acessível nesta conta.',
'Forbidden' => 'Esta conta não tem acesso a esta operação.',
'Forbidden.' => 'Esta conta não tem acesso a esta operação.',
'Unauthenticated.' => 'Entre na sua conta para continuar esta operação.',
'CSRF token mismatch.' => 'A sessão expirou. Entre novamente antes de repetir a operação.',
'Too Many Attempts.' => 'Foram feitas várias tentativas. Aguarde um pouco e tente novamente.',
'Server Error' => 'Não foi possível concluir a operação. Tente novamente.',
];
if (is_array($body) && is_string($body['message'] ?? null) && isset($genericMessages[$body['message']])) {
$body['message'] = $genericMessages[$body['message']];
$response->setContent(json_encode($body, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR));
} elseif (is_array($body) && is_string($body['message'] ?? null)
&& $response->getStatusCode() === 404 && str_starts_with($body['message'], 'The route ')
&& str_ends_with($body['message'], ' could not be found.')) {
$body['message'] = $genericMessages['Not Found'];
$response->setContent(json_encode($body, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR));
}
}
if ($request->is('api/*')) {
$response->headers->set(
'X-Correlation-ID',
+1 -1
View File
@@ -78,7 +78,7 @@ return [
|
*/
'locale' => env('APP_LOCALE', 'en'),
'locale' => env('APP_LOCALE', 'pt'),
'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en'),
+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),
];
+3 -2
View File
@@ -1,8 +1,9 @@
<?php
return [
// These lists are deliberately empty by default. A portal manager and MFA are
// also required; governance roles never activate rules in the calculation engine.
// Optional specific assignments. Existing authorised administration can maintain
// fiscal parameters with MFA, without four people or four email designations.
// Rule publication never activates the calculation consumer by itself.
'editors' => array_values(array_filter(array_map('strtolower', array_map('trim', explode(',', (string) env('INVESTMOZ_TAX_RULE_EDITORS', '')))))),
'reviewers' => array_values(array_filter(array_map('strtolower', array_map('trim', explode(',', (string) env('INVESTMOZ_TAX_RULE_REVIEWERS', '')))))),
'approvers' => array_values(array_filter(array_map('strtolower', array_map('trim', explode(',', (string) env('INVESTMOZ_TAX_RULE_APPROVERS', '')))))),
@@ -0,0 +1,68 @@
<?php
use App\Support\IsolatedQaDatabase;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
public function up(): void
{
if (! IsolatedQaDatabase::allowsSchemaChanges()) {
throw new LogicException('Ensaie esta correcção apenas numa base QA isolada. A aplicação no destino exige decisão, backup/restauro e acção autenticada.');
}
// Only fiscal rule maintenance changes. Opportunity/zone workflow checks remain intact.
DB::statement('ALTER TABLE simulation_tax_rule_versions DROP CONSTRAINT tax_rule_stage_evidence_check, DROP CONSTRAINT tax_rule_approval_check, DROP CONSTRAINT tax_rule_publication_check');
DB::statement("ALTER TABLE simulation_tax_rule_versions
ADD CONSTRAINT tax_rule_stage_evidence_check CHECK(status NOT IN ('reviewed','rejected','approved','published','suspended','archived') OR (reviewer_id IS NOT NULL AND submitted_at IS NOT NULL AND reviewed_at IS NOT NULL AND length(trim(review_evidence))>=20)),
ADD CONSTRAINT tax_rule_approval_check CHECK(status NOT IN ('approved','published','suspended','archived') OR (approver_id IS NOT NULL AND approved_at IS NOT NULL AND length(trim(approval_evidence))>=20)),
ADD CONSTRAINT tax_rule_publication_check CHECK(status NOT IN ('published','suspended','archived') OR (publisher_id IS NOT NULL AND published_at IS NOT NULL AND length(trim(publication_evidence))>=20))");
DB::statement('ALTER TABLE investment_legal_sources ADD COLUMN fiscal_acceptance jsonb NULL');
DB::statement("ALTER TABLE investment_legal_sources ADD CONSTRAINT investment_legal_source_fiscal_acceptance_check CHECK (
fiscal_acceptance IS NULL OR COALESCE((
fiscal_acceptance->>'scope'='fiscal_parameters' AND
fiscal_acceptance->>'basis'='Tempo-official-sources-20261008' AND
fiscal_acceptance->>'responsible_user_id'=owner_id::text AND
fiscal_acceptance->>'sha256' ~ '^[a-f0-9]{64}$' AND
fiscal_acceptance->>'legal_opinion'='false' AND fiscal_acceptance->>'independent_review'='false' AND
length(trim(fiscal_acceptance->>'citation'))>=10 AND
length(trim(fiscal_acceptance->>'official_origin'))>=10
), false))");
DB::statement('DROP POLICY simulation_tax_rule_versions_public_read ON simulation_tax_rule_versions');
DB::statement("CREATE POLICY simulation_tax_rule_versions_public_read ON simulation_tax_rule_versions FOR SELECT USING (
status='published' AND EXISTS (SELECT 1 FROM investment_legal_sources source
WHERE source.id=simulation_tax_rule_versions.legal_source_id
AND (source.validation_status='validated' OR (source.validation_status='pending'
AND source.fiscal_acceptance->>'scope'='fiscal_parameters'
AND source.fiscal_acceptance->>'basis'='Tempo-official-sources-20261008'
AND source.fiscal_acceptance->>'sha256'=simulation_tax_rule_versions.source_sha256))
AND source.version_no=simulation_tax_rule_versions.source_version_no
AND source.valid_from<=simulation_tax_rule_versions.valid_from
AND (source.valid_until IS NULL OR (simulation_tax_rule_versions.valid_until IS NOT NULL AND source.valid_until>=simulation_tax_rule_versions.valid_until))
AND (simulation_tax_rule_versions.valid_until IS NOT NULL OR source.valid_until IS NULL)))");
}
public function down(): void
{
if (! IsolatedQaDatabase::allowsSchemaChanges()) {
throw new LogicException('Reversão permitida apenas em QA isolado.');
}
$used = DB::table('investment_legal_sources')->whereNotNull('fiscal_acceptance')->exists()
|| DB::table('simulation_tax_rule_versions')->whereRaw('reviewer_id=author_id OR approver_id=author_id OR approver_id=reviewer_id OR publisher_id=author_id OR publisher_id=reviewer_id OR publisher_id=approver_id')->exists();
if ($used) {
throw new LogicException('Reversão recusada: conservar os cadastros e históricos legítimos; desactivar novas execuções em vez de apagar registos.');
}
DB::statement('ALTER TABLE simulation_tax_rule_versions DROP CONSTRAINT tax_rule_stage_evidence_check, DROP CONSTRAINT tax_rule_approval_check, DROP CONSTRAINT tax_rule_publication_check');
DB::statement("ALTER TABLE simulation_tax_rule_versions
ADD CONSTRAINT tax_rule_stage_evidence_check CHECK(status NOT IN ('reviewed','rejected','approved','published','suspended','archived') OR (reviewer_id IS NOT NULL AND reviewer_id<>author_id AND submitted_at IS NOT NULL AND reviewed_at IS NOT NULL AND length(trim(review_evidence))>=20)),
ADD CONSTRAINT tax_rule_approval_check CHECK(status NOT IN ('approved','published','suspended','archived') OR (approver_id IS NOT NULL AND approver_id<>author_id AND approver_id<>reviewer_id AND approved_at IS NOT NULL AND length(trim(approval_evidence))>=20)),
ADD CONSTRAINT tax_rule_publication_check CHECK(status NOT IN ('published','suspended','archived') OR (publisher_id IS NOT NULL AND publisher_id<>author_id AND publisher_id<>reviewer_id AND publisher_id<>approver_id AND published_at IS NOT NULL AND length(trim(publication_evidence))>=20))");
DB::statement('DROP POLICY simulation_tax_rule_versions_public_read ON simulation_tax_rule_versions');
(require __DIR__.'/2026_10_03_190000_allow_public_read_of_published_tax_parameters.php')->up();
DB::statement('ALTER TABLE investment_legal_sources DROP CONSTRAINT investment_legal_source_fiscal_acceptance_check');
DB::statement('ALTER TABLE investment_legal_sources DROP COLUMN fiscal_acceptance');
}
};
+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,100 @@
# 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
**Controlo de manutenção corrigido emQA,21/10/2026:** por decisão expressa da Tempo, a mesma administração autorizada pode cadastrar a fonte e concluir as etapas da regra, comMFA, responsável, evidência e histórico. A obrigação técnica de quatro pessoas não tinha fundamento aprovado identificado para este cadastro fiscal. Foi retirada apenas deste fluxo; o novo fundamento de aceitação da fonte oficial não é parecer jurídico nem revisão independente. Âmbito, exclusões, pressupostos e identificação do resultado como estimativa sobre base declarada mantêm-se. O manual consolidado contém a origem da condição, as6provas dirigidas/86asserções e o procedimento actualizado; não se reensaiou o cálculo nem se publicou ou activou a regra real nesta correcçãoQA.
**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.
@@ -0,0 +1,281 @@
{
"base": "59ab4fe95635",
"branch": "codex/simulator-integrated-review",
"scope": "QA only; no main merge or remote change",
"source_files": [
{
"path": "config/app.php",
"sha256": "a01f552093570f8744f7b874392824c15b5a6b49d61759273a658e8660a1d240"
},
{
"path": "docs/investmoz/unification/traceability-and-manual.md",
"sha256": "347c340bcc4f5adc7965bd66cef1f357d98b6c271fdcd35206a3a086e59ad34d"
},
{
"path": "docs/investmoz/v2/auditoria-portal/backlog-priorizado.csv",
"sha256": "a3473a391634c32fa8c53ecb2528b4de08f145ec0284b50f3bbf89aa97e5cf24"
},
{
"path": "docs/investmoz/v2/auditoria-portal/backlog.md",
"sha256": "1294586bfd452e6430aa1fcb71a512cbf64177e529a416ac28dfab954839664b"
},
{
"path": "docs/investmoz/v2/auditoria-portal/matriz-requisitos.csv",
"sha256": "f57861a36e310a892d55a16be6d06fecd48763defe11011036df8a3a33a57d6d"
},
{
"path": "lang/pt.json",
"sha256": "c21fa0b63a3729e001f7d4ba891a23890bcfcfd718137d99a7c6c18d3be1a235"
},
{
"path": "lang/pt/auth.php",
"sha256": "d32cc31b75d4056ef561baf501d9cbf71ec136c7f63bef635c0c3e31b9a2357d"
},
{
"path": "lang/pt/validation.php",
"sha256": "bb95fe0f683cd6d8a6d0475dc322c71bce58197b952220e70ac74f5e97cdcf60"
},
{
"path": "resources/js/components/appearance-tabs.tsx",
"sha256": "d53f48b0883713bf786fdf4b7877e03638bcf2e707b64ea6b8559501bfc8c4c7"
},
{
"path": "resources/js/components/delete-user.tsx",
"sha256": "2f94b4dc6edf20bf9e00940d4d7e7b5da31bdf560f26b84efd65672bcee585b8"
},
{
"path": "resources/js/components/manage-passkeys.tsx",
"sha256": "8f3e8970d3b82deba721ac27e57718e2e7dcecc67b1e78cb6404c475c9f9c62e"
},
{
"path": "resources/js/components/manage-two-factor.tsx",
"sha256": "35fea615cf3a0454448057b0ef3137e24c21df4de30a58fce04cb847eb89eba1"
},
{
"path": "resources/js/components/passkey-item.tsx",
"sha256": "a307c5ef209e24f3f913f77e355a4d04a1433f59f520ad45ad40112111ab4677"
},
{
"path": "resources/js/components/passkey-register.tsx",
"sha256": "e7cb9d853eb943fd0899bc8530378eddbf941f9334a77bcbb7693d2e29d99470"
},
{
"path": "resources/js/components/passkey-verify.tsx",
"sha256": "6db387920437ab39cd734a2af9bec7e64ae6469c0f1b368d536b275e57cbea37"
},
{
"path": "resources/js/components/simulation-fiscal-status.tsx",
"sha256": "540dc6addbec0be79114526f7d27f53942291b0c7be05a2608a515498970d3e0"
},
{
"path": "resources/js/components/simulation-form.tsx",
"sha256": "1b2d3a917e354eef7ccdb47201fb7e515ea646d45099beb9b15798c80e1dd2db"
},
{
"path": "resources/js/components/two-factor-recovery-codes.tsx",
"sha256": "9384ae3987a58ae89018f2a997dec7faebec4f51f516ebbdc2b337c8e757f00f"
},
{
"path": "resources/js/components/two-factor-setup-modal.tsx",
"sha256": "8586833e10310dbca8a097df80b86f40638f874170b7668fa476f0d7017a311b"
},
{
"path": "resources/js/components/ui/dialog.tsx",
"sha256": "abeb6df2254f0bf5f73de1c9b5aeb4e667afe8ac0319e8b9aa099a612cc70b8d"
},
{
"path": "resources/js/components/ui/sheet.tsx",
"sha256": "a525cc98beaf974e0dfe6cda1c5da1101798b6843ea355a758b3c5df5235f506"
},
{
"path": "resources/js/components/user-menu-content.tsx",
"sha256": "9cb79c2a999bb363bd9d4fc1869b672c474b088eff23fca6e9f588ecae00d3ce"
},
{
"path": "resources/js/hooks/use-two-factor-auth.ts",
"sha256": "16d9434379531f1994096e4d1fcfd778b2c829602cce5731d602a25ea9c0073f"
},
{
"path": "resources/js/layouts/settings/layout.tsx",
"sha256": "78ea66c1eb6cad907e23de9c81a4d309a127f251355f04771f72eb5689609a4e"
},
{
"path": "resources/js/lib/project-dossier-document.ts",
"sha256": "d0a66ca759e8710c1a3900bcd27cc7f0506587c782ab798c49703396c1fb31d6"
},
{
"path": "resources/js/lib/project-dossier-report.ts",
"sha256": "6c5f52362a6218048f321b41211bfd328fa1e19ea4ab45fd1424932130482c8d"
},
{
"path": "resources/js/lib/simulation-form.ts",
"sha256": "40df1a9b6939b1910d12045835e981d6e47d4c1d534775631ac2f6b655a5e2bd"
},
{
"path": "resources/js/pages/auth/forgot-password.tsx",
"sha256": "6cdedca491d5a05dad0083fb637cd618d0b290bd6b48b98e7c6163dc0d740166"
},
{
"path": "resources/js/pages/auth/login.tsx",
"sha256": "8468308dacd26bd94ee41683684dcb7719386a1a53196ed90496ca406b1b94da"
},
{
"path": "resources/js/pages/auth/reset-password.tsx",
"sha256": "f3561726ae5d7ee682d968a295bbf60ec9f8cbabba1721c33f48bafd80d1b20a"
},
{
"path": "resources/js/pages/auth/two-factor-challenge.tsx",
"sha256": "0fd14dff2f0a572f4b99905d0fb152baa476eaa55a221998b67da85fa4787610"
},
{
"path": "resources/js/pages/auth/verify-email.tsx",
"sha256": "ffc3220d0ee6cc243e8e08ea46f8b1637794626fa45d7aeaa77f54203692864d"
},
{
"path": "resources/js/pages/portal/services.tsx",
"sha256": "9369add7c012c1e8a9e217b46be55e2927382bb8f18b90973d3b1f4199442d9b"
},
{
"path": "resources/js/pages/settings/appearance.tsx",
"sha256": "638367104837b028aeeb49b4cad1d2c9a44a92d1b9aebe71c31995605e0479b4"
},
{
"path": "resources/js/pages/settings/profile.tsx",
"sha256": "c599b1ca523ece69c3f5d3fe480a9f2ce4c323e97afb42095f61f2ddb7d4ba7b"
},
{
"path": "resources/js/pages/settings/security.tsx",
"sha256": "39264620329657af975f990a50d657c42186ead57458cd83040aa07f9cd78444"
},
{
"path": "tests/frontend/project-dossier-report.test.mjs",
"sha256": "fcdcabfc659f2f4ca0d5a51e592719d53d51420271c7cbe350176b48cef3fc97"
},
{
"path": "tests/frontend/simulation-form.test.mjs",
"sha256": "294a1b996d8131771ebaca85d2ff0e44bb6afd62e460073b7535486f13ae3a6b"
}
],
"evidence": [
{
"path": "outputs/simulator-integrated-review/effective-parameter-inventory.json",
"sha256": "aeec1251ef5dbbf7ae547af9f1ebcd2575aaa6c251ef65b37c6c31b8cf371acc"
},
{
"path": "outputs/simulator-integrated-review/independent-controls-proof.json",
"sha256": "47ad49b2136fd74c801b7a5d057c891732d820fb6a5002ccf08c71a2ffabf575"
},
{
"path": "outputs/simulator-integrated-review/visible-language-inventory.json",
"sha256": "5f1f50236ae492c8428a9528af350900e4f2c44c387fe8f23ebce0e4f1b14b3a"
},
{
"path": "outputs/simulator-integrated-review/dynamic-language-inventory.json",
"sha256": "fe3246f570c333c2c59e9fe2acb0ec01078a158a46e53d37087ec59d419578a5"
},
{
"path": "outputs/simulator-integrated-review/portuguese-validation-proof.json",
"sha256": "b7a454cdc1e934faada40b683ff2b7a6edce73aebfd22ba34e5da17a1854b0a1"
},
{
"path": "outputs/simulator-integrated-review/http-reports-proof.json",
"sha256": "9d37fbee89f7dc556ff9496f6b0980e1c9626790eb50a41a499cf23e2f27fe5f"
},
{
"path": "outputs/simulator-integrated-review/date-field-error.jpg",
"sha256": "6a792c09c61857a0a064a56d3d61b47fc2adc1d6c8bcf627161f6fa77c7b099d"
},
{
"path": "outputs/simulator-integrated-review/stale-result.jpg",
"sha256": "84b37c41ba47d44f52979e83dbc3426184f5cbfee6d38e7c7d4caa3be225320b"
},
{
"path": "outputs/simulator-integrated-review/tax-lookup-error.jpg",
"sha256": "d3f366690fa3acbda45bc021ade90dc003cea7dc0517c4d2932935548dbb40d0"
},
{
"path": "outputs/simulator-integrated-review/positivo.docx",
"sha256": "61b1ea80476922aa1be8c29f9a757b389c3ecc8f0e75f414a29eddd5b37ecefd"
},
{
"path": "outputs/simulator-integrated-review/positivo-model.json",
"sha256": "fc8df79e7ce81e4a475b99f5ea07763c1cee140bc9dcb24550ed26ce412e4e0d"
},
{
"path": "outputs/simulator-integrated-review/checked-positivo/positivo.pdf",
"sha256": "60d655b33fcc27dbbaae40a0739c1ebeb9b366df9b00621a6536feecd60da452"
},
{
"path": "outputs/simulator-integrated-review/negativo.docx",
"sha256": "f266cff1a1f9f63449703cc5ef51a7f4d2d7bf94230be70ae382bc89af8139bd"
},
{
"path": "outputs/simulator-integrated-review/negativo-model.json",
"sha256": "6aff37f66695e1d2a7441bf7b361f7980bc10f6951f915713f55a01e5b00da48"
},
{
"path": "outputs/simulator-integrated-review/checked-negativo/negativo.pdf",
"sha256": "3a2577194bc6c9e7234b650e69f3795f6cef6aef41d62658839c0790989a8f5c"
},
{
"path": "outputs/simulator-integrated-review/nulo.docx",
"sha256": "efb83aa9044e35515880e40641d6bea52c6ea6f81289c3abc9d3bdbc76a9ba26"
},
{
"path": "outputs/simulator-integrated-review/nulo-model.json",
"sha256": "94bbc6cf72cc613ebcd5ab9e9c074220b2f50dfb5b03959e92a7c84032a86750"
},
{
"path": "outputs/simulator-integrated-review/checked-nulo/nulo.pdf",
"sha256": "c93e989a7af230e56f5d01766a4e386161e6fbbfa99341d5bfa0569bafe9e18e"
},
{
"path": "outputs/simulator-integrated-review/incompleto.docx",
"sha256": "026f0ff1877ed5f23359d220e3223a319c213e742ff9704d81812fc2ed3ae20e"
},
{
"path": "outputs/simulator-integrated-review/incompleto-model.json",
"sha256": "748b9cecde2acc99d7e2bbfd16188bf9f9ac0ab411ad850ba687c6e1f2e36cf5"
},
{
"path": "outputs/simulator-integrated-review/checked-incompleto/incompleto.pdf",
"sha256": "0daac41c80ae131df6d66b7b542353a61a804d02d2e77769b9e5cc2bc9cfbc88"
},
{
"path": "outputs/simulator-integrated-review/completed-calculation.jpg",
"sha256": "7180ff0f536e2e377c2a28b85574ec7c7fefcce5eed28ed08f07628b16870293"
}
],
"checks": {
"targeted_frontend": 34,
"typescript": "passed",
"php_lint": "passed",
"build": "passed",
"reports_pages": {
"positivo": 6,
"negativo": 6,
"nulo": 6,
"incompleto": 5
},
"tax_lookup_retry": "503 then successful lookup; IVA; 2026-01-01 through 2026-12-31; indeterminate parameters",
"arithmetic_ui": "100*16/100*(1-25/100)=12; base change removes result",
"build_manifest_sha256": "af96a5f5682dad7a741d0ee8b302d8dc7f4d08a1806bc000265bd2602e5bfc61",
"terminal_status_ui": "200*16/100*(1-25/100)=24; completed notice visible; awaiting-calculation notice absent"
},
"remaining_hours": [
6,
10
],
"limitations": [
"No published fiscal rule versions in QA; no legal tax validation",
"Authenticated cross-dossier and new-session frontend delta still pending",
"Parameter-version change and additional business-plan/cash-flow controls pending",
"PDF examples are DOCX rendering; native Edge print and final UI download comparison pending",
"Dynamic backend/catalogue messages and directed save/export error checks remain open"
],
"deployment": {
"authorised_now": false,
"migrations": [],
"locale_on_future_deploy": "APP_LOCALE=pt; preserve existing secrets"
}
}
File diff suppressed because it is too large Load Diff
@@ -1,5 +1,15 @@
# Governação de versões de regras fiscais
## Correcção de controlo — decisão expressa da Tempo,21/10/2026
A manutenção dos parâmetros fiscais reutiliza a administração já autorizada, com autenticação/MFA, fonte, versão, responsável e histórico. Uma mesma pessoa pode concluir `submit` → `review` → `approve` → `publish`; esses estados preservam o percurso técnico e os eventos, sem afirmar independência pessoal ou parecer jurídico. As quatro listas específicas de permissões permanecem opcionais. A utilização do simulador pelo investidor conserva as permissões e os limites próprios, separados da manutenção.
A obrigação de actores distintos foi encontrada no serviço fiscal e nosCHECKs da migration de03/10/2026, incorporados em `e65cb269` (06/10/2026), e descrita como implementação em `simulator-evolution-progress.md`. Não foi localizada uma decisão expressa da Tempo ou requisito aplicável que a imponha ao cadastro fiscal. A origem identificada é escolha de implementação; a autoria ou integração do commit não demonstra aprovação dessa regra organizacional. A decisão actual da Tempo manda removê-la neste fluxo, sem alterar a independência acordada nas oportunidades, perfis ou outros processos.
A correcçãoQA retira apenas as comparações de identidade entre autor/revisor/aprovador/publicador dos controlos fiscais; mantém campos obrigatórios, evidência, datas, etapas, versão optimista, fonte, bloqueios, RLS e eventos imutáveis. Para cadastrar uma fonte oficial neste percurso existe `POST /api/v2/investment/admin/tax-rule-sources`, sob a mesma administração autenticada. Recebe os metadados existentes mais `sha256`, `citation` e `official_origin`; regista a aceitação da fonte oficial segundo a decisão Tempo de08/10/2026, o responsável real e a indicação explícita de que não é parecer jurídico nem revisão independente. O hash da regra tem de coincidir com o exemplar cadastrado. A fonte permanece pending no estado geral: a aceitação fiscal não a valida para outros módulos. A projecção pública e o resolvedor fiscal reconhecem esse fundamento e o hash correspondente, sem alterar a fórmula. A fonte não activa nem publica uma regra automaticamente. A validação normal de fontes para os outros processos mantém o seu controlo; não se alteraram os workflows de oportunidades/zonas/perfis.
Migration de correcção: `2026_10_21_130000_correct_fiscal_maintenance_actor_constraints.php`, protegida contra execução fora deQA isolado. Acrescenta metadados de aceitação fiscal à fonte e corrige osCHECKs que impediam o actor único. Foi ensaiada apenas emQA, com6testes/86asserções do controlo alterado, sem cálculo, build ou repetição dos testes aritméticos/exportadores. O manual consolidado regista provas e procedimento actualizado das três operações. Os estados históricos abaixo descrevem o incremento original e não reabrem as provas já aceites da candidata226fd308.
## Âmbito deste incremento
Foi criado um fluxo administrativo para registar uma versão declarativa de regra e a sua referência jurídica. O fluxo existe separado do motor de cálculo. Nenhum endpoint de simulação consulta `simulation_tax_rule_versions`; `published` significa apenas publicada no registo interno de governação, não activa no produto nem aprovada para aconselhamento fiscal.
@@ -0,0 +1,84 @@
{
"branch": "codex/educational-content-cards",
"base": "0e7d51f18fb424857fdaad48498a2fc43c044749",
"scope": "New QA delta; separate Tempo review before main",
"files": [
{
"path": "app/Http/Controllers/InvestmentDiscoveryController.php",
"sha256": "693d1217ead894747c266a7295774b26a8b987f8faf06003ed60a6d3e15aca6c"
},
{
"path": "app/Services/LegacyContent.php",
"sha256": "e856ba2a042e971933a401fd9544a50f6bc5c1541c28062719d840a8277da7b8"
},
{
"path": "app/Services/SectorInvestment.php",
"sha256": "a0ee1c9bf864ef765ed70fc1bcdc839781d0bed7d705d991c801bfaa9bf7e4a0"
},
{
"path": "docs/investmoz/unification/traceability-and-manual.md",
"sha256": "bbbb79e84cae659d1921dfa4440e3fcc192521e0976cace0fa2694828219ec00"
},
{
"path": "docs/investmoz/v2/auditoria-portal/backlog-priorizado.csv",
"sha256": "420369f562508f68ea50e78d386721d6b62fa730893c4e6ccd06817f4dfb8a27"
},
{
"path": "docs/investmoz/v2/auditoria-portal/backlog.md",
"sha256": "d77e4931e8f1a0fcbf0f1d80be83a54b22682ffdc0286852d3ad167652056156"
},
{
"path": "docs/investmoz/v2/auditoria-portal/matriz-requisitos.csv",
"sha256": "757d457a295eb9a516abd1eec6002a52aa1d9a35faea291046e3c99681a1cdcb"
},
{
"path": "resources/js/components/educational-article-card.tsx",
"sha256": "03a298406702cd3211c44fad1fdf79d455ad029d997d417a416f450d6d420b6e"
},
{
"path": "resources/js/components/educational-guides.tsx",
"sha256": "2a2a52e50694e336f05544d51fc776a3bbc2b06d2ab61a1b3cdf1d261afca474"
},
{
"path": "resources/js/components/sector-entry.tsx",
"sha256": "39004abf04f096b987d852d834ce9b27fdf557d2283db3b5868678d3ff0bff1a"
},
{
"path": "resources/js/pages/investment/catalogue.tsx",
"sha256": "ced5e8d4b2910aeeaeb709d6ff0eb2d492f96d9b671d6ff832eea66d022a1f70"
},
{
"path": "resources/js/pages/investment/detail.tsx",
"sha256": "719b3c008887676dd0b69ffe48bd5a64426de92362d92da8ad39412a578c51bc"
},
{
"path": "resources/js/pages/portal/legacy.tsx",
"sha256": "dcd8462e940614f5935efa19d9c9444fe3432fbc704e9c05144508d5c944c90d"
},
{
"path": "tests/Feature/EducationalGuidesTest.php",
"sha256": "aa9e3f62890f1d75f639ad4e7e05713c18112ec96a7fd49c864e5e007807d3cf"
}
],
"build_manifest_sha256": "89c3ce0c67d9faf8cb3c5f7e9d74b3ad5d0ad70dff81ca2e37ca5cac7a471620",
"migrations": [],
"imports": [],
"publication": false,
"remote_operations": [],
"ai": false,
"evidence": {
"backend": "1 test, 8 assertions; exact isolated QA guard and transactions",
"types": "passed",
"build": "passed",
"desktop_width": 2048,
"mobile_width": 390,
"mobile_document_width": 375,
"keyboard": "visible focus and Enter opened guide",
"visual_data": "Read-only copy of existing public Tourism article/image; not synthetic",
"real_library": "Normal sector -> local image 25 and source summary -> normal article /turismo -> library resource 10; existing isolated QA; no import"
},
"limitations": [
"Tempo visual review pending before main integration; prior accepted dossier proof retained, not repeated"
],
"rollback": "Restore previous sources/build through existing deployment chain, preserving database/uploads/permissions and later data"
}
@@ -1521,3 +1521,443 @@ Rever a branch e o manifesto nominal; reconciliar novos commits dos programadore
Não há migrations, importação ou publicação neste pacote. A reversão repõe as fontes/build da revisão anterior através da cadeia existente, preservando a base, uploads, referências, permissões e dados posteriores. Os helpers de contexto só transportam query local; não aceitam URL externa como destino de retorno. O manifesto lista fontes e hash do build QA; não inclui `.env`, credenciais, dumps, vendor/node_modules ou fixtures persistentes.
Conferência final dos consumidores: negócios também conferido no navegador com pesquisa vazia; retirado o resumo local repetido, conservando o resumo partilhado. Fintech é identificado como especialização do Mercado Financeiro também nesse selector. Os cartões de zonas traduzem categoria, desenvolvimento, disponibilidade e estado de actividade, preservando os códigos e significados persistidos. A vista combinada não repete na lista de não localizados a ficha que já aparece na lista principal.
## Guias educativos relacionados — 20/10/2026
Delta independente de `0e7d51f18fb4`, que já foi integrado e confirmado na homologação. Este incremento permanece na branch `codex/educational-content-cards`, em QA, sujeito a revisão própria. Origem: pedido expresso da Tempo; nenhuma nova regra organizacional.
O componente `EducationalArticleCard` usa imagem pública autorizada, título original, resumo da edição apresentada, autoria e «Ler o guia». Turismo descreve hotelaria, ecoturismo, infraestruturas e referências regionais presentes no artigo, sem repetir metas financeiras como promessa. Outros artigos usam um excerto do texto efectivo. A apresentação não altera originais, fontes, autoria, direitos, versões, publicação ou scan. A imagem continua sujeita aos filtros públicos existentes. A acção secundária só surge quando a consulta de oportunidades públicas do sector tem resultados; não se infere disponibilidade por menção no artigo.
Consumidores: sector (`SectorEntry`), exploração editorial e leituras relacionadas (`portal/legacy`), catálogo de oportunidades com sector explícito (incluindo vista territorial/mapa) e ficha de oportunidade com sector. Um filtro de localização sem sector não determina um artigo; não se inventaram associações territoriais. Nos restantes catálogos territoriais não foi encontrado um cartão editorial equivalente a substituir. Não se alteraram cartões de anúncios, documentos ou entidades que não sejam recomendações de artigos.
Após a leitura, mantém-se «Iniciar ou continuar uma ideia», com `/investimentos/a-partir-de/{slug}` e o contexto autorizado do dossier quando existente. Não há novo percurso, conta ou privilégio. As provas anteriores de gravação documental mantêm-se; não foram repetidas.
Provas novas: `EducationalGuidesTest`, isolado por guarda QA, transacções e armazenamento fictício do teste, 1 teste / 8 asserções (excerto, autoria, destino, percurso, ausência de resultados, fonte não publicada e scan pendente). TypeScript, sintaxe PHP e build passaram. Capturas a 2048 px e 390 px; a 390 px, conteúdo com 375 px, sem transbordo horizontal, foco visível e Enter no botão «Ler o guia». Imagem real carregada com 900 px de largura natural. A prévia HTTP `http://127.0.0.1:18570/qa/guias/turismo` usa uma cópia por leitura do artigo/imagem já públicos no destino; não usa texto ou imagem sintéticos nem escreve registos. O router privado e a cópia ficam fora do Git. O destino não foi alterado.
Limitação: o artigo real não existe na base QA actual. A prévia permite conferir cartão e leitura; não comprova recuperação desse artigo num dossier autenticado nessa base. O URL original do percurso foi preservado e conferido pelo teste. Não se importaram dados ou fabricaram resultados para preencher essa lacuna. Capturas em `outputs/educational-guides-20261020/desktop.jpg` e `mobile.jpg` (ficheiros locais de prova).
Gestão: editar e publicar pelos ecrãs existentes da biblioteca/editorial; o resumo acompanha a edição apresentada. A acção de oportunidades é calculada pelos registos publicados, não pelo editor. Disponibilização futura: revisão própria e integração em `main` pelo processo automático, após decisão. Sem migrations, importação ou publicação neste delta. Reversão pelas fontes/build anteriores, conservando base, ficheiros, permissões e dados posteriores. IA, oito lacunas contratuais e P7 separados.
### Library integration evidence supplement - 20/10/2026
Functional code remains 3258a25f7659. Existing QA database legacy_trio_20261015_test and its original local storage were selected by an isolated loopback harness; no import, publication or application route override. Normal /sectores/turismo rendered the source-derived summary and image /documentos/25/view (HTTP 200; 900px natural width). Keyboard Enter on Ler o guia opened normal /turismo; the article links to library resource /documentos/10 (source SHA256 8cab862be245382dd4c1fd5fcb423c47aed7be3a22e0a7f8d5521510cc36bc72). Thus the real-library integration limitation of the earlier copied preview is resolved. URLs: http://127.0.0.1:18572/sectores/turismo and /turismo. Evidence: outputs/educational-guides-20261020/real-library-receipt.json, real-library-sector.jpg and real-library-guide.jpg. No dossier path/context change; previous accepted dossier proof retained, not repeated. Tempo visual review remains pending before main integration. AI excluded. This supplement changes evidence/documentation only, not functional code.
## Revisão integrada do simulador — diagnóstico inicial, 20/10/2026
Branch `codex/simulator-integrated-review`, base `e69ab1499fe99196db240c90d149b7ee46292576`. Pedido expresso da Tempo: cálculos e isolamento primeiro; continuidade e resultados a seguir; português transversal. Apenas QA, sem regras fiscais presumidas, activação de parâmetros, alterações reais ou integração em main.
O código separa cálculo aritmético exploratório (`SimulationExecution`/`RunSimulation`, `custom_planning`, `legal_interpretation=false`, moeda não definida), resolução fiscal versionada (`SimulationTaxParameterResolver`, vigência/condições, estados indeterminados) e planeamento financeiro (`financial-calculators`, `business-financial-plan`, `monthly-cashflow`, ponte fiscal). Não os apresentar como um único motor fiscal integralmente validado. A ausência de moeda no cenário aritmético é explícita no contrato; falta conferir a explicação nos seus consumidores, não atribuir MZN por presunção.
| ID | Prioridade / problema ou verificação | Causa / evidência | Acção e prova nova necessária | Estado |
| --- | --- | --- | --- | --- |
| AUD-SIM-INT01 | P0 — data fiscal inválida interrompe validação | `getFiscalPeriodRange` chamava `toISOString` sobre data inválida | Guarda `Number.isFinite`; datas 2026-99-99, 2026-00-01, dia zero, 30 Fevereiro e vazio devolvem null | Corrigido em QA; testes dirigidos passaram |
| AUD-SIM-INT02 | P0 — fiabilidade e dados fiscais | Resolutor existente conserva período, condições, unidade e versão; sem correspondência devolve valor null | Conferir acervo e parâmetros efectivos QA; cruzar casos independentes, distinguir desconhecido/zero; não activar por publicação | Verificação delimitada pendente, não erro comprovado |
| AUD-SIM-INT03 | P0 — isolamento e recuperação | Âmbito cliente por case/draft, cookies anónimos e RLS/políticas/testes existentes | Reconciliar provas recentes de contexto e conta; verificar só lacunas de nova sessão/projectos diferentes sem repetir os casos aceites | Não há fuga comprovada; verificação pendente |
| AUD-SIM-INT04 | P1 — invalidar e conservar resultados | Fingerprints e versões existentes; resultados/snapshots separados do formulário | Conferir alteração de entradas/parâmetros, histórico imutável e destino dossier correcto | Verificação pendente |
| AUD-SIM-INT05 | P1 — unidades, moeda e cálculos independentes | Aritmética SQL numeric; ferramentas com montantes exactos; falta reconciliação integral do acervo actual | Livro de controlo independente para resultados positivos/negativos/zero/incompletos, sem pressupor incentivos por localização | Verificação pendente |
| AUD-SIM-INT06 | P1 — relatórios e recuperação de exportações | Relatórios/snapshots e exportações já existem; provas anteriores em simulations/ e manual | Conferir exemplos efectivamente gerados, coerência e paginação; reutilizar exemplos aceites compatíveis | Exemplos desta revisão ainda não entregues |
| AUD-SIM-INT07 | P1 — mensagens e operações | APIs com idempotência e concorrência; mensagens e falhas por consumidor carecem de inventário final | Validar/calcular/guardar/exportar com confirmação e dados preservados; conferir erros concretos | Verificação pendente |
| AUD-SIM-INT08 | P1 — português transversal | Exportação apresentava nominal/effective; páginas de segurança e spinner em inglês | Tipo de taxa traduzido na saída, confirmação de palavra-passe/segurança e indicador de carregamento traduzidos; códigos internos preservados | Primeira correcção QA; restante inventário aberto |
Inventário de idioma observado: segurança/confirmação, perfil, reposição de palavra-passe, confirmação de email, MFA, chaves de acesso e recuperação. Pesquisa textual em menus, consumidores de investimento, gestão e componentes comuns também devolve identificadores internos; essas ocorrências são candidatos a inspecção, não erros de tradução automaticamente demonstrados. Não traduzir chaves, contratos/API, dados históricos nem nomes de fontes.
Provas reutilizáveis identificadas: `SimulationExecutionTest`, `SimulationDraftContinuityTest`, `SimulationFormDraftTest`, testes de regras/resolução, contratos e fingerprints frontend; relatórios e capturas `docs/investmoz/simulations/` e a secção recente de apresentação no manual. Não correr RefreshDatabase em bases com acervo. As provas documentadas de Outubro antigo não certificam por si só o estado de todas as versões actuais.
Esforço inicial para este recorte: 18–30 h: dados/cálculos 6–10; isolamento/continuidade 5–8; relatórios/erros 4–7; idioma 3–5. Estimativa de engenharia, não tempo já executado nem desenvolvimento fiscal novo. Reestimar após a reconciliação das provas e parâmetros efectivos; fontes/catálogos realmente ausentes ficam separados e não impedem cálculos independentes que disponham dos dados necessários. Nenhuma nova função organizacional exigida.
Correcções iniciais verificadas: 38 testes frontend dirigidos passaram, TypeScript passou. Sem ensaio remoto, SQL de alteração, novas migrations ou mudança do motor fiscal. A revisão integral permanece aberta. Roteiro Tempo, após completar as verificações: escolher contexto → preencher/rever dados → calcular → guardar/recuperar → alterar e recalcular → comparar e exportar. Os relatórios finais e a matriz problema/causa/correcção/prova/pendência serão complementados nesta mesma secção, sem outro backlog.
### Simulador integrado — saldo da verificação QA, 20/10/2026
Continuação de `59ab4fe95635`, exclusivamente na branch `codex/simulator-integrated-review`, baseada no editorial `e69ab149`. Não houve integração em main, instalação remota, migrations, importação ou activação de parâmetros. A base utilizada foi `opportunity_search_20261020_test`, porta 55441, com guarda de isolamento. Os exemplos são sintéticos e explicitamente identificados.
| Item | Resultado e causa/correcção | Prova e saldo concreto |
| --- | --- | --- |
| AUD-SIM-INT01 | Data pontual inválida passa a produzir erro em `fiscal_reference_date`, sem período alternativo. | Testes de vazio, data impossível e ano incompatível; navegador mostrou mensagem junto ao campo, `aria-invalid` e descrição associada. Encerrado este delta. |
| AUD-SIM-INT02 | Inventário efectivo: zero versões de regras fiscais na base QA. As seis fontes territoriais existentes não fornecem taxas ao motor. | Consulta IVA/2026 devolveu indeterminado, nunca zero. Fórmula executada é apenas base × taxa/100 × (1−redução/100), `portal-arithmetic-v1`, valores declarados, sem moeda, regra fiscal ou interpretação legal. A data de execução não é período de vigência fiscal. Fontes oficiais mantêm a aceitação; não se pediu nova autenticação da proveniência. Parâmetros fiscais ausentes ficam indisponíveis, não estimados pela aplicação. |
| AUD-SIM-INT03 | Remontagem do formulário por conta/contexto/rascunho para não reutilizar estado de outro âmbito. Recuperação do rascunho QA observada após reload. | Provas backend anteriores de isolamento mantidas; falta a conferência positiva do delta frontend com dois dossiers e nova sessão autenticada. Reload não foi contado como nova sessão. |
| AUD-SIM-INT04 | Alterar uma entrada retira o resultado actual e mostra aviso de recálculo; não escreve sobre snapshots históricos. | Navegador calculou 12 e, ao alterar base 100→200, retirou o resultado e mostrou o aviso. Versões/hashes backend preservados. Falta concluir a conferência positiva do histórico/contexto no consumidor autenticado e da mudança de versão de parâmetros. |
| AUD-SIM-INT05 | Controlos independentes por livro de caixa, Decimal e recorrência mensal/bissecção para o crédito. | Saldo mensal +100/−100/0; excedente de fundos −200; equilíbrio 11 unidades; crédito sem juros 100 por mês; nominal 12%/ano, 1000/12 meses: 88,85 na primeira prestação; campo vazio recusado. Cenário personalizado 100×16%×75%=12 confirmado no servidor. São controlos matemáticos, não validação de taxas legais. Restam controlos delimitados de cash-flow/plano de negócio. |
| AUD-SIM-INT06 | Resumo do relatório passou a apresentar os resultados financeiros do mesmo modelo de detalhe. Secção sem anexos ficou compacta, eliminando uma página quase vazia. | Quatro exemplos completos pelo exportador real: positivo, negativo, nulo (6 páginas cada), incompleto (5). Todas as páginas DOCX renderizadas conferidas; testes comparam resumo/detalhe. PDF disponibilizado é a renderização do DOCX, não prova da impressão nativa do Edge. Falta essa impressão e a comparação final ecrã/download pela interface. |
| AUD-SIM-INT07 | Removidos requisitos de residência/documentação que não entram no cenário percentual personalizado; mantida validação específica de base/taxa/redução. Consulta fiscal tem nova tentativa e ignora respostas abortadas. Estado de espera é substituído pelo estado terminal do cálculo. | Cenário personalizado calculou sem esses campos; erro 503 sintético na consulta mostrou mensagem e Tentar novamente; repetição preservou IVA/2026 e devolveu indeterminado por falta de parâmetro. Falhas de gravação/exportação ainda carecem de conferência dirigida dos consumidores afectados. |
| AUD-SIM-INT08 | Traduzidos textos de autenticação, perfil, preferências, MFA/chaves, diálogos, notificações e validações comuns, preservando códigos e mecanismos. | Inventário de 4519 textos/atributos JSX literais em todos os TSX; três candidatos restantes são português com a palavra continue. Isto não cobre todas as mensagens dinâmicas do backend ou do catálogo. Inglês explicitamente seleccionável no formulário bilingue e títulos de fontes preservados. Falta classificar o inventário dinâmico e conferir mensagens residuais em execução. |
Operação de idioma para uma eventual disponibilização: usar `APP_LOCALE=pt` na configuração nominal. O fallback de `config/app.php` passou a pt, mas não substitui um valor explícito do ambiente; não copiar `.env` QA. Foram adicionados `lang/pt/auth.php`, `lang/pt/validation.php` e `lang/pt.json`; as regras comuns cobertas não equivalem ao catálogo completo de validações Laravel.
Complemento do inventário: os 77 candidatos de literais TS/TSX restantes foram classificados como classes CSS internas ou traduções explicitamente seleccionáveis (incluindo menus, formulário bilingue e assistência, cuja activação não foi alterada). Foram ainda corrigidos o breadcrumb de segurança e as três mensagens de falha no carregamento de QR/chave/códigos. O fecho desse levantamento estático não certifica todas as mensagens produzidas por backend, conteúdo e serviços em execução; esse é o saldo de idioma ainda aberto.
Provas novas: 34 testes dirigidos de formulário/relatório passaram; TypeScript, build e lint PHP dos ficheiros de idioma/configuração passaram. Mantidas as provas anteriores não afectadas de contratos, APIs, isolamento e versões; nenhum RefreshDatabase executado sobre o acervo QA. Artefactos locais: `outputs/simulator-integrated-review/` (inventário efectivo, controlos independentes, modelos dos quatro relatórios, DOCX/HTML e `checked-*` com PDF/páginas conferidas). As pastas anteriores `render-*`/`final-*` são intermédias e não são as versões entregues.
Exemplos para revisão por HTTP na aplicação QA local, sem `file:`: `http://127.0.0.1:18570/qa/relatorios/simulador/positivo.pdf`, `negativo.pdf`, `nulo.pdf`, `incompleto.pdf`, e os mesmos nomes com extensão `.docx`. Estes endereços são uma whitelist do router privado de QA, exclusivamente loopback; não são rotas públicas de produção. Ficheiros não sintéticos e configuração privada não são servidos por esta whitelist.
**Estimativa restante: 6–10 h**, já descontado o trabalho acima e as provas aceites; não são as 18–30 h iniciais. Distribuição: continuidade autenticada/histórico e mudança de parâmetros 2–3 h; controlos adicionais delimitados 1–2 h; impressão Edge e comparação ecrã/download 1–2 h; mensagens dinâmicas/falhas de gravação/exportação e fecho 2–3 h. Não inclui cadastrar regras fiscais ausentes nem decidir taxas/incentivos sem fundamento. Falta de dados fiscais não bloqueia os controlos aritméticos disponíveis. A revisão integral continua aberta nos pontos precisos desta tabela.
### Continuação delimitada do simulador — controlo operacional e fontes
Base `a690c665fb4e`, mesma branch QA. Clone exclusivo `simulator_review_delta_20261020_test`, loopback 55441, aplicação `http://127.0.0.1:18576`. A base anterior e as contas reais foram preservadas. Duas contas e dois dossiers SINTÉTICOS QA foram preparados apenas neste clone; sem funções de gestão e sem transporte para o destino. O sinalizador local `INVESTOR_PATH_ENABLED=true` habilitou a capacidade já existente neste clone; não representa configuração aplicada remotamente.
| Item existente | Resultado desta continuação | Saldo |
| --- | --- | --- |
| AUD-SIM-INT01 | Prova anterior de data inválida preservada; código não alterado neste ponto. | Concluído o delta anterior; sem repetição. |
| AUD-SIM-INT02 | Confirmado zero de regras de qualquer estado na base QA original, com visibilidade administrativa sem ocultação por RLS; biblioteca contém 30 documentos. Inventariados 9901 caminhos do acervo antes da selecção: 32 candidatos por nome fiscal e três originais versionados no repositório, estes com hash actual conferido. Existem análises de textos e correspondências preparadas em `primary-text-review-2025-2026.md`, `official-source-register.md`, `fiscal-source-references-2026-10-06.csv` e comando de arquivo existente. Não se identificou um conjunto serializado de parâmetros fiscais reais nos seeders/configuração pesquisados. | Não confundir ausência do cadastro executável QA com ausência de fontes/trabalho documental. Leitura actual da homologação aguarda autenticação SSH local; não se afirmou que o destino também está vazio. Os binários do acervo montado não foram todos relidos: enumeração de caminhos não comprova conteúdo/versão. |
| AUD-SIM-INT03 | A e B recuperaram títulos próprios; B conservou a edição após erro/repetição. Logout e novo login normais, depois recuperação de A e B, conferidos no navegador. Recusa de outra conta: 403 nos dois dossiers e 404 no resultado, pelo serviço/política utilizados pelo controlador. | Concluída a conferência delimitada. O papel PostgreSQL deste clone é superutilizador: esta prova de recusa é da aplicação, não nova certificação de RLS. Provas anteriores de RLS não privilegiado mantidas. |
| AUD-SIM-INT04 | Corrigida a abertura de histórico que carregava mas não mostrava o resultado no percurso de pequeno negócio. Resultado seleccionado agora visível e explicitamente separado das entradas do dossier. Navegador abriu 24 e 12; três snapshots mantiveram hashes/versões originais após a utilização. | Versões sintéticas exclusivamente em memória do resolvedor: 7,5% até Junho e 9,25% a partir de Julho, fonte e código QA que não representam legislação. Versão histórica conservada; transição dentro do período fica indeterminada; versão da fonte incompatível deixa de resolver. Nada foi registado/publicado na base ou no destino. É prova do resolvedor, não de execução fiscal integrada. |
| AUD-SIM-INT05 | Livro independente de três meses: resultado antes de impostos 225 MZN, fundo de maneio 130, usos 730, caixa inicial após usos 470 e caixa final 638. Distintos reconhecimento/pagamento de juros, capital, compras, despesas e cobranças. Tesouraria com mês de imposto desconhecido mantém total indeterminado; caixa conhecida 210, sem somar crédito/pedido/reconhecimento de IVA como dinheiro. | Controlos passaram. Pressupostos completos no teste `simulator-independent-review.test.mjs`; não são previsões ou taxas legais. |
| AUD-SIM-INT06 | CSV e Word efectivamente descarregados pelos botões normais do dossier B, não por gerador autónomo. Prestação 88,85, juros 66,19 e saldo 100 coincidem com o ecrã. DOCX usa vírgula decimal; CSV/ecrã usam ponto. Word de seis páginas integralmente conferido após renderização, com pressupostos e campos desconhecidos explícitos. | Impressão nativa Edge aberta, aguardando gravação de `SINTETICO-QA-Edge-continuacao.pdf` pelo utilizador; o diálogo não é acessível à automação. Não contar o PDF renderizado do DOCX como essa prova. |
| AUD-SIM-INT07 | Corrigido Abrir cálculo: pequeno negócio abre a etapa financeira existente, em vez de procurar uma etapa fiscal ausente. Falha única 503 de gravação preservou edição; botão Guardar agora repete efectivamente a escrita, com fila serializada e rechecagem da impressão digital para evitar gravação redundante. Recuperação posterior confirmou a edição. | Exportação normal Word/CSV comprovada. Provas automáticas aceites de limites de anexos preservadas e regressão do exportador passou. A tentativa interactiva com anexo sintético de 16 MiB foi impedida pela permissão de URLs de ficheiro da extensão, antes de o ficheiro entrar na aplicação; não foi registada como falha do portal nem como prova de recusa pela interface. |
| AUD-SIM-INT08 | Mensagem dinâmica Not found observada foi tratada no respondedor comum: mensagens genéricas de ausência/acesso/sessão/tentativas/servidor em português, preservando HTTP e correlação. Traduzidas as mensagens de validação de parâmetros/expressões que o controlador de governação apresenta nos campos, sem alterar DSL, códigos ou limites. | Conferência do 404 por pedido real ao kernel passou; mensagens das validações revistas no código/controlador. Inventário estático anterior e traduções intencionalmente seleccionáveis mantidos. Não se certificou cada conteúdo dinâmico do catálogo nem uma sessão administrativa de governação. |
**Pressupostos do crédito de controlo:** capital 1000 MZN; prazo 12 meses; periodicidade mensal (12 prestações); taxa nominal anual hipotética 12%, dividida por 12, resultando em 1% por mês; sem carência, comissão inicial ou periódica. Montantes com duas casas decimais, juros/prestação com metade para cima; saldo residual ajustado na última prestação. Primeira prestação 88,85; última 88,84; juros totais 66,19; total pago 1066,19 MZN. O CSV contém o cronograma completo; o Word apresenta prestação inicial, juros e receita de cálculo. O controlo anterior de 100 por mês usa capital 1200 MZN, 12 meses, periodicidade mensal, taxa nominal anual zero, sem carência/comissões, mesmo arredondamento, total 1200. Estes pressupostos foram acrescentados às saídas e à prova; nenhum produto de crédito foi confirmado.
**Reconciliação fiscal:** os três originais já versionados (BR 248/2025, republicação Lei 22/2022 e BR 89/2017), as análises e relações documentais são trabalho reutilizável. Não pedir novamente documentos já disponíveis nem reabrir a credibilidade das fontes oficiais aceites pela Tempo em 08/10. As referências antigas a reautenticação genérica ficam ultrapassadas por essa decisão; continuam a ser tarefas distintas identificar artigo, âmbito, período e parâmetro. A biblioteca documental não é a tabela de parâmetros. O repositório contém governação, DSL e resolvedor temporal; `RunSimulation` executa a aritmética personalizada e não consome essas fórmulas fiscais. Mesmo encontrar parâmetros no destino não comprovaria cálculo fiscal integrado. Essa ligação funcional não foi implementada silenciosamente neste recorte.
Artefactos em `outputs/simulator-continuation-20261020/`: `interface-comparison.json`, `interface-financial.csv`, `interface-report.docx`, seis páginas em `docx-render/`, provas de A/B e nova sessão, `history-preservation-proof.json`, `fiscal-corpus-inventory.json` e `local-rule-visibility.json`. Credenciais, dump e fixtures privadas ficam excluídos do Git. O script `read-fiscal-remote.py` preparado só faz leitura administrativa e `BEGIN READ ONLY`/SELECT/ROLLBACK; ainda não foi executado por faltar autenticar a ligação. Nenhum teste ou dado sintético foi enviado à homologação.
**Esforço restante do recorte: 1–3 h após os acessos/ficheiro necessários**, para recolha remota, conferência do PDF nativo e fecho das limitações de interface. O tempo de espera do utilizador não é esforço de programação. Não são as 6–10 h anteriores nem as 18–30 h iniciais. Não inclui implementar o motor fiscal integrado ou activar parâmetros reais. As pendências de acesso e impressão não bloqueiam a revisão dos resultados QA já utilizáveis.
**Achado adicional de continuidade, identificado antes da correcção:** o componente de tesouraria mantinha os seus pressupostos apenas no estado React; não recebia nem devolvia snapshot ao rascunho. Foi acrescentado `cashflow_snapshot` versionado ao JSON económico existente e ao validador do pedido, sem migration. O componente recupera os campos e o modo de imposto; guarda apenas após edição e não converte vazios em zero. Snapshots desconhecidos/inválidos não geram resultados alternativos e mostram aviso. O relatório consolidado e o CSV do dossier passam a incluir pressupostos, valores conhecidos/indeterminados e meses; o resumo destaca a tesouraria quando existir snapshot. Dossiers antigos sem esse campo mantêm a apresentação anterior — as seis páginas do DOCX de B já conferidas não tinham esse campo e não foram reemitidas desnecessariamente.
Provas adicionais: teste HTTP com dois dossiers, logout lógico/nova sessão, recuperação exacta e recusa por outra conta passou (8 asserções), em transacção revertida no clone. Quatro controlos frontend dirigidos passaram, incluindo recuperação de mês vazio, valor inválido para correcção, relatório sem zero inventado e pressupostos completos do crédito. As 10 verificações do exportador afectado passaram; teste temporal sintético passou (7 asserções); erro HTTP em português passou (3). TypeScript/build e sintaxe PHP passaram. A conferência visual do novo componente de tesouraria com sessão real QA permanece por executar depois de fechar a impressão do Edge; não foi substituída por uma prova unitária.
**Saldo revisto após esse achado: 2–4 h de verificação delimitada**, já descontado o desenvolvimento concluído: leitura remota 0,5–1 h; PDF nativo 0,5–1 h; continuidade visual/saída da tesouraria recém-corrigida 0,5–1 h; consolidação das provas e limitações 0,5–1 h. Exclui espera por autenticação/ficheiro e a integração funcional futura das regras fiscais. Esta estimativa substitui 1–3 h acima por uma divergência concreta; não retoma as 6–10 h nem as 18–30 h originais.
### Retoma da conferência visual de tesouraria — 21/10/2026
[Retoma 21/10/2026] QA 18576, clone simulator_review_delta_20261020_test: sessão sintética renovada por login normal; gravação autenticada e recarga recuperaram saldo 123, receita 900, custos 600, calendário mensal 10/vazio/0, crédito IVA estimado 500. Caixa conhecida 3713 MZN; total indeterminado desde mês 2; crédito estimado excluído da caixa. Prova visual cashflow-recovered-20261021.png e snapshot .txt em outputs/simulator-continuation-20261020. Não certifica recuperação destes novos valores após outro login, CSV/DOCX ou PDF nativo. Ligação de controlo do navegador perdida durante espera de download; não atribuir falha à aplicação. Leitura remota aguarda identificação da ligação SSH; zero alterações no destino.
### Leitura fiscal remota e saída nativa — 21/10/2026
[Leitura SSH 21/10/2026 08:17 Maputo] Zero regras em simulation_tax_rule_versions na homologação, incluindo todos os estados por leitura administrativa sem ocultação RLS. Seis referências em investment_legal_sources; não são parâmetros executáveis. QA também tinha zero regras. BEGIN READ ONLY/SELECT/ROLLBACK; zero escritas. Prova homologation-fiscal-readonly.json. O git HEAD registado é do checkout consultado, não certifica a revisão do build servido. A ausência de cadastro não é ausência de fontes oficiais nem reabre a aceitação da Tempo de 08/10. Não implementa a ligação funcional ao motor fiscal.
[Exportação nativa 21/10/2026] Saída actual aberta pelo botão do portal: revisão 4, 12 meses, MZN, saldo inicial123/receita900/custos600, calendário10/vazio/0, caixa conhecida3713 e total indeterminado coincidem com o ecrã. Crédito estimado500 identificado como pressuposto, não somado à caixa. Prova portal-print-current-20261021.txt. Relatório de20/10/revisão2 preservado, sem o confundir com o actual. Downloads: sem novo CSV na pasta habitual ou nos temporários locais conferidos; isso não demonstra falha nem cancelamento. Histórico de downloads bloqueado pela política da ferramenta; caminho do ficheiro solicitado antes de repetir. PDF nativo aberto, mas paginação/legibilidade aguardam o ficheiro efectivamente guardado pelo Edge; conversão DOCX/PDF anterior não conta como essa prova. Perda da ligação e bloqueio de controlo durante impressão são limitações da ferramenta, sem falha do simulador demonstrada.
### Ficheiros de Transferências recuperados — 21/10/2026
[Fecho da verificação dos ficheiros 21/10/2026] Transferências Windows redireccionada para \\Mac\Home\Downloads; CSV concluído08:12:46, PDF nativo08:17:19. Sem repetir downloads. Os12 registos mensais do CSV coincidem exactamente com o snapshot do ecrã após correspondência de colunas; PDF revisão4 conserva valores mensais, MZN,12meses,123/900/600, crédito estimado500 não caixa e caixa conhecida3713. Os10 PNG do PDF nativo foram inspeccionados: defeito confirmado na linha repetida de rodapé, sobreposta ao corpo perto do topo de páginas, incluindo2,6e10. Conteúdo/valores conferidos; paginação/legibilidade não aceites até correcção e nova prova nativa. Não é conversão DOCX/PDF. Perda de ligação continua limitação da ferramenta e não causou falha do download; sobreposição é defeito distinto demonstrado no PDF. Provas: download-native-inspection-20261021.json, portal-cashflow-20261021.csv, portal-native-20261021.pdf e native-pdf-render/. A indicação anterior de ficheiros pendentes fica superada por esta prova. Sem alteração funcional nem escrita remota.
### Correcção do rodapé do PDF nativo em QA — 21/10/2026
[Correcção do rodapé nativo QA 21/10/2026] Origem: pedido expresso da Tempo nesta conversa; solução de implementação, sem regra organizacional nova. Causa em createProjectDossierPrintHtml (project-dossier-document.ts): footer position:fixed/bottom:-14mm sobrepunha a área fragmentada de impressão. Substituído na impressão por caixas @page @bottom-left/@bottom-right, dentro da margem inferior reservada20mm, com identificação/revisão e número de página/total. Rodapé normal de ecrã conservado; sem diminuir letras do corpo (10,5pt), tabelas, cabeçalhos ou origens (9pt), nem retirar conteúdo. Mesmo dossier B/revisão4 regenerado pelo botão do portal QA18576 e guardado no Edge, não por conversão DOCX. 10→10 páginas A4; comparação automática do corpo por página, descontando apenas hora de emissão/rodapé/contador, coincide integralmente. Inspecção visual dos10 PNG: sem sobreposição, cabeçalhos de tabela repetidos, quebras/linhas preservadas, última página íntegra e numeração1/10→10/10. Rodapés medidos inteiramente na margem inferior. Ecrã QA conferido após build; estilos screen e geradores DOCX/ZIP inalterados por comparação de fontes. 10 testes do exportador, TypeScript e build passaram; escape da revisão na nova string CSS verificado. Reutilizadas provas de valores e CSV aceites; não repetido download/aritmética. Provas: native-footer-fix-20261021.json; portal-native-fixed-20261021.pdf; native-footer-before-after-20261021.jpg; native-pdf-fixed-render/; screen-after-print-fix-20261021.png. Fechado exclusivamente o defeito de rodapé neste relatório QA; limitações e restantes critérios de AUD-SIM-INT06 mantêm âmbito próprio. Relato anterior de sobreposição fica superado neste delta pela comparação antes/depois. Branch codex/simulator-integrated-review separada de main, sem integração, escrita remota ou disponibilização activa; revisão prevista mantém-se pendente.
### Saldo consolidado dos oito itens — 21/10/2026
Reconciliação a pedido expresso da Tempo. O fecho do rodapé limita-se à apresentação deste relatório e baseia-se nas provas apresentadas; o utilizador declarou não ter conferido a imagem nem o PDF pelos caminhos locais. Não é fecho da revisão integral do simulador. Reutilizadas as provas aceites, sem repetir10 páginas, valores,CSV ou consulta SSH.
| Item existente | Estado no recorte | Saldo / limite |
|---|---|---|
| AUD-SIM-INT01 | Concluído no recorte | Nenhum delta de validação de data por demonstrar; prova anterior preservada. |
| AUD-SIM-INT02 | Parcial | Leitura remota concluída: zero regras de todos os estados na homologação, tal como QA; seis referências jurídicas não são parâmetros executáveis. Falta cadastro fundamentado por componente/âmbito/período e ligação funcional ao motor fiscal. Fontes e análises existentes reutilizáveis; aceitação oficial da Tempo de08/10 preservada. Não activar taxas nem importar/publicar por esta reconciliação. |
| AUD-SIM-INT03 | Concluído no recorte | A/B, logout/novo login, recuperação e recusa pela aplicação demonstrados anteriormente. A tesouraria tem prova API de nova sessão e prova visual de recuperação por recarga. Não atribuir ao navegador nova sessão dos valores de tesouraria, nem nova certificação RLS; limites expressos conservados. |
| AUD-SIM-INT04 | Concluído no recorte demonstrado | Histórico24/12 visível, hashes preservados e versões temporais sintéticas do resolvedor conferidas. Execução fiscal integrada com parâmetros reais pertence ao saldo de02 e não foi demonstrada por estes testes. |
| AUD-SIM-INT05 | Concluído nos controlos delimitados | Controlos independentes de caixa/plano/crédito, zero/desconhecido e moedas preservados. Não são validação de taxas legais nem certificação de toda a contabilidade. |
| AUD-SIM-INT06 | Concluído na apresentação e comparação deste relatório | Rodapé do PDF nativo fechado em QA segundo provas apresentadas;10 páginas/valores/CSV não repetidos. Tempo declarou que não abriu imagem/PDF pelos caminhos locais: registar aceitação das provas, não revisão visual directa do utilizador. Outras provas de relatório conservadas; falhas interactivas de exportação/anexos mantêm saldo em07. |
| AUD-SIM-INT07 | Parcial | Gravação503→nova tentativa→recuperação e exportações normais demonstradas. Faltam falhas dirigidas de geração Word/ZIP/PDF e retoma; teste interactivo de limite de anexo não ocorreu por bloqueio da ferramenta antes do envio. Nova leitura do código: três catch apresentam cause.message para Error, podendo mostrar texto técnico sem tradução. Sem nova falha de execução alegada. |
| AUD-SIM-INT08 | Parcial | Inventário estático,404 real e traduções de validação preservados. Classificar/apresentar em português os erros dinâmicos das bibliotecas de exportação (cause.message, três consumidores) e conferir estados residuais/governação delimitados; não certificar todos os textos dinâmicos do catálogo. |
**Estimativa restante deste recorte:1–2h de engenharia**, excluindo espera por interacção necessária da ferramenta: 07/08 agrupados0,75–1,5h para tratamento e conferência dirigida de erros dinâmicos/retoma/anexos; 0,25–0,5h para reconciliação final das provas. Não somar o mesmo erro duas vezes por aparecer em07e08. Substitui o saldo anterior2–4h porque leitura remota, apresentação nativa e comparação da tesouraria já foram executadas. Não inclui cadastrar/aplicar regras fiscais reais, integrar fórmulas no motor ou certificar todo o catálogo. O esforço para02 integral permanece por dimensionar por componente, âmbito, período e correspondência documental; não há estimativa de conclusão integral obtida apenas deste1–2h.
Próxima execução delimitada: corrigir a apresentação de excepções brutas nos três exportadores, conferir falha/retoma e os limites interactivos que faltam, preservando os relatórios/CSV já aceites. Branch codex/simulator-integrated-review mantida separada de main até à revisão consolidada. Nenhuma nova concessão, regra fiscal, migration ou escrita remota.
### Candidata consolidada da revisão do simulador — 21/10/2026
Origem: pedido expresso da Tempo para fechar apenas falhas dirigidas de exportação/anexos, apresentação das mensagens e consolidação. As 1–2 h anteriores respeitavam a esse recorte, não à implementação fiscal integral. A branch `codex/simulator-integrated-review` permanece separada de `main`; não integrar antes da revisão consolidada porque a cadeia existente disponibiliza automaticamente a revisão integrada.
**Separação funcional preservada.** Os cálculos de planeamento usam valores declarados pelo utilizador, com origem e pressupostos identificados. O relatório não os transforma em resultados fiscais. Falta de imposto/parâmetro continua indeterminada, não zero; crédito/pedido/reconhecimento de IVA não se torna dinheiro recebido. A correcção de falhas de exportação e do rodapé não encerra a componente fiscal.
**Implementação deste delta.** O exportador apresenta mensagens em português, identifica a operação e a referência de diagnóstico e mantém as selecções para nova tentativa pelo mesmo botão. A excepção original é registada na consola técnica local com operação/referência; não há novo envio ao servidor, telemetria nem registo de conteúdo do dossier/anexos. A consola é um diagnóstico local, não uma auditoria persistente no servidor. Uma guarda síncrona recusa outra execução enquanto a primeira está activa, mesmo antes da actualização do estado React; após erro a guarda é libertada. Preparação dos anexos é atómica: falhas/limites não substituem a lista existente nem as legendas. A selecção repetida do mesmo nome com os mesmos bytes mantém uma entrada; bytes diferentes com o mesmo nome e nomes diferentes continuam distintos. Esta identidade é escolha de implementação local, não regra de cadastro/publicação documental. Os limites de quantidade/tamanho já existentes não foram alterados.
**Provas reutilizadas.** Data inválida; A/B e nova sessão; recusa de outra conta; histórico24/12 e hashes; resolvedor temporal sintético; controlos financeiros independentes; Word/CSV e respectivos valores; PDF nativo10 páginas/rodapé/última página; inventários/documentos e leitura SSH sem escrita. A Tempo aceitou o fecho do rodapé segundo as provas apresentadas e declarou que não abriu a imagem/PDF pelos caminhos locais. Não lhe atribuir inspecção visual directa. Os testes temporais sintéticos não representam legislação. A prova de recusa do clone com superutilizador é da aplicação; não é uma nova certificação de RLS. Não repetir as provas aceites apenas para gerar uma nova lista de resultados.
**Ensaio dirigido isolado.** `http://127.0.0.1:18576/qa/export-recovery` usa o componente real e os geradores reais, com uma falha única de biblioteca injectada no módulo de ensaio, sem alteração das rotas de produção. Os controlos de falha e os ficheiros sintéticos ficam em `outputs/`, fora do Git e do pacote. Os contadores referem-se a chamadas/generações; para ZIP contam a geração interna de Word e a preparação do arquivo, não duas transferências. No ensaio de nova tentativa de PDF é omitida apenas a impressão automática, para observar a janela com conteúdo e não repetir a prova nativa aceite. Nenhuma dessas injecções entra na candidata funcional.
**Procedimento de integração após revisão.** Fixar o commit candidato e manifesto de fontes; obter o `main` remoto actualizado e reconciliar apenas os deltas dos programadores que afectem estas fontes/consumidores. Rever o diff e conferir no ambiente QA a revisão exacta a integrar; repetir apenas verificações afectadas por conflitos ou alterações posteriores. Este delta não contém migrations, cadastro de regras, importação, publicação de documentos, contas, permissões ou alterações de MFA/configuração. Após decisão da Tempo, integrar pelo processo Git existente; confirmar o recibo da cadeia e a revisão efectivamente servida em `/build/revision.json`, com consulta pública e login privado conferidos nos consumidores afectados. Uma falha deve conservar a release anterior; não editar a pasta servida para contornar a cadeia. A reversão usa o mecanismo de release anterior de código/build, preservando base, anexos, configuração e dados posteriores; não repor um dump antigo. Se uma futura entrega incluir regras/dados activos, exigir o procedimento específico já aprovado de backup/restauro e acção autenticada; esse trabalho não está incluído aqui.
### Diagnóstico fiscal separado da candidata — 21/10/2026
| Camada | Existente e comprovado | Saldo efectivo |
|---|---|---|
| Fontes | Inventário anterior de9901 caminhos e32 candidatos por nome; três originais versionados: BR248/I/Suplemento1/2025, republicação Lei22/2022 no BR250/2.ºSuplemento e BR89/2017. Análises em `primary-text-review-2025-2026.md`, `official-source-register.md`, `fiscal-reform-2026-review.md` e referências em `fiscal-source-references-2026-10-06.csv`. | Reutilizar conteúdo, hashes, versões e citações já disponíveis; não pedir novamente esses documentos. A enumeração de caminhos não equivale à leitura de todos os binários. Distinguir fontes já lidas de fontes só inventariadas e alterações/anexos ainda sem correspondência. |
| Parâmetros executáveis | Leitura administrativa de todos os estados confirmou zero linhas em `simulation_tax_rule_versions` tanto na QA como na homologação. A homologação tem seis referências em `investment_legal_sources`, ligadas a estruturas territoriais; não são taxas/fórmulas prontas. | Cadastrar por componente: código/versão, diploma/artigo/versão/hash, âmbito e condições, início/fim de aplicação, valor/unidade, bases/limites/exclusões, fórmula e arredondamento fundamentados. Os campos necessários variam por regra; não preencher lacunas por zero ou pela taxa mais recente. |
| Governação e consulta | Modelo versionado, controlador de transições, API de resolução por componente/período/contexto, selecção de publicados e compatibilidade de versão da fonte; resolução temporal e indeterminação já ensaiadas. | Reutilizar estes serviços; conferir as correspondências documentais e a operação com parâmetros reais quando estes existirem. Não criar aprovações, nomeações ou uma nova exigência organizacional por escolha técnica. A Tempo define os papéis e as regras. |
| Expressões | `SimulationTaxRuleExpression` já valida e avalia a DSL `investmoz.tax.v1` com aritmética racional, arredondamento e valores em falta. | Não reconstruir o avaliador. Verificar se cada regra fundamentada cabe na linguagem existente e estender apenas operações concretamente necessárias. |
| Execução fiscal integrada | `RunSimulation` executa `base × taxa/100 × (1−redução/100)`, identifica `custom_planning`, `legal_interpretation=false`, `ruleset_version=null` e moeda não indicada nesse cenário. Consulta/DSL não são consumidas por este fluxo como motor fiscal. | Desenvolver orquestração explícita de componente/período/âmbito → regras elegíveis → entradas e parâmetros → avaliação → resultado e explicação com fontes/versões imutáveis. Tratar ambiguidade/transição/ausência como indeterminação. Manter o cenário de planeamento separado, sem o relabelar como imposto calculado. |
| Resultado, dossier e relatório | Snapshots, histórico e exportadores existentes podem transportar resultados e proveniência; relatórios actuais identificam pressupostos/estimativas. | Integrar o resultado fiscal com identificação própria de regra, período, unidade/moeda, base, passos, arredondamento e lacunas. Guardar reprodução histórica e demonstrar casos de referência por regra, isolamento e não reaplicação de uma versão actual a um resultado antigo. |
**Componentes a parametrizar, não taxas inventadas.** O acervo já analisado identifica famílias de alterações para ICE (Lei7/2025/tabela), ISPC (Lei9/2025), IVA (republicação Lei22/2022 e Lei10/2025, além da consolidação/rectificação aplicável), IRPS (Lei11/2025) e IRPC (Lei12/2025). A API também contempla INSS, retenções e direitos aduaneiros. Isto é inventário de domínios, não prova de cobertura documental completa nem autorização para aplicar uma taxa genérica. Para ICE/aduaneiro são relevantes código/unidade/tabela e período; para IVA, operação, incidência/exclusões/dedução e estado de reembolso; para ISPC, elegibilidade/limites e obrigações; para IRPS/IRPC, categoria, residência/estabelecimento, base, retenções e regimes específicos. Prazos, taxas, anexos e incentivos só entram com correspondência documental aplicável.
A aceitação das fontes oficiais determinada pela Tempo em08/10 prevalece sobre pedidos históricos genéricos de reconfirmação de autenticidade ou de um fiscalista adicional. Identificar o artigo, âmbito, período, alterações/revogação e parâmetros é aplicação/rastreabilidade da fonte aceite, não segunda validação da sua credibilidade. A biblioteca documental, a situação jurídica e a utilização em cálculo são estados independentes; importar ou publicar documentos nunca activa cálculo fiscal.
**Desenvolvimento fiscal necessário:** correspondência documental e cadastro versionado; adaptação concreta de expressões quando necessária; orquestração do cálculo; contrato de resultado/proveniência/histórico; ligação à interface/relatórios e casos de referência por componente/período. Nenhuma dessas frentes é encerrada por corrigir crédito, orçamento, tesouraria, falhas de exportação ou rodapé. O esforço integral deve ser dimensionado por regras/componentes e lacunas documentais identificadas; não cabe na estimativa de1–2h do recorte de exportação.
### Fecho do recorte de exportações e saldo da candidata — 21/10/2026
| Item existente | Saldo consolidado da candidata | Prova / limite |
|---|---|---|
| AUD-SIM-INT01 | Concluído no recorte | Validação de data: prova anterior reutilizada. |
| AUD-SIM-INT02 | Parcial; implementação fiscal integral aberta | Zero regras remotas/QA; fontes, cadastro e integração discriminados no diagnóstico separado. |
| AUD-SIM-INT03 | Concluído nas provas delimitadas | A/B/nova sessão e recusas anteriores; tesouraria com API/nova sessão e UI/recarga, sem alegar uma nova prova browser da mesma sessão. Limite RLS mantido. |
| AUD-SIM-INT04 | Concluído no recorte demonstrado | Histórico/snapshots e resolvedor sintético reutilizados; integração real fiscal permanece em02. |
| AUD-SIM-INT05 | Concluído nos controlos delimitados | Controlos independentes e provas financeiras aceites reutilizados; sem validação legal de impostos. |
| AUD-SIM-INT06 | Concluído na apresentação/comparação deste relatório | Rodapé nativo aceito segundo provas; sem repetir10páginas/valores/CSV; sem atribuir inspecção local ao utilizador. |
| AUD-SIM-INT07 | Concluído no recorte dirigido QA |3falhas de geração/retoma, anexo16MB recusado com lista conservada, re-selecção sem duplicação e ZIP íntegro; injecção sintética fora da entrega. |
| AUD-SIM-INT08 | Concluído nos consumidores afectados | Mensagens portuguesas/diagnóstico técnico separado; limites de catálogo/governação mantidos, sem certificação global. |
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.
### Disponibilização autorizada pela Tempo —21/10/2026, execução em curso
A Tempo autorizou a implementação após o fecho da candidata226fd308 e a identificação das três operações. A integração foi executada por avanço directo de13f68f1b para `226fd308dd45d2805ef4678b20ca4398d7fef6c6`, seguida de envio aorigin/main. A disponibilização automática foi conferida através da camada HTTP autenticada: `/build/revision.json` serve essa revisão emmain, build de21/10/2026 às10:07:12.300278UTC, manifest SHA256 `d524c957dc95e92fcb6eb0d802b9df845743466bd51e398a485bd5ba3d7e3d8f`. `/api/v1/fiscal-consumer` responde200 com `enabled:false` e cobertura2026. A primeira leitura sem autenticação da camada HTTP foi recusada; a conferência seguinte utilizou o acesso já disponibilizado, sem divulgar credenciais. Não se repetiram provas aceites.
**Saldo das operações:** integração e disponibilização do código concluídas; publicação da versão real e activação ainda não executadas. A versão a cadastrar/publicar é `IVA-GERAL-BASE-LIQUIDA`, `rule_version:1`, da ficha documentalv1.2, vinculada ao exemplar da Lei22/2022 com hash `ea1b0dc6f5cf6e595f6d9a19e7042f398999e0087caef1785f6e8fac571b5f53`; oID real do destino ainda não foi obtido. Não usar a fixtureQA deversão2 ou os seus actores. A autorização de disponibilização não é apresentada como validação jurídica do utilizador.
**Dependência operacional concreta:** a sessão SSH anterior terminou comfalha de ligação; autenticação não interactiva no destino foi recusada. Pediu-se a reabertura do mecanismo existente `outputs/investmoz-git-delivery-20261020/resume-git-session.py`, com autenticação exclusivamente na consola local. Está preparado um inventário remoto somente por leitura para conferir os papéis/actores existentes, fonte, versão e estado do consumidor. Ainda não foi submetido, por falta da sessão autenticada. Backup/restauro e acção autenticada da governação serão confirmados antes de escrever dados fiscais ou alterar a activação. Não há nova pendência de fundamentação, nova ronda QA ou pedido de autorização para as operações já autorizadas.
Recibo privado da integração em `outputs/investmoz-iva-disponibilizacao-20261021/integration-receipt.json` no checkout principal; o recibo consolidado existente replica o estado. Nenhuma escrita fiscal, importação da fixture ou activação no destino foi executada nesta passagem. A continuação começa na autenticação remota e nas duas operações restantes, sem voltar a integrar ou preparar a candidata.
### Correcção QA da manutenção fiscal por um responsável — decisão Tempo de21/10/2026
**Origem e decisão:** a obrigação de quatro pessoas foi localizada em `SimulationTaxRuleGovernance::transition` e nos trêsCHECKs de `2026_10_03_170000_create_simulation_tax_rule_governance.php`, incorporados por `e65cb269` (06/10/2026, «varias alteracoes»). A nota de `simulator-evolution-progress.md` descreve «autor distinto de revisor, aprovador e publicador» como implementação. Não foi encontrada no acervo consultado uma decisão expressa da Tempo ou um requisito aplicável que imponha essa separação ao cadastro fiscal. Classificação: **escolha de implementação**, não regra organizacional aprovada. A autoria do commit não equivale à aprovação da condição. A Tempo agora instruiu expressamente a sua remoção neste fluxo e indicou `nala@tempo.co.mz` como responsável. Não se criam quatro designações nem uma excepção artificial limitada à primeira regra; a administração autorizada pode manter os parâmetros fiscais, sem extensão à independência acordada noutros processos.
**O que ficou corrigido em QA:**
- A administração existente, incluindo o perfil administrativo já autorizado comMFA recente, pode cadastrar/manter parâmetros sem preencher quatro listas específicas. As listas existentes continuam disponíveis para atribuições específicas. Utilizadores comuns e perfis apenas de auditoria não adquirem manutenção fiscal; autenticação, verificação de email eMFA permanecem. O investidor continua a utilizar o simulador pelas permissões próprias de execução/consulta; a manutenção não é requisito de utilização.
- O mesmo responsável pode percorrer `draft` → `submitted` → `reviewed` → `approved` → `published`, conservando osID reais em cada etapa. Retiraram-se as comparações de identidade no serviço fiscal e nos respectivosCHECKs; mantiveram-se evidência, datas, estado, versão, coerência da fonte, unicidade/sobreposição, bloqueios, RLS e eventos imutáveis. Não há alegação de revisão independente quando o actor é o mesmo.
- Como o destino não tem a fonte deste lote cadastrada, o percurso fiscal permite registá-la em `POST /api/v2/investment/admin/tax-rule-sources`: metadados existentes, origem oficial identificada, citação eSHA256. Regista a aceitação oficial já decidida pela Tempo em08/10/2026, responsável e momento, com `legal_opinion:false` e `independent_review:false`. O estado geral da fonte permanece `pending`; o fundamento `fiscal_acceptance` permite a sua utilização apenas na governação/resolução fiscal. Assim, não fica automaticamente validada para zonas ou outros processos, nem certifica juridicamente uma operação ou atribui parecer à Tempo. A regra deve referenciar o mesmo hash e versão. Cadastro de fonte não publica regra nem activa consumidor.
- A mesma pessoa pode concluir essa curadoria fiscal sem inventar outro actor para «validar» a fonte oficial aceite. A condição normal da fonte fora deste percurso, as relações/validações de zonas, oportunidades e perfis mantêm-se. O novo fundamento explícito `fiscal_acceptance` fica na fonte e na auditoria; o endpoint normal de zonas não permite marcar esse fundamento. Não se alteraram os fluxos com independência já acordada.
**Esquema:** `2026_10_21_130000_correct_fiscal_maintenance_actor_constraints.php` foi ensaiada apenas na base QA isolada. O delta é explícito: retirar desigualdades de identidade dos trêsCHECKs fiscais; acrescentar o fundamento de aceitação fiscal à fonte; registar a responsabilidade única na aceitação fiscal da fonte; ajustar apenas a projecção pública/resolução fiscal para reconhecer esse fundamento com hash e versão correspondentes. A condição normal de validação independente da fonte não foi removida e o seu estado continua pending. A guarda recusa execução da migration foraQA. Nenhuma constraint de oportunidades, zonas, perfis ou outra tabela foi modificada. A reversão só é permitida em QA sem registos dependentes; quando existirem cadastros legítimos, recusa apagá-los ou voltar a impor uma condição que os invalide. Desligar novas execuções preserva os históricos.
**Verificação estritamente do controlo alterado:**6testes/86asserções passaram em `simulator_fiscal_checks_20261021_test`, em transacções revertidas. Cobrem administração existente por lista e por perfil/MFA recente, sem quatro designações; curadoria da fonte, cadastro e quatro etapas pelo mesmo actor; responsabilidade/hashes dos eventos; consulta e suspensão; consumidor ainda desligado; recusa de investidor, auditor, ausência/expiração deMFA, falta de evidência, hash/versão de fonte incoerente e versão optimista errada; imutabilidade; compatibilidade das constraints não fiscais; reversão vazia e recusa de reversão com dados dependentes; guarda contra alvo nãoQA. Não se executou uma simulação: a contagem de execuções foi preservada. Não houve testes novos de cálculo, decimal, exportadores, PDF, TypeScript ou build, pois esse código não mudou. Pint foi conferido nos ficheirosPHP do delta. Prova privada: `outputs/simulator-continuation-20261020/fiscal-maintenance-actor-tests.txt`. Os testes antigos de governação foram ajustados para retirar a expectativa de uma proibição agora expressamente revogada, preservando os casos de permissões, versões e histórico.
O esquema foi depois aplicado, apenas pelo caminho desta migration, ao clone persistente `simulator_fiscal_delta_20261021_test`, mantendo a guarda de isolamento. Prova por leitura antes/depois:2versões de regra,7eventos de governação,11resultados,11relatórios e8fontes com as mesmas contagens e hashes de conteúdo. Na comparação da fonte exclui-se apenas a coluna nova, que ficaNULL nos registos anteriores; nenhum conteúdo ou snapshot histórico foi reescrito. Prova privada `fiscal-maintenance-schema-proof.json` na mesma pasta. O clone QA está, assim, com o controlo aplicado para revisão; a base activa e os seus actores não foram alterados.
**Estado real do destino:** a dificuldade de introduzir o comandoSSH foi resolvida abrindo directamente a consola habitual; a autenticação foi feita pelo utilizador e a sessão ficou pronta. A leitura do vhost identificou o directório servido `/var/www/html/teste-investidor.tempo.co.mz/current`, que aponta para a release226fd308; a cópia anterior conservada na raiz não é o consumidor servido. Foi confirmado por leitura que `nala@tempo.co.mz` existe (ID15), email verificado, MFA configurado e perfil de superutilizador existente; não foi criada conta ou alterado perfil. Não se confunde matrículaMFA com sessão autenticada recente: as acções de cadastro continuam a exigir a autenticação normal do responsável.
O backup/restauro anterior à escrita ficou comprovado em21/10/2026 às10:18:00UTC: cópia consistente por snapshot de `teste_investidor`, arquivo privado `/var/backups/investmoz/iva-rollout-20261021_101750/database.dump`, SHA256 `4e972c028fbe70c347c518ba86fe7e293093b7c81278ac4d64a077bf3e5325b7`,4.584.693bytes; restauro isolado em `iva_rollout_20261021_101750_test`, acesso público à base revogado. Contagens e hashes canónicos das171tabelas coincidiram. A configuração anterior ficou protegida na mesma pasta. Isto comprova backup/restauro, sem ensaiar ou escrever os parâmetros no activo. A primeira validação local do cabeçalho do arquivo usava texto inadequado e foi corrigida para o formatoPGDMP; a segunda operação completou o restauro e a comparação. Não é falha do simulador.
#### Procedimento actualizado das três operações, sem quatro responsáveis obrigatórios
1. **Código e controlo de esquema:** a revisão226fd308 já está integrada e servida. A presente correcção de governação fica na branch QA para revisão; não foi enviada amain. O delta inclui o esquema acima, cuja aplicação no destino deve acompanhar a referência exacta do código, por operação técnica autenticada e combackup/restauro actualizado conforme o estado nesse momento. A migration protegida é o ensaioQA, não um comando a executar contra a base activa. Conservar os históricos e o consumidor desligado durante a aplicação do controlo; não usar o deploy para cadastrar/publicar dados. Não voltar a integrar226fd308 nem repetir as provas de cálculo.
2. **Fonte e versão fundamentada:** o responsável real indicado pela Tempo autentica-se normalmente, comMFA. Reutiliza a fonte aceite se já existir; caso contrário, cadastra-a pelo endpoint fiscal acima, com exemplar/hash/citação reais. Cadastra `IVA-GERAL-BASE-LIQUIDA`, `rule_version:1`, conforme fichav1.2 e âmbito/exclusões2026. A mesma conta autorizada conclui `submit` → `review` → `approve` → `publish`, sempre com `version_no` corrente e evidência fiel: manutenção técnica autorizada, não parecer jurídico nem revisão independente. Registar oID real; não usar a fixtureQA deversão2 nem contas sintéticas. Não é preciso criar quatro designações.
3. **Activação do consumidor:** permanece separada da publicação. Aplicar a ligação aoID e `rule_version` reais autorizados e o interruptor de activação pelo procedimento já definido, conservando cálculo sobre base declarada como estimativa, sem apuramento final do imposto a pagar. Conferir somente a revisão/regra efectivamente servidas e o delta no destino. Desactivar o consumidor ou suspender a regra recusa novas admissões, preservando as já admitidas, históricos e relatórios.
**Saldo actual:** controlo corrigido e verificado emQA, fontes e funcionamento do cálculo não reabertos; sem nova publicação ou activação na homologação. A referência servida continua226fd308 e o consumidor desligado. A correcção não foi aplicada ao esquema activo. A indicação do responsável e a autorização operacional não são tratadas como conferência de ficheiros locais ou validação jurídica da Tempo. Esta actualização substitui a indicação anterior de que a simples falta de quatro listas configuradas bloqueava obrigatoriamente a disponibilização.
### Execução autorizada da correcção fiscal — 21/10/2026
A Tempo autorizou incorporar4a4d1212 e concluir apenas as operações pendentes. A correcção foi integrada emmain; a equipa integrou entretanto d3fe8a02, que inclui4a4d1212 e conserva o mesmo código fiscal. A revisão efectivamente servida, conferida em `/build/revision.json`, é `d3fe8a02ec7f5dcd01984c4f4a1eba18b5a0cf19`. Não se reinstalou226fd308.
Backup/restauro actualizado: `/var/backups/investmoz/iva-rollout-20261021_104253`, dumpSHA256 `b37bf4aad0abd839bc81d70adf802535ad7738fabe75fda61d74f39dfc11e74b`; restauro isolado `iva_rollout_20261021_104253_test`,171tabelas com contagens e hashes coincidentes. A operação técnica autenticada aplicou exclusivamente oDDL revisto, com registos/históricos e constraints alheias preservados. A migration protegida não foi executada na base activa nem teve a guarda enfraquecida. O contrato do executor foi actualizado apenas para essa migration, após registo da compatibilidade; a primeira disponibilização conservou a release anterior ao encontrar a alteração de esquema. A retoma usou o mesmo executor e não passou a executar migrations no destino.
Publicação e activação concluídas conforme recibo. O consumidor está activo. Não foram repetidas provas aceites do cálculo, precisão, exportadores ouPDF, nem importada a fixtureQA. A autenticação da infraestrutura não é identidade administrativa do portal. A conta Christiana/example.test reportada pelo utilizador pertence ao ensaio local anterior e não é usada no destino.
Mantém-se o procedimento único: **1)** código/controlo do esquema, agora integrados; **2)** cadastro das fontes reais com hash/citação e da versão1 `IVA-GERAL-BASE-LIQUIDA`, seguido das etapas autenticadas pelo mesmo responsável autorizado; **3)** activação separada, ligando o consumidor aoID e versão publicados. Cobertura2026, operação interna geral sobre base líquida declarada emMZN; excluídos preços comIVA incluído, taxas reduzidas, isenções, regimes especiais, importações, digitais não residentes, deduções/créditos/reembolsos, incentivos, pagamento/declaração e outros impostos. O resultado é estimativa deIVA sobre a base declarada, não apuramento final a pagar.
Para suspender, desligar `INVESTMOZ_IVA_CONSUMER_ENABLED` e reiniciar graciosamente os workers pelo mecanismo existente, ou suspender a versão publicada pela governação autenticada, com `version_no` corrente e evidência. Novas admissões são recusadas; as já admitidas concluem com snapshot congelado, mantendo históricos e relatórios. Não apagar fontes, versões ou execuções para reverter a exposição. A autorização e o cadastro não constituem conferência local dos ficheiros pelo utilizador nem validação jurídica atribuída à Tempo. O recibo JSON existente regista a revisão, a regra e o estado efectivamente observados.
**Recibo final das três operações:** código4a4d1212 integrado e servido dentro ded3fe8a02; três fontes oficiais reais cadastradas comversão1, hashes e citações; regra `IVA-GERAL-BASE-LIQUIDA`, **rule_version1**, ID `01a15e1a-5338-700f-8a45-f87707d696a7`, estado `published`, controlo optimista `version_no5`; autor/revisor/aprovador/publicador reais: conta indicada `nala@tempo.co.mz`, ID15. A sessão normal comMFA foi efectuada pelo utilizador numa página temporária de apoio ligada apenas a127.0.0.1; não se guardaram palavra-passe, códigoMFA ou cookies. Não há alegação de revisão independente ou parecer jurídico. A aceitação fiscal das fontes tem o fundamentoTempo08/10/2026; o estado geral permanecepending, sem validação automática noutros processos.
Activação separada aplicada em21/10/2026 às10:55:58UTC, alterando apenas as três chaves do consumidor. Ligação exacta aoID acima, rule_version1 eenabled=true; restante configuração preservada. `/api/v1/fiscal-consumer` confirmouenabled=true porHTTP200; a leitura do resolvedor, sem executar cálculo, confirmouIVA_GENERAL_RATE=16percent, mesmoID/versão/fonte no contextoMZN/internal_general_net de2026. Cinco eventos de governação; contagens de resultados/relatórios preservadas, sem simulações sintéticas no destino. O restauro de segurança e a configuração anterior estão conservados no arquivo privado identificado no recibo.
Suspensão concreta: configurar `INVESTMOZ_IVA_CONSUMER_ENABLED=false` e executar o reinício gracioso existente `php8.4 artisan queue:restart` na releaseactual como operador autorizado; ou `POST /api/v2/investment/admin/tax-rules/01a15e1a-5338-700f-8a45-f87707d696a7/suspend`, com `version_no:5` enquanto corrente e evidência verdadeira de pelo menos20caracteres, pela administração autenticada/MFA. Não repetir a suspensão se o estado/versão já tiver mudado. Conservar a ligação e todos os registos; futuras retomas respeitam o estado da regra e o procedimento de versão aplicável, sem escolher automaticamente outra regra.
@@ -7381,3 +7381,12 @@ P1,AUD-SEARCH-T08,QA — revisão Tempo pendente,Nenhuma alteração de relaçõ
P1,AUD-SEARCH-T09,QA — revisão Tempo pendente,Gestão de procura/capacidade permanece separada e protegida; sem alteração de permissões.,Reutiliza integralmente a pesquisa corrigida com âmbito educação mantido.,,HTTP/Inertia: results.total=0 com texto inexistente; filtros e componente comuns.,,,,,,navegação/pesquisa,"Conversa Tempo 20/10/2026; manual, secção Pesquisa transversal QA",Consulta coerente e compreensível; nenhuma alteração de dados para coincidir contagens.,investment/catalogue,Gestão de procura/capacidade permanece separada e protegida; sem alteração de permissões.,,Rever a branch antes de integrar em main; sem disponibilização remota nesta execução.,,,Pesquisa transversal QA 20/10/2026,HTTP/Inertia: results.total=0 com texto inexistente; filtros e componente comuns.,,,,Apenas delta identificado; não certifica funcionalidades não alteradas.,
P1,AUD-SEARCH-T10,QA — revisão Tempo pendente,Não houve importação/publicação; a cópia QA usada não contém recursos públicos reais para nova prova positiva.,"Resumo único nomeado Documentos, chips removíveis, controlos vazios ocultos sem apagar valores seleccionados, rodapé comum.",,HTTP/Inertia e navegador: vazio e termos preservados; retorno documental existente conservado.,,,,,,navegação/pesquisa,"Conversa Tempo 20/10/2026; manual, secção Pesquisa transversal QA",Consulta coerente e compreensível; nenhuma alteração de dados para coincidir contagens.,portal/services; portal/document,Não houve importação/publicação; a cópia QA usada não contém recursos públicos reais para nova prova positiva.,,Rever a branch antes de integrar em main; sem disponibilização remota nesta execução.,,,Pesquisa transversal QA 20/10/2026,HTTP/Inertia e navegador: vazio e termos preservados; retorno documental existente conservado.,,,,Apenas delta identificado; não certifica funcionalidades não alteradas.,
P1,AUD-SEARCH-T11,QA — revisão Tempo pendente,"Situação jurídica, consulta e cálculo continuam independentes.",Reutiliza a correcção da biblioteca e preserva filtros jurídicos/documentais pertinentes.,,HTTP/Inertia: resources.total=0; consumidor e query pública partilhados.,,,,,,navegação/pesquisa,"Conversa Tempo 20/10/2026; manual, secção Pesquisa transversal QA",Consulta coerente e compreensível; nenhuma alteração de dados para coincidir contagens.,portal/services; portal/document,"Situação jurídica, consulta e cálculo continuam independentes.",,Rever a branch antes de integrar em main; sem disponibilização remota nesta execução.,,,Pesquisa transversal QA 20/10/2026,HTTP/Inertia: resources.total=0; consumidor e query pública partilhados.,,,,Apenas delta identificado; não certifica funcionalidades não alteradas.,
P1,AUD-GUIDE-CARD-20261020,QA review pending,New delta; not approved for main,Common educational card and existing source/context adapter,,Desktop/mobile/keyboard; public-state and source checks,,,,,,educational presentation,Tempo 20/10/2026; same manual,,,Tempo visual review; library integration verified on existing QA; previous dossier proof retained,,Integration approval for new delta,,,,1 test / 8 assertions; TypeScript/build; real public-source visual preview,,,,No import; no remote changes; no claim of new dossier proof,
P1,AUD-SIM-INT01,Previous inline-date proof retained; unchanged delta not repeated,Invalid one-off fiscal date can throw instead of returning validation state,,,,,,,,,simulator and shared language,Tempo request 20/10/2026; same manual,,,,,No new activation authorised,,,,Previous inline-date proof retained; unchanged delta not repeated,,,,No claim of full review completion,
P1,AUD-SIM-INT02,QA zero rules verified with full visibility; prepared archive and analyses reused; current homologation read awaits SSH authentication,Review actual fiscal parameters sources periods and eligibility,,,,,,,,,simulator and shared language,Tempo request 20/10/2026; same manual,,,,,No new activation authorised,,,,QA zero rules verified with full visibility; prepared archive and analyses reused; current homologation read awaits SSH authentication,,,,No claim of full review completion,
P1,AUD-SIM-INT03,Two dossiers and new login recovered in browser; cashflow persistence corrected and HTTP-tested; new visual cashflow delta pending,Review isolation recovery across projects and sessions,,,,,,,,,simulator and shared language,Tempo request 20/10/2026; same manual,,,,,No new activation authorised,,,,Two dossiers and new login recovered in browser; cashflow persistence corrected and HTTP-tested; new visual cashflow delta pending,,,,No claim of full review completion,
P1,AUD-SIM-INT04,Historical results visible after fix; 12 and 24 recovered; hashes unchanged; synthetic temporal resolver versions verified in memory only,Review stale and historical results and correct dossier,,,,,,,,,simulator and shared language,Tempo request 20/10/2026; same manual,,,,,No new activation authorised,,,,Historical results visible after fix; 12 and 24 recovered; hashes unchanged; synthetic temporal resolver versions verified in memory only,,,,No claim of full review completion,
P1,AUD-SIM-INT05,Independent business-plan and cashflow ledgers passed; complete credit recipe included in screen and exports,Independent financial controls unknown zero units and currencies,,,,,,,,,simulator and shared language,Tempo request 20/10/2026; same manual,,,,,No new activation authorised,,,,Independent business-plan and cashflow ledgers passed; complete credit recipe included in screen and exports,,,,No claim of full review completion,
P1,AUD-SIM-INT06,Normal UI CSV and DOCX match shared values; all six DOCX pages checked; native Edge PDF awaits user save; new cashflow visual output pending,Generated reports screen download and pagination,,,,,,,,,simulator and shared language,Tempo request 20/10/2026; same manual,,,,,No new activation authorised,,,,Normal UI CSV and DOCX match shared values; all six DOCX pages checked; native Edge PDF awaits user save; new cashflow visual output pending,,,,No claim of full review completion,
P1,AUD-SIM-INT07,Calculation navigation and failed-save retry fixed and browser-verified; normal downloads verified; interactive attachment blocked by extension permission,Explicit validate calculate save export errors and retry,,,,,,,,,simulator and shared language,Tempo request 20/10/2026; same manual,,,,,No new activation authorised,,,,Calculation navigation and failed-save retry fixed and browser-verified; normal downloads verified; interactive attachment blocked by extension permission,,,,No claim of full review completion,
P1,AUD-SIM-INT08,Generic HTTP and fiscal-expression validation messages translated; status/correlation verified; selectable translations and source titles preserved,Portuguese across portal and simulator outputs,,,,,,,,,simulator and shared language,Tempo request 20/10/2026; same manual,,,,,No new activation authorised,,,,Generic HTTP and fiscal-expression validation messages translated; status/correlation verified; selectable translations and source titles preserved,,,,No claim of full review completion,
Can't render this file because it is too large.
@@ -2990,3 +2990,19 @@ Provas: outputs/investmoz-git-delivery-20261020/RECIBO-DISPONIBILIZACAO-GIT-MAIN
## Pesquisa transversal QA — 20/10/2026
Inventário finito AUD-SEARCH-T01–T11 na mesma matriz/CSV e comparação no manual. Correcções manuais na branch `codex/opportunity-search`, sem integração/remoto/publicação. Assistência NLP ainda não implementada: interpretador e prévia/confirmar da mesma query manual, 6–10 h para recorte local delimitado; modelo externo sujeito a decisão. As oito lacunas contratuais e P7 mantêm-se separados. Revisão Tempo pendente; as provas comuns não encerram requisitos de gestão ou publicação não alterados.
## Guias educativos relacionados — delta QA após 0e7d51f18fb4
Verificação complementar concluída: código funcional `3258a25f7659` ligado ao acervo QA existente `legacy_trio_20261015_test`, sem importação. Página normal `/sectores/turismo` apresenta imagem local `/documentos/25/view` (HTTP 200; 900 px naturais) e resumo; «Ler o guia» abre `/turismo`, ligado ao exemplar real `/documentos/10`. A limitação de integração com biblioteca indicada na primeira prévia fica superada por esta prova. Mantém-se a prova anterior do dossier, pois código/contexto não mudaram. Falta apenas revisão visual da Tempo antes de integrar. IA fora do lote.
AUD-GUIDE-CARD-20261020: componente comum com imagem autorizada, título, resumo do artigo publicado, autoria e «Ler o guia». Reutilizado em sectores, exploração editorial e leituras relacionadas; recomendado no catálogo por sector explícito e nas fichas de oportunidades. Sem associação territorial inferida. Botão secundário apenas com oportunidades públicas. QA: teste isolado 1/8, TypeScript/build e exemplos computador/móvel. Falta decisão sobre integração deste novo delta; a prévia visual de Turismo usa uma cópia por leitura do conteúdo público, sem importação. Recuperação documental num dossier com este acervo não demonstrada, pois o artigo não existe na base QA actual. Percurso e regras anteriores preservados; IA fora do lote.
## Integrated simulator review - QA 20/10/2026
AUD-SIM-INT01 through AUD-SIM-INT08 are recorded in the same matrix and manual. Initial diagnosis and targeted date/Portuguese corrections only; full review remains open. Initial effort 18-30 h, subject to discounting accepted evidence and completed work. No main integration, real-data modification or tax parameter activation.
Actualização QA de 20/10/2026: os mesmos oito itens foram reconciliados no manual e nas duas tabelas existentes. Data com mensagem junto ao campo; inventário dos parâmetros efectivos (zero regras fiscais QA); controlos matemáticos independentes; aviso de recálculo; quatro relatórios reais do exportador com 6/6/6/5 páginas; consulta fiscal com erro/nova tentativa e traduções comuns verificados. O formulário personalizado já não exige contexto fiscal que não envia ao cálculo. Restam continuidade autenticada entre dois dossiers/nova sessão e histórico, mudança de parâmetros, controlos delimitados de cash-flow/plano, impressão nativa Edge/comparação pela interface e mensagens dinâmicas/falhas dirigidas de gravação/exportação. Estimativa restante **6–10 h**, distinta das 18–30 h iniciais. Sem main, servidor remoto, importação ou activação fiscal. Exemplos PDF são renderizações do DOCX; não certificam impressão do Edge. Ver o saldo detalhado AUD-SIM-INT01–08 no mesmo manual.
Continuação delimitada: dois dossiers e nova entrada conferidos no navegador; histórico agora visível no percurso de pequeno negócio, hashes preservados; Abrir cálculo encaminha para a etapa financeira; repetição de gravação após 503 corrigida e conferida; controlos independentes de tesouraria/plano passaram. CSV e Word descarregados pela interface coincidem nos valores comuns e apresentam a receita completa do crédito. Seis páginas do DOCX conferidas; PDF nativo ainda aguarda gravação pelo utilizador. QA tem zero regras com visibilidade completa, mas existem três originais fiscais versionados e análises/referências reutilizáveis; não se concluiu ausência na homologação sem autenticação SSH. Versões sintéticas testadas apenas em memória, sem cadastro/activação. Encontrada e corrigida perda dos pressupostos da tesouraria: snapshot no JSON existente, recuperação por dossier e relatório, sem migration; testes HTTP/frontend passaram, falta conferência visual deste novo delta. Saldo **2–4 h** após acessos/ficheiro, detalhado no mesmo manual; integração do motor fiscal permanece separada e não foi implementada. Sem main ou intervenção remota.
File diff suppressed because one or more lines are too long
+6
View File
@@ -0,0 +1,6 @@
{
"The provided two factor authentication code was invalid.": "O código de autenticação está incorrecto ou expirou. Introduza um novo código da sua aplicação autenticadora.",
"The provided two factor recovery code was invalid.": "O código de recuperação está incorrecto ou já foi utilizado.",
"The provided password is incorrect.": "A palavra-passe está incorrecta.",
"The provided password does not match your current password.": "A palavra-passe indicada não corresponde à palavra-passe actual."
}
+6
View File
@@ -0,0 +1,6 @@
<?php
return [
'failed' => 'O email ou a palavra-passe não correspondem aos nossos registos.',
'password' => 'A palavra-passe está incorrecta.',
'throttle' => 'Foram feitas demasiadas tentativas. Tente novamente dentro de :seconds segundos.',
];
+29
View File
@@ -0,0 +1,29 @@
<?php
return [
'required' => 'O campo :attribute é obrigatório.',
'required_if' => 'O campo :attribute é obrigatório quando :other tem o valor :value.',
'present' => 'O campo :attribute deve estar presente.',
'filled' => 'Preencha o campo :attribute.',
'numeric' => 'O campo :attribute deve conter um número.',
'integer' => 'O campo :attribute deve conter um número inteiro.',
'decimal' => 'O campo :attribute deve ter :decimal casas decimais.',
'string' => 'O campo :attribute deve conter texto.',
'array' => 'O campo :attribute deve conter uma lista.',
'boolean' => 'O campo :attribute deve indicar sim ou não.',
'email' => 'Introduza um endereço de correio electrónico válido em :attribute.',
'uuid' => 'A referência indicada em :attribute não é válida.',
'in' => 'Seleccione uma opção válida em :attribute.',
'exists' => 'A referência seleccionada em :attribute não foi encontrada.',
'unique' => 'O valor indicado em :attribute já está registado.',
'confirmed' => 'A confirmação de :attribute não corresponde ao valor introduzido.',
'current_password' => 'A palavra-passe actual está incorrecta.',
'date' => 'Introduza uma data válida em :attribute.',
'date_format' => 'Introduza uma data válida para :attribute.',
'before_or_equal' => 'A data em :attribute deve ser anterior ou igual a :date.',
'after_or_equal' => 'A data em :attribute deve ser posterior ou igual a :date.',
'accepted' => 'Confirme o campo :attribute para continuar.',
'between' => ['numeric' => 'O valor de :attribute deve estar entre :min e :max.', 'string' => 'O campo :attribute deve ter entre :min e :max caracteres.', 'array' => 'O campo :attribute deve ter entre :min e :max elementos.', 'file' => 'O ficheiro :attribute deve ter entre :min e :max KB.'],
'min' => ['numeric' => 'O valor de :attribute deve ser igual ou superior a :min.', 'string' => 'O campo :attribute deve ter pelo menos :min caracteres.', 'array' => 'O campo :attribute deve ter pelo menos :min elementos.', 'file' => 'O ficheiro :attribute deve ter pelo menos :min KB.'],
'max' => ['numeric' => 'O valor de :attribute não pode exceder :max.', 'string' => 'O campo :attribute não pode exceder :max caracteres.', 'array' => 'O campo :attribute não pode exceder :max elementos.', 'file' => 'O ficheiro :attribute não pode exceder :max KB.'],
'attributes' => ['base' => 'base de cálculo', 'rate' => 'taxa', 'reduction' => 'redução', 'email' => 'email', 'password' => 'palavra-passe', 'current_password' => 'palavra-passe actual', 'economic_snapshot.fiscal_reference_date' => 'data da operação fiscal'],
];
+3 -3
View File
@@ -12,9 +12,9 @@ export default function AppearanceToggleTab({
const { appearance, updateAppearance } = useAppearance();
const tabs: { value: Appearance; icon: LucideIcon; label: string }[] = [
{ value: 'light', icon: Sun, label: 'Light' },
{ value: 'dark', icon: Moon, label: 'Dark' },
{ value: 'system', icon: Monitor, label: 'System' },
{ value: 'light', icon: Sun, label: 'Claro' },
{ value: 'dark', icon: Moon, label: 'Escuro' },
{ value: 'system', icon: Monitor, label: 'Sistema' },
];
return (
+20 -13
View File
@@ -23,14 +23,16 @@ export default function DeleteUser() {
<div className="space-y-6">
<Heading
variant="small"
title="Delete account"
description="Delete your account and all of its resources"
title="Eliminar conta"
description="Eliminar a sua conta e os recursos associados"
/>
<div className="space-y-4 rounded-lg border border-red-100 bg-red-50 p-4 dark:border-red-200/10 dark:bg-red-700/10">
<div className="relative space-y-0.5 text-red-600 dark:text-red-100">
<p className="font-medium">Warning</p>
<p className="text-sm">
Please proceed with caution, this cannot be undone.
{' '}
Esta operação é irreversível. Confirme os dados antes de
continuar.{' '}
</p>
</div>
@@ -40,18 +42,20 @@ export default function DeleteUser() {
variant="destructive"
data-test="delete-user-button"
>
Delete account
{' '}
Eliminar conta{' '}
</Button>
</DialogTrigger>
<DialogContent>
<DialogTitle>
Are you sure you want to delete your account?
{' '}
Pretende eliminar a sua conta?{' '}
</DialogTitle>
<DialogDescription>
Once your account is deleted, all of its resources
and data will also be permanently deleted. Please
enter your password to confirm you would like to
permanently delete your account.
{' '}
Ao eliminar a conta, os recursos e dados associados
também serão eliminados permanentemente. Introduza a
sua palavra-passe para confirmar.{' '}
</DialogDescription>
<Form
@@ -70,14 +74,15 @@ export default function DeleteUser() {
htmlFor="password"
className="sr-only"
>
Password
{' '}
Palavra-passe{' '}
</Label>
<PasswordInput
id="password"
name="password"
ref={passwordInput}
placeholder="Password"
placeholder="Palavra-passe"
autoComplete="current-password"
/>
@@ -92,7 +97,8 @@ export default function DeleteUser() {
resetAndClearErrors()
}
>
Cancel
{' '}
Cancelar{' '}
</Button>
</DialogClose>
@@ -105,7 +111,8 @@ export default function DeleteUser() {
type="submit"
data-test="confirm-delete-user-button"
>
Delete account
{' '}
Eliminar conta{' '}
</button>
</Button>
</DialogFooter>
@@ -0,0 +1,73 @@
import { Link } from '@inertiajs/react';
export type EducationalArticle = {
title: string;
href: string;
summary: string;
author?: string;
image: { href: string; alt: string } | null;
opportunities_href?: string | null;
opportunities_label?: string | null;
has_opportunities?: boolean;
};
export default function EducationalArticleCard({
article,
}: {
article: EducationalArticle;
}) {
const focus =
'focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-emerald-800';
return (
<article className="flex min-w-0 flex-col overflow-hidden rounded-2xl border border-slate-200 bg-white text-slate-900 shadow-sm">
{article.image && (
<img
className="aspect-[16/9] w-full object-cover"
src={article.image.href}
alt={article.image.alt}
loading="lazy"
width={800}
height={450}
/>
)}
<div className="flex flex-1 flex-col items-start gap-4 p-5 sm:p-6">
<p className="text-sm font-medium text-emerald-800">
Guia sectorial
</p>
<h3 className="text-2xl leading-tight font-bold">
<Link href={article.href} className={focus}>
{article.title}
</Link>
</h3>
{article.summary && (
<p className="text-base leading-relaxed text-slate-700">
{article.summary}
</p>
)}
{article.author && (
<p className="text-sm text-slate-600">
Por {article.author}
</p>
)}
<div className="mt-auto flex flex-wrap items-center gap-x-5 gap-y-3 pt-2">
<Link
href={article.href}
className={`inline-flex min-h-11 items-center rounded-lg bg-emerald-900 px-5 py-3 font-semibold text-white hover:bg-emerald-800 ${focus}`}
>
Ler o guia
</Link>
{article.has_opportunities &&
article.opportunities_href && (
<Link
href={article.opportunities_href}
className={`inline-flex min-h-11 items-center py-2 font-semibold text-emerald-900 underline underline-offset-4 ${focus}`}
>
{article.opportunities_label ??
'Ver oportunidades'}
</Link>
)}
</div>
</div>
</article>
);
}
@@ -0,0 +1,27 @@
import EducationalArticleCard, {
type EducationalArticle,
} from './educational-article-card';
export default function EducationalGuides({
articles,
}: {
articles: EducationalArticle[];
}) {
if (!articles.length) return null;
return (
<section
aria-label="Guias para preparar o investimento"
className="space-y-5"
>
<h2 className="text-2xl font-bold">Descubra onde investir</h2>
<div className="grid gap-6 md:grid-cols-2">
{articles.map((article) => (
<EducationalArticleCard
key={article.href}
article={article}
/>
))}
</div>
</section>
);
}
@@ -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>
);
}
+5 -4
View File
@@ -17,9 +17,10 @@ const EmptyState = () => {
<div className="bg-muted mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-2xl">
<KeyRound className="text-muted-foreground h-7 w-7" />
</div>
<p className="font-medium">No passkeys yet</p>
<p className="font-medium"> Ainda não existem chaves de acesso </p>
<p className="text-muted-foreground mt-1 text-sm">
Add a passkey to sign in without a password
{' '}
Adicione uma chave de acesso para entrar sem palavra-passe{' '}
</p>
</div>
);
@@ -47,8 +48,8 @@ export default function ManagePasskeys(props: Props) {
<div className="space-y-6">
<Heading
variant="small"
title="Passkeys"
description="Manage your passkeys for passwordless sign-in"
title="Chaves de acesso"
description="Gerir as chaves de acesso para entrar sem palavra-passe"
/>
<div className="border-border overflow-hidden rounded-lg border">
+16 -13
View File
@@ -48,15 +48,15 @@ export default function ManageTwoFactor(props: Props) {
<div className="space-y-6">
<Heading
variant="small"
title="Two-factor authentication"
description="Manage your two-factor authentication settings"
title="Autenticação de dois factores"
description="Gerir a autenticação de dois factores"
/>
{twoFactorEnabled ? (
<div className="flex flex-col items-start justify-start space-y-4">
<p className="text-muted-foreground text-sm">
You will be prompted for a secure, random pin during
login, which you can retrieve from the TOTP-supported
application on your phone.
{' '}
Ao entrar, será solicitado o código temporário da
aplicação autenticadora do seu telefone.{' '}
</p>
<div className="relative inline">
@@ -67,7 +67,9 @@ export default function ManageTwoFactor(props: Props) {
type="submit"
disabled={processing}
>
Disable 2FA
{' '}
Desactivar autenticação de dois
factores{' '}
</Button>
)}
</Form>
@@ -82,17 +84,16 @@ export default function ManageTwoFactor(props: Props) {
) : (
<div className="flex flex-col items-start justify-start space-y-4">
<p className="text-muted-foreground text-sm">
When you enable two-factor authentication, you will be
prompted for a secure pin during login. This pin can be
retrieved from a TOTP-supported application on your
phone.
{' '}
Depois de activar a autenticação de dois factores, use o
código temporário da aplicação autenticadora para
entrar.{' '}
</p>
<div>
{hasSetupData ? (
<Button onClick={() => setShowSetupModal(true)}>
<ShieldCheck />
Continue setup
<ShieldCheck /> Continuar configuração{' '}
</Button>
) : (
<Form
@@ -101,7 +102,9 @@ export default function ManageTwoFactor(props: Props) {
>
{({ processing }) => (
<Button type="submit" disabled={processing}>
Enable 2FA
{' '}
Activar autenticação de dois
factores{' '}
</Button>
)}
</Form>
+9 -9
View File
@@ -43,13 +43,14 @@ export default function PasskeyItem({ passkey, onDelete }: Props) {
)}
</div>
<p className="text-muted-foreground text-sm">
Added {passkey.created_at_diff}
{' '}
Adicionada em {passkey.created_at_diff}
{passkey.last_used_at_diff && (
<>
<span className="text-muted-foreground/50 mx-1">
/
</span>
Last used {passkey.last_used_at_diff}
</span>{' '}
Última utilização {passkey.last_used_at_diff}
</>
)}
</p>
@@ -64,19 +65,18 @@ export default function PasskeyItem({ passkey, onDelete }: Props) {
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
>
<Trash2 className="h-4 w-4" />
<span className="sr-only">Remove</span>
<span className="sr-only"> Remover </span>
</Button>
</DialogTrigger>
<DialogContent>
<DialogTitle>Remove passkey</DialogTitle>
<DialogTitle>Remover chave de acesso</DialogTitle>
<DialogDescription>
Are you sure you want to remove the "{passkey.name}"
passkey? You will no longer be able to use it to sign
in.
Pretende remover a chave de acesso «{passkey.name}»?
Deixará de poder usá-la para entrar.
</DialogDescription>
<DialogFooter className="gap-2">
<DialogClose asChild>
<Button variant="secondary">Cancel</Button>
<Button variant="secondary"> Cancelar </Button>
</DialogClose>
<Button
variant="destructive"
+11 -7
View File
@@ -59,7 +59,8 @@ export default function PasskeyRegistration({ onSuccess }: Props) {
if (!isSupported) {
return (
<div className="text-muted-foreground text-sm">
Passkeys are not supported in this browser.
{' '}
Este navegador não suporta chaves de acesso.{' '}
</div>
);
}
@@ -67,7 +68,8 @@ export default function PasskeyRegistration({ onSuccess }: Props) {
if (!showForm) {
return (
<Button variant="outline" onClick={() => setShowForm(true)}>
Add passkey
{' '}
Adicionar chave de acesso{' '}
</Button>
);
}
@@ -78,18 +80,19 @@ export default function PasskeyRegistration({ onSuccess }: Props) {
className="border-border bg-muted/50 space-y-4 rounded-lg border p-4"
>
<div className="grid gap-2">
<Label htmlFor="passkey-name">Passkey name</Label>
<Label htmlFor="passkey-name"> Nome da chave de acesso </Label>
<Input
id="passkey-name"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g., MacBook Pro, iPhone"
placeholder="Por exemplo, MacBook Pro ou iPhone"
className="border-foreground/20 mt-1 block w-full"
autoFocus
/>
<p className="text-muted-foreground text-xs">
A name helps you identify this passkey later.
{' '}
O nome permite identificar esta chave mais tarde.{' '}
</p>
</div>
@@ -97,10 +100,11 @@ export default function PasskeyRegistration({ onSuccess }: Props) {
<div className="flex gap-2">
<Button type="submit" disabled={isLoading || !name.trim()}>
{isLoading ? 'Registering...' : 'Register passkey'}
{isLoading ? 'Registering...' : 'Registar chave de acesso'}
</Button>
<Button type="button" variant="ghost" onClick={handleCancel}>
Cancel
{' '}
Cancelar{' '}
</Button>
</div>
</form>
+3 -3
View File
@@ -51,8 +51,8 @@ export default function PasskeyVerify({
>
{isLoading ? <Spinner /> : <KeyRound className="h-4 w-4" />}
{isLoading
? (loadingLabel ?? 'Authenticating...')
: (label ?? 'Sign in with a passkey')}
? (loadingLabel ?? 'A autenticar…')
: (label ?? 'Entrar com uma chave de acesso')}
</Button>
{error && (
<InputError message={error} className="text-center" />
@@ -65,7 +65,7 @@ export default function PasskeyVerify({
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background text-muted-foreground px-2">
{separator ?? 'Or continue with email'}
{separator ?? 'Ou continuar com email'}
</span>
</div>
</div>
+133 -100
View File
@@ -1,11 +1,15 @@
import { useMemo, useState } from 'react';
import { useMemo, useRef, useState } from 'react';
import {
DossierExportGate,
dossierExportFailure,
prepareDossierAttachments,
} from '@/lib/dossier-export-state';
import {
createProjectDossierPrintHtml,
createProjectDossierReport,
dossierReportSections,
generateProjectDossierDocx,
generateProjectDossierPackage,
validateDossierAttachments,
type DossierAttachment,
type DossierReference,
type DossierCurrentCalculation,
@@ -57,6 +61,8 @@ export function ProjectDossierExport({
const [attachments, setAttachments] = useState<DossierAttachment[]>([]);
const [error, setError] = useState('');
const [busy, setBusy] = useState('');
const [notice, setNotice] = useState('');
const exportGate = useRef(new DossierExportGate());
const selected = useMemo(
() => attachments.filter((item) => item.include),
[attachments],
@@ -66,20 +72,33 @@ export function ProjectDossierExport({
[selectedSections],
);
function addFiles(files: FileList | null): void {
async function addFiles(files: FileList | null): Promise<void> {
if (!files?.length) return;
const incoming = Array.from(files);
const combined = [...attachments.map((item) => item.file), ...incoming];
const problem = validateDossierAttachments(combined);
if (problem) {
setError(problem);
return;
}
setAttachments((current) => [
...current,
...incoming.map((file) => ({ file, include: true, caption: '' })),
]);
setError('');
await exportGate.current.run(async () => {
setBusy('attachments');
setNotice('');
try {
const result = await prepareDossierAttachments(
attachments,
incoming,
);
if (result.problem) {
setError(result.problem);
return;
}
setAttachments(result.attachments);
setError('');
if (result.duplicateCount)
setNotice(
'Os ficheiros que já estavam na lista foram mantidos uma única vez.',
);
} catch (cause) {
setError(dossierExportFailure('attachments', cause));
} finally {
setBusy('');
}
});
}
function toggleSection(id: DossierReportSectionId, checked: boolean): void {
@@ -118,99 +137,103 @@ export function ProjectDossierExport({
}
async function exportWord(): Promise<void> {
setBusy('word');
setError('');
try {
const report = model();
const blob = await generateProjectDossierDocx(
report,
selected,
selectedSections.has('annexes'),
);
download(
blob,
`relatorio-investmoz-${filenameStem(report.title)}.docx`,
);
} catch (cause) {
setError(
cause instanceof Error
? cause.message
: 'Não foi possível gerar o relatório Word.',
);
} finally {
setBusy('');
}
await exportGate.current.run(async () => {
setBusy('word');
setError('');
try {
const report = model();
const blob = await generateProjectDossierDocx(
report,
selected,
selectedSections.has('annexes'),
);
download(
blob,
`relatorio-investmoz-${filenameStem(report.title)}.docx`,
);
} catch (cause) {
setError(dossierExportFailure('word', cause));
} finally {
setBusy('');
}
});
}
async function exportPackage(): Promise<void> {
setBusy('package');
setError('');
try {
const report = model();
const docx = await generateProjectDossierDocx(
report,
selected,
selectedSections.has('annexes'),
);
const archive = await generateProjectDossierPackage(
docx,
selected,
selectedSections.has('annexes'),
);
download(
archive,
`relatorio-investmoz-${filenameStem(report.title)}-anexos.zip`,
);
} catch (cause) {
setError(
cause instanceof Error
? cause.message
: 'Não foi possível preparar o pacote de anexos.',
);
} finally {
setBusy('');
}
await exportGate.current.run(async () => {
setBusy('package');
setError('');
try {
const report = model();
const docx = await generateProjectDossierDocx(
report,
selected,
selectedSections.has('annexes'),
);
const archive = await generateProjectDossierPackage(
docx,
selected,
selectedSections.has('annexes'),
);
download(
archive,
`relatorio-investmoz-${filenameStem(report.title)}-anexos.zip`,
);
} catch (cause) {
setError(dossierExportFailure('package', cause));
} finally {
setBusy('');
}
});
}
async function exportPdf(): Promise<void> {
const printWindow = window.open('about:blank', '_blank');
if (!printWindow) {
setError(
'O browser bloqueou a janela de impressão. Permita pop-ups para abrir a versão PDF.',
);
return;
}
setBusy('pdf');
setError('');
try {
const html = await createProjectDossierPrintHtml(
model(),
selected,
selectedSections.has('annexes'),
);
printWindow.document.open();
printWindow.document.write(html);
printWindow.document.close();
} catch (cause) {
printWindow.close();
setError(
cause instanceof Error
? cause.message
: 'Não foi possível preparar a versão para PDF.',
);
} finally {
setBusy('');
}
await exportGate.current.run(async () => {
const printWindow = window.open('about:blank', '_blank');
if (!printWindow) {
setError(
'O browser bloqueou a janela de impressão. Permita pop-ups para abrir a versão PDF.',
);
return;
}
setBusy('pdf');
setError('');
try {
const html = await createProjectDossierPrintHtml(
model(),
selected,
selectedSections.has('annexes'),
);
printWindow.document.open();
printWindow.document.write(html);
printWindow.document.close();
} catch (cause) {
printWindow.close();
setError(dossierExportFailure('pdf', cause));
} finally {
setBusy('');
}
});
}
function exportCsv(): void {
const blob = new Blob([exportProjectDossierCsv(draft)], {
type: 'text/csv;charset=utf-8',
async function exportCsv(): Promise<void> {
await exportGate.current.run(async () => {
setBusy('csv');
setError('');
try {
const blob = new Blob([exportProjectDossierCsv(draft)], {
type: 'text/csv;charset=utf-8',
});
download(
blob,
`dossier-projecto-investmoz-${filenameStem(String(draft.project_title ?? ''))}.csv`,
);
} catch (cause) {
setError(dossierExportFailure('csv', cause));
} finally {
setBusy('');
}
});
download(
blob,
`dossier-projecto-investmoz-${filenameStem(String(draft.project_title ?? ''))}.csv`,
);
}
const totalBytes = attachments.reduce(
@@ -261,6 +284,7 @@ export function ProjectDossierExport({
>
<input
type="checkbox"
disabled={Boolean(busy)}
checked={selectedSections.has(section.id)}
onChange={(event) =>
toggleSection(
@@ -298,9 +322,10 @@ export function ProjectDossierExport({
id="project-report-files"
type="file"
multiple
disabled={Boolean(busy)}
accept="image/jpeg,image/png,text/csv,text/plain,.csv,.tsv,.txt,.pdf,.doc,.docx,.xlsx,.odt"
onChange={(event) => {
addFiles(event.currentTarget.files);
void addFiles(event.currentTarget.files);
event.currentTarget.value = '';
}}
/>
@@ -322,6 +347,7 @@ export function ProjectDossierExport({
<input
aria-label={`Incluir ${item.file.name}`}
type="checkbox"
disabled={Boolean(busy)}
checked={item.include}
onChange={(event) =>
updateAttachment(index, {
@@ -340,6 +366,7 @@ export function ProjectDossierExport({
<button
type="button"
className="rounded border px-2 py-1 text-sm"
disabled={Boolean(busy)}
onClick={() => removeAttachment(index)}
>
Remover
@@ -352,6 +379,7 @@ export function ProjectDossierExport({
<input
className="rounded border px-3 py-2"
maxLength={300}
disabled={Boolean(busy)}
value={item.caption}
onChange={(event) =>
updateAttachment(index, {
@@ -372,6 +400,11 @@ export function ProjectDossierExport({
em zero. O relatório identifica pressupostos, estimativas e
referências sem as apresentar como resultados confirmados.
</p>
{notice && (
<p role="status" className="text-sm">
{notice}
</p>
)}
{error && (
<p role="alert" className="text-sm text-red-800">
{error}
@@ -414,7 +447,7 @@ export function ProjectDossierExport({
type="button"
className="rounded border px-4 py-2"
disabled={Boolean(busy)}
onClick={exportCsv}
onClick={() => void exportCsv()}
>
Exportar CSV complementar
</button>
+6 -25
View File
@@ -1,11 +1,9 @@
import { Link } from '@inertiajs/react';
import EducationalArticleCard, {
type EducationalArticle,
} from './educational-article-card';
export type SectorEntryData = {
articles: {
title: string;
href: string;
image: { href: string; alt: string } | null;
scope: string;
}[];
articles: EducationalArticle[];
relations: {
id: string;
title: string;
@@ -66,9 +64,7 @@ export default function SectorEntry({ data }: { data: SectorEntryData }) {
perfil e a modalidade já autorizados; se ainda não tem perfil,
pode preparar a ideia e pedir o perfil de promotor.
</p>
<h2 className="text-xl font-bold">
Conteúdo e possibilidades de negócio
</h2>
<h2 className="text-xl font-bold">Descubra onde investir</h2>
{data.content_notice && <p>{data.content_notice}</p>}
{!data.articles.length && (
<p>
@@ -78,22 +74,7 @@ export default function SectorEntry({ data }: { data: SectorEntryData }) {
)}
<div className="grid gap-4 md:grid-cols-2">
{data.articles.map((a) => (
<article
className="min-w-0 rounded border p-4"
key={a.href}
>
{a.image && (
<img
className="mb-3 h-36 w-full object-cover"
src={a.image.href}
alt={a.image.alt}
/>
)}
<Link className="font-semibold underline" href={a.href}>
{a.title}
</Link>
<p>{a.scope}</p>
</article>
<EducationalArticleCard key={a.href} article={a} />
))}
</div>
{data.specialization_href && (
@@ -1,7 +1,11 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import {
exportMonthlyCashflowCsv,
projectMonthlyCashflow,
defaultCashflowDraftInputs,
restoreCashflowDraftSnapshot,
serializeCashflowDraftSnapshot,
type CashflowDraftSnapshot,
} from '@/lib/monthly-cashflow';
import {
isSimulationCurrency,
@@ -11,28 +15,38 @@ import {
export function SimulationCashflowPlanner({
currency,
onCurrencyChange,
snapshot,
onSnapshotChange,
}: {
currency: string;
onCurrencyChange: (currency: string) => void;
snapshot?: string;
onSnapshotChange?: (snapshot: string) => void;
}) {
const [taxMode, setTaxMode] = useState<'fixed' | 'schedule'>('fixed');
const [inputs, setInputs] = useState({
openingCash: '0',
monthlyRevenue: '200000',
monthlyOperatingCosts: '150000',
monthlyDebtService: '0',
monthlyTaxAssessed: '',
monthlyTaxSchedule: Array.from({ length: 12 }, () => ''),
vatCreditEstimated: '0',
vatRefundRequested: '0',
vatRefundRecognized: '0',
vatRefundExpectedReceipt: '0',
vatRefundExpectedMonth: '',
});
const restored = restoreCashflowDraftSnapshot(snapshot);
const [taxMode, setTaxMode] = useState<'fixed' | 'schedule'>(
restored?.taxMode ?? 'fixed',
);
const [inputs, setInputs] = useState(
restored?.inputs ?? defaultCashflowDraftInputs(),
);
useEffect(() => {
const saved = restoreCashflowDraftSnapshot(snapshot);
setTaxMode(saved?.taxMode ?? 'fixed');
setInputs(saved?.inputs ?? defaultCashflowDraftInputs());
}, [snapshot]);
function update(
next: typeof inputs,
mode: CashflowDraftSnapshot['taxMode'] = taxMode,
) {
setInputs(next);
setTaxMode(mode);
onSnapshotChange?.(serializeCashflowDraftSnapshot(mode, next));
}
const set = (
key: Exclude<keyof typeof inputs, 'monthlyTaxSchedule'>,
value: string,
) => setInputs((current) => ({ ...current, [key]: value }));
) => update({ ...inputs, [key]: value });
const cashflowInput = {
...inputs,
...(taxMode === 'fixed'
@@ -45,6 +59,10 @@ export function SimulationCashflowPlanner({
let projection;
let error = '';
try {
if (snapshot && !restored)
throw new Error(
'Não foi possível recuperar os pressupostos guardados de tesouraria. Reveja os campos antes de voltar a guardar; nenhum resultado foi calculado com dados alternativos.',
);
projection = projectMonthlyCashflow({ ...cashflowInput, currency });
} catch (cause) {
error =
@@ -97,9 +115,11 @@ export function SimulationCashflowPlanner({
role="note"
className="rounded bg-amber-50 p-3 text-sm text-amber-950"
>
Os valores preenchidos ao abrir são exemplos demonstrativos.
Confirme ou altere os pressupostos antes de interpretar os
resultados ou exportar o CSV.
{restored
? 'Pressupostos recuperados do rascunho deste investimento; reveja-os antes de interpretar os resultados ou exportar.'
: snapshot
? 'O rascunho guardado não pôde ser interpretado. Os campos mostram exemplos, que não substituem os dados guardados até os editar.'
: 'Os valores preenchidos ao abrir são exemplos demonstrativos. Confirme ou altere os pressupostos antes de interpretar os resultados ou exportar o CSV.'}
</p>
{projection?.status === 'incomplete' && (
<p
@@ -121,7 +141,7 @@ export function SimulationCashflowPlanner({
type="radio"
name="cashflow-tax-mode"
checked={taxMode === 'fixed'}
onChange={() => setTaxMode('fixed')}
onChange={() => update(inputs, 'fixed')}
/>
Valor mensal fixo
</label>
@@ -130,7 +150,7 @@ export function SimulationCashflowPlanner({
type="radio"
name="cashflow-tax-mode"
checked={taxMode === 'schedule'}
onChange={() => setTaxMode('schedule')}
onChange={() => update(inputs, 'schedule')}
/>
Calendário variável por mês
</label>
@@ -162,16 +182,16 @@ export function SimulationCashflowPlanner({
step="0.01"
value={value}
onChange={(event) =>
setInputs((current) => ({
...current,
update({
...inputs,
monthlyTaxSchedule:
current.monthlyTaxSchedule.map(
inputs.monthlyTaxSchedule.map(
(amount, month) =>
month === index
? event.target.value
: amount,
),
}))
})
}
className="mt-1 w-full rounded border p-2"
/>
@@ -352,6 +352,12 @@ export function SimulationFinancialTools({
/>
{schedule && isSimulationCurrency(currency) && (
<>
<p className="text-sm">
Montantes a duas casas decimais; juros e
prestação arredondados à unidade monetária
mínima, metade para cima. A última prestação
ajusta o saldo residual.
</p>
<p aria-live="polite">
Taxa por período: {schedule.periodicRatePercent}
%; desembolso líquido: {
@@ -66,6 +66,7 @@ export function SimulationFiscalStatus({
}) {
const [parameters, setParameters] = useState<Parameter[]>([]);
const [requestState, setRequestState] = useState('idle');
const [retry, setRetry] = useState(0);
useEffect(() => {
const apiComponent = component
@@ -104,12 +105,14 @@ export function SimulationFiscalStatus({
const payload = (await response.json()) as {
parameters?: Parameter[];
};
if (abort.signal.aborted) return;
setParameters(
Array.isArray(payload.parameters) ? payload.parameters : [],
);
setRequestState('done');
})
.catch((error: unknown) => {
if (abort.signal.aborted) return;
if (
error instanceof DOMException &&
error.name === 'AbortError'
@@ -125,6 +128,7 @@ export function SimulationFiscalStatus({
periodStart,
periodEnd,
JSON.stringify(context ?? {}),
retry,
]);
const salaryViewMessages: Record<string, string> = {
@@ -160,6 +164,16 @@ export function SimulationFiscalStatus({
: `Período consultado: ${periodStart} a ${periodEnd}.`}
</p>
)}
{requestState === 'error' &&
scenarioMode === 'approved_parameters' && (
<button
type="button"
onClick={() => setRetry((value) => value + 1)}
className="rounded border border-amber-900 px-4 py-2 text-amber-950 underline focus-visible:outline-2 focus-visible:outline-offset-2"
>
Tentar novamente
</button>
)}
{scenarioMode === 'custom_planning' && (
<p role="note" className="text-sm">
Modo de planeamento separado: pressupostos do utilizador,
+105 -42
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,
@@ -834,8 +836,10 @@ export function SimulationForm() {
}, [openedTool, step]);
function openDraftTool(tool: 'calculation' | 'fiscal') {
setToolError('');
const index = journeySections.findIndex(
(section) => section.id === 'tax',
const index = journeySections.findIndex((section) =>
tool === 'calculation'
? ['financial', 'tax'].includes(section.id)
: section.id === 'tax',
);
if (index < 0) {
setToolError(
@@ -864,6 +868,10 @@ export function SimulationForm() {
const [isCalculating, setIsCalculating] = useState(false);
const [hasSaveConflict, setHasSaveConflict] = useState(false);
const [result, setResult] = useState<ApiSimulationExecutionV1 | null>(null);
const [calculationStale, setCalculationStale] = useState(false);
useEffect(() => {
if (result) setCalculationStale(false);
}, [result]);
const arithmeticResult = useMemo(
() => readArithmeticSnapshot(result?.result_snapshot),
[result],
@@ -880,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;
@@ -923,6 +932,7 @@ export function SimulationForm() {
const [businessLinkError, setBusinessLinkError] = useState('');
const [businessLinkBusy, setBusinessLinkBusy] = useState(false);
const [continueLaterSaved, setContinueLaterSaved] = useState(false);
const [saveAttempt, setSaveAttempt] = useState(0);
const calculationInFlightRef = useRef(false);
const draftIdRef = useRef<string | null>(null);
const draftEtagRef = useRef<string | null>(null);
@@ -1039,6 +1049,13 @@ export function SimulationForm() {
: (simulationFieldGroups[current.id] ?? []);
const fields = fieldsForCurrent.filter(
(field) =>
!(
current.id === 'tax' &&
draft.fiscal_scenario_mode === 'custom_planning' &&
!['fiscal_scenario_mode', 'base', 'rate', 'reduction'].includes(
field.key,
)
) &&
(!field.showWhen || field.showWhen(draft)) &&
!(
draft.purpose === 'simulate_only' &&
@@ -1311,6 +1328,7 @@ export function SimulationForm() {
setSaveStatus('A guardar o rascunho no servidor…');
saveQueueRef.current = saveQueueRef.current
.then(async () => {
if (fingerprint === lastSavedFingerprintRef.current) return;
const headers = {
Accept: 'application/json',
'Content-Type': 'application/json',
@@ -1465,6 +1483,7 @@ export function SimulationForm() {
draftCreateKey,
draftIdKey,
draftKey,
saveAttempt,
]);
async function returnToInvestment() {
@@ -1689,6 +1708,7 @@ export function SimulationForm() {
async function saveAndContinueLater() {
if (!draft.purpose) return;
setContinueLaterSaved(false);
setSaveAttempt((attempt) => attempt + 1);
await new Promise((resolve) => window.setTimeout(resolve, 550));
await saveQueueRef.current;
const target = canSavePersonalData ? draft : getAutosaveSnapshot(draft);
@@ -1943,7 +1963,8 @@ export function SimulationForm() {
return next;
});
if (key === 'purpose') setStep(0);
if (key !== 'purpose') setResult(null);
if (result) setCalculationStale(true);
setResult(null);
}
async function clearDraft() {
@@ -2139,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]}`)
@@ -2158,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,
@@ -2266,7 +2287,7 @@ export function SimulationForm() {
}
}
if (
JSON.stringify({
publishedInput || JSON.stringify({
base: draft.base,
rate: draft.rate,
reduction: draft.reduction,
@@ -2274,7 +2295,19 @@ export function SimulationForm() {
)
setResult(simulation ?? null);
if (canSavePersonalData) void loadSimulationHistory();
sessionStorage.removeItem(idempotencyStorageKey);
if (simulation?.status === 'completed') {
setSaveStatus(
'Cálculo concluído. Reveja o resultado e os pressupostos.',
);
} else if (
simulation?.status === 'failed' ||
simulation?.status === 'cancelled'
) {
setSaveStatus(
'O cálculo não foi concluído. Os dados introduzidos foram preservados.',
);
}
if (!publishedInput) sessionStorage.removeItem(idempotencyStorageKey);
} catch (cause) {
setServerError(
cause instanceof Error
@@ -2396,6 +2429,16 @@ export function SimulationForm() {
aria-labelledby="simulator-step-title"
aria-busy={!ready}
>
{calculationStale && (
<p
role="status"
className="rounded border border-amber-700 bg-amber-50 p-3 text-amber-950"
>
As entradas foram alteradas. Calcule novamente antes de
guardar ou exportar um novo resultado. Os resultados
anteriores mantêm-se no histórico.
</p>
)}
{!ready && (
<p role="status">
A recuperar o rascunho. Aguarde para editar.
@@ -3415,6 +3458,10 @@ export function SimulationForm() {
(draft.purpose === 'simulate_only' &&
current.id === 'tax')) && (
<SimulationCashflowPlanner
snapshot={String(draft.cashflow_snapshot ?? '')}
onSnapshotChange={(value) =>
change('cashflow_snapshot', value)
}
currency={String(draft.currency ?? '')}
onCurrencyChange={(value) => change('currency', value)}
/>
@@ -3554,6 +3601,7 @@ export function SimulationForm() {
}
/>
)}
{current.id === 'tax' && <SimulationPublishedIva busy={isCalculating} onCalculate={runSimulation} />}
{current.id === 'tax' && (
<div
id="draft-tool-fiscal"
@@ -3651,43 +3699,56 @@ 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.
</p>
)}
{current.id === 'tax' && (
{(current.id === 'tax' || result) && (
<div className="space-y-3 border-t pt-4">
<h4 className="font-semibold">
Cenário personalizado de planeamento
{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>
{draft.fiscal_scenario_mode === 'custom_planning' && (
<p
role="note"
className="rounded bg-slate-50 p-3 text-sm"
>
Os valores abaixo são pressupostos que
introduziu para planeamento. O resultado é uma
operação percentual genérica, não uma taxa
legal, imposto devido nem confirmação de
elegibilidade.
{current.id !== 'tax' && result && (
<p>
Este resultado conserva os pressupostos e a
versão da execução seleccionada. Não substitui
as entradas deste dossier.
</p>
)}
{draft.fiscal_scenario_mode === 'custom_planning' && (
<button
type="button"
disabled={isCalculating}
aria-busy={isCalculating}
className="rounded-lg bg-emerald-800 px-4 py-3 font-semibold text-white focus-visible:outline-2 disabled:cursor-wait disabled:opacity-60"
onClick={() => void runSimulation()}
>
{isCalculating
? 'A calcular…'
: 'Validar e calcular no servidor'}
</button>
)}
{current.id === 'tax' &&
draft.fiscal_scenario_mode ===
'custom_planning' && (
<p
role="note"
className="rounded bg-slate-50 p-3 text-sm"
>
Os valores abaixo são pressupostos que
introduziu para planeamento. O resultado é
uma operação percentual genérica, não uma
taxa legal, imposto devido nem confirmação
de elegibilidade.
</p>
)}
{current.id === 'tax' &&
draft.fiscal_scenario_mode ===
'custom_planning' && (
<button
type="button"
disabled={isCalculating}
aria-busy={isCalculating}
className="rounded-lg bg-emerald-800 px-4 py-3 font-semibold text-white focus-visible:outline-2 disabled:cursor-wait disabled:opacity-60"
onClick={() => void runSimulation()}
>
{isCalculating
? 'A calcular…'
: 'Validar e calcular no servidor'}
</button>
)}
{serverError && (
<p role="alert" className="text-red-800">
{serverError}
@@ -3712,7 +3773,7 @@ export function SimulationForm() {
submetido.
</p>
)}
{result.status === 'completed' && (
{result.status === 'completed' && !isFiscalQaPreview && (
<p>
Resultado aritmético indicativo:{' '}
<strong className="break-all">
@@ -3749,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">
@@ -4122,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 ===
@@ -4362,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>;
}
@@ -56,12 +56,14 @@ export default function TwoFactorRecoveryCodes({
<Card>
<CardHeader>
<CardTitle className="flex gap-3">
<LockKeyhole className="size-4" aria-hidden="true" />
2FA recovery codes
<LockKeyhole className="size-4" aria-hidden="true" />{' '}
Códigos de recuperação{' '}
</CardTitle>
<CardDescription>
Recovery codes let you regain access if you lose your 2FA
device. Store them in a secure password manager.
{' '}
Os códigos de recuperação permitem recuperar o acesso se
perder o dispositivo autenticador. Guarde-os num gestor de
palavras-passe seguro.{' '}
</CardDescription>
</CardHeader>
<CardContent>
@@ -76,7 +78,8 @@ export default function TwoFactorRecoveryCodes({
className="size-4"
aria-hidden="true"
/>
{codesAreVisible ? 'Hide' : 'View'} recovery codes
{codesAreVisible ? 'Ocultar' : 'Ver'} códigos de
recuperação{' '}
</Button>
{canRegenerateCodes && (
@@ -92,7 +95,7 @@ export default function TwoFactorRecoveryCodes({
disabled={processing}
aria-describedby="regenerate-warning"
>
<RefreshCw /> Regenerate codes
<RefreshCw /> Gerar novos códigos{' '}
</Button>
)}
</Form>
@@ -112,7 +115,7 @@ export default function TwoFactorRecoveryCodes({
ref={codesSectionRef}
className="bg-muted grid gap-1 rounded-lg p-4 font-mono text-sm"
role="list"
aria-label="Recovery codes"
aria-label="Códigos de recuperação"
>
{recoveryCodesList.length ? (
recoveryCodesList.map((code, index) => (
@@ -127,7 +130,7 @@ export default function TwoFactorRecoveryCodes({
) : (
<div
className="space-y-2"
aria-label="Loading recovery codes"
aria-label="A carregar os códigos de recuperação"
>
{Array.from(
{ length: 8 },
@@ -145,13 +148,15 @@ export default function TwoFactorRecoveryCodes({
<div className="text-muted-foreground text-xs select-none">
<p id="regenerate-warning">
Each recovery code can be used once to
access your account and will be removed
after use. If you need more, click{' '}
{' '}
Cada código permite uma única entrada e
deixa de ser válido após a utilização.
Para obter novos códigos, seleccione{' '}
<span className="font-bold">
Regenerate codes
{' '}
Gerar novos códigos{' '}
</span>{' '}
above.
acima.{' '}
</p>
</div>
</>
@@ -104,7 +104,8 @@ function TwoFactorSetupStep({
<div className="relative flex w-full items-center justify-center">
<div className="bg-border absolute inset-0 top-1/2 h-px w-full" />
<span className="bg-card relative px-2 py-1">
or, enter the code manually
{' '}
ou introduza a chave manualmente{' '}
</span>
</div>
@@ -210,7 +211,8 @@ function TwoFactorVerificationStep({
onClick={onBack}
disabled={processing}
>
Back
{' '}
Voltar{' '}
</Button>
<Button
type="submit"
@@ -219,7 +221,8 @@ function TwoFactorVerificationStep({
processing || code.length < OTP_MAX_LENGTH
}
>
Confirm
{' '}
Confirmar{' '}
</Button>
</div>
</div>
@@ -262,27 +265,27 @@ export default function TwoFactorSetupModal({
}>(() => {
if (twoFactorEnabled) {
return {
title: 'Two-factor authentication enabled',
title: 'Autenticação de dois factores activada',
description:
'Two-factor authentication is now enabled. Scan the QR code or enter the setup key in your authenticator app.',
buttonText: 'Close',
'A autenticação de dois factores está activada. Leia o código QR ou introduza a chave na aplicação autenticadora.',
buttonText: 'Fechar',
};
}
if (showVerificationStep) {
return {
title: 'Verify authentication code',
title: 'Confirmar código de autenticação',
description:
'Enter the 6-digit code from your authenticator app',
buttonText: 'Continue',
'Introduza o código de seis dígitos da aplicação autenticadora',
buttonText: 'Continuar',
};
}
return {
title: 'Enable two-factor authentication',
title: 'Activar autenticação de dois factores',
description:
'To finish enabling two-factor authentication, scan the QR code or enter the setup key in your authenticator app',
buttonText: 'Continue',
'Para concluir a configuração, leia o código QR ou introduza a chave na aplicação autenticadora.',
buttonText: 'Continuar',
};
}, [twoFactorEnabled, showVerificationStep]);
+1 -1
View File
@@ -63,7 +63,7 @@ function DialogContent({
{children}
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
<XIcon />
<span className="sr-only">Close</span>
<span className="sr-only"> Fechar </span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
+1 -1
View File
@@ -72,7 +72,7 @@ function SheetContent({
{children}
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
<span className="sr-only"> Fechar </span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
+1 -1
View File
@@ -6,7 +6,7 @@ function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
return (
<Loader2Icon
role="status"
aria-label="Loading"
aria-label="A carregar"
className={cn("size-4 animate-spin", className)}
{...props}
/>
@@ -40,8 +40,7 @@ export function UserMenuContent({ user }: Props) {
prefetch
onClick={cleanup}
>
<Settings className="mr-2" />
Settings
<Settings className="mr-2" /> Definições{' '}
</Link>
</DropdownMenuItem>
</DropdownMenuGroup>
+12 -3
View File
@@ -55,7 +55,10 @@ export const useTwoFactorAuth = (): UseTwoFactorAuthReturn => {
setQrCodeSvg(svg);
} catch {
setErrors((prev) => [...prev, 'Failed to fetch QR code']);
setErrors((prev) => [
...prev,
'Não foi possível carregar o código QR.',
]);
setQrCodeSvg(null);
}
}, [submit]);
@@ -68,7 +71,10 @@ export const useTwoFactorAuth = (): UseTwoFactorAuthReturn => {
setManualSetupKey(key);
} catch {
setErrors((prev) => [...prev, 'Failed to fetch a setup key']);
setErrors((prev) => [
...prev,
'Não foi possível carregar a chave de configuração.',
]);
setManualSetupKey(null);
}
}, [submit]);
@@ -79,7 +85,10 @@ export const useTwoFactorAuth = (): UseTwoFactorAuthReturn => {
const codes = (await submit(recoveryCodes())) as string[];
setRecoveryCodesList(codes);
} catch {
setErrors((prev) => [...prev, 'Failed to fetch recovery codes']);
setErrors((prev) => [
...prev,
'Não foi possível carregar os códigos de recuperação.',
]);
setRecoveryCodesList([]);
}
}, [submit]);
+6 -6
View File
@@ -12,17 +12,17 @@ import type { NavItem } from '@/types';
const sidebarNavItems: NavItem[] = [
{
title: 'Profile',
title: 'Perfil',
href: edit(),
icon: null,
},
{
title: 'Security',
title: 'Segurança',
href: editSecurity(),
icon: null,
},
{
title: 'Appearance',
title: 'Aparência',
href: editAppearance(),
icon: null,
},
@@ -34,15 +34,15 @@ export default function SettingsLayout({ children }: PropsWithChildren) {
return (
<div className="px-4 py-6">
<Heading
title="Settings"
description="Manage your profile and account settings"
title="Definições"
description="Gerir o perfil e as definições da conta"
/>
<div className="flex flex-col lg:flex-row lg:space-x-12">
<aside className="w-full max-w-xl lg:w-48">
<nav
className="flex flex-col space-y-1 space-x-0"
aria-label="Settings"
aria-label="Definições"
>
{sidebarNavItems.map((item, index) => (
<Button
+89
View File
@@ -0,0 +1,89 @@
import {
validateDossierAttachments,
type DossierAttachment,
} from './project-dossier-report.ts';
export type DossierExportOperation =
| 'word'
| 'package'
| 'pdf'
| 'csv'
| 'attachments';
const messages: Record<DossierExportOperation, string> = {
word: 'Não foi possível gerar o relatório Word.',
package: 'Não foi possível preparar o pacote de anexos.',
pdf: 'Não foi possível preparar a versão para PDF.',
csv: 'Não foi possível preparar o ficheiro CSV.',
attachments: 'Não foi possível ler os anexos seleccionados.',
};
let failureSequence = 0;
export function dossierExportFailure(
operation: DossierExportOperation,
cause: unknown,
log: (message: string, detail: unknown) => void = console.error,
): string {
const reference = `EXP-${Date.now().toString(36)}-${++failureSequence}`;
// Local diagnostics only: never send dossier values or file contents.
log('[InvesteMoz exportação]', { reference, operation, cause });
return `${messages[operation]} Os dados, as secções e os anexos foram mantidos. Tente novamente pelo mesmo botão. Referência: ${reference}.`;
}
/** Synchronous acquisition prevents a second click before React renders busy. */
export class DossierExportGate {
private active = false;
async run(task: () => Promise<void>): Promise<boolean> {
if (this.active) return false;
this.active = true;
try {
await task();
return true;
} finally {
this.active = false;
}
}
}
export async function prepareDossierAttachments(
current: DossierAttachment[],
incoming: File[],
): Promise<{
attachments: DossierAttachment[];
duplicateCount: number;
problem?: string;
}> {
for (const file of incoming) {
const problem = validateDossierAttachments([file]);
if (problem)
return { attachments: current, duplicateCount: 0, problem };
}
const identity = async (file: File) => {
const hash = await crypto.subtle.digest(
'SHA-256',
await file.arrayBuffer(),
);
return `${file.name}:${Array.from(new Uint8Array(hash), (b) => b.toString(16).padStart(2, '0')).join('')}`;
};
const known = new Set(
await Promise.all(current.map((item) => identity(item.file))),
);
const added: DossierAttachment[] = [];
let duplicateCount = 0;
for (const file of incoming) {
const key = await identity(file);
if (known.has(key)) {
duplicateCount++;
continue;
}
known.add(key);
added.push({ file, include: true, caption: '' });
}
const problem = validateDossierAttachments(
[...current, ...added].map((item) => item.file),
);
return {
attachments: problem ? current : [...current, ...added],
duplicateCount,
problem: problem ?? undefined,
};
}
+8 -1
View File
@@ -468,9 +468,16 @@ export function exportFinancialToolsCsv(
['Pressupostos do empréstimo'],
['Capital', input.loan.principal],
['Taxa anual (%)', input.loan.annualRatePercent],
['Tipo de taxa', input.loan.rateType],
[
'Tipo de taxa',
input.loan.rateType === 'nominal' ? 'Nominal' : 'Efectiva',
],
['Prazo (meses)', input.loan.termMonths],
['Periodicidade (meses)', input.loan.periodMonths],
[
'Arredondamento',
'Montantes a duas casas decimais; juros e prestação arredondados à unidade monetária mínima, metade para cima; última prestação ajusta o saldo residual.',
],
['Carência (períodos)', input.loan.gracePeriods ?? 0],
['Comissão inicial', input.loan.upfrontFee ?? '0'],
['Comissão periódica', input.loan.recurringFee ?? '0'],
+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.",
],
];
}
+68
View File
@@ -1,5 +1,73 @@
import { csvCell } from './csv.js';
export function defaultCashflowDraftInputs() {
return {
openingCash: '0',
monthlyRevenue: '200000',
monthlyOperatingCosts: '150000',
monthlyDebtService: '0',
monthlyTaxAssessed: '',
monthlyTaxSchedule: Array.from({ length: 12 }, () => ''),
vatCreditEstimated: '0',
vatRefundRequested: '0',
vatRefundRecognized: '0',
vatRefundExpectedReceipt: '0',
vatRefundExpectedMonth: '',
};
}
export type CashflowDraftSnapshot = {
version: 1;
taxMode: 'fixed' | 'schedule';
inputs: ReturnType<typeof defaultCashflowDraftInputs>;
};
/** Preserve editable strings, including unanswered and invalid amounts, without inventing zero. */
export function restoreCashflowDraftSnapshot(
snapshot: string | undefined,
): CashflowDraftSnapshot | null {
if (!snapshot || snapshot.length > 20000) return null;
try {
const parsed = JSON.parse(snapshot);
if (
!parsed ||
parsed.version !== 1 ||
!['fixed', 'schedule'].includes(parsed.taxMode) ||
!parsed.inputs ||
typeof parsed.inputs !== 'object' ||
Array.isArray(parsed.inputs)
)
return null;
const keys = Object.keys(defaultCashflowDraftInputs());
if (Object.keys(parsed.inputs).some((key) => !keys.includes(key)))
return null;
for (const key of keys) {
const value = parsed.inputs[key];
if (key === 'monthlyTaxSchedule') {
if (
!Array.isArray(value) ||
value.length !== 12 ||
value.some(
(item) => typeof item !== 'string' || item.length > 160,
)
)
return null;
} else if (typeof value !== 'string' || value.length > 160)
return null;
}
return { version: 1, taxMode: parsed.taxMode, inputs: parsed.inputs };
} catch {
return null;
}
}
export function serializeCashflowDraftSnapshot(
taxMode: CashflowDraftSnapshot['taxMode'],
inputs: CashflowDraftSnapshot['inputs'],
): string {
return JSON.stringify({ version: 1, taxMode, inputs });
}
export type MonthlyCashflowInput = {
openingCash: string;
monthlyRevenue: string;
+26 -2
View File
@@ -398,6 +398,15 @@ export async function generateProjectDossierDocx(
];
for (const section of model.sections) {
if (section.id === 'annexes') {
if (!attachments.some((item) => item.include)) {
children.push(
new Paragraph({
text: 'Anexos: não foram seleccionados ficheiros.',
spacing: { before: 80, after: 80 },
}),
);
continue;
}
children.push(
new Paragraph({
text: section.title,
@@ -447,7 +456,10 @@ export async function generateProjectDossierDocx(
alignment: AlignmentType.RIGHT,
children: [
new TextRun({
text: 'InvesteMoz · '+model.title.slice(0, 60)+' · privado · página ',
text:
'InvesteMoz · ' +
model.title.slice(0, 60) +
' · privado · página ',
size: 18,
}),
new SimpleField('PAGE'),
@@ -580,6 +592,13 @@ export async function createProjectDossierPrintHtml(
): Promise<string> {
const content: string[] = [];
for (const section of model.sections) {
if (
section.id === 'annexes' &&
!attachments.some((item) => item.include)
) {
content.push('<p>Anexos: não foram seleccionados ficheiros.</p>');
continue;
}
content.push(`<section><h2>${escapeHtml(section.title)}</h2>`);
for (const paragraph of section.paragraphs)
content.push(`<p>${escapeHtml(paragraph)}</p>`);
@@ -640,8 +659,13 @@ export async function createProjectDossierPrintHtml(
content.push('<p>Não há informação fornecida nesta secção.</p>');
content.push('</section>');
}
// Page margin boxes reserve footer space on every printed page. A fixed
// body element can overlap fragmented tables in Chromium's print layout.
const printFooter = JSON.stringify(
`InvesteMoz · Relatório privado · ${model.version}`,
).replaceAll('<', '\\3c ');
return `<!doctype html><html lang="pt-MZ"><head><meta charset="utf-8"><title>Relatório — ${escapeHtml(model.title)}</title><style>
@page{size:A4;margin:18mm 17mm 20mm}*{box-sizing:border-box}body{font:10.5pt/1.45 Arial,sans-serif;color:#17211f;margin:0}h1{font-size:24pt;color:#123b35;margin:0 0 7mm}h2{font-size:16pt;color:#123b35;border-bottom:1px solid #b8c9c3;padding-bottom:2mm;margin:8mm 0 3mm;break-after:avoid}h3{font-size:12pt;color:#27675c;break-after:avoid}p{white-space:pre-wrap}table{width:100%;border-collapse:collapse;table-layout:fixed;margin:3mm 0 5mm;break-inside:auto}th,td{border:1px solid #cbd5d1;padding:5px 6px;text-align:left;vertical-align:top;overflow-wrap:anywhere}th{background:#123b35;color:white}tr{break-inside:avoid}.report-table th:first-child,.report-table td:first-child{width:22%}.report-table th:last-child,.report-table td:last-child{width:32%}.report-table td:last-child{font-size:9pt;color:#40524d}.annex-table{table-layout:fixed;font-size:9pt}thead{display:table-header-group}.private{padding:3mm;background:#fff4df;border-left:3px solid #b66a0c;font-weight:bold}section{break-inside:auto}figure{margin:5mm auto;text-align:center;break-inside:avoid}img{max-width:100%;max-height:210mm;object-fit:contain}figcaption{font-style:italic;color:#52615d}pre{white-space:pre-wrap;overflow-wrap:anywhere;font:9.5pt/1.4 Arial,sans-serif}@media screen{body{max-width:1040px;margin:24px auto;padding:24px}table{font-size:11pt}}@media screen{body{max-width:1040px;margin:24px auto;padding:24px}table{font-size:11pt}}@media print{a{color:inherit;text-decoration:none}.no-print{display:none}th{color:#17211f;background:#edf4f1}footer{position:fixed;bottom:-14mm;left:0;right:0;font-size:9pt;color:#40524d}}
@page{size:A4;margin:18mm 17mm 20mm;@bottom-left{content:${printFooter};font:9pt/1.3 Arial,sans-serif;color:#40524d;vertical-align:middle;width:80%}@bottom-right{content:counter(page) " / " counter(pages);font:9pt/1.3 Arial,sans-serif;color:#40524d;vertical-align:middle}}*{box-sizing:border-box}body{font:10.5pt/1.45 Arial,sans-serif;color:#17211f;margin:0}h1{font-size:24pt;color:#123b35;margin:0 0 7mm}h2{font-size:16pt;color:#123b35;border-bottom:1px solid #b8c9c3;padding-bottom:2mm;margin:8mm 0 3mm;break-after:avoid}h3{font-size:12pt;color:#27675c;break-after:avoid}p{white-space:pre-wrap}table{width:100%;border-collapse:collapse;table-layout:fixed;margin:3mm 0 5mm;break-inside:auto}th,td{border:1px solid #cbd5d1;padding:5px 6px;text-align:left;vertical-align:top;overflow-wrap:anywhere}th{background:#123b35;color:white}tr{break-inside:avoid}.report-table th:first-child,.report-table td:first-child{width:22%}.report-table th:last-child,.report-table td:last-child{width:32%}.report-table td:last-child{font-size:9pt;color:#40524d}.annex-table{table-layout:fixed;font-size:9pt}thead{display:table-header-group}.private{padding:3mm;background:#fff4df;border-left:3px solid #b66a0c;font-weight:bold}section{break-inside:auto}figure{margin:5mm auto;text-align:center;break-inside:avoid}img{max-width:100%;max-height:210mm;object-fit:contain}figcaption{font-style:italic;color:#52615d}pre{white-space:pre-wrap;overflow-wrap:anywhere;font:9.5pt/1.4 Arial,sans-serif}@media screen{body{max-width:1040px;margin:24px auto;padding:24px}table{font-size:11pt}}@media screen{body{max-width:1040px;margin:24px auto;padding:24px}table{font-size:11pt}}@media print{a{color:inherit;text-decoration:none}.no-print{display:none}th{color:#17211f;background:#edf4f1}footer{display:none}}
</style></head><body><h1>InvesteMoz<br>Relatório consolidado do projecto</h1><p><strong>${escapeHtml(model.title)}</strong><br>Gerado em ${escapeHtml(new Date(model.generatedAt).toLocaleString('pt-MZ'))}<br>Versão/revisão: ${escapeHtml(model.version)}</p><p class="private">Documento privado. O portal não publica nem partilha este conteúdo.</p>${content.join('')}<footer><small>InvesteMoz · Relatório privado · ${escapeHtml(model.version)}</small></footer><script>window.addEventListener('load',()=>setTimeout(()=>window.print(),350));</script></body></html>`;
}
+86 -14
View File
@@ -1,4 +1,5 @@
import { displayValue } from './display-value.ts';
import { fiscalQaRows } from './fiscal-qa-preview.ts';
import {
buildProjectDossierRows,
type ProjectDossierRow,
@@ -111,29 +112,57 @@ function classification(origin: string): string {
return 'Referência/estado indicado';
}
export function formatDossierReportValue(field: string, value: string, currency = ''): string {
export function formatDossierReportValue(
field: string,
value: string,
currency = '',
): string {
if (field === 'Estado do plano detalhado') {
return value.replace('Snapshot versionado disponível; usar também a exportação do plano financeiro', 'Plano guardado disponível; consulte também a exportação financeira');
return value.replace(
'Snapshot versionado disponível; usar também a exportação do plano financeiro',
'Plano guardado disponível; consulte também a exportação financeira',
);
}
if (field === 'Reconciliação do financiamento') {
const states: Record<string, string> = { shortfall: 'Financiamento insuficiente', surplus: 'Fontes superiores aos usos previstos', balanced: 'Fontes e usos equilibrados', unknown: 'Informação insuficiente', incomplete: 'Informação insuficiente' };
const states: Record<string, string> = {
shortfall: 'Financiamento insuficiente',
surplus: 'Fontes superiores aos usos previstos',
balanced: 'Fontes e usos equilibrados',
unknown: 'Informação insuficiente',
incomplete: 'Informação insuficiente',
};
return states[value] ?? value;
}
if (/^Necessidade \d+$/.test(field)) {
value = value.replace('; gap;', '; Competência em falta;').replace('; unknown;', '; Por confirmar;').replace('; available;', '; Disponível (declarado);');
value = value
.replace('; gap;', '; Competência em falta;')
.replace('; unknown;', '; Por confirmar;')
.replace('; available;', '; Disponível (declarado);');
}
if (/^Marco \d+$/.test(field)) {
value = value.replace('; planned;', '; Planeado;').replace('; in_progress;', '; Em curso;').replace('; completed;', '; Concluído (declarado);');
value = value
.replace('; planned;', '; Planeado;')
.replace('; in_progress;', '; Em curso;')
.replace('; completed;', '; Concluído (declarado);');
}
if (/^(Necessidade|Marco) \d+$/.test(field)) {
value = value.replace(/(custo|planeado|realizado): ([^;]*)(?=;|$)/g, (_, label: string, amount: string) => {
const raw = amount.trim();
return `${label}: ${raw ? (/^\d+(?:\.\d{1,2})?$/.test(raw) ? `${raw} ${currency || '(moeda não informada)'}` : raw) : 'Não informado'}`;
}).replace(/prazo: (?=;|$)/g, 'prazo: Não informado');
value = value
.replace(
/(custo|planeado|realizado): ([^;]*)(?=;|$)/g,
(_, label: string, amount: string) => {
const raw = amount.trim();
return `${label}: ${raw ? (/^\d+(?:\.\d{1,2})?$/.test(raw) ? `${raw} ${currency || '(moeda não informada)'}` : raw) : 'Não informado'}`;
},
)
.replace(/prazo: (?=;|$)/g, 'prazo: Não informado');
}
// Presentation only: group the decimal string without converting the amount
// to a JavaScript float or changing the saved financial snapshot.
return value.replace(/^(-?)(\d+)\.(\d{2}) ([A-Z]{3})$/, (_, sign: string, whole: string, cents: string, currency: string) => `${sign}${whole.replace(/\B(?=(\d{3})+(?!\d))/g, ' ')},${cents} ${currency}`);
return value.replace(
/^(-?)(\d+)\.(\d{2}) ([A-Z]{3})$/,
(_, sign: string, whole: string, cents: string, currency: string) =>
`${sign}${whole.replace(/\B(?=(\d{3})+(?!\d))/g, ' ')},${cents} ${currency}`,
);
}
const rowSection: Record<string, DossierReportSectionId> = {
Projecto: 'project',
@@ -143,6 +172,7 @@ const rowSection: Record<string, DossierReportSectionId> = {
'Empréstimo educativo': 'finance',
'Ponto de equilíbrio': 'finance',
Orçamento: 'finance',
Tesouraria: 'finance',
Equipa: 'team',
'Necessidade de negócio': 'resources',
Financiamento: 'resources',
@@ -214,6 +244,36 @@ function makeSections(
classification: 'Referência da revisão',
},
);
const highlights = new Set([
'Saldo operacional estimado',
'Diferença por financiar',
'Prestação do primeiro período',
'Saldo final total',
'Caixa conhecida antes de imposto em falta',
]);
const budgetPeriod = rows.find(
(row) => row[1] === 'Periodicidade do orçamento',
)?.[2];
for (const [, field, value, origin] of rows.slice(1)) {
if (!highlights.has(field)) continue;
summary.rows.push({
field:
field === 'Saldo operacional estimado' && budgetPeriod
? `${field} — ${budgetPeriod}`
: field === 'Saldo final total'
? 'Saldo final de tesouraria — 12 meses'
: field,
value: absent(
formatDossierReportValue(
field,
value,
text(draft, 'currency'),
),
),
origin,
classification: classification(origin),
});
}
}
for (const [sectionName, field, value, origin] of rows.slice(1)) {
@@ -226,9 +286,14 @@ function makeSections(
if (!section) continue;
section.rows.push({
field,
value: absent(formatDossierReportValue(field, value, text(draft, 'currency'))),
value: absent(
formatDossierReportValue(field, value, text(draft, 'currency')),
),
origin,
classification: value.trim() === '' ? 'Informação em falta' : classification(origin),
classification:
value.trim() === ''
? 'Informação em falta'
: classification(origin),
});
}
@@ -280,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 =
@@ -353,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',
},
{
+68 -2
View File
@@ -1,5 +1,9 @@
import { displayValue } from './display-value.ts';
import { csvCellWithDelimiter } from './csv.js';
import {
restoreCashflowDraftSnapshot,
projectMonthlyCashflow,
} from './monthly-cashflow.ts';
import {
calculateFinancialTools,
restoreFinancialToolsSnapshot,
@@ -330,7 +334,7 @@ export function buildProjectDossierRows(
[
'Empréstimo educativo',
'Taxa anual pressuposta',
`${finance.loan.rate}% (${finance.loan.rateType})`,
`${finance.loan.rate}% (${finance.loan.rateType === 'nominal' ? 'nominal anual' : 'efectiva anual'})`,
'Declarada pelo utilizador; não é taxa de produto',
],
[
@@ -342,7 +346,7 @@ export function buildProjectDossierRows(
[
'Empréstimo educativo',
'Método e pressupostos substituídos',
`Saldo decrescente; capital ${finance.loan.principal}; taxa ${finance.loan.rate}% ${finance.loan.rateType}; ${finance.loan.months} meses; período ${finance.loan.periodMonths} meses; carência ${finance.loan.gracePeriods}; comissão inicial ${finance.loan.upfrontFee}; comissão periódica ${finance.loan.recurringFee}`,
`Saldo decrescente; capital ${finance.loan.principal} ${currency}; taxa ${finance.loan.rate}% ${finance.loan.rateType === 'nominal' ? 'nominal anual (dividida pelos períodos do ano)' : 'efectiva anual (convertida para o período)'}; prazo ${finance.loan.months} meses; periodicidade ${finance.loan.periodMonths} meses; carência ${finance.loan.gracePeriods} períodos; comissão inicial ${finance.loan.upfrontFee} ${currency}; comissão periódica ${finance.loan.recurringFee} ${currency}. Montantes a duas casas decimais; juros e prestação arredondados à unidade monetária mínima, metade para cima; última prestação ajusta o saldo residual.`,
'Cronograma do simulador; taxa declarada, sem correspondência a produto',
],
[
@@ -604,6 +608,68 @@ export function buildProjectDossierRows(
'Planeado/realizado declarado pelo utilizador',
]);
}
if (draft.cashflow_snapshot) {
const saved = restoreCashflowDraftSnapshot(
String(draft.cashflow_snapshot),
);
const origin =
'Estimativa de planeamento; pressupostos guardados da ferramenta que podem incluir exemplos iniciais. Não confirma impostos nem recebimentos.';
try {
if (!saved)
throw new Error(
'Pressupostos de tesouraria não recuperáveis; não foram substituídos por valores alternativos.',
);
const currency = String(draft.currency ?? '');
if (!currency)
throw new Error(
'Indique uma moeda para interpretar os montantes de tesouraria.',
);
const input = {
...saved.inputs,
currency,
...(saved.taxMode === 'fixed'
? { monthlyTaxSchedule: undefined }
: { monthlyTaxAssessed: '' }),
};
const projection = projectMonthlyCashflow(input);
const amount = (value: string | null) =>
value === null ? 'Indeterminado' : `${value} ${currency}`;
rows.push([
'Tesouraria',
'Pressupostos e período',
`12 meses; moeda ${currency}; saldo inicial ${saved.inputs.openingCash}; receita mensal ${saved.inputs.monthlyRevenue}; custos mensais ${saved.inputs.monthlyOperatingCosts}; dívida mensal ${saved.inputs.monthlyDebtService}; imposto ${saved.taxMode === 'fixed' ? 'mensal fixo: ' + (saved.inputs.monthlyTaxAssessed || 'não informado') : 'variável por mês (vazio não é zero)'}. Crédito estimado ${saved.inputs.vatCreditEstimated}; pedido ${saved.inputs.vatRefundRequested}; reconhecido ${saved.inputs.vatRefundRecognized}; recebimento previsto ${saved.inputs.vatRefundExpectedReceipt}; mês previsto ${saved.inputs.vatRefundExpectedMonth || 'não informado'}.`,
origin,
]);
rows.push([
'Tesouraria',
'Saldo final total',
amount(projection.endingCash),
origin,
]);
rows.push([
'Tesouraria',
'Caixa conhecida antes de imposto em falta',
amount(projection.endingCashBeforeUnassessedTax),
origin,
]);
for (const row of projection.rows)
rows.push([
'Tesouraria',
`Mês ${row.month}`,
`Imposto assumido: ${amount(row.taxAssessed)}; recebimento de IVA previsto: ${amount(row.vatRefundExpectedReceipt)}; fluxo líquido: ${amount(row.netFlow)}; saldo final total: ${amount(row.closingCash)}.`,
origin,
]);
} catch (cause) {
rows.push([
'Tesouraria',
'Estado da tesouraria',
cause instanceof Error
? cause.message
: 'Tesouraria indeterminada; reveja os pressupostos.',
origin,
]);
}
}
return rows;
}
+37 -2
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[]>;
@@ -28,9 +29,10 @@ export function getFiscalPeriodRange(
return { start: `${yearValue}-01-01`, end: `${yearValue}-12-31` };
if (draft.fiscal_periodicity === 'one_off') {
const date = String(draft.fiscal_reference_date ?? '');
const parsed = new Date(`${date}T00:00:00.000Z`);
return /^\d{4}-\d{2}-\d{2}$/.test(date) &&
new Date(`${date}T00:00:00.000Z`).toISOString().slice(0, 10) ===
date &&
Number.isFinite(parsed.getTime()) &&
parsed.toISOString().slice(0, 10) === date &&
date.startsWith(`${yearValue}-`)
? { start: date, end: date }
: null;
@@ -1543,6 +1545,10 @@ export function validateSimulationDraft(
draft: SimulationDraft,
sectionId: string,
): Record<string, string> {
// The separate arithmetic validator checks its three inputs. Fiscal
// eligibility facts are not inputs of this planning-only calculation.
if (sectionId === 'tax' && draft.fiscal_scenario_mode === 'custom_planning')
return {};
const errors: Record<string, string> = {};
for (const field of simulationFieldGroups[sectionId] ?? []) {
if (field.showWhen && !field.showWhen(draft)) continue;
@@ -1593,6 +1599,15 @@ export function validateSimulationDraft(
errors[field.key] =
'Introduza um endereço de correio electrónico válido.';
}
if (
sectionId === 'tax' &&
draft.fiscal_scenario_mode === 'approved_parameters' &&
draft.fiscal_periodicity === 'one_off' &&
!getFiscalPeriodRange(draft)
) {
errors.fiscal_reference_date =
'Introduza uma data válida da operação no ano fiscal seleccionado. Não foi usada uma data alternativa.';
}
if (
sectionId === 'financial' &&
[
@@ -1660,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',
@@ -1834,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(
+9 -9
View File
@@ -16,7 +16,7 @@ import PasskeyVerify from '@/components/passkey-verify';
export default function ConfirmPassword() {
return (
<>
<Head title="Confirm password" />
<Head title="Confirmar palavra-passe" />
{/* @chisel-passkeys */}
<PasskeyVerify
@@ -24,9 +24,9 @@ export default function ConfirmPassword() {
options: confirmOptions(),
submit: confirmStore(),
}}
label="Confirm with passkey"
loadingLabel="Confirming..."
separator="Or confirm with password"
label="Confirmar com chave de acesso"
loadingLabel="A confirmar…"
separator="Ou confirmar com palavra-passe"
/>
{/* @end-chisel-passkeys */}
@@ -34,11 +34,11 @@ export default function ConfirmPassword() {
{({ processing, errors }) => (
<div className="space-y-6">
<div className="grid gap-2">
<Label htmlFor="password">Password</Label>
<Label htmlFor="password">Palavra-passe</Label>
<PasswordInput
id="password"
name="password"
placeholder="Password"
placeholder="Palavra-passe"
autoComplete="current-password"
autoFocus
/>
@@ -53,7 +53,7 @@ export default function ConfirmPassword() {
data-test="confirm-password-button"
>
{processing && <Spinner />}
Confirm password
Confirmar palavra-passe
</Button>
</div>
</div>
@@ -64,7 +64,7 @@ export default function ConfirmPassword() {
}
ConfirmPassword.layout = {
title: 'Confirm password',
title: 'Confirmar palavra-passe',
description:
'This is a secure area of the application. Please confirm your password before continuing.',
'Esta é uma área protegida. Confirme a sua palavra-passe para continuar.',
};
+10 -6
View File
@@ -12,7 +12,7 @@ import { email } from '@/routes/password';
export default function ForgotPassword({ status }: { status?: string }) {
return (
<>
<Head title="Forgot password" />
<Head title="Recuperar palavra-passe" />
{status && (
<div className="mb-4 text-center text-sm font-medium text-green-600">
@@ -25,7 +25,10 @@ export default function ForgotPassword({ status }: { status?: string }) {
{({ processing, errors }) => (
<>
<div className="grid gap-2">
<Label htmlFor="email">Email address</Label>
<Label htmlFor="email">
{' '}
Endereço de correio electrónico{' '}
</Label>
<Input
id="email"
type="email"
@@ -46,8 +49,8 @@ export default function ForgotPassword({ status }: { status?: string }) {
>
{processing && (
<LoaderCircle className="h-4 w-4 animate-spin" />
)}
Email password reset link
)}{' '}
Enviar ligação de recuperação{' '}
</Button>
</div>
</>
@@ -64,6 +67,7 @@ export default function ForgotPassword({ status }: { status?: string }) {
}
ForgotPassword.layout = {
title: 'Forgot password',
description: 'Enter your email to receive a password reset link',
title: 'Recuperar palavra-passe',
description:
'Indique o seu email para receber a ligação de recuperação da palavra-passe',
};
+20 -8
View File
@@ -39,15 +39,19 @@ export default function Login({ status, canResetPassword, qaAccount }: Props) {
Ambiente QA — conta {qaAccount.id}
</p>
<p>
Para os cenários A/B: {qaAccount.email}. Use a password
e o autenticador desta conta. Outros perfis devem usar
as respetivas contas sintéticas QA.
Para os cenários A/B: {qaAccount.email}. Use a
palavra-passe e o autenticador desta conta. Outros
perfis devem usar as respetivas contas sintéticas QA.
</p>
</aside>
)}
{/* @chisel-passkeys */}
<PasskeyVerify label="Entrar com chave de acesso" loadingLabel="A autenticar…" separator="Ou continuar com email" />
<PasskeyVerify
label="Entrar com chave de acesso"
loadingLabel="A autenticar…"
separator="Ou continuar com email"
/>
{/* @end-chisel-passkeys */}
<Form
@@ -68,7 +72,10 @@ export default function Login({ status, canResetPassword, qaAccount }: Props) {
tabIndex={1}
autoComplete="email"
placeholder="email@example.com"
defaultValue={qaAccount?.email ?? participationIdentity().email}
defaultValue={
qaAccount?.email ??
participationIdentity().email
}
/>
<InputError
message={errors.email ?? pageErrors.email}
@@ -77,7 +84,9 @@ export default function Login({ status, canResetPassword, qaAccount }: Props) {
<div className="grid gap-2">
<div className="flex items-center">
<Label htmlFor="password">Palavra-passe</Label>
<Label htmlFor="password">
Palavra-passe
</Label>
{canResetPassword && (
<TextLink
href={request()}
@@ -105,7 +114,9 @@ export default function Login({ status, canResetPassword, qaAccount }: Props) {
name="remember"
tabIndex={3}
/>
<Label htmlFor="remember">Manter sessão iniciada</Label>
<Label htmlFor="remember">
Manter sessão iniciada
</Label>
</div>
<Button
@@ -143,5 +154,6 @@ export default function Login({ status, canResetPassword, qaAccount }: Props) {
Login.layout = {
title: 'Entrar na sua conta',
description: 'Entre para continuar a operação que escolheu. Os dados preenchidos permanecem guardados neste navegador.',
description:
'Entre para continuar a operação que escolheu. Os dados preenchidos permanecem guardados neste navegador.',
};
+10 -9
View File
@@ -16,7 +16,7 @@ type Props = {
export default function ResetPassword({ token, email, passwordRules }: Props) {
return (
<>
<Head title="Reset password" />
<Head title="Redefinir palavra-passe" />
<Form
{...update.form()}
@@ -43,14 +43,14 @@ export default function ResetPassword({ token, email, passwordRules }: Props) {
</div>
<div className="grid gap-2">
<Label htmlFor="password">Password</Label>
<Label htmlFor="password"> Palavra-passe </Label>
<PasswordInput
id="password"
name="password"
autoComplete="new-password"
className="mt-1 block w-full"
autoFocus
placeholder="Password"
placeholder="Palavra-passe"
passwordrules={passwordRules}
/>
<InputError message={errors.password} />
@@ -58,14 +58,15 @@ export default function ResetPassword({ token, email, passwordRules }: Props) {
<div className="grid gap-2">
<Label htmlFor="password_confirmation">
Confirm password
{' '}
Confirmar palavra-passe{' '}
</Label>
<PasswordInput
id="password_confirmation"
name="password_confirmation"
autoComplete="new-password"
className="mt-1 block w-full"
placeholder="Confirm password"
placeholder="Confirmar palavra-passe"
passwordrules={passwordRules}
/>
<InputError
@@ -80,8 +81,8 @@ export default function ResetPassword({ token, email, passwordRules }: Props) {
disabled={processing}
data-test="reset-password-button"
>
{processing && <Spinner />}
Reset password
{processing && <Spinner />} Redefinir
palavra-passe{' '}
</Button>
</div>
)}
@@ -91,6 +92,6 @@ export default function ResetPassword({ token, email, passwordRules }: Props) {
}
ResetPassword.layout = {
title: 'Reset password',
description: 'Please enter your new password below',
title: 'Redefinir palavra-passe',
description: 'Introduza a nova palavra-passe',
};
@@ -27,18 +27,18 @@ export default function TwoFactorChallenge({
}>(() => {
if (showRecoveryInput) {
return {
title: 'Recovery code',
title: 'Código de recuperação',
description:
'Please confirm access to your account by entering one of your emergency recovery codes.',
toggleText: 'login using an authentication code',
'Confirme o acesso à conta com um dos seus códigos de recuperação.',
toggleText: 'entrar com um código de autenticação',
};
}
return {
title: 'Authentication code',
title: 'Código de autenticação',
description:
'Enter the authentication code provided by your authenticator application.',
toggleText: 'login using a recovery code',
'Introduza o código apresentado pela sua aplicação autenticadora.',
toggleText: 'entrar com um código de recuperação',
};
}, [showRecoveryInput]);
@@ -55,7 +55,7 @@ export default function TwoFactorChallenge({
return (
<>
<Head title="Two-factor authentication" />
<Head title="Autenticação de dois factores" />
{qaAccount && (
<p
@@ -81,7 +81,7 @@ export default function TwoFactorChallenge({
<Input
name="recovery_code"
type="text"
placeholder="Enter recovery code"
placeholder="Introduza um código de recuperação"
autoFocus={showRecoveryInput}
required
/>
@@ -123,7 +123,8 @@ export default function TwoFactorChallenge({
className="w-full"
disabled={processing}
>
Continue
{' '}
Continuar{' '}
</Button>
<div className="text-muted-foreground text-center text-sm">
+10 -9
View File
@@ -9,12 +9,13 @@ import { send } from '@/routes/verification';
export default function VerifyEmail({ status }: { status?: string }) {
return (
<>
<Head title="Email verification" />
<Head title="Confirmação de email" />
{status === 'verification-link-sent' && (
<div className="mb-4 text-center text-sm font-medium text-green-600">
A new verification link has been sent to the email address
you provided during registration.
{' '}
Foi enviada uma nova ligação de confirmação para o email
indicado no registo.{' '}
</div>
)}
@@ -22,15 +23,16 @@ export default function VerifyEmail({ status }: { status?: string }) {
{({ processing }) => (
<>
<Button disabled={processing} variant="secondary">
{processing && <Spinner />}
Resend verification email
{processing && <Spinner />} Reenviar email de
confirmação{' '}
</Button>
<TextLink
href={logout()}
className="mx-auto block text-sm"
>
Log out
{' '}
Terminar sessão{' '}
</TextLink>
</>
)}
@@ -40,7 +42,6 @@ export default function VerifyEmail({ status }: { status?: string }) {
}
VerifyEmail.layout = {
title: 'Email verification',
description:
'Please verify your email address by clicking on the link we just emailed to you.',
title: 'Confirmação de email',
description: 'Confirme o seu email através da ligação que enviámos.',
};
@@ -1,3 +1,5 @@
import EducationalGuides from '@/components/educational-guides';
import type { EducationalArticle } from '@/components/educational-article-card';
import OpportunitySearchFilters from '@/components/opportunity-search-filters';
import SearchState from '@/components/search-state';
import { Link, router, useForm, usePage } from '@inertiajs/react';
@@ -14,6 +16,7 @@ type Feature = {
properties: { id: number; title: string; location: string; url: string };
};
export default function Catalogue({
guides = [],
filters,
references,
results,
@@ -22,6 +25,7 @@ export default function Catalogue({
territorySelection,
catalogueUrl,
}: {
guides?: EducationalArticle[];
filters: Record<string, string | string[] | boolean>;
references: References;
results: {
@@ -712,6 +716,7 @@ export default function Catalogue({
</div>
</section>
)}
<EducationalGuides articles={guides} />
</Frame>
);
}
+5
View File
@@ -1,3 +1,5 @@
import EducationalGuides from '@/components/educational-guides';
import type { EducationalArticle } from '@/components/educational-article-card';
import { ClassificationSummary } from '@/components/opportunity-classification';
import { Link, router, usePage } from '@inertiajs/react';
import { useState } from 'react';
@@ -10,12 +12,14 @@ import TerritorialSources from '@/components/territorial-source-evidence';
import { Frame, card, button, money, groupLabels } from './shared';
import type { Opportunity, Context } from './shared';
export default function Detail({
guides = [],
record: r,
context: c,
zone_origin,
returnUrl = '/oportunidades',
dossierOrigin,
}: {
guides?: EducationalArticle[];
record: Opportunity;
context: Context;
zone_origin?: { id: string; name: string } | null;
@@ -309,6 +313,7 @@ export default function Detail({
</p>
)}
</section>
<EducationalGuides articles={guides} />
</Frame>
);
}
@@ -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>
);
}
+12 -31
View File
@@ -1,6 +1,13 @@
import { Head, Link, useForm } from '@inertiajs/react';
import EducationalArticleCard, {
type EducationalArticle,
} from '@/components/educational-article-card';
export type LegacyArticle = {
summary: string;
opportunities_href?: string | null;
opportunities_label?: string | null;
has_opportunities?: boolean;
id: number;
title: string;
author: string;
@@ -13,12 +20,7 @@ export type LegacyArticle = {
landing_href: string;
start_href: string;
group: { label: string; heading: string };
related_articles: {
id: number;
slug: string;
title: string;
href: string;
}[];
related_articles: (EducationalArticle & { id: number })[];
html: string;
library_href: string;
image: { href: string; alt: string; source_url: string } | null;
@@ -153,29 +155,10 @@ export default function LegacySector({
{match ? (
<div className="grid gap-4 sm:grid-cols-2">
{articles.map((item) => (
<article
<EducationalArticleCard
key={item.id}
className="space-y-3 rounded-xl border bg-white p-5"
>
<h3 className="text-xl font-semibold">
<Link
className="underline"
href={item.href}
>
{item.title}
</Link>
</h3>
<p>
Conteúdo editorial preservado do portal
Tempo.
</p>
<Link
className="inline-block rounded bg-[#10352b] px-5 py-3 text-white"
href={item.href}
>
Ler o artigo
</Link>
</article>
article={item}
/>
))}
</div>
) : (
@@ -199,9 +182,7 @@ export default function LegacySector({
<ul className="grid gap-3 sm:grid-cols-2">
{article.related_articles.map((item) => (
<li key={item.id}>
<Link className="underline" href={item.href}>
{item.title}
</Link>
<EducationalArticleCard article={item} />
</li>
))}
</ul>
+4 -1
View File
@@ -419,6 +419,9 @@ function PartnershipForm() {
}
function Simulator() {
const page = usePage<{ auth: { user: { id: number } | null } }>();
const query = new URL(page.url, 'https://investmoz.invalid').searchParams;
const scope = `${page.props.auth.user?.id ?? 'anonymous'}:${query.get('context') ?? ''}:${query.get('draft') ?? ''}`;
return (
<div className="space-y-5">
<p>
@@ -428,7 +431,7 @@ function Simulator() {
para rever mercado, localização, recursos, formalização e
execução.
</p>
<SimulationForm />
<SimulationForm key={scope} />
</div>
);
}
+5 -5
View File
@@ -6,15 +6,15 @@ import { edit as editAppearance } from '@/routes/appearance';
export default function Appearance() {
return (
<>
<Head title="Appearance settings" />
<Head title="Definições de aparência" />
<h1 className="sr-only">Appearance settings</h1>
<h1 className="sr-only"> Definições de aparência </h1>
<div className="space-y-6">
<Heading
variant="small"
title="Appearance settings"
description="Update the appearance settings for your account"
title="Definições de aparência"
description="Escolha a aparência da sua conta"
/>
<AppearanceTabs />
</div>
@@ -25,7 +25,7 @@ export default function Appearance() {
Appearance.layout = {
breadcrumbs: [
{
title: 'Appearance settings',
title: 'Definições de aparência',
href: editAppearance(),
},
],
+25 -15
View File
@@ -34,15 +34,15 @@ export default function Profile(
return (
<>
<Head title="Profile settings" />
<Head title="Definições do perfil" />
<h1 className="sr-only">Profile settings</h1>
<h1 className="sr-only"> Definições do perfil </h1>
<div className="space-y-6">
<Heading
variant="small"
title="Profile"
description="Update your name and email address"
title="Perfil"
description="Actualize o seu nome e endereço de correio electrónico"
/>
<Form
@@ -55,7 +55,7 @@ export default function Profile(
{({ processing, errors }) => (
<>
<div className="grid gap-2">
<Label htmlFor="name">Name</Label>
<Label htmlFor="name"> Nome </Label>
<Input
id="name"
@@ -64,7 +64,7 @@ export default function Profile(
name="name"
required
autoComplete="name"
placeholder="Full name"
placeholder="Nome completo"
/>
<InputError
@@ -74,7 +74,10 @@ export default function Profile(
</div>
<div className="grid gap-2">
<Label htmlFor="email">Email address</Label>
<Label htmlFor="email">
{' '}
Endereço de correio electrónico{' '}
</Label>
<Input
id="email"
@@ -84,7 +87,7 @@ export default function Profile(
name="email"
required
autoComplete="username"
placeholder="Email address"
placeholder="Endereço de correio electrónico"
/>
<InputError
@@ -98,22 +101,28 @@ export default function Profile(
auth.user.email_verified_at === null && (
<div>
<p className="text-muted-foreground -mt-4 text-sm">
Your email address is unverified.{' '}
{' '}
O seu endereço de correio
electrónico ainda não foi
confirmado.{' '}
<Link
href={send()}
as="button"
className="text-foreground underline decoration-neutral-300 underline-offset-4 transition-colors duration-300 ease-out hover:decoration-current! dark:decoration-neutral-500"
>
Click here to re-send the
verification email.
{' '}
Reenviar o email de
confirmação.{' '}
</Link>
</p>
{status ===
'verification-link-sent' && (
<div className="mt-2 text-sm font-medium text-green-600">
A new verification link has been
sent to your email address.
{' '}
Foi enviada uma nova ligação de
confirmação para o seu
email.{' '}
</div>
)}
</div>
@@ -125,7 +134,8 @@ export default function Profile(
disabled={processing}
data-test="update-profile-button"
>
Save
{' '}
Guardar{' '}
</Button>
</div>
</>
@@ -141,7 +151,7 @@ export default function Profile(
Profile.layout = {
breadcrumbs: [
{
title: 'Profile settings',
title: 'Definições do perfil',
href: edit(),
},
],
+14 -12
View File
@@ -28,15 +28,15 @@ export default function Security(props: Props) {
return (
<>
<Head title="Security settings" />
<Head title="Definições de segurança" />
<h1 className="sr-only">Security settings</h1>
<h1 className="sr-only">Definições de segurança</h1>
<div className="space-y-6">
<Heading
variant="small"
title="Update password"
description="Ensure your account is using a long, random password to stay secure"
title="Alterar palavra-passe"
description="Use uma palavra-passe longa e difícil de adivinhar para proteger a sua conta."
/>
<Form
@@ -65,7 +65,7 @@ export default function Security(props: Props) {
<>
<div className="grid gap-2">
<Label htmlFor="current_password">
Current password
Palavra-passe actual
</Label>
<PasswordInput
@@ -74,14 +74,16 @@ export default function Security(props: Props) {
name="current_password"
className="mt-1 block w-full"
autoComplete="current-password"
placeholder="Current password"
placeholder="Palavra-passe actual"
/>
<InputError message={errors.current_password} />
</div>
<div className="grid gap-2">
<Label htmlFor="password">New password</Label>
<Label htmlFor="password">
Nova palavra-passe
</Label>
<PasswordInput
id="password"
@@ -89,7 +91,7 @@ export default function Security(props: Props) {
name="password"
className="mt-1 block w-full"
autoComplete="new-password"
placeholder="New password"
placeholder="Nova palavra-passe"
passwordrules={props.passwordRules}
/>
@@ -98,7 +100,7 @@ export default function Security(props: Props) {
<div className="grid gap-2">
<Label htmlFor="password_confirmation">
Confirm password
Confirmar palavra-passe
</Label>
<PasswordInput
@@ -106,7 +108,7 @@ export default function Security(props: Props) {
name="password_confirmation"
className="mt-1 block w-full"
autoComplete="new-password"
placeholder="Confirm password"
placeholder="Confirmar palavra-passe"
passwordrules={props.passwordRules}
/>
@@ -120,7 +122,7 @@ export default function Security(props: Props) {
disabled={processing}
data-test="update-password-button"
>
Save
Guardar
</Button>
</div>
</>
@@ -149,7 +151,7 @@ export default function Security(props: Props) {
Security.layout = {
breadcrumbs: [
{
title: 'Security settings',
title: 'Definições de segurança',
href: edit(),
},
],
+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 () {
+3
View File
@@ -20,6 +20,7 @@ Route::get('/api/v2/investment/zones/export.csv', [InvestmentZoneController::cla
Route::get('/api/v2/investment/zones/{id}', [InvestmentZoneController::class, 'show'])->whereUuid('id')->middleware('throttle:api');
Route::get('/zonas/{zone}/anexos/{attachment}', [InvestmentZoneAttachmentController::class, 'downloadPublic'])->whereUuid(['zone', 'attachment'])->middleware('throttle:api');
Route::middleware([CorrelationId::class, 'auth', 'verified', \App\Http\Middleware\EnsureScopedPortalAdministration::class, RequireZoneMfa::class, PrivateInvestmentResponse::class, 'throttle:30,1'])->group(function () {
Route::post('/api/v2/investment/admin/tax-rule-sources', [\App\Http\Controllers\SimulationFiscalSourceController::class, 'store']);
Route::get('/api/v2/investment/admin/legal-sources', [InvestmentLegalSourceController::class, 'index']);
Route::post('/api/v2/investment/admin/legal-sources', [InvestmentLegalSourceController::class, 'store']);
Route::post('/api/v2/investment/admin/legal-sources/{id}/{action}', [InvestmentLegalSourceController::class, 'transition'])->whereUuid('id')->whereIn('action', ['validate', 'revoke']);
@@ -27,6 +28,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())]);});
+59
View File
@@ -0,0 +1,59 @@
<?php
namespace Tests\Feature;
use App\Services\LegacyContent;
use App\Services\SectorInvestment;
use App\Models\Sector;
use App\Support\IsolatedQaDatabase;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Tests\TestCase;
class EducationalGuidesTest extends TestCase
{
use DatabaseTransactions;
public function createApplication(): Application
{
$app = parent::createApplication();
if (! IsolatedQaDatabase::allowsSchemaChanges()) throw new \LogicException('Isolated QA required');
return $app;
}
public function test_guide_uses_public_edition_and_hides_unpublished_or_unscanned_sources(): void
{
Storage::fake('local');
config(['portal.legacy_content_qa_enabled' => true]);
$identity = config('legacy_content.articles.turismo');
$body = '<p>Conteúdo sintético para conferir a apresentação do guia e preservar o texto da edição consultada.</p>';
$bytes = json_encode(['format' => 'investmoz-legacy-article-v1', 'source_url' => $identity['source_url'], 'content_html' => $body,
'title' => 'Guia sintético', 'author' => 'Autor QA', 'legacy_id' => 171990, 'legacy_category' => 'Turismo',
'published_at' => '2026-06-16', 'modified_at' => '2026-06-16'], JSON_THROW_ON_ERROR);
$id = (string) Str::uuid();
Storage::disk('local')->put('guides/'.$id, $bytes);
DB::table('documents')->insert(['id' => $id, 'external_id' => $identity['external_id'], 'source_system' => 'synthetic-test',
'file_name' => 'guide.json', 'media_type' => 'application/json', 'storage_key' => 'guides/'.$id, 'size_bytes' => strlen($bytes),
'sha256' => hash('sha256', $bytes), 'malware_scanned_sha256' => hash('sha256', $bytes), 'malware_scan_status' => 'clean',
'status' => 'active', 'rights_status' => 'authorized', 'data_classification' => 'public']);
$resource = DB::table('portal_resources')->insertGetId(['document_id' => $id, 'title' => 'Guia sintético', 'category' => 'education',
'topic' => 'turismo', 'published' => true, 'rights_status' => 'authorized', 'source_url' => $identity['source_url'], 'source_reference' => 'Synthetic isolated QA fixture', 'metadata_version' => 1]);
$sector = Sector::where('slug', 'turismo')->firstOrFail();
$guides = app(SectorInvestment::class)->guides($sector);
$this->assertSame(strip_tags($body), $guides[0]['summary']);
$this->assertSame('Autor QA', $guides[0]['author']);
$this->assertSame('/turismo', $guides[0]['href']);
$this->assertSame('/oportunidades?sector=turismo', $guides[0]['opportunities_href']);
$this->assertSame('/investimentos/a-partir-de/turismo', app(LegacyContent::class)->article('turismo')['start_href']);
DB::table('opportunities')->where('sector_id', $sector->id)->update(['status' => 'draft']);
$this->assertFalse(app(SectorInvestment::class)->guides($sector)[0]['has_opportunities']);
DB::table('portal_resources')->where('id', $resource)->update(['published' => false]);
$this->assertSame([], app(SectorInvestment::class)->guides($sector));
DB::table('portal_resources')->where('id', $resource)->update(['published' => true]);
DB::table('documents')->where('id', $id)->update(['malware_scan_status' => 'pending']);
$this->assertSame([], app(SectorInvestment::class)->guides($sector));
}
}
@@ -0,0 +1,217 @@
<?php
namespace Tests\Feature;
use App\Models\InvestmentLegalSource;
use App\Models\SimulationTaxRuleVersion;
use App\Models\User;
use App\Services\PortalProfileAdministration;
use App\Services\SimulationTaxParameterResolver;
use Illuminate\Database\QueryException;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
use Tests\TestCase;
/** Only the maintenance control is exercised; no simulation or arithmetic runs here. */
class SimulationFiscalMaintenanceActorTest extends TestCase
{
use DatabaseTransactions;
private User $maintainer;
private array $otherConstraints;
protected function setUp(): void
{
parent::setUp();
$this->otherConstraints = $this->unrelatedConstraints();
if (! Schema::hasColumn('investment_legal_sources', 'fiscal_acceptance')) {
(require database_path('migrations/2026_10_21_130000_correct_fiscal_maintenance_actor_constraints.php'))->up();
}
$this->maintainer = User::factory()->withTwoFactor()->create();
config(['portal.managers' => [$this->maintainer->email],
'simulation-tax-governance.editors' => [], 'simulation-tax-governance.reviewers' => [],
'simulation-tax-governance.approvers' => [], 'simulation-tax-governance.publishers' => [],
'simulation-fiscal-consumer.enabled' => false]);
}
private function unrelatedConstraints(): array
{
return array_map(fn ($row) => (array) $row, DB::select("SELECT conrelid::regclass::text AS relation, conname, pg_get_constraintdef(oid) AS definition FROM pg_constraint
WHERE connamespace='public'::regnamespace AND conrelid NOT IN ('public.investment_legal_sources'::regclass,'public.simulation_tax_rule_versions'::regclass)
ORDER BY relation,conname"));
}
private function sourceInput(): array
{
return ['title' => 'Synthetic QA official source curation; not a destination record',
'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,
'sha256' => str_repeat('a', 64), 'citation' => 'QA fixture: PDF 3, art.17(1); no legal opinion.',
'official_origin' => 'QA fixture of an identified official publication; not a new legal validation.'];
}
private function source(): string
{
return $this->actingAs($this->maintainer)->postJson('/api/v2/investment/admin/tax-rule-sources', $this->sourceInput())
->assertCreated()->assertJsonPath('fiscal_acceptance.legal_opinion', false)
->assertJsonPath('fiscal_acceptance.independent_review', false)
->assertJsonPath('owner_id', $this->maintainer->id)->assertJsonPath('validator_id', null)
->assertJsonPath('validation_status', 'pending')->assertJsonPath('fiscal_acceptance.responsible_user_id', $this->maintainer->id)->json('id');
}
private function rule(string $source): string
{
$payload = json_decode(file_get_contents(database_path('qa/iva-general-net-v1.json')), true, flags: JSON_THROW_ON_ERROR);
$payload['legal_source_id'] = $source;
$payload['source_sha256'] = str_repeat('a', 64);
return $this->postJson('/api/v2/investment/admin/tax-rules', $payload)->assertCreated()->json('id');
}
public function test_one_existing_administrator_completes_fiscal_curation_with_responsibility_and_history(): void
{
$simulationCount = DB::table('simulations')->count();
$source = $this->source();
$id = $this->rule($source);
$evidence = 'QA technical parameter maintenance by the authorised same actor; not independent review or legal validation.';
$this->postJson('/api/v2/investment/admin/tax-rules/'.$id.'/publish', ['version_no' => 1, 'evidence' => $evidence])->assertUnprocessable();
foreach (['submit', 'review', 'approve', 'publish'] as $offset => $action) {
$this->postJson('/api/v2/investment/admin/tax-rules/'.$id.'/'.$action,
['version_no' => $offset + 1, 'evidence' => $evidence])->assertOk()->assertJsonPath('version_no', $offset + 2);
}
$rule = SimulationTaxRuleVersion::findOrFail($id);
foreach (['author_id', 'reviewer_id', 'approver_id', 'publisher_id'] as $column) {
$this->assertSame($this->maintainer->id, (int) $rule->$column);
}
$this->assertSame('published', $rule->status);
$this->assertFalse(InvestmentLegalSource::whereKey($source)->where('validation_status', 'validated')->exists());
// Source eligibility is checked through the existing resolver, without executing a formula.
DB::statement('CREATE ROLE qa_fiscal_maintenance_read_20261021 NOLOGIN NOSUPERUSER NOBYPASSRLS');
DB::statement('GRANT USAGE ON SCHEMA public TO qa_fiscal_maintenance_read_20261021');
DB::statement('GRANT SELECT ON simulation_tax_rule_versions, investment_legal_sources TO qa_fiscal_maintenance_read_20261021');
DB::statement("SELECT set_config('app.simulation_tax_governance', 'off', true)");
DB::statement('SET LOCAL ROLE qa_fiscal_maintenance_read_20261021');
try {
$this->assertSame(1, SimulationTaxRuleVersion::whereKey($id)->count());
$parameters = app(SimulationTaxParameterResolver::class)->resolvePublishedForPeriod('IVA', '2026-10-21', '2026-10-21',
['currency' => 'MZN', 'operation' => 'internal_general_net']);
$this->assertSame('resolved', $parameters[0]['status']);
} finally {
DB::statement('RESET ROLE');
}
$this->assertSame(1, (int) $rule->source_version_no);
$this->assertSame(str_repeat('a', 64), $rule->source_sha256);
$events = DB::table('simulation_tax_rule_events')->where('rule_version_id', $id)->orderBy('version_no')->get();
$this->assertSame(['created', 'submit', 'review', 'approve', 'publish'], $events->pluck('action')->all());
foreach ($events as $event) {
$this->assertSame($this->maintainer->id, (int) $event->actor_user_id);
$canonical = DB::selectOne('select CAST(? AS jsonb)::text as canonical', [$event->snapshot])->canonical;
$this->assertSame(hash('sha256', $canonical), $event->sha256);
}
$this->assertDatabaseHas('audit_events', ['entity_id' => $source, 'actor_user_id' => $this->maintainer->id, 'action' => 'official_source_acceptance']);
$this->getJson('/api/v2/investment/admin/tax-rules/'.$id)->assertOk()->assertJsonPath('id', $id);
$this->getJson('/api/v1/fiscal-consumer')->assertOk()->assertJsonPath('data.enabled', false);
$this->assertSame($simulationCount, DB::table('simulations')->count());
$this->assertSame($this->otherConstraints, $this->unrelatedConstraints());
$this->postJson('/api/v2/investment/admin/tax-rules/'.$id.'/suspend', ['version_no' => 5, 'evidence' => $evidence])->assertOk();
$this->assertSame(6, DB::table('simulation_tax_rule_events')->where('rule_version_id', $id)->count());
}
public function test_investor_and_unconfirmed_mfa_cannot_maintain_fiscal_parameters(): void
{
$path = '/api/v2/investment/admin/tax-rule-sources';
$this->postJson($path, $this->sourceInput())->assertUnauthorized();
$this->actingAs(User::factory()->withTwoFactor()->create())->postJson($path, $this->sourceInput())->assertForbidden();
$withoutMfa = User::factory()->create();
config(['portal.managers' => [$withoutMfa->email]]);
$this->actingAs($withoutMfa)->postJson($path, $this->sourceInput())->assertForbidden();
config(['portal.managers' => [$this->maintainer->email], 'portal.profile_access_enabled' => true]);
app(PortalProfileAdministration::class)->insertGrant((string) Str::uuid(), User::factory()->withTwoFactor()->create(),
['user_id' => $this->maintainer->id, 'role' => 'auditor', 'organization_id' => null,
'opportunity_types' => ['investment_project'], 'source' => 'synthetic-qa-fiscal-control']);
$this->actingAs($this->maintainer)->postJson($path, $this->sourceInput())->assertForbidden();
}
public function test_evidence_source_version_locking_and_immutable_events_remain_required(): void
{
$this->actingAs($this->maintainer)->postJson('/api/v2/investment/admin/tax-rule-sources',
[...$this->sourceInput(), 'sha256' => ''])->assertUnprocessable();
$source = $this->source();
$wrong = json_decode(file_get_contents(database_path('qa/iva-general-net-v1.json')), true, flags: JSON_THROW_ON_ERROR);
$wrong['legal_source_id'] = $source;
$wrong['source_sha256'] = str_repeat('b', 64);
$this->postJson('/api/v2/investment/admin/tax-rules', $wrong)->assertUnprocessable();
$id = $this->rule($source);
$path = '/api/v2/investment/admin/tax-rules/'.$id.'/submit';
$this->postJson($path, ['version_no' => 1, 'evidence' => 'short'])->assertUnprocessable();
$this->postJson($path, ['version_no' => 2, 'evidence' => 'QA evidence and correct actor; stale version.'])->assertConflict();
InvestmentLegalSource::whereKey($source)->update(['version_no' => 2]);
$this->postJson($path, ['version_no' => 1, 'evidence' => 'QA evidence and correct actor; source changed.'])->assertUnprocessable();
$this->assertSame('draft', SimulationTaxRuleVersion::findOrFail($id)->status);
try {
DB::transaction(fn () => DB::table('simulation_tax_rule_events')->where('rule_version_id', $id)->update(['action' => 'tampered']));
$this->fail('Audit must stay immutable.');
} catch (QueryException) {
$this->assertDatabaseHas('simulation_tax_rule_events', ['rule_version_id' => $id, 'action' => 'created']);
}
try {
(require database_path('migrations/2026_10_21_130000_correct_fiscal_maintenance_actor_constraints.php'))->down();
$this->fail('Rollback must preserve legitimate fiscal curation.');
} catch (\LogicException $exception) {
$this->assertStringContainsString('conservar', $exception->getMessage());
}
}
public function test_other_source_validation_keeps_its_independence_and_active_migration_is_refused(): void
{
$data = $this->sourceInput();
unset($data['sha256'], $data['citation'], $data['official_origin']);
$source = InvestmentLegalSource::create([...$data, 'owner_id' => $this->maintainer->id, 'validation_status' => 'pending']);
config(['investment-zones.legal_reviewers' => [$this->maintainer->email]]);
$this->actingAs($this->maintainer)->postJson('/api/v2/investment/admin/legal-sources/'.$source->id.'/validate',
['version_no' => 1])->assertForbidden();
try {
DB::transaction(fn () => $source->update(['validator_id' => $this->maintainer->id, 'validated_at' => now(), 'validation_status' => 'validated']));
$this->fail('Other source curation must retain its check.');
} catch (QueryException) {
$this->assertSame('pending', $source->fresh()->validation_status);
}
config(['database.connections.pgsql.url' => 'not-an-isolated-qa-target']);
$this->expectException(\LogicException::class);
(require database_path('migrations/2026_10_21_130000_correct_fiscal_maintenance_actor_constraints.php'))->up();
}
public function test_scoped_administration_is_reused_without_four_email_designations(): void
{
config(['portal.managers' => []]);
config(['portal.profile_access_enabled' => true]);
$second = User::factory()->withTwoFactor()->create();
// Pre-existing-authority fixture, exclusively inside the reverted QA transaction.
DB::table('portal_superuser_pair')->updateOrInsert(['id' => 1], [
'first_user_id' => $this->maintainer->id, 'second_user_id' => $second->id,
'approved_by' => $second->id, 'version' => 1, 'created_at' => now(), 'updated_at' => now(),
]);
$this->actingAs($this->maintainer)->withSession(['portal.mfa_actor' => $this->maintainer->id, 'portal.mfa_at' => time()]);
$source = $this->source();
$id = $this->rule($source);
foreach (['submit', 'review', 'approve', 'publish'] as $offset => $action) {
$this->postJson('/api/v2/investment/admin/tax-rules/'.$id.'/'.$action,
['version_no' => $offset + 1, 'evidence' => 'QA existing scoped administrator completes fiscal maintenance; not independent legal validation.'])->assertOk();
}
$this->withSession(['portal.mfa_at' => time() - 901])->postJson('/api/v2/investment/admin/tax-rule-sources', $this->sourceInput())->assertForbidden();
}
public function test_unused_correction_can_be_rolled_back_and_reapplied_only_in_qa(): void
{
$migration = require database_path('migrations/2026_10_21_130000_correct_fiscal_maintenance_actor_constraints.php');
$migration->down();
$this->assertFalse(Schema::hasColumn('investment_legal_sources', 'fiscal_acceptance'));
$migration->up();
$this->assertTrue(Schema::hasColumn('investment_legal_sources', 'fiscal_acceptance'));
$this->assertSame($this->otherConstraints, $this->unrelatedConstraints());
}
}
@@ -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');
}
}
}
@@ -56,7 +56,7 @@ class SimulationTaxRuleGovernanceTest extends TestCase
]);
}
public function test_rule_requires_independent_review_approval_publication_and_records_hash_chained_versions(): void
public function test_rule_allows_separate_actors_and_records_hash_chained_versions(): void
{
$this->actingAs($this->editor);
$created = $this->postJson('/api/v2/investment/admin/tax-rules', $this->rulePayload())
@@ -66,9 +66,6 @@ class SimulationTaxRuleGovernanceTest extends TestCase
$this->postJson("/api/v2/investment/admin/tax-rules/{$id}/submit", $this->transitionPayload(1))
->assertOk()->assertJsonPath('status', 'submitted')->assertJsonPath('version_no', 2);
$this->postJson("/api/v2/investment/admin/tax-rules/{$id}/review", $this->transitionPayload(2))
->assertForbidden();
$this->actingAs($this->reviewer)
->postJson("/api/v2/investment/admin/tax-rules/{$id}/review", $this->transitionPayload(2))
->assertOk()->assertJsonPath('status', 'reviewed')->assertJsonPath('reviewer_id', $this->reviewer->id);
@@ -135,9 +132,9 @@ class SimulationTaxRuleGovernanceTest extends TestCase
->assertOk()->assertJsonPath('status', 'suspended');
}
public function test_draft_edit_uses_optimistic_locking_and_other_roles_cannot_create_rules(): void
public function test_draft_edit_uses_optimistic_locking_and_unauthorised_users_cannot_create_rules(): void
{
$this->actingAs($this->reviewer)->postJson('/api/v2/investment/admin/tax-rules', $this->rulePayload())->assertForbidden();
$this->actingAs(User::factory()->withTwoFactor()->create())->postJson('/api/v2/investment/admin/tax-rules', $this->rulePayload())->assertForbidden();
$this->actingAs($this->editor);
$created = $this->postJson('/api/v2/investment/admin/tax-rules', $this->rulePayload())->assertCreated();
@@ -0,0 +1,34 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use App\Services\VirtualDesk;
use App\Support\IsolatedQaDatabase;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Str;
use Tests\TestCase;
class SimulatorCashflowContinuityTest extends TestCase
{
use DatabaseTransactions;
public function test_cashflow_snapshot_recovers_in_each_dossier_after_a_new_session_without_turning_unknown_tax_into_zero(): void
{
$this->assertTrue(IsolatedQaDatabase::allowsSchemaChanges());
config(['investor-path.enabled' => true]);
$owner = User::factory()->create(['name' => 'SINTETICO QA continuidade']);
$cases = [];
foreach (['77.01', '99.02'] as $cash) {
$case = app(VirtualDesk::class)->createCase($owner, ['title' => 'SINTETICO QA tesouraria '.$cash, 'client_key' => (string) Str::uuid(), 'reuse_consent' => true]);
$snapshot = json_encode(['version' => 1, 'taxMode' => 'schedule', 'inputs' => ['openingCash' => $cash, 'monthlyRevenue' => '200', 'monthlyOperatingCosts' => '150', 'monthlyDebtService' => '20', 'monthlyTaxAssessed' => '', 'monthlyTaxSchedule' => ['10', '', '20', ...array_fill(0, 9, '0')], 'vatCreditEstimated' => '0', 'vatRefundRequested' => '0', 'vatRefundRecognized' => '0', 'vatRefundExpectedReceipt' => '0', 'vatRefundExpectedMonth' => '']], JSON_THROW_ON_ERROR);
$this->actingAs($owner)->postJson('/api/v1/simulation-drafts', ['context' => $case->id, 'personal_snapshot' => [], 'economic_snapshot' => ['purpose' => 'simulate_only', 'journey_type' => 'small_business', 'cashflow_snapshot' => $snapshot]], ['Idempotency-Key' => (string) Str::uuid()])->assertCreated();
$cases[] = [$case->id, $snapshot];
}
$this->flushSession();
foreach ($cases as [$id,$snapshot]) {
$this->actingAs(User::findOrFail($owner->id))->getJson('/api/v1/simulation-drafts/current?context='.$id)->assertOk()->assertJsonPath('data.economic_snapshot.cashflow_snapshot', $snapshot);
}
$this->actingAs(User::factory()->create())->getJson('/api/v1/simulation-drafts/current?context='.$cases[0][0])->assertForbidden();
}
}
@@ -0,0 +1,16 @@
<?php
namespace Tests\Feature;
use Tests\TestCase;
class SimulatorReviewMessagesTest extends TestCase
{
public function test_missing_api_route_has_a_portuguese_message_without_changing_the_status(): void
{
config(['app.debug' => false]);
$this->getJson('/api/v1/synthetic-qa-route-not-installed')->assertNotFound()
->assertJsonPath('message', 'O registo não foi encontrado ou não está acessível nesta conta.')
->assertHeader('X-Correlation-ID');
}
}
@@ -11,6 +11,28 @@ use PHPUnit\Framework\TestCase;
final class SimulationTaxParameterResolverTest extends TestCase
{
#[Test]
public function synthetic_qa_versions_keep_historical_values_and_expose_a_period_transition_without_activating_rules(): void
{
$before = $this->publicVersion('2026-01-01', '2026-06-30', '7.5', 1);
$after = $this->publicVersion('2026-07-01', null, '9.25', 2);
$before['rule_code'] = $after['rule_code'] = 'QA.SYNTHETIC.NOT.LEGISLATION';
$before['source']['title'] = $after['source']['title'] = 'SINTETICO QA — nao e legislacao';
$before['source']['diploma'] = $after['source']['diploma'] = 'QA-FIXTURE-ONLY';
$resolver = new SimulationTaxParameterResolver;
$historical = $resolver->resolveAvailableParameters([$before], 'IVA', '2026-06-01', '2026-06-30', ['operation' => 'domestic']);
$frozen = json_encode($historical, JSON_THROW_ON_ERROR);
$current = $resolver->resolveAvailableParameters([$before, $after], 'IVA', '2026-07-01', '2026-07-31', ['operation' => 'domestic']);
self::assertSame('7.5', $historical[0]['value']);
self::assertSame('9.25', $current[0]['value']);
self::assertSame(2, $current[0]['rule_version']);
self::assertSame('indeterminate_period_change', $resolver->resolveAvailableParameters([$before, $after], 'IVA', '2026-01-01', '2026-12-31', ['operation' => 'domestic'])[0]['status']);
self::assertSame($frozen, json_encode($historical, JSON_THROW_ON_ERROR));
self::assertSame($historical, $resolver->resolveAvailableParameters([$before, $after], 'IVA', '2026-06-01', '2026-06-30', ['operation' => 'domestic']));
$after['source']['version_no'] = 2;
self::assertSame([], $resolver->resolveAvailableParameters([$after], 'IVA', '2026-07-01', '2026-07-31', ['operation' => 'domestic']));
}
#[Test]
public function it_selects_the_published_version_effective_on_the_requested_date_and_returns_its_source(): void
{

Some files were not shown because too many files have changed in this diff Show More