Files
Investidor-V2/app/Services/SimulationTaxRuleGovernance.php
T
2026-10-21 12:39:07 +02:00

227 lines
11 KiB
PHP

<?php
namespace App\Services;
use App\Models\InvestmentLegalSource;
use App\Models\SimulationTaxRuleVersion;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Symfony\Component\HttpKernel\Exception\HttpException;
final class SimulationTaxRuleGovernance
{
/** @param array<string,mixed> $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<string,mixed> $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<string,mixed> $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<string,mixed> $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;
}
}