This commit is contained in:
@@ -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
|
||||
|
||||
@@ -37,11 +37,17 @@ class SimulationController extends Controller
|
||||
$token = $actorId === null ? $request->header('X-Simulation-Token') : null;
|
||||
abort_if($actorId === null && (! is_string($token) || strlen($token) < 32 || strlen($token) > 256), 422, 'X-Simulation-Token opaco gerado pelo cliente é obrigatório para execução anónima.');
|
||||
$validated = $request->validated();
|
||||
$data = ['base' => (string) $validated['base'], 'rate' => (string) $validated['rate'],
|
||||
'reduction' => (string) ($validated['reduction'] ?? '0'), 'organization_id' => $validated['organization_id'] ?? null];
|
||||
$organizationId = $data['organization_id'] ?? null;
|
||||
$organizationId = $validated['organization_id'] ?? null;
|
||||
$execution->authorizeOrganization($actorId, $organizationId);
|
||||
$result = $execution->create($data, $actorId, $organizationId, $token, $key);
|
||||
if (($validated['scenario_kind'] ?? null) === 'fiscal_rule_published') {
|
||||
$input = array_intersect_key($validated, array_flip(['base', 'currency', 'reference_date', 'operation', 'scope_declared']));
|
||||
$input['scope_declared'] = (bool) $input['scope_declared'];
|
||||
$result = $execution->createPublishedFiscal($input, $actorId, $organizationId, $token, $key);
|
||||
} else {
|
||||
$data = ['base' => (string) $validated['base'], 'rate' => (string) $validated['rate'],
|
||||
'reduction' => (string) ($validated['reduction'] ?? '0')];
|
||||
$result = $execution->create($data, $actorId, $organizationId, $token, $key);
|
||||
}
|
||||
$simulation = $result['simulation'];
|
||||
$body = ['data' => $simulation, 'replayed' => $result['replayed']];
|
||||
if ($actorId === null) {
|
||||
|
||||
@@ -40,6 +40,7 @@ class InvestmentLegalSourceController extends Controller
|
||||
$data = $request->validate(['version_no' => ['required', 'integer', 'min:1']]);
|
||||
|
||||
return DB::transaction(function () use ($request, $id, $action, $data) {
|
||||
DB::select('select pg_advisory_xact_lock(261022, hashtext(?))', [$id]);
|
||||
$source = InvestmentLegalSource::whereKey($id)->lockForUpdate()->firstOrFail();
|
||||
abort_if((int) $source->version_no !== (int) $data['version_no'], 409, 'Versão alterada.');
|
||||
if ($action === 'validate') {
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -65,10 +65,12 @@ class RunSimulation implements ShouldQueue
|
||||
$input = json_decode($scenario->parameters, true, flags: JSON_THROW_ON_ERROR);
|
||||
$resultId = (string) Str::uuid();
|
||||
$engineVersion = 'portal-arithmetic-v1+simulation-v'.$simulation->version_no;
|
||||
if (($input['scenario_kind'] ?? null) === 'fiscal_rule_qa_preview') {
|
||||
abort_unless(IsolatedQaDatabase::allowsSchemaChanges(), 404);
|
||||
if (in_array($input['scenario_kind'] ?? null, ['fiscal_rule_qa_preview', 'fiscal_rule_published'], true)) {
|
||||
if ($input['scenario_kind'] === 'fiscal_rule_qa_preview') {
|
||||
abort_unless(IsolatedQaDatabase::allowsSchemaChanges(), 404);
|
||||
}
|
||||
$result = $input['fiscal_preview'];
|
||||
$engineVersion = 'fiscal-draft-preview-v1';
|
||||
$engineVersion = $result['context']['engine_version'];
|
||||
$result['context']['scenario_id'] = (string) $scenario->id;
|
||||
$result['context']['scenario_version'] = (int) $scenario->version_no;
|
||||
} else {
|
||||
@@ -108,7 +110,7 @@ class RunSimulation implements ShouldQueue
|
||||
$sourceJson = json_encode(isset($result['fiscal_provenance']) ? [
|
||||
'base' => $result['base'], 'currency' => $result['currency'],
|
||||
'reference_date' => $result['context']['reference_date'],
|
||||
'context' => $result['fiscal_provenance']['context'], 'scope_status' => 'user_declared',
|
||||
'context' => $result['fiscal_provenance']['context'], 'scope_status' => $result['fiscal_provenance']['scope_status'],
|
||||
] : $input, JSON_THROW_ON_ERROR);
|
||||
$canonicalSource = DB::selectOne('select CAST(? AS jsonb)::text as canonical', [$sourceJson])->canonical;
|
||||
DB::table('simulation_result_sources')->insertOrIgnore(['id' => (string) Str::uuid(), 'result_id' => $resultId,
|
||||
@@ -119,7 +121,7 @@ class RunSimulation implements ShouldQueue
|
||||
$provenanceJson = json_encode($provenance, JSON_THROW_ON_ERROR);
|
||||
$canonicalProvenance = DB::selectOne('select CAST(? AS jsonb)::text as canonical', [$provenanceJson])->canonical;
|
||||
DB::table('simulation_result_sources')->insert([
|
||||
'id' => (string) Str::uuid(), 'result_id' => $resultId, 'source_type' => 'tax_rule_draft',
|
||||
'id' => (string) Str::uuid(), 'result_id' => $resultId, 'source_type' => $input['scenario_kind'] === 'fiscal_rule_published' ? 'tax_rule_published' : 'tax_rule_draft',
|
||||
'source_id' => $provenance['rule']['id'], 'source_version' => (string) $provenance['rule']['rule_version'],
|
||||
'source_snapshot' => $provenanceJson, 'source_sha256' => hash('sha256', $canonicalProvenance), 'created_at' => now(),
|
||||
]);
|
||||
|
||||
@@ -34,7 +34,15 @@ final class SimulationExecution
|
||||
$actorId, null, null, $key);
|
||||
}
|
||||
|
||||
private function createPayload(array $payload, ?int $actorId, ?string $organizationId, ?string $token, string $key): array
|
||||
public function createPublishedFiscal(array $input, ?int $actorId, ?string $organizationId, ?string $token, string $key): array
|
||||
{
|
||||
$payload = ['scenario_kind' => 'fiscal_rule_published', 'fiscal_input' => $input, 'organization_id' => $organizationId];
|
||||
|
||||
return $this->createPayload($payload, $actorId, $organizationId, $token, $key,
|
||||
fn (): array => app(SimulationPublishedFiscal::class)->admit($input));
|
||||
}
|
||||
|
||||
private function createPayload(array $payload, ?int $actorId, ?string $organizationId, ?string $token, string $key, ?callable $admit = null): array
|
||||
{
|
||||
$tokenHash = $actorId === null ? hash_hmac('sha256', (string) $token, (string) config('app.key')) : null;
|
||||
$scopeHash = hash('sha256', $actorId !== null ? (string) $actorId : ($tokenHash ?? 'anonymous'));
|
||||
@@ -42,7 +50,7 @@ final class SimulationExecution
|
||||
ksort($payload);
|
||||
$payloadHash = hash('sha256', json_encode($payload, JSON_THROW_ON_ERROR));
|
||||
|
||||
return DB::transaction(function () use ($actorId, $organizationId, $token, $tokenHash, $scopeHash, $keyHash, $payloadHash, $payload): array {
|
||||
return DB::transaction(function () use ($actorId, $organizationId, $token, $tokenHash, $scopeHash, $keyHash, $payloadHash, $payload, $admit): array {
|
||||
$this->setContext($actorId, $tokenHash);
|
||||
DB::select('select pg_advisory_xact_lock(261001, hashtext(?))', [$scopeHash.$keyHash]);
|
||||
$receipt = DB::table('simulation_idempotency')->where('scope_hash', $scopeHash)->where('key_hash', $keyHash)->first();
|
||||
@@ -54,6 +62,10 @@ final class SimulationExecution
|
||||
return ['simulation' => Simulation::with(['scenarios', 'reports'])->whereKey((string) $receipt->simulation_id)->firstOrFail(), 'token' => null, 'replayed' => true];
|
||||
}
|
||||
|
||||
if ($admit !== null) {
|
||||
$payload['fiscal_preview'] = $admit();
|
||||
}
|
||||
|
||||
$plainToken = $actorId === null ? $token : null;
|
||||
$tokenHash = $actorId === null ? hash_hmac('sha256', $plainToken, (string) config('app.key')) : null;
|
||||
$this->setContext($actorId, $tokenHash);
|
||||
@@ -61,8 +73,8 @@ final class SimulationExecution
|
||||
$simulation = Simulation::create([
|
||||
'id' => $id, 'questionnaire_version_id' => '61000100-0000-4000-8000-000000000001', 'owner_user_id' => $actorId, 'organization_id' => $organizationId,
|
||||
'anonymous_token_hash' => $tokenHash, 'status' => 'queued', 'version_no' => 1,
|
||||
'input_snapshot' => $payload, 'calculation_snapshot' => $payload['scenario_kind'] === 'fiscal_rule_qa_preview'
|
||||
? ['engine' => 'fiscal-draft-preview-v1', 'mode' => 'qa_draft_only', 'arithmetic' => 'exact_decimal_no_rounding']
|
||||
'input_snapshot' => $payload, 'calculation_snapshot' => in_array($payload['scenario_kind'], ['fiscal_rule_qa_preview', 'fiscal_rule_published'], true)
|
||||
? ['engine' => $payload['fiscal_preview']['context']['engine_version'], 'mode' => $payload['fiscal_preview']['fiscal_provenance']['mode'], 'arithmetic' => 'exact_decimal_no_rounding']
|
||||
: ['engine' => 'portal-arithmetic-v1', 'formula' => 'base * rate / 100 * (1 - reduction / 100)', 'legal_rate_source' => null],
|
||||
]);
|
||||
$scenarioId = (string) Str::uuid();
|
||||
@@ -104,7 +116,7 @@ final class SimulationExecution
|
||||
return DB::transaction(function () use ($id, $scenarioId, $data, $request): Simulation {
|
||||
$this->setContext($request->user()?->id, $this->tokenHash($request));
|
||||
$simulation = Simulation::whereNull('deleted_at')->lockForUpdate()->findOrFail($id);
|
||||
abort_if(($simulation->input_snapshot['scenario_kind'] ?? null) === 'fiscal_rule_qa_preview', 422,
|
||||
abort_if(in_array($simulation->input_snapshot['scenario_kind'] ?? null, ['fiscal_rule_qa_preview', 'fiscal_rule_published'], true), 422,
|
||||
'Crie uma nova pré-visualização fiscal; este snapshot conserva a regra e as entradas originais.');
|
||||
$this->requireMatch($request, $simulation);
|
||||
$scenario = DB::table('simulation_scenarios')->where('simulation_id', $id)->where('id', $scenarioId)->lockForUpdate()->first();
|
||||
|
||||
@@ -7,7 +7,6 @@ namespace App\Services;
|
||||
use App\Models\InvestmentLegalSource;
|
||||
use App\Models\User;
|
||||
use App\Support\IsolatedQaDatabase;
|
||||
use Brick\Math\BigRational;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/** Exact operation-level IVA calculation against an explicit QA draft; never a payment/return total. */
|
||||
@@ -43,37 +42,7 @@ final class SimulationFiscalQaPreview
|
||||
['base_tributavel' => $input['base']], ['iva_general_rate' => $rate['value']]);
|
||||
}
|
||||
}
|
||||
$exact = ($evaluation['status'] ?? null) === 'determinate'
|
||||
? (string) BigRational::of($evaluation['result'])->toBigDecimal()
|
||||
: null;
|
||||
if ($exact === null && $missing === []) {
|
||||
$missing = $evaluation['missing'] ?? ['eligible_parameter'];
|
||||
}
|
||||
$snapshot = [
|
||||
'contract_version' => 1, 'scenario_kind' => 'fiscal_rule_qa_preview',
|
||||
'base' => $input['base'], 'currency' => $input['currency'], 'indicative_total' => $exact,
|
||||
'legal_interpretation' => false, 'formula_version' => 'investmoz.tax.v1',
|
||||
'context' => [
|
||||
'reference_date' => $date, 'currency' => $input['currency'], 'currency_fraction_digits' => 2,
|
||||
'scenario_id' => null, 'scenario_version' => 1, 'engine_version' => 'fiscal-draft-preview-v1',
|
||||
'ruleset_version' => $rule->rule_code.':'.$rule->rule_version,
|
||||
],
|
||||
'result' => [
|
||||
'status' => $exact === null ? 'indeterminate' : 'calculated', 'value' => $exact, 'unit' => 'MZN',
|
||||
'explanation' => $exact === null
|
||||
? 'IVA indeterminado: o contexto, a fonte ou o período não permitem resolver o parâmetro deste lote QA.'
|
||||
: 'IVA calculado com precisão decimal exacta, sem arredondamento. Ensaio QA de regra em rascunho; enquadramento declarado, não validado. Não é saldo de declaração nem valor a pagar.',
|
||||
'missing_inputs' => $missing, 'source_ids' => [(string) $source->id],
|
||||
],
|
||||
'presentation' => ['value' => $exact, 'currency' => $input['currency'], 'rounding_applied' => false, 'purpose' => 'operation_tax_not_payment'],
|
||||
// Exact rational steps supplement the decimal result; no float conversion.
|
||||
'intermediate' => $evaluation,
|
||||
'fiscal_provenance' => [
|
||||
'mode' => 'qa_draft_only', 'scope_status' => 'user_declared', 'context' => $context,
|
||||
'rounding_policy' => $rule->rounding_policy, 'resolution' => $resolution,
|
||||
'rule' => $version, 'source_acceptance' => 'Fonte oficial aceite pela decisão Tempo de 08/10/2026; não constitui aprovação jurídica desta operação ou publicação da regra.',
|
||||
],
|
||||
];
|
||||
$snapshot = app(SimulationFiscalSnapshot::class)->build($version, $input, $resolution, $evaluation, $missing);
|
||||
|
||||
return app(SimulationExecution::class)->createFiscalQaPreview($snapshot, $actor->id, $key);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Brick\Math\BigRational;
|
||||
|
||||
/** Shared snapshot representation; eligibility is resolved by each admission adapter. */
|
||||
final class SimulationFiscalSnapshot
|
||||
{
|
||||
public function build(array $version, array $input, array $resolution, ?array $evaluation, array $missing, bool $published = false): array
|
||||
{
|
||||
$date = $input['reference_date'];
|
||||
$context = ['operation' => $input['operation'], 'currency' => $input['currency']];
|
||||
$exact = ($evaluation['status'] ?? null) === 'determinate'
|
||||
? (string) BigRational::of($evaluation['result'])->toBigDecimal()
|
||||
: null;
|
||||
if ($exact === null && $missing === []) {
|
||||
$missing = $evaluation['missing'] ?? ['eligible_parameter'];
|
||||
}
|
||||
$snapshot = [
|
||||
'contract_version' => 1, 'scenario_kind' => $published ? 'fiscal_rule_published' : 'fiscal_rule_qa_preview',
|
||||
'base' => $input['base'], 'currency' => $input['currency'], 'indicative_total' => $exact,
|
||||
'legal_interpretation' => false, 'formula_version' => 'investmoz.tax.v1',
|
||||
'context' => [
|
||||
'reference_date' => $date, 'currency' => $input['currency'], 'currency_fraction_digits' => 2,
|
||||
'scenario_id' => null, 'scenario_version' => 1, 'engine_version' => $published ? 'fiscal-published-v1' : 'fiscal-draft-preview-v1',
|
||||
'ruleset_version' => $version['rule_code'].':'.$version['rule_version'],
|
||||
],
|
||||
'result' => [
|
||||
'status' => $exact === null ? 'indeterminate' : 'calculated', 'value' => $exact, 'unit' => 'MZN',
|
||||
'explanation' => $exact === null
|
||||
? 'IVA indeterminado: o contexto, a fonte ou o período não permitem resolver o parâmetro deste lote QA.'
|
||||
: 'IVA calculado com precisão decimal exacta, sem arredondamento. Ensaio QA de regra em rascunho; enquadramento declarado, não validado. Não é saldo de declaração nem valor a pagar.',
|
||||
'missing_inputs' => $missing, 'source_ids' => [(string) $version['legal_source_id']],
|
||||
],
|
||||
'presentation' => ['value' => $exact, 'currency' => $input['currency'], 'rounding_applied' => false, 'purpose' => 'operation_tax_not_payment'],
|
||||
// Exact rational steps supplement the decimal result; no float conversion.
|
||||
'intermediate' => $evaluation,
|
||||
'fiscal_provenance' => [
|
||||
'mode' => $published ? 'published_version' : 'qa_draft_only', 'scope_status' => ($input['scope_declared'] ?? true) ? 'user_declared' : 'undetermined', 'context' => $context,
|
||||
'rounding_policy' => $version['rounding_policy'], 'resolution' => $resolution,
|
||||
'rule' => $version, 'source_acceptance' => 'Fonte oficial aceite pela decisão Tempo de 08/10/2026; não constitui aprovação jurídica desta operação ou publicação da regra.',
|
||||
],
|
||||
];
|
||||
|
||||
if ($published) {
|
||||
$snapshot['result']['explanation'] = $exact === null
|
||||
? 'IVA indeterminado: falta contexto, fonte ou parâmetro elegível para esta operação.'
|
||||
: 'IVA calculado pela versão publicada identificada, sem arredondamento. Enquadramento declarado, não validado. Não é saldo de declaração nem valor a pagar.';
|
||||
$snapshot['fiscal_provenance']['admitted_at'] = now()->toISOString();
|
||||
$snapshot['fiscal_provenance']['source_acceptance'] = 'Fonte oficial aceite pela Tempo em 08/10/2026; não constitui validação jurídica desta operação.';
|
||||
}
|
||||
|
||||
return $snapshot;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\InvestmentLegalSource;
|
||||
use App\Models\SimulationTaxRuleVersion;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/** Admission freezes eligibility without granting the public consumer governance write access. */
|
||||
final class SimulationPublishedFiscal
|
||||
{
|
||||
public function availability(): array
|
||||
{
|
||||
$enabled = (bool) config('simulation-fiscal-consumer.enabled');
|
||||
$eligible = $enabled && $this->boundRule()->where('status', 'published')->exists();
|
||||
|
||||
return ['enabled' => $eligible, 'coverage_start' => '2026-01-01', 'coverage_end' => '2026-12-31',
|
||||
'message' => $eligible ? 'IVA geral sobre base líquida disponível; enquadramento declarado.'
|
||||
: 'O cálculo de IVA geral sobre base líquida está indisponível para novas execuções. Os históricos mantêm-se acessíveis.'];
|
||||
}
|
||||
|
||||
private function boundRule(): Builder
|
||||
{
|
||||
if (! Str::isUuid((string) config('simulation-fiscal-consumer.rule_id')) || config('simulation-fiscal-consumer.rule_version') < 1) {
|
||||
return SimulationTaxRuleVersion::query()->whereRaw('false');
|
||||
}
|
||||
|
||||
return SimulationTaxRuleVersion::query()->whereKey(config('simulation-fiscal-consumer.rule_id'))
|
||||
->where('rule_version', config('simulation-fiscal-consumer.rule_version'))
|
||||
->where('rule_code', 'IVA-GERAL-BASE-LIQUIDA')->where('component', 'IVA')
|
||||
->where('scope_key', 'mz.internal-general-net.mzn');
|
||||
}
|
||||
|
||||
public function admit(array $input): array
|
||||
{
|
||||
// Called inside createPayload, after idempotent replay, never by the worker.
|
||||
abort_unless(DB::transactionLevel() > 0, 500);
|
||||
abort_unless(config('simulation-fiscal-consumer.enabled'), 503,
|
||||
'O cálculo de IVA está temporariamente indisponível. Os dados e históricos foram preservados.');
|
||||
DB::select('select pg_advisory_xact_lock_shared(261021, hashtext(?))', [(string) config('simulation-fiscal-consumer.rule_id')]);
|
||||
$rule = $this->boundRule()->first();
|
||||
abort_unless($rule && $rule->status === 'published', 503,
|
||||
'A versão autorizada de IVA não está disponível para novas execuções. Os históricos foram preservados.');
|
||||
abort_if($input['reference_date'] < '2026-01-01' || $input['reference_date'] > '2026-12-31', 422,
|
||||
'Este cálculo abrange apenas exigibilidade em 2026. As entradas foram preservadas.');
|
||||
abort_if(collect($rule->rule_definition['nodes'] ?? [])->contains(fn (array $node): bool => ($node['op'] ?? null) === 'round'), 422,
|
||||
'A versão seleccionada não é compatível com o cálculo exacto deste consumidor.');
|
||||
DB::select('select pg_advisory_xact_lock_shared(261022, hashtext(?))', [(string) $rule->legal_source_id]);
|
||||
$source = InvestmentLegalSource::whereKey($rule->legal_source_id)->firstOrFail();
|
||||
// Publish calculation provenance, not private governance notes or staff identifiers.
|
||||
$version = array_intersect_key($rule->attributesToArray(), array_flip([
|
||||
'id', 'rule_code', 'rule_version', 'component', 'scope_key', 'status', 'valid_from', 'valid_until',
|
||||
'legal_source_id', 'source_version_no', 'source_sha256', 'rule_definition', 'parameters', 'rounding_policy',
|
||||
]));
|
||||
$version['source'] = array_intersect_key($source->attributesToArray(), array_flip([
|
||||
'id', 'title', 'url', 'diploma', 'article', 'competent_entity', 'valid_from', 'valid_until', 'validation_status', 'version_no',
|
||||
]));
|
||||
$resolution = app(SimulationTaxParameterResolver::class)->resolvePublishedForPeriod('IVA',
|
||||
$input['reference_date'], $input['reference_date'], ['currency' => $input['currency'], 'operation' => $input['operation']]);
|
||||
$rate = collect($resolution)->firstWhere('parameter_code', 'IVA_GENERAL_RATE');
|
||||
$missing = [];
|
||||
$evaluation = null;
|
||||
if (! ($input['scope_declared'] ?? false)) {
|
||||
$missing[] = 'operation_scope';
|
||||
}
|
||||
if (($rate['status'] ?? null) !== 'resolved' || ($rate['unit'] ?? null) !== 'percent'
|
||||
|| ($rate['rule_id'] ?? null) !== (string) $rule->id
|
||||
|| (int) ($rate['rule_version'] ?? 0) !== (int) $rule->rule_version) {
|
||||
$missing[] = 'eligible_parameter';
|
||||
}
|
||||
if ($missing === []) {
|
||||
$evaluation = app(SimulationTaxRuleExpression::class)->evaluate($rule->rule_definition,
|
||||
['base_tributavel' => $input['base']], ['iva_general_rate' => $rate['value']]);
|
||||
}
|
||||
|
||||
return app(SimulationFiscalSnapshot::class)->build($version, $input, $resolution, $evaluation, $missing, true);
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
|
||||
@@ -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),
|
||||
];
|
||||
@@ -87,6 +87,8 @@ As15–26h anteriores eram condicionais e incluíam o trabalho de cadastro e int
|
||||
|
||||
## Revisão consolidada de83d2f97d
|
||||
|
||||
**Actualização técnica de21/10/2026 — delta2ced7b79 implementado em QA:** o consumidor por versão publicada está ligado ao simulador normal, com activação desligada por omissão, selecção da regra no servidor e gravação/recuperação/relatório pelo histórico existente. O mesmo manual regista a demonstração pelo Edge, indeterminação sem declaração de âmbito, repetição sem duplicação, suspensão antes do processamento da fila, conclusão com versão congelada e recusa de novas execuções. Oito testes dirigidos/75asserções e dois testes frontend novos passaram, além deTypeScript/build/Pint. Mantêm-se integralmente o âmbito2026, exclusões, fontes e precisão desta ficha; não se repete a fundamentação jurídica nem se lhe atribui validação da Tempo. O procedimento único foi actualizado para integração, publicação da versão real e activação, decisões ainda não executadas na homologação. A fixture publicada exclusivamente no clone QA foi suspensa após o ensaio; os históricos foram preservados. As notas abaixo sobre ausência do consumidor descrevem o estado anterior à implementação deste delta.
|
||||
|
||||
**Disponibilização no simulador existente:** a proposta única «Consumidor IVA no simulador da homologação», no mesmo manual, delimita o código ainda necessário e as três operações distintas: integração com consumidor desligado, publicação da versão pela governação existente e activação da ligação à versão autorizada. O percurso QA aceite não equivale a esse consumidor público. Mantêm-se âmbito2026, exclusões, precisão exacta e históricos; suspensão impede novas admissões e preserva execuções já admitidas. Esta proposta não executa nenhuma das três operações nem amplia a cobertura fiscal.
|
||||
|
||||
O [mesmo manual de rastreabilidade](../unification/traceability-and-manual.md) consolida âmbito/período, operações excluídas/indeterminadas e os procedimentos concretos de integração, publicação, activação e desactivação, preservando históricos. Identifica também que83d2f97d não contém um consumidor fiscal público: a publicação torna parâmetros elegíveis na API existente, mas não transforma o percurso QA em cálculo público activado.
|
||||
|
||||
@@ -484,5 +484,109 @@
|
||||
"consumer_activation": false,
|
||||
"user_legal_validation": false,
|
||||
"record": "docs/investmoz/unification/traceability-and-manual.md: Proposta unica - consumidor IVA"
|
||||
},
|
||||
"published_consumer_qa_20261021": {
|
||||
"proposal_base": "2ced7b79",
|
||||
"status": "Implemented and demonstrated in normal QA simulator; separate integration/publication/activation decisions pending",
|
||||
"consumer_default_enabled": false,
|
||||
"scope": "IVA general net taxable base, declared internal general operation, MZN, exigibility 2026 only",
|
||||
"feature_tests": {
|
||||
"passed": 8,
|
||||
"assertions": 75,
|
||||
"isolated_database": "simulator_fiscal_checks_20261021_test",
|
||||
"rls_non_superuser": true,
|
||||
"separate_connection_lock_probe": true
|
||||
},
|
||||
"frontend_new_link_tests_passed": 2,
|
||||
"typescript": "passed",
|
||||
"build": "passed",
|
||||
"pint": "passed",
|
||||
"accepted_evidence_reused": [
|
||||
"decimal calculation",
|
||||
"exporters",
|
||||
"native PDF pagination",
|
||||
"financial planning and recovery"
|
||||
],
|
||||
"normal_simulator_browser_journey": {
|
||||
"database": "simulator_fiscal_delta_20261021_test",
|
||||
"url": "http://127.0.0.1:18580/simulador-fiscal",
|
||||
"fixture_only": true,
|
||||
"user_id": 167,
|
||||
"rule_id": "01a15ddc-b0c5-727f-bb24-d806a69bd1f5",
|
||||
"rule_status": "suspended",
|
||||
"suspended_at": "2026-10-21T09:52:16.000000Z",
|
||||
"executions": [
|
||||
{
|
||||
"id": "53663785-2e5d-4be2-8a05-c0c96a57896d",
|
||||
"status": "completed",
|
||||
"base": "1000.03",
|
||||
"value": null,
|
||||
"result_status": "indeterminate",
|
||||
"scope_status": "undetermined",
|
||||
"admitted_at": "2026-10-21T09:50:53.949756Z",
|
||||
"frozen_rule_status": "published",
|
||||
"rule_id": "01a15ddc-b0c5-727f-bb24-d806a69bd1f5",
|
||||
"rule_version": 2,
|
||||
"report_id": "63d427ac-e6ae-481c-adad-e5f39fcc3c15",
|
||||
"report_sha256": "d405705f6ce050518a0b400bed842b2aeaec143af3d78c54088100417d326c64",
|
||||
"result_report_hash_equal": true
|
||||
},
|
||||
{
|
||||
"id": "5fa778f1-413e-4643-8c67-1b93ab290da7",
|
||||
"status": "completed",
|
||||
"base": "1000.03",
|
||||
"value": "160.0048",
|
||||
"result_status": "calculated",
|
||||
"scope_status": "user_declared",
|
||||
"admitted_at": "2026-10-21T09:51:11.307106Z",
|
||||
"frozen_rule_status": "published",
|
||||
"rule_id": "01a15ddc-b0c5-727f-bb24-d806a69bd1f5",
|
||||
"rule_version": 2,
|
||||
"report_id": "533b60df-ce44-40fd-9911-c7ed492dbd7e",
|
||||
"report_sha256": "2c5cc798c06920aee0707465ab8b7fee517783db24939b3921c76a70999dad77",
|
||||
"result_report_hash_equal": true
|
||||
},
|
||||
{
|
||||
"id": "be1e8399-ea95-4aa7-9ac0-7fa392932ac1",
|
||||
"status": "completed",
|
||||
"base": "1001.03",
|
||||
"value": "160.1648",
|
||||
"result_status": "calculated",
|
||||
"scope_status": "user_declared",
|
||||
"admitted_at": "2026-10-21T09:52:11.782758Z",
|
||||
"frozen_rule_status": "published",
|
||||
"rule_id": "01a15ddc-b0c5-727f-bb24-d806a69bd1f5",
|
||||
"rule_version": 2,
|
||||
"report_id": "5e416a48-db07-449b-8eae-aa51a1538f04",
|
||||
"report_sha256": "997f026665e586c662ae574fd2ff7f62e0254819adb26aea920575e47e0490a3",
|
||||
"result_report_hash_equal": true
|
||||
}
|
||||
],
|
||||
"execution_count": 3,
|
||||
"pending_queue_records": 0,
|
||||
"main_integration": false,
|
||||
"homologation_publication_or_activation": false
|
||||
},
|
||||
"observed": [
|
||||
"initial disabled consumer",
|
||||
"insufficient scope persisted as indeterminate",
|
||||
"declared scope calculated through published server binding",
|
||||
"repeat without duplicate",
|
||||
"database queue admitted before suspension and processed after suspension",
|
||||
"new input refused with values preserved",
|
||||
"hard reload and history recovery of calculated and indeterminate snapshots",
|
||||
"CSV control enabled without rerunning download"
|
||||
],
|
||||
"final_qa_fixture_status": "suspended",
|
||||
"final_qa_switch_enabled": false,
|
||||
"historical_records_preserved": true,
|
||||
"main_local_head": "13f68f1b",
|
||||
"main_integration": false,
|
||||
"homologation_rule_publication": false,
|
||||
"homologation_consumer_activation": false,
|
||||
"user_legal_validation": false,
|
||||
"user_local_file_review": false,
|
||||
"procedure": "Existing manual: Procedimento unico das tres operacoes - agora executavel apos decisao",
|
||||
"proofs_private": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1864,3 +1864,43 @@ Excluem-se preço com IVA incluído, taxas reduzidas, isenções/listas, bases e
|
||||
Históricos não são recalculados, apagados ou renomeados como se tivessem usado a versão nova. A suspensão fica no registo de governação, sem alterar retroactivamente o conteúdo/hash do relatório. Retoma exige versão publicada elegível e ligação/activação autorizada; não existe transição directa de suspended parapublished no fluxo actual. Não eliminar a leitura de snapshots fiscais ao reverter código.
|
||||
|
||||
**Verificação nova estritamente necessária:** contrato público e permissões existentes; impossibilidade de executar desligado ou com versão não publicada; correspondência temporal/âmbito e precisão na ligação; cenário publicado na interface/histórico/relatório/exportação; suspensão versus admissão concorrente e repetição sem duplicação; preservação da leitura histórica e do modo personalizado. Não repetir as10páginas, valores/CSV ou ensaios QA aceites. Não se cria nova matriz/backlog; AUD-SIM-INT02 conserva o estado parcial do simulador fiscal integral e referencia esta delimitação. As fontes e provas existentes sustentam a proposta; não se atribui ao utilizador conferência local ou validação jurídica.
|
||||
|
||||
### Consumidor publicado implementado no simulador normal — fecho QA de21/10/2026
|
||||
|
||||
**Autorização:** instrução expressa da Tempo para implementar2ced7b79 em QA, sem nova proposta, integração emmain, publicação de regra real ou activação na homologação. O delta descrito acima está agora implementado. O fecho é da ligação deste único consumidor; mantém âmbito/exclusões da ficha v1.2 e não encerra o simulador fiscal integral.
|
||||
|
||||
**Percurso utilizável:** `/simulador-fiscal` → percurso habitual → etapa «Operação fiscal» → «IVA geral sobre base líquida» → base decimal textual, moeda explicitamente seleccionada, exigibilidade e declaração de contexto → «Calcular IVA e gravar». O resultado aparece no próprio simulador e no histórico existente. Pode recuperar-se sem recalcular; relatório, referência/hash e exportações usam o mesmo snapshot. Não é necessário acesso à administração fiscal para calcular. A consulta de parâmetros e o planeamento personalizado conservam os seus percursos e rótulos próprios. A base de orçamento e a taxa manual não são usadas como entradas implícitas deste IVA.
|
||||
|
||||
#### Implementação e fronteiras efectivas
|
||||
|
||||
- `StoreSimulationRequest` e `SimulationController::store` distinguem `fiscal_rule_published` do planeamento. O servidor recebe apenas entradas declaradas, não taxa/regra/versão/snapshot executável fornecidos pelo cliente; valida formato decimal textual, MZN, data e contexto. Falta de declaração de enquadramento grava indeterminação com valor nulo, nunca zero. Campos inválidos/período fora2026 são recusados com mensagens portuguesas e entradas preservadas.
|
||||
- `SimulationPublishedFiscal` selecciona o ID/versão configurado no servidor, exige estado publicado e âmbito do lote, reutiliza `SimulationTaxParameterResolver` e `SimulationTaxRuleExpression`, e congela a proveniência antes da fila. `SimulationFiscalSnapshot` partilha a representação decimal com o adaptador QA, cujas guardas não foram removidas. Não há regra fiscal activada por importação ou arranque.
|
||||
- `SimulationExecution` consulta a chave idempotente antes de verificar nova admissão. Assim, desligamento/suspensão não impedem recuperar o mesmo pedido já admitido. Nova chave exige consumidor ligado e versão publicada elegível; mudar entradas com a mesma chave dá conflito. A interface conserva a chave do pedido fiscal concluído para que repetir o mesmo cálculo não crie uma execução adicional. Novas entradas produzem nova chave/execução.
|
||||
- Bloqueios consultivos transaccionais partilhados na admissão e exclusivos nas transições de governação/fonte serializam suspensão/revogação e admissão. Não se usa `SELECT FOR SHARE` na regra pública: poderia exigir uma política de alteração, contrariando as permissões existentes. A decisão de usar bloqueios consultivos é escolha de implementação, não nova exigência da Tempo. Não se alteraram políticas/grants nem se acrescentaram papéis de produto.
|
||||
- `RunSimulation` processa o snapshot fiscal já admitido, sem resolver outra taxa/versão e sem reconsultar a activação. Guarda resultado, relatório e proveniência nos modelos existentes. Notas internas de revisão e identificadores dos funcionários ficam fora da proveniência devolvida pelo novo consumidor. Snapshots QA antigos conservam os seus rótulos/estado originais.
|
||||
- O leitor de resultados do simulador foi adaptado ao contrato fiscal: o leitor aritmético anterior marcava o novo snapshot como inconsistente e desactivava exportação/conversão. Agora reconhece valor fiscal e indeterminação coerentes, e recusa divergência entre valor calculado e apresentação. CSV e dossier encaminham o novo tipo pelos componentes existentes; não se reescreveram os exportadores nem se repetiram provas aceites de paginação/valores.
|
||||
- Configuração nova e desligada por omissão: `INVESTMOZ_IVA_CONSUMER_ENABLED=false`, `INVESTMOZ_IVA_CONSUMER_RULE_ID=` e `INVESTMOZ_IVA_CONSUMER_RULE_VERSION=0`, documentadas em `.env.example`. `/api/v1/fiscal-consumer` informa a disponibilidade sem cache; o servidor continua a exigir autorização na execução, independentemente do estado previamente visto no ecrã. A interface actualiza disponibilidade após uma tentativa, preservando entradas quando encontra suspensão. Configuração incompleta não escolhe outra regra automaticamente.
|
||||
|
||||
#### Provas novas, sem reabrir o conjunto aceite
|
||||
|
||||
Oito testes dirigidos deAPI/serviço,75asserções, passaram no clone isolado `simulator_fiscal_checks_20261021_test`, com transacções revertidas: ligação publicada, identidade/propriedade, token anónimo, idempotência/conflito, histórico/relatório/hash, contexto/fonte insuficientes, contrato/cobertura, suspensão/desligamento e trabalhador congelado, planeamento ainda disponível, configuração inválida e bloqueios de admissão. Um papel PostgreSQL temporário sem superutilizador/bypassRLS comprovou execução com a política pública de leitura, sem acesso aos eventos da governação ou alteração da regra; papel/grants de ensaio foram revertidos com a transacção. Uma segunda ligação comprovou que os bloqueios exclusivos da regra/fonte não atravessam a transacção de admissão. Dois testes frontend dirigidos ao novo tipo publicado passaram: encaminhamento coerente para ecrã/CSV/dossier e aceitação da indeterminação/recusa da apresentação divergente. TypeScript, build ePint passaram. Aviso do build sobre optimização opcional de fontes/fontaine não impediu a compilação.
|
||||
|
||||
**Observação no Edge, formulário normal:** porta18580, clone `simulator_fiscal_delta_20261021_test`, conta sintética de utilizador comum167, sem papéis fiscais administrativos. A fixture publicada é a versão sintética2 de `IVA-GERAL-BASE-LIQUIDA`, ID`01a15ddc-b0c5-727f-bb24-d806a69bd1f5`, para não substituir o rascunho QA anterior; fonte/actores sintéticos só neste clone. A governação existente foi usada na preparação da fixture. Não houve publicação na homologação nem declaração de aprovação jurídica da regra real.
|
||||
|
||||
1. Observou-se inicialmente o consumidor desligado e o botão indisponível. Habilitou-se somente a fixture local guardada contra qualquer outra base.
|
||||
2. Base1000.03MZN, exigibilidade2026-10-21, sem declaração de contexto: execução`53663785-2e5d-4be2-8a05-c0c96a57896d`, resultado indeterminado, entradas conservadas e relatório guardado. A mesma base/data/moeda com declaração criou`5fa778f1-413e-4643-8c67-1b93ab290da7`, valor160.0048. Repetir o botão manteve essas duas execuções, sem terceira duplicada.
|
||||
3. Reteve-se somente a fila QA dedicada `fiscal-consumer-qa-20261021`. A base1001.03 admitiu`be1e8399-ea95-4aa7-9ac0-7fa392932ac1` às09:52:11.782758UTC. Suspendeu-se a fixture pela governação às09:52:16UTC, ainda com essa execução emqueued e snapshot publicado congelado. O trabalhador real `queue:work database --queue=fiscal-consumer-qa-20261021 --once --tries=1 --timeout=30` concluiu-a depois da suspensão, com160.1648 e a mesma versão2. Nenhuma outra fila foi consumida.
|
||||
4. Nova entrada1002.03 foi recusada: «A versão autorizada de IVA não está disponível para novas execuções. Os históricos foram preservados.» A base1002.03, data, moeda e declaração continuaram no ecrã; não houve quarta execução. Após recarga completa, recuperaram-se pelo histórico tanto o resultado calculado como o indeterminado. Ambos mostraram o snapshot original, sem alertas de inconsistência, com CSV disponível. O ficheiro não foi descarregado novamente; a implementação e as provas aceites do exportador foram reutilizadas.
|
||||
5. Conferência por leitura: exactamente3execuções da conta, todascompleted; hashes de resultado/relatório iguais nas três; zero trabalhos pendentes na fila dedicada. O relatório da execução que concluiu após suspensão é`5e416a48-db07-449b-8eae-aa51a1538f04`, SHA256`997f026665e586c662ae574fd2ff7f62e0254819adb26aea920575e47e0490a3`. A fixture permanece suspensa e a configuração local foi reposta emdesligado; os históricos ficam acessíveis.
|
||||
|
||||
Provas privadas em `outputs/simulator-continuation-20261020`: `published-consumer-tests.txt`, `published-consumer-journey-receipt.json`, `published-consumer-calculated-ui.txt/.png`, `published-consumer-suspended-ui.txt`, `published-consumer-recovered-ui.txt/.png` e `published-consumer-indeterminate-recovered-ui.txt`. O primeiro acesso ao separador antecedeu o arranque do servidor local e apresentou ligação recusada; após arranque normal prosseguiu-se. Não é evidência de falha do simulador. Não se repetiram as10páginas do PDF nativo nem o conjunto aceite de83d2f97d/13f68f1b. A aritmética do exemplo demonstra a ligação e preservação do resultado, não a aplicabilidade fiscal a qualquer operação.
|
||||
|
||||
#### Procedimento único das três operações — agora executável após decisão
|
||||
|
||||
1. **Integrar código:** a Tempo identifica a referência exacta desta candidata na branch `codex/simulator-integrated-review`. Integrar emmain apenas após decisão; o envio desencadeia disponibilização automática na homologação. Manter `INVESTMOZ_IVA_CONSUMER_ENABLED=false`; não executar fixture, comandoQA ou cadastro fiscal no deploy. Confirmar build servido e aplicação; aplicar o procedimento habitual de recarga da configuração e reinício de trabalhadores persistentes, se existirem, antes de activar o novo tipo de trabalho. Não há migration neste delta. A leitura dos snapshots deve permanecer em qualquer versão de reversão; desligar o consumidor é preferível a voltar a um build que desconheça os históricos novos.
|
||||
2. **Publicar versão real:** com autorização de escrita, backup/restauro confirmado e acção autenticada, reutilizar a fonte real aceite e os actores/papéis existentes. Cadastrar a versão fundamentada e usar `POST /api/v2/investment/admin/tax-rules/{id}/{action}`, com `version_no` e `evidence`, em `submit` → `review` → `approve` → `publish`. RegistarID e `rule_version` publicados. Não copiar a fixture/versão2/actoresUUID deste ensaio para homologação. Se a fonte já estiver cadastrada correctamente, reutilizá-la; não exigir nova certificação da credibilidade oficial. A publicação disponibiliza parâmetros naAPI existente, mas o consumidor continua desligado.
|
||||
3. **Activar consumidor:** por decisão específica da Tempo, preencher `INVESTMOZ_IVA_CONSUMER_RULE_ID=<ID real autorizado>` e `INVESTMOZ_IVA_CONSUMER_RULE_VERSION=<rule_version autorizada>`; só então definir `INVESTMOZ_IVA_CONSUMER_ENABLED=true`. Aplicar a configuração pelo procedimento operacional existente (`php artisan config:cache` quando se usar cache de configuração; recarregar processos persistentes conforme o alojamento). Confirmar `/api/v1/fiscal-consumer` e a etapa fiscal do simulador, registando decisão, commit/build, ID/versão e instante de exposição. Não usar `version_no` de concorrência como se fosse `rule_version`. O servidor recusa outra versão em vez de escolher a mais recente silenciosamente.
|
||||
|
||||
**Suspender/desactivar:** repor o interruptor emfalse e aplicar a configuração impede novas admissões; para retirar a elegibilidade da própria regra usar a transição existente `suspend`, com versão corrente/evidência e actor autorizado. Pedidos já admitidos concluem pelo snapshot; repetição idempotente recupera-os. Histórico/relatório/exportação continuam acessíveis sem novo cálculo, publicação ou activação. Retoma segue uma versão publicada elegível e activação explícita, nuncaSQL directo sobre o estado suspenso. A janela2026 mantém-se um limite de cobertura, não data de caducidade legal.
|
||||
|
||||
**Saldo:** ligação funcional do primeiro consumidor fechada em QA, pronta para revisão e decisão sobre as três operações; sem outra preparação genérica identificada. AUD-SIM-INT02 permanece parcial para cobertura fiscal integral. As estimativas anteriores não constituem horas ainda por executar deste delta já concluído. Main local continua em13f68f1b; sem merge/push, publicação real ou activação na homologação. A Tempo não conferiu os ficheiros locais nem validou juridicamente as fontes nesta execução.
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -5,11 +5,11 @@ export default function FiscalQaPreview({ snapshot }: { snapshot: unknown }) {
|
||||
if (rows.length === 0) return null;
|
||||
return (
|
||||
<section
|
||||
aria-label="Pré-visualização fiscal QA"
|
||||
aria-label="Resultado fiscal preservado"
|
||||
className="my-3 rounded border border-amber-700 bg-amber-50 p-3"
|
||||
>
|
||||
<h5 className="font-semibold">
|
||||
Pré-visualização fiscal QA — cálculo exacto sem arredondamento
|
||||
{rows[0][1]} — cálculo exacto sem arredondamento
|
||||
</h5>
|
||||
<dl className="mt-2 space-y-2 text-sm">
|
||||
{rows.map(([label, value]) => (
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { SimulationPublishedIva, type PublishedIvaInput } from './simulation-published-iva';
|
||||
import {
|
||||
PublicDocumentContent,
|
||||
type PublicDocument,
|
||||
@@ -887,7 +888,7 @@ export function SimulationForm() {
|
||||
() => readVersionedArithmeticReportReference(result),
|
||||
[result],
|
||||
);
|
||||
const isFiscalQaPreview = arithmeticSnapshot?.scenario_kind === 'fiscal_rule_qa_preview';
|
||||
const isFiscalQaPreview = ['fiscal_rule_qa_preview', 'fiscal_rule_published'].includes(String(arithmeticSnapshot?.scenario_kind));
|
||||
const versionedReportMissing =
|
||||
arithmeticResult.integrity === 'versioned' &&
|
||||
arithmeticReportReference === null;
|
||||
@@ -2159,15 +2160,15 @@ export function SimulationForm() {
|
||||
);
|
||||
}
|
||||
|
||||
async function runSimulation() {
|
||||
async function runSimulation(publishedInput?: PublishedIvaInput) {
|
||||
if (calculationInFlightRef.current) return;
|
||||
const validation = validateSimulationDraft(draft, 'tax');
|
||||
if (draft.fiscal_scenario_mode === 'custom_planning') {
|
||||
const validation = publishedInput ? {} : validateSimulationDraft(draft, 'tax');
|
||||
if (!publishedInput && draft.fiscal_scenario_mode === 'custom_planning') {
|
||||
Object.assign(validation, validateCustomFiscalPlanning(draft));
|
||||
}
|
||||
setErrors(validation);
|
||||
setServerError('');
|
||||
if (draft.fiscal_scenario_mode !== 'custom_planning') return;
|
||||
if (!publishedInput && draft.fiscal_scenario_mode !== 'custom_planning') return;
|
||||
if (Object.keys(validation).length) {
|
||||
document
|
||||
.getElementById(`field-${Object.keys(validation)[0]}`)
|
||||
@@ -2178,7 +2179,7 @@ export function SimulationForm() {
|
||||
setIsCalculating(true);
|
||||
try {
|
||||
const xsrf = csrfToken();
|
||||
const body = {
|
||||
const body = publishedInput ?? {
|
||||
base: draft.base,
|
||||
rate: draft.rate,
|
||||
reduction: draft.reduction,
|
||||
@@ -2286,7 +2287,7 @@ export function SimulationForm() {
|
||||
}
|
||||
}
|
||||
if (
|
||||
JSON.stringify({
|
||||
publishedInput || JSON.stringify({
|
||||
base: draft.base,
|
||||
rate: draft.rate,
|
||||
reduction: draft.reduction,
|
||||
@@ -2306,7 +2307,7 @@ export function SimulationForm() {
|
||||
'O cálculo não foi concluído. Os dados introduzidos foram preservados.',
|
||||
);
|
||||
}
|
||||
sessionStorage.removeItem(idempotencyStorageKey);
|
||||
if (!publishedInput) sessionStorage.removeItem(idempotencyStorageKey);
|
||||
} catch (cause) {
|
||||
setServerError(
|
||||
cause instanceof Error
|
||||
@@ -3600,6 +3601,7 @@ export function SimulationForm() {
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{current.id === 'tax' && <SimulationPublishedIva busy={isCalculating} onCalculate={runSimulation} />}
|
||||
{current.id === 'tax' && (
|
||||
<div
|
||||
id="draft-tool-fiscal"
|
||||
@@ -3697,8 +3699,8 @@ export function SimulationForm() {
|
||||
O período e as respostas servem para encaminhar uma
|
||||
exploração informativa. A residência é auto-indicada ou
|
||||
fica por determinar; não é inferida por nacionalidade ou
|
||||
dias de presença. Regras de 2026 continuam pendentes de
|
||||
validação e este formulário não determina cumprimento
|
||||
dias de presença. Cada cálculo identifica a regra, versão, cobertura e
|
||||
limitações; este formulário não determina cumprimento
|
||||
fiscal. Um crédito estimado de IVA não significa pedido
|
||||
aprovado nem dinheiro disponível. Não carregue
|
||||
documentos nesta simulação exploratória.
|
||||
@@ -3707,7 +3709,7 @@ export function SimulationForm() {
|
||||
{(current.id === 'tax' || result) && (
|
||||
<div className="space-y-3 border-t pt-4">
|
||||
<h4 className="font-semibold">
|
||||
{isFiscalQaPreview ? 'Pré-visualização fiscal QA seleccionada' : current.id === 'tax'
|
||||
{isFiscalQaPreview ? (arithmeticSnapshot?.scenario_kind === 'fiscal_rule_published' ? 'IVA pela versão publicada seleccionada' : 'Pré-visualização fiscal QA seleccionada') : current.id === 'tax'
|
||||
? 'Cenário personalizado de planeamento'
|
||||
: 'Resultado histórico seleccionado'}
|
||||
</h4>
|
||||
@@ -4182,7 +4184,7 @@ export function SimulationForm() {
|
||||
'não identificada',
|
||||
'não identificada',
|
||||
)}{' '}
|
||||
· {source.result_snapshot.scenario_kind === 'fiscal_rule_qa_preview' ? 'IVA calculado exacto em QA' : 'resultado aritmético'}{' '}
|
||||
· {['fiscal_rule_qa_preview', 'fiscal_rule_published'].includes(String(source.result_snapshot.scenario_kind)) ? 'IVA calculado exacto' : 'resultado aritmético'}{' '}
|
||||
{displayScalar(
|
||||
sourceResult.value ??
|
||||
(sourceResult.integrity ===
|
||||
@@ -4421,7 +4423,9 @@ export function SimulationForm() {
|
||||
e, se já escolhido, o cálculo percentual
|
||||
personalizado. Consultar parâmetros fiscais
|
||||
apresenta fontes e lacunas; não determina imposto
|
||||
devido.
|
||||
devido. Na etapa Operação fiscal encontra também
|
||||
o cálculo de IVA geral sobre base líquida, quando
|
||||
activado, com âmbito e versão identificados.
|
||||
</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>;
|
||||
}
|
||||
@@ -3,7 +3,8 @@ import { isApiSimulationResultSnapshotV1 } from "./simulation-contract.ts";
|
||||
export function fiscalQaRows(value: unknown): Array<[string, string]> {
|
||||
if (!isApiSimulationResultSnapshotV1(value)) return [];
|
||||
const snapshot = value as unknown as Record<string, unknown>;
|
||||
if (snapshot.scenario_kind !== "fiscal_rule_qa_preview") return [];
|
||||
if (!["fiscal_rule_qa_preview", "fiscal_rule_published"].includes(String(snapshot.scenario_kind))) return [];
|
||||
const published = snapshot.scenario_kind === "fiscal_rule_published";
|
||||
const provenance = snapshot.fiscal_provenance as
|
||||
Record<string, unknown> | undefined;
|
||||
const rule = provenance?.rule as Record<string, unknown> | undefined;
|
||||
@@ -19,7 +20,7 @@ export function fiscalQaRows(value: unknown): Array<[string, string]> {
|
||||
? String(item)
|
||||
: "não disponível";
|
||||
return [
|
||||
["Relatório", "Pré-visualização fiscal QA de rascunho — sem activação"],
|
||||
["Relatório", published ? "IVA pela versão publicada; ver enquadramento abaixo" : "Pré-visualização fiscal QA de rascunho — sem activação"],
|
||||
[
|
||||
"IVA calculado (exacto; não é saldo a pagar)",
|
||||
value.result.value ?? "Indeterminado",
|
||||
@@ -31,11 +32,11 @@ export function fiscalQaRows(value: unknown): Array<[string, string]> {
|
||||
["Data de exigibilidade declarada", value.context.reference_date],
|
||||
[
|
||||
"Enquadramento",
|
||||
"Declarado pelo utilizador; não validado juridicamente",
|
||||
provenance?.scope_status === "undetermined" ? "Indeterminado: falta declaração do enquadramento" : "Declarado pelo utilizador; não validado juridicamente",
|
||||
],
|
||||
[
|
||||
"Regra / versão / estado",
|
||||
`${text(rule?.rule_code)} / ${text(rule?.rule_version)} / rascunho`,
|
||||
`${text(rule?.rule_code)} / ${text(rule?.rule_version)} / ${published ? "publicada no momento da admissão" : "rascunho"}`,
|
||||
],
|
||||
[
|
||||
"Vigência cadastrada",
|
||||
@@ -53,7 +54,7 @@ export function fiscalQaRows(value: unknown): Array<[string, string]> {
|
||||
],
|
||||
[
|
||||
"Limitações",
|
||||
"Regra não publicada; cobertura demonstrada em 2026; sem deduções, declaração ou saldo a pagar.",
|
||||
published ? "Cobertura de 2026; operação interna geral em MZN; sem excepções, deduções, declaração ou saldo a pagar." : "Regra não publicada; cobertura demonstrada em 2026; sem deduções, declaração ou saldo a pagar.",
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -345,9 +345,9 @@ function makeSections(
|
||||
}
|
||||
|
||||
const finance = sections.get('finance');
|
||||
if (finance && currentCalculation?.snapshot?.scenario_kind === 'fiscal_rule_qa_preview') {
|
||||
if (finance && ['fiscal_rule_qa_preview', 'fiscal_rule_published'].includes(String(currentCalculation?.snapshot?.scenario_kind)) && currentCalculation?.snapshot) {
|
||||
for (const [field, value] of fiscalQaRows(currentCalculation.snapshot)) {
|
||||
finance.rows.push({ field, value, origin: 'Snapshot fiscal QA preservado; sem recálculo', classification: 'Rascunho QA; cálculo exacto sem arredondamento automático; não é saldo a pagar' });
|
||||
finance.rows.push({ field, value, origin: 'Snapshot fiscal preservado; sem recálculo', classification: 'Cálculo exacto; ver estado da regra no snapshot; não é saldo a pagar' });
|
||||
}
|
||||
}
|
||||
if (finance && currentCalculation) {
|
||||
@@ -423,12 +423,12 @@ function makeSections(
|
||||
{
|
||||
field: 'Fórmula e valores usados',
|
||||
value:
|
||||
snapshot?.scenario_kind === 'fiscal_rule_qa_preview'
|
||||
? `Base tributável declarada × taxa cadastrada ÷ 100; cálculo decimal exacto, sem arredondamento. Ver regra e proveniência QA acima.`
|
||||
['fiscal_rule_qa_preview', 'fiscal_rule_published'].includes(String(snapshot?.scenario_kind))
|
||||
? `Base tributável declarada × taxa cadastrada ÷ 100; cálculo decimal exacto, sem arredondamento. Ver regra e proveniência acima.`
|
||||
: snapshot && typeof snapshot.formula_version === 'string'
|
||||
? `Fórmula ${snapshot.formula_version}; base ${displayValue(snapshot.base, 'não disponível')}; taxa ${displayValue(snapshot.rate_percent, 'não disponível')}%; redução ${displayValue(snapshot.reduction_percent, 'não disponível')}%; moeda ${displayValue(snapshot.currency, 'não indicada')}`
|
||||
: 'A fórmula não foi identificada no snapshot; ver explicação da execução',
|
||||
origin: snapshot?.scenario_kind === 'fiscal_rule_qa_preview' ? 'Snapshot QA de regra em rascunho; sem activação ou validação jurídica da operação' : 'Snapshot guardado; sem aplicação de regra legal',
|
||||
origin: ['fiscal_rule_qa_preview', 'fiscal_rule_published'].includes(String(snapshot?.scenario_kind)) ? (snapshot?.scenario_kind === 'fiscal_rule_published' ? 'Versão publicada na admissão; sem validação jurídica da operação' : 'Snapshot QA de regra em rascunho; sem activação ou validação jurídica da operação') : 'Snapshot guardado; sem aplicação de regra legal',
|
||||
classification: 'Reutilização do resultado existente',
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1675,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',
|
||||
@@ -1849,7 +1862,7 @@ export function exportCompletedArithmeticCsv(
|
||||
);
|
||||
}
|
||||
const result = simulation.result_snapshot as Record<string, unknown>;
|
||||
if (result.scenario_kind === 'fiscal_rule_qa_preview') {
|
||||
if (['fiscal_rule_qa_preview', 'fiscal_rule_published'].includes(String(result.scenario_kind))) {
|
||||
const rows = fiscalQaRows(result);
|
||||
const report = readVersionedArithmeticReportReference(simulation);
|
||||
if (!rows.length || !report) throw new Error('A pré-visualização fiscal não corresponde ao relatório preservado.');
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { fiscalQaRows } from "../../resources/js/lib/fiscal-qa-preview.ts";
|
||||
import { exportCompletedArithmeticCsv } from "../../resources/js/lib/simulation-form.ts";
|
||||
import { exportCompletedArithmeticCsv, readArithmeticSnapshot } from "../../resources/js/lib/simulation-form.ts";
|
||||
import { createProjectDossierReport } from "../../resources/js/lib/project-dossier-report.ts";
|
||||
|
||||
function fixture() {
|
||||
@@ -131,3 +131,46 @@ test("dossier report uses the same exact snapshot and preserves genuine indeterm
|
||||
"Indeterminado",
|
||||
);
|
||||
});
|
||||
|
||||
test("published consumer routes the frozen snapshot through screen CSV and dossier without QA labels", () => {
|
||||
const simulation = fixture();
|
||||
const snapshot = simulation.result_snapshot;
|
||||
snapshot.scenario_kind = "fiscal_rule_published";
|
||||
snapshot.context.engine_version = "fiscal-published-v1";
|
||||
snapshot.presentation = { value: snapshot.result.value, rounding_applied: false };
|
||||
snapshot.result.explanation = "IVA pela versão publicada; enquadramento declarado.";
|
||||
snapshot.fiscal_provenance.rule.status = "published";
|
||||
snapshot.fiscal_provenance.scope_status = "user_declared";
|
||||
snapshot.fiscal_provenance.mode = "published_version";
|
||||
simulation.reports[0].report_snapshot = structuredClone(snapshot);
|
||||
const before = JSON.stringify(simulation);
|
||||
const rows = fiscalQaRows(snapshot);
|
||||
assert.equal(readArithmeticSnapshot(snapshot).integrity, "versioned");
|
||||
assert.match(rows[0][1], /publicada/);
|
||||
assert.doesNotMatch(JSON.stringify(rows), /rascunho|não publicada|QA/);
|
||||
const csv = exportCompletedArithmeticCsv(simulation, "2026-10-21T12:00:00Z");
|
||||
for (const [label, value] of rows) {
|
||||
assert.ok(csv.includes(label));
|
||||
assert.ok(csv.includes(value.replaceAll('"', '""')));
|
||||
}
|
||||
const report = createProjectDossierReport({ project_title: "Published IVA QA", currency: "MZN" }, {
|
||||
selectedSections: new Set(["finance"]), currentCalculation: { status: "completed", version: 2, snapshot },
|
||||
});
|
||||
const finance = report.sections.find((section) => section.id === "finance");
|
||||
for (const [field, value] of rows) assert.ok(finance.rows.some((row) => row.field === field && row.value === value));
|
||||
assert.doesNotMatch(JSON.stringify(finance), /rascunho|proveniência QA/);
|
||||
assert.equal(JSON.stringify(simulation), before);
|
||||
});
|
||||
|
||||
test("published consumer indeterminate snapshot is valid and a mismatched presentation is refused", () => {
|
||||
const snapshot = fixture().result_snapshot;
|
||||
snapshot.scenario_kind = "fiscal_rule_published";
|
||||
snapshot.result.status = "indeterminate";
|
||||
snapshot.result.value = null;
|
||||
snapshot.result.missing_inputs = ["operation_scope"];
|
||||
snapshot.indicative_total = null;
|
||||
snapshot.presentation = { value: null, rounding_applied: false };
|
||||
assert.equal(readArithmeticSnapshot(snapshot).integrity, "versioned");
|
||||
snapshot.presentation.value = "0";
|
||||
assert.equal(readArithmeticSnapshot(snapshot).integrity, "invalid");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user