Files
Investidor-V2/app/Services/SimulationTaxParameterResolver.php
T

342 lines
16 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Services;
use Brick\Math\BigRational;
use DateTimeImmutable;
use Illuminate\Support\Facades\DB;
use InvalidArgumentException;
/** Selects a published, date-effective parameter from an explicitly scoped candidate set. */
final class SimulationTaxParameterResolver
{
/** @param array<array-key,mixed> $parameters */
public function validateParameters(array $parameters): void
{
if ($parameters === [] || count($parameters) > 100) {
throw new InvalidArgumentException('Indique entre 1 e 100 parâmetros versionados.');
}
foreach ($parameters as $code => $parameter) {
if (! is_string($code) || ! preg_match('/^[A-Z][A-Z0-9._-]{1,63}$/', $code) || ! is_array($parameter)
|| array_diff(array_keys($parameter), ['value', 'unit', 'conditions']) !== []
|| array_diff(['value', 'unit', 'conditions'], array_keys($parameter)) !== []) {
throw new InvalidArgumentException('Cada parâmetro deve conter apenas valor, unidade e condições.');
}
if ((! is_string($parameter['value']) && ! is_int($parameter['value'])) || (string) $parameter['value'] === ''
|| ! is_string($parameter['unit']) || ! preg_match('/^[a-z][a-z0-9._-]{0,31}$/', $parameter['unit'])
|| ! is_array($parameter['conditions'])) {
throw new InvalidArgumentException('Reveja o valor, a unidade e as condições do parâmetro.');
}
$isNumericValue = preg_match('/^-?\d+(?:\.\d+)?$/', (string) $parameter['value']) === 1;
if (in_array($parameter['unit'], ['percent', 'mzn', 'amount', 'days', 'count', 'mzn_per_unit', 'per_unit'], true) && ! $isNumericValue) {
throw new InvalidArgumentException('Esta unidade requer um valor numérico decimal.');
}
if ($isNumericValue) {
$value = BigRational::of((string) $parameter['value']);
if ($value->isNegative() || ($parameter['unit'] === 'percent' && $value->compareTo(100) > 0)) {
throw new InvalidArgumentException('Os valores não podem ser negativos; as percentagens devem estar entre 0 e 100.');
}
}
foreach ($parameter['conditions'] as $condition => $expected) {
if (! is_string($condition) || ! is_string($expected)) {
throw new InvalidArgumentException('Indique as condições numa lista simples de nomes e valores em texto.');
}
}
}
}
/**
* Resolve only rows made visible by the published-only RLS policy. The method
* never executes a tax formula and requires each parameter to declare context.
*
* @param array<string,string> $context
* @return list<array<string,mixed>>
*/
public function resolvePublishedForPeriod(string $component, string $periodStart, string $periodEnd, array $context): array
{
$start = $this->date($periodStart);
$end = $this->date($periodEnd);
if ($end < $start || $start->diff($end)->days > 365) {
throw new InvalidArgumentException('Reveja o período de aplicação indicado.');
}
$rows = DB::table('simulation_tax_rule_versions as rule')
->join('investment_legal_sources as source', 'source.id', '=', 'rule.legal_source_id')
->where('rule.component', $component)
->where('rule.status', 'published')
->whereDate('rule.valid_from', '<=', $periodEnd)
->where(fn ($query) => $query->whereNull('rule.valid_until')->orWhereDate('rule.valid_until', '>=', $periodStart))
->where('source.validation_status', 'validated')
->whereColumn('source.version_no', 'rule.source_version_no')
->whereDate('source.valid_from', '<=', $periodStart)
->where(fn ($query) => $query->whereNull('source.valid_until')->orWhereDate('source.valid_until', '>=', $periodEnd))
->select([
'rule.id', 'rule.rule_code', 'rule.rule_version', 'rule.component', 'rule.scope_key',
'rule.valid_from', 'rule.valid_until', 'rule.parameters', 'rule.rule_definition',
'rule.source_version_no', 'rule.source_sha256',
'source.title as source_title', 'source.diploma as source_diploma', 'source.article as source_article',
'source.url as source_url', 'source.validation_status as source_validation_status',
'source.version_no as source_current_version', 'source.valid_from as source_valid_from',
'source.valid_until as source_valid_until',
])
->orderBy('rule.rule_code')->orderBy('rule.rule_version')->limit(500)->get();
$versions = array_values($rows->map(function (object $row): array {
$parameters = is_array($row->parameters)
? $row->parameters
: json_decode((string) $row->parameters, true, flags: JSON_THROW_ON_ERROR);
$definition = is_array($row->rule_definition)
? $row->rule_definition
: json_decode((string) $row->rule_definition, true, flags: JSON_THROW_ON_ERROR);
return [
'id' => $row->id,
'rule_code' => $row->rule_code,
'rule_version' => (int) $row->rule_version,
'component' => $row->component,
'scope_key' => $row->scope_key,
'status' => 'published',
'valid_from' => (string) $row->valid_from,
'valid_until' => $row->valid_until === null ? null : (string) $row->valid_until,
'rule_definition' => $definition,
'parameters' => $parameters,
'source_version_no' => (int) $row->source_version_no,
'source_sha256' => $row->source_sha256,
'source' => [
'title' => $row->source_title,
'diploma' => $row->source_diploma,
'article' => $row->source_article,
'url' => $row->source_url,
'validation_status' => $row->source_validation_status,
'version_no' => (int) $row->source_current_version,
'valid_from' => (string) $row->source_valid_from,
'valid_until' => $row->source_valid_until === null ? null : (string) $row->source_valid_until,
],
];
})->all());
return $this->resolveAvailableParameters($versions, $component, $periodStart, $periodEnd, $context);
}
/**
* @param list<array<string,mixed>> $versions
* @param array<string,string> $context
* @return list<array<string,mixed>>
*/
public function resolveAvailableParameters(array $versions, string $component, string $periodStart, string $periodEnd, array $context): array
{
$start = $this->date($periodStart);
$end = $this->date($periodEnd);
$groups = [];
foreach ($versions as $version) {
if (($version['component'] ?? null) !== $component
|| ($version['status'] ?? null) !== 'published'
|| ($version['source']['validation_status'] ?? null) !== 'validated'
|| (int) ($version['source_version_no'] ?? 0) !== (int) ($version['source']['version_no'] ?? -1)
|| ! $this->overlaps($version, $start, $end)
|| ! $this->covers($version['source'], $start) || ! $this->covers($version['source'], $end)
|| ($version['rule_definition']['language'] ?? null) !== 'investmoz.tax.v1'
|| ! is_array($version['parameters'] ?? null)) {
continue;
}
$coversWholePeriod = $this->covers($version, $start) && $this->covers($version, $end);
foreach ($version['parameters'] as $code => $parameter) {
if (! is_string($code) || ! is_array($parameter) || ! is_array($parameter['conditions'] ?? null)) {
continue;
}
$groups[$code] ??= ['matches' => [], 'partial' => false, 'saw_unscoped' => false, 'saw_context_mismatch' => false];
$conditions = $parameter['conditions'];
if (! $coversWholePeriod) {
if ($conditions !== [] && $this->conditionsMatch($conditions, $context) && ($version['scope_key'] ?? '') !== '') {
$groups[$code]['partial'] = true;
}
continue;
}
if ($conditions === []) {
$groups[$code]['saw_unscoped'] = true;
} elseif (! $this->conditionsMatch($conditions, $context)) {
$groups[$code]['saw_context_mismatch'] = true;
} elseif (($version['scope_key'] ?? '') === '') {
$groups[$code]['saw_unscoped'] = true;
} else {
$groups[$code]['matches'][] = [$version, $parameter];
}
}
}
$resolved = [];
foreach ($groups as $code => $group) {
if ($group['partial']) {
$state = count($group['matches']) > 0 ? 'indeterminate_overlap' : 'indeterminate_period_change';
$resolved[] = ['parameter_code' => $code, 'status' => $state, 'value' => null];
continue;
}
if (count($group['matches']) !== 1) {
$state = count($group['matches']) > 1
? 'indeterminate_overlap'
: ($group['saw_unscoped'] ? 'indeterminate_scope' : ($group['saw_context_mismatch'] ? 'indeterminate_context' : 'indeterminate_missing'));
$resolved[] = ['parameter_code' => $code, 'status' => $state, 'value' => null];
continue;
}
[$version, $parameter] = $group['matches'][0];
if (! isset($parameter['value'], $parameter['unit']) || ! is_string($parameter['unit'])) {
$resolved[] = ['parameter_code' => $code, 'status' => 'indeterminate_invalid', 'value' => null];
continue;
}
$resolved[] = [
'parameter_code' => $code,
'status' => 'resolved',
'value' => (string) $parameter['value'],
'unit' => $parameter['unit'],
'conditions' => $parameter['conditions'],
'scope_key' => $version['scope_key'],
'rule_code' => $version['rule_code'],
'rule_id' => $version['id'],
'rule_version' => (int) $version['rule_version'],
'period' => ['start' => $periodStart, 'end' => $periodEnd],
'validity' => ['start' => $version['valid_from'], 'end' => $version['valid_until'] ?? null],
'source' => [
'title' => $version['source']['title'],
'diploma' => $version['source']['diploma'],
'article' => $version['source']['article'],
'url' => $version['source']['url'],
'declared_sha256' => $version['source_sha256'],
],
];
}
usort($resolved, static fn (array $left, array $right): int => $left['parameter_code'] <=> $right['parameter_code']);
return $resolved;
}
/**
* @param iterable<array<string,mixed>> $publishedVersions Caller must supply only records visible under the applicable access policy.
* @param array<string,scalar|null> $conditions
* @return array<string,mixed>
*/
public function resolve(iterable $publishedVersions, string $component, string $scopeKey, string $asOf, string $parameterCode, array $conditions = []): array
{
$date = $this->date($asOf);
$matches = [];
foreach ($publishedVersions as $version) {
if (($version['status'] ?? null) !== 'published'
|| ($version['component'] ?? null) !== $component
|| ($version['scope_key'] ?? null) !== $scopeKey
|| ! $this->covers($version, $date)
|| ($version['source']['validation_status'] ?? null) !== 'validated'
|| (int) ($version['source_version_no'] ?? 0) !== (int) ($version['source']['version_no'] ?? -1)
|| ! $this->covers($version['source'], $date)
|| ! preg_match('/^[a-f0-9]{64}$/D', (string) ($version['source_sha256'] ?? ''))) {
continue;
}
$parameter = $version['parameters'][$parameterCode] ?? null;
if (! is_array($parameter) || ! isset($parameter['value'], $parameter['unit']) || ! is_string($parameter['unit'])
|| ! is_array($parameter['conditions'] ?? null) || ! $this->conditionsMatch($parameter['conditions'], $conditions)) {
continue;
}
$matches[] = [$version, $parameter];
}
if (count($matches) !== 1) {
return ['status' => count($matches) > 1 ? 'indeterminate_overlap' : 'indeterminate_missing', 'value' => null, 'parameter_code' => $parameterCode];
}
[$version, $parameter] = $matches[0];
return [
'status' => 'resolved',
'value' => (string) $parameter['value'],
'unit' => $parameter['unit'],
'parameter_code' => $parameterCode,
'conditions' => $parameter['conditions'],
'rule_code' => $version['rule_code'],
'rule_version' => (int) $version['rule_version'],
'rule_id' => $version['id'],
'scope_key' => $scopeKey,
'valid_from' => $version['valid_from'],
'valid_until' => $version['valid_until'] ?? null,
'source' => [
'title' => $version['source']['title'],
'diploma' => $version['source']['diploma'],
'article' => $version['source']['article'],
'url' => $version['source']['url'],
'declared_sha256' => $version['source_sha256'],
'validation_status' => $version['source']['validation_status'],
],
];
}
/** @param array<string,mixed> $version */
private function covers(array $version, DateTimeImmutable $date): bool
{
$fromValue = $version['valid_from'] ?? null;
$untilValue = $version['valid_until'] ?? null;
if (! is_string($fromValue) || ! preg_match('/^\d{4}-\d{2}-\d{2}$/D', $fromValue)
|| ($untilValue !== null && (! is_string($untilValue) || ! preg_match('/^\d{4}-\d{2}-\d{2}$/D', $untilValue)))) {
return false;
}
try {
$from = DateTimeImmutable::createFromFormat('!Y-m-d', $fromValue);
$until = $untilValue === null ? null : DateTimeImmutable::createFromFormat('!Y-m-d', $untilValue);
if ($from === false || $from->format('Y-m-d') !== $fromValue
|| ($untilValue !== null && ($until === false || $until->format('Y-m-d') !== $untilValue))) {
return false;
}
} catch (\Throwable) {
return false;
}
return $date >= $from && ($until === null || $date <= $until);
}
/** @param array<string,mixed> $version */
private function overlaps(array $version, DateTimeImmutable $start, DateTimeImmutable $end): bool
{
$fromValue = $version['valid_from'] ?? null;
$untilValue = $version['valid_until'] ?? null;
if (! is_string($fromValue) || ! preg_match('/^\d{4}-\d{2}-\d{2}$/D', $fromValue)) {
return false;
}
$from = $this->date($fromValue);
$until = $untilValue === null ? null : (is_string($untilValue) ? $this->date($untilValue) : null);
return $from <= $end && ($until === null || $until >= $start);
}
private function date(string $date): DateTimeImmutable
{
$parsed = DateTimeImmutable::createFromFormat('!Y-m-d', $date);
if ($parsed === false || $parsed->format('Y-m-d') !== $date) {
throw new InvalidArgumentException('Indique uma data de aplicação válida no formato ano-mês-dia.');
}
return $parsed;
}
/**
* @param array<string,mixed>|null $required
* @param array<string,scalar|null> $actual
*/
private function conditionsMatch(?array $required, array $actual): bool
{
if ($required === null) {
return false;
}
foreach ($required as $key => $value) {
if (! array_key_exists($key, $actual) || $actual[$key] !== $value) {
return false;
}
}
return true;
}
}