*/ public function getGlobalConfig(): array { $raw = Setting::getSettings(self::FIELDS)->all(); return $this->hydrateDefaults($this->decryptFields($raw)); } /** * Read the per-company AI config with decrypted secrets. * * The response always includes the `use_custom_ai_config` toggle so the * frontend can render the override switch. Driver fields are present * regardless of the toggle — the client decides whether to show them. * * @return array */ public function getCompanyConfig(int|string $companyId): array { $companyKeys = array_merge( ['use_custom_ai_config'], $this->getCompanySettingKeys(), ); $raw = CompanySetting::getSettings($companyKeys, $companyId)->all(); return array_merge( ['use_custom_ai_config' => $raw['use_custom_ai_config'] ?? 'NO'], $this->hydrateDefaults($this->decryptFields($this->stripCompanyPrefix($raw))), ); } /** * Persist the global AI config, encrypting sensitive fields. * * @param array $payload */ public function saveGlobalConfig(array $payload): void { Setting::setSettings($this->prepareSettingsForStorage($payload, self::GLOBAL_SCOPE)); } /** * Persist the per-company AI config. * * When `use_custom_ai_config` is NOT 'YES', only the toggle is written — * driver fields in the payload are discarded. This mirrors the mail pattern * exactly and prevents stale per-company config from lingering after toggle-off. * * @param array $payload */ public function saveCompanyConfig(int|string $companyId, array $payload): void { if (($payload['use_custom_ai_config'] ?? 'YES') !== 'YES') { CompanySetting::setSettings([ 'use_custom_ai_config' => 'NO', ], $companyId); return; } $toStore = $this->prepareSettingsForStorage($payload, self::COMPANY_SCOPE); $toStore['use_custom_ai_config'] = 'YES'; CompanySetting::setSettings($toStore, $companyId); } /** * Resolve the effective AI config for a company. * * Returns the decrypted config array, or `null` when AI is unavailable. * Resolution order: * * 1. Global `ai_enabled` must be YES. Otherwise AI is off for everyone. * 2. If the company has `use_custom_ai_config = YES`, return the company * config. The company's own `ai_enabled` inside that override controls * whether AI is on for this company (the company can opt out by * setting `use_custom_ai_config = YES` and `ai_enabled = NO`). * 3. Otherwise, return the global config. * * @return array|null */ public function resolveForCompany(int|string $companyId): ?array { $global = $this->getGlobalConfig(); // Global kill-switch — applies to all companies if (($global['ai_enabled'] ?? 'NO') !== 'YES') { return null; } $company = $this->getCompanyConfig($companyId); if (($company['use_custom_ai_config'] ?? 'NO') === 'YES') { // Company-specific config — can opt out via ai_enabled=NO if (($company['ai_enabled'] ?? 'NO') !== 'YES') { return null; } return $this->addBooleanFlags($company); } return $this->addBooleanFlags($global); } /** * Convenience: resolve config and instantiate a driver for the company. * * Returns `null` when AI is disabled for the company. */ public function makeDriver(int|string $companyId): ?AiDriver { $config = $this->resolveForCompany($companyId); if ($config === null || empty($config['ai_api_key']) || empty($config['ai_driver'])) { return null; } return AiDriverFactory::make( $config['ai_driver'], $config['ai_api_key'], ['base_url' => $config['ai_base_url'] ?? null], ); } /** * Build dynamic validation rules for a save request. * * @return array */ public function validationRules(bool $allowDisabledCustomConfig = false): array { $availableDrivers = AiDriverFactory::availableDrivers(); return [ 'use_custom_ai_config' => $allowDisabledCustomConfig ? ['nullable', 'in:YES,NO'] : ['prohibited'], 'ai_enabled' => ['nullable', 'in:YES,NO'], 'ai_driver' => ['required_if:ai_enabled,YES', 'nullable', 'string', 'in:'.implode(',', $availableDrivers)], 'ai_api_key' => ['required_if:ai_enabled,YES', 'nullable', 'string'], 'ai_base_url' => ['nullable', 'string', 'url', new PublicHttpUrl], 'ai_chat_enabled' => ['nullable', 'in:YES,NO'], 'ai_chat_model' => ['nullable', 'string', 'max:200'], 'ai_text_generation_enabled' => ['nullable', 'in:YES,NO'], 'ai_text_generation_model' => ['nullable', 'string', 'max:200'], ]; } /** * Get driver metadata for the AI type — what the frontend needs to render forms. * * Shape matches the exchange rate driver list so the UI can reuse the same * data-driven rendering pattern. * * @return array> */ public function listDrivers(): array { return collect(Registry::allDrivers('ai')) ->map(fn (array $meta, string $name) => [ 'value' => $name, 'label' => $meta['label'] ?? $name, 'website' => $meta['website'] ?? '', 'default_base_url' => $meta['default_base_url'] ?? '', 'supported_roles' => $meta['supported_roles'] ?? [], 'suggested_models' => $meta['suggested_models'] ?? [], 'config_fields' => $meta['config_fields'] ?? [], ]) ->values() ->all(); } /** * Company-scope keys: the FIELDS list with `company_` prefix. * * @return array */ protected function getCompanySettingKeys(): array { return array_map(fn (string $field) => 'company_'.$field, self::FIELDS); } /** * Strip the `company_` prefix from keys when reading company-scoped settings. * * @param array $raw * @return array */ protected function stripCompanyPrefix(array $raw): array { $normalized = []; foreach ($raw as $key => $value) { if ($key === 'use_custom_ai_config') { continue; } if (str_starts_with($key, 'company_')) { $normalized[substr($key, strlen('company_'))] = $value; } } return $normalized; } /** * Decrypt sensitive fields on read. * * @param array $settings * @return array */ protected function decryptFields(array $settings): array { foreach (self::ENCRYPTED_FIELDS as $field) { if (isset($settings[$field]) && $settings[$field] !== '') { try { $settings[$field] = Crypt::decryptString($settings[$field]); } catch (\Throwable) { // Backward compat: if the value was stored before encryption // was introduced, leave it as-is rather than wiping it. } } } return $settings; } /** * Fill in defaults for fields missing from storage so consumers get a complete array. * * @param array $settings * @return array */ protected function hydrateDefaults(array $settings): array { return array_merge([ 'ai_enabled' => 'NO', 'ai_driver' => 'openrouter', 'ai_api_key' => '', 'ai_base_url' => '', 'ai_chat_enabled' => 'NO', 'ai_chat_model' => 'anthropic/claude-sonnet-4.6', 'ai_text_generation_enabled' => 'NO', 'ai_text_generation_model' => 'anthropic/claude-haiku-4.5', ], $settings); } /** * Add derived boolean flags to a config array so consumers don't have to * compare against the 'YES'/'NO' strings everywhere. * * @param array $config * @return array */ protected function addBooleanFlags(array $config): array { $config['chat_enabled'] = ($config['ai_chat_enabled'] ?? 'NO') === 'YES'; $config['text_generation_enabled'] = ($config['ai_text_generation_enabled'] ?? 'NO') === 'YES'; return $config; } /** * Prepare a payload for storage: strip fields the caller doesn't own, encrypt * sensitive fields, prefix with `company_` for the company scope. * * @param array $payload * @return array */ protected function prepareSettingsForStorage(array $payload, string $scope): array { $prepared = []; foreach (self::FIELDS as $field) { if (! array_key_exists($field, $payload)) { continue; } $value = $payload[$field]; if (in_array($field, self::ENCRYPTED_FIELDS, true) && is_string($value) && $value !== '') { $value = Crypt::encryptString($value); } $storageKey = $scope === self::COMPANY_SCOPE ? 'company_'.$field : $field; $prepared[$storageKey] = $value; } return $prepared; } }