$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 $context * @return list>|array{parameters:list>,diagnostics:list>} */ public function resolvePublishedForPeriod(string $component, string $periodStart, string $periodEnd, array $context, bool $diagnostics = false): 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') ->when(! $diagnostics, fn ($query) => $query->whereDate('rule.valid_from', '<=', $periodEnd) ->where(fn ($query) => $query->whereNull('rule.valid_until')->orWhereDate('rule.valid_until', '>=', $periodStart))) ->where(fn ($query) => $query->where('source.validation_status', 'validated')->orWhere(fn ($fiscal) => $fiscal->where('source.validation_status', 'pending')->where('source.fiscal_acceptance->scope', 'fiscal_parameters') ->where('source.fiscal_acceptance->basis', 'Tempo-official-sources-20261008') ->whereRaw("source.fiscal_acceptance->>'sha256'=rule.source_sha256"))) ->whereColumn('source.version_no', 'rule.source_version_no') ->when(! $diagnostics, fn ($query) => $query->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.fiscal_acceptance', '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, 'fiscal_acceptance' => is_array($row->fiscal_acceptance) ? $row->fiscal_acceptance : json_decode($row->fiscal_acceptance ?? 'null', true), 'version_no' => (int) $row->source_current_version, 'valid_from' => (string) $row->source_valid_from, 'valid_until' => $row->source_valid_until === null ? null : (string) $row->source_valid_until, ], ]; })->all()); $parameters = $this->resolveAvailableParameters($versions, $component, $periodStart, $periodEnd, $context); $resolvedIva = array_filter($parameters, fn (array $parameter): bool => $parameter['parameter_code'] === 'IVA_GENERAL_RATE' && $parameter['status'] === 'resolved'); return $diagnostics ? ['parameters' => $parameters, 'diagnostics' => $resolvedIva !== [] ? [] : (new SimulationIvaDiagnostics)->explain($versions, $periodStart, $periodEnd, $context)] : $parameters; } /** * @param list> $versions * @param array $context * @return list> */ public function resolveAvailableParameters(array $versions, string $component, string $periodStart, string $periodEnd, array $context): array { return $this->resolveWithStatus($versions, $component, $periodStart, $periodEnd, $context, 'published'); } /** Explicit draft preview. Never used by the published-parameter API. * @param array $context * @param array $versions * @return list> */ public function resolveDraftForQa(array $versions, string $component, string $periodStart, string $periodEnd, array $context): array { abort_unless(IsolatedQaDatabase::allowsSchemaChanges(), 404); return $this->resolveWithStatus($versions, $component, $periodStart, $periodEnd, $context, 'draft'); } /** * @param array $context * @param array $versions * @return list> */ private function resolveWithStatus(array $versions, string $component, string $periodStart, string $periodEnd, array $context, string $requiredStatus): array { $start = $this->date($periodStart); $end = $this->date($periodEnd); if ($end < $start || $start->diff($end)->days > 365) { throw new InvalidArgumentException('Reveja o período de aplicação indicado.'); } $groups = []; foreach ($versions as $version) { if (($version['component'] ?? null) !== $component || ($version['status'] ?? null) !== $requiredStatus || ! SimulationFiscalSourceAcceptance::eligible($version['source'] ?? [], $version['source_sha256'] ?? null) || (int) ($version['source_version_no'] ?? 0) !== (int) ($version['source']['version_no'] ?? -1) || ! $this->overlaps($version, $start, $end) || ! $this->covers($version['source'], $start) || ! $this->covers($version['source'], $end) || ($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> $publishedVersions Caller must supply only records visible under the applicable access policy. * @param array $conditions * @return array */ 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) || ! SimulationFiscalSourceAcceptance::eligible($version['source'] ?? [], $version['source_sha256'] ?? null) || (int) ($version['source_version_no'] ?? 0) !== (int) ($version['source']['version_no'] ?? -1) || ! $this->covers($version['source'], $date) || ! preg_match('/^[a-f0-9]{64}$/D', (string) ($version['source_sha256'] ?? ''))) { 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 $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 $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|null $required * @param array $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; } }