$data */ public function create(User $actor, array $data): SimulationTaxRuleVersion { SimulationTaxGovernanceAccess::requireRole($actor, 'editors'); return DB::transaction(function () use ($actor, $data): SimulationTaxRuleVersion { $this->setContext($actor); $source = $this->sourceSnapshot($data); $rule = SimulationTaxRuleVersion::query()->create([ ...$data, 'id' => (string) Str::uuid(), 'status' => 'draft', 'author_id' => $actor->id, 'source_version_no' => $source->version_no, 'version_no' => 1, ]); $this->event($rule, $actor, 'created'); return $rule->refresh(); }, 3); } /** @param array $data */ public function update(User $actor, string $id, int $version, array $data): SimulationTaxRuleVersion { SimulationTaxGovernanceAccess::requireRole($actor, 'editors'); return DB::transaction(function () use ($actor, $id, $version, $data): SimulationTaxRuleVersion { $this->setContext($actor); $rule = SimulationTaxRuleVersion::query()->lockForUpdate()->findOrFail($id); $this->matchVersion($rule, $version); abort_unless((int) $rule->author_id === $actor->id && $rule->status === 'draft', 403, 'Só o autor pode alterar um rascunho ainda não submetido. Crie uma nova versão da regra após revisão.'); $source = $this->sourceSnapshot($data); $rule->fill([...$data, 'source_version_no' => $source->version_no, 'version_no' => $version + 1])->save(); $this->event($rule, $actor, 'updated'); return $rule->refresh(); }, 3); } public function transition(User $actor, string $id, int $version, string $action, string $evidence): SimulationTaxRuleVersion { $role = match ($action) { 'submit' => 'editors', 'review', 'reject' => 'reviewers', 'approve' => 'approvers', 'publish', 'suspend', 'archive' => 'publishers', default => throw new HttpException(422, 'Transição de governação inválida.'), }; SimulationTaxGovernanceAccess::requireRole($actor, $role); 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]; switch ($action) { case 'submit': abort_unless((int) $rule->author_id === $actor->id && $rule->status === 'draft', 403, 'Só o autor pode submeter o seu rascunho.'); $this->sourceStillValid($rule); $next += ['status' => 'submitted', 'submitted_at' => now()]; break; case 'review': case 'reject': 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($rule->status === 'reviewed', 422, 'A regra tem de estar revista antes da aprovação.'); $this->sourceStillValid($rule); $next += ['status' => 'approved', 'approver_id' => $actor->id, 'approval_evidence' => $evidence, 'approved_at' => now()]; break; case 'publish': abort_unless($rule->status === 'approved', 422, 'A regra tem de estar aprovada antes da publicação.'); $this->sourceStillValid($rule); $this->assertNoPublishedOverlap($rule); $next += ['status' => 'published', 'publisher_id' => $actor->id, 'publication_evidence' => $evidence, 'published_at' => now()]; break; case 'suspend': abort_unless($rule->status === 'published', 422, 'Só uma regra publicada pode ser suspensa.'); $next += ['status' => 'suspended', 'closure_evidence' => $evidence, 'suspended_at' => now()]; break; case 'archive': abort_unless(in_array($rule->status, ['published', 'suspended'], true), 422, 'Só uma regra publicada ou suspensa pode ser arquivada.'); $next += ['status' => 'archived', 'closure_evidence' => $evidence, 'archived_at' => now()]; break; } $rule->fill($next)->save(); $this->event($rule, $actor, $action); return $rule->refresh(); }, 3); } /** @param array $filters */ public function listing(User $actor, array $filters): mixed { SimulationTaxGovernanceAccess::requireAnyRole($actor); return DB::transaction(function () use ($actor, $filters) { $this->setContext($actor); $query = SimulationTaxRuleVersion::query()->orderByDesc('created_at'); foreach (['rule_code', 'component', 'scope_key', 'status'] as $key) { if (isset($filters[$key])) { $query->where($key, $filters[$key]); } } return $query->paginate($filters['per_page'] ?? 20)->withQueryString(); }); } public function show(User $actor, string $id): SimulationTaxRuleVersion { SimulationTaxGovernanceAccess::requireAnyRole($actor); return DB::transaction(function () use ($actor, $id): SimulationTaxRuleVersion { $this->setContext($actor); return SimulationTaxRuleVersion::query()->findOrFail($id); }); } /** @param array $data */ private function sourceSnapshot(array $data): InvestmentLegalSource { $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(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, 'A vigência da fonte não cobre todo o período declarado para a regra.'); return $source; } private function sourceStillValid(SimulationTaxRuleVersion $rule): void { $source = InvestmentLegalSource::query()->lockForUpdate()->find($rule->legal_source_id); 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'))) && ($rule->valid_until !== null || $source->valid_until === null), 422, 'A fonte aprovada foi revogada, alterada ou não cobre toda a vigência da regra.'); } private function assertNoPublishedOverlap(SimulationTaxRuleVersion $rule): void { DB::select('select pg_advisory_xact_lock(hashtext(?), hashtext(?))', [$rule->rule_code, $rule->scope_key]); $overlap = SimulationTaxRuleVersion::query()->where('id', '<>', $rule->id)->where('status', 'published') ->where('component', $rule->component)->where('rule_code', $rule->rule_code)->where('scope_key', $rule->scope_key) ->whereDate('valid_from', '<=', $rule->valid_until?->format('Y-m-d') ?? '9999-12-31') ->where(fn ($query) => $query->whereNull('valid_until')->orWhereDate('valid_until', '>=', $rule->valid_from->format('Y-m-d'))) ->exists(); abort_unless(! $overlap, 409, 'Já existe regra publicada com vigência sobreposta para o mesmo código, componente e scope_key.'); } private function matchVersion(SimulationTaxRuleVersion $rule, int $version): void { abort_unless((int) $rule->version_no === $version, 409, 'Versão alterada. Recarregue antes de continuar.'); } private function setContext(User $actor): void { DB::select("select set_config('app.actor_user_id', ?, true), set_config('app.simulation_tax_governance', 'on', true)", [(string) $actor->id]); } private function event(SimulationTaxRuleVersion $rule, User $actor, string $action): void { $snapshot = $this->canonical($rule->attributesToArray()); $json = json_encode($snapshot, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE); $canonicalJson = DB::selectOne('select CAST(? AS jsonb)::text as canonical', [$json])->canonical; DB::table('simulation_tax_rule_events')->insert([ 'id' => (string) Str::uuid(), 'rule_version_id' => $rule->id, 'version_no' => $rule->version_no, 'actor_user_id' => $actor->id, 'action' => $action, 'snapshot' => $json, 'sha256' => hash('sha256', $canonicalJson), 'created_at' => now(), ]); } private function canonical(mixed $value): mixed { if (! is_array($value)) { return $value; } if (! array_is_list($value)) { ksort($value); } foreach ($value as $key => $item) { $value[$key] = $this->canonical($item); } return $value; } }