Add support for dynamic config UI (#256)

* Add support for dynamic config UI

* Add support for section description

* Better dynamic class settings

Added dynamic_fields hash field - Stores all undeclared settings
[] method - Checks declared fields first, then falls back to dynamic hash
[]= method - Updates declared fields normally, stores others in hash
No runtime field declaration - Fields are never dynamically created on the class

* FIX proper lookup for provider keys

- Also validate configurable values properly.
- Change Provider factory to use Rails autoloading (Zeitwerk)

* Fix factory

The derive_adapter_name method relies on string manipulation ("PlaidAccount".sub(/Account$/, "") + "Adapter" → "PlaidAdapter"), but we already have explicit registration in place.

* Make updates atomic, field-aware, and handle blanks explicitly

* Small UX detail

* Add support for PlaidEU in UI also

- This looks like partial support atm
This commit is contained in:
soky srm
2025-10-29 13:11:04 +01:00
committed by GitHub
parent 9fefe57de5
commit 96713ee8b4
13 changed files with 763 additions and 43 deletions

View File

@@ -0,0 +1,108 @@
class Settings::ProvidersController < ApplicationController
layout "settings"
guard_feature unless: -> { self_hosted? }
before_action :ensure_admin, only: [ :show, :update ]
def show
@breadcrumbs = [
[ "Home", root_path ],
[ "Bank Sync Providers", nil ]
]
# Load all provider configurations
Provider::Factory.ensure_adapters_loaded
@provider_configurations = Provider::ConfigurationRegistry.all
end
def update
# Build index of valid configurable fields with their metadata
Provider::Factory.ensure_adapters_loaded
valid_fields = {}
Provider::ConfigurationRegistry.all.each do |config|
config.fields.each do |field|
valid_fields[field.setting_key.to_s] = field
end
end
updated_fields = []
# Perform all updates within a transaction for consistency
Setting.transaction do
provider_params.each do |param_key, param_value|
# Only process keys that exist in the configuration registry
field = valid_fields[param_key.to_s]
next unless field
# Clean the value and convert blank/empty strings to nil
value = param_value.to_s.strip
value = nil if value.empty?
# For secret fields only, skip placeholder values to prevent accidental overwrite
if field.secret && value == "********"
next
end
# Set the value using dynamic hash-style access
Setting[field.setting_key] = value
updated_fields << param_key
end
end
if updated_fields.any?
# Reload provider configurations if needed
reload_provider_configs(updated_fields)
redirect_to settings_providers_path, notice: "Provider settings updated successfully"
else
redirect_to settings_providers_path, notice: "No changes were made"
end
rescue => error
Rails.logger.error("Failed to update provider settings: #{error.message}")
flash.now[:alert] = "Failed to update provider settings: #{error.message}"
render :show, status: :unprocessable_entity
end
private
def provider_params
# Dynamically permit all provider configuration fields
Provider::Factory.ensure_adapters_loaded
permitted_fields = []
Provider::ConfigurationRegistry.all.each do |config|
config.fields.each do |field|
permitted_fields << field.setting_key
end
end
params.require(:setting).permit(*permitted_fields)
end
def ensure_admin
redirect_to settings_providers_path, alert: "Not authorized" unless Current.user.admin?
end
# Reload provider configurations after settings update
def reload_provider_configs(updated_fields)
# Build a set of provider keys that had fields updated
updated_provider_keys = Set.new
# Look up the provider key directly from the configuration registry
updated_fields.each do |field_key|
Provider::ConfigurationRegistry.all.each do |config|
field = config.fields.find { |f| f.setting_key.to_s == field_key.to_s }
if field
updated_provider_keys.add(field.provider_key)
break
end
end
end
# Reload configuration for each updated provider
updated_provider_keys.each do |provider_key|
adapter_class = Provider::ConfigurationRegistry.get_adapter_class(provider_key)
adapter_class&.reload_configuration
end
end
end

View File

@@ -0,0 +1,286 @@
# Module for providers to declare their configuration requirements
#
# Providers can declare their own configuration fields without needing to modify
# the Setting model. Settings are stored dynamically using RailsSettings::Base's
# hash-style access (Setting[:key] = value).
#
# Configuration fields are automatically registered and displayed in the UI at
# /settings/providers. The system checks Setting storage first, then ENV variables,
# then falls back to defaults.
#
# Example usage in an adapter:
# class Provider::PlaidAdapter < Provider::Base
# include Provider::Configurable
#
# configure do
# description <<~DESC
# Setup instructions:
# 1. Visit [Plaid Dashboard](https://dashboard.plaid.com) to get your API credentials
# 2. Configure your Client ID and Secret Key below
# DESC
#
# field :client_id,
# label: "Client ID",
# required: true,
# env_key: "PLAID_CLIENT_ID",
# description: "Your Plaid Client ID from the dashboard"
#
# field :secret,
# label: "Secret Key",
# required: true,
# secret: true,
# env_key: "PLAID_SECRET",
# description: "Your Plaid Secret key"
#
# field :environment,
# label: "Environment",
# required: false,
# env_key: "PLAID_ENV",
# default: "sandbox",
# description: "Plaid environment: sandbox, development, or production"
# end
# end
#
# The provider_key is automatically derived from the class name:
# Provider::PlaidAdapter -> "plaid"
# Provider::SimplefinAdapter -> "simplefin"
#
# Fields are stored with keys like "plaid_client_id", "plaid_secret", etc.
# Access values via: configuration.get_value(:client_id) or field.value
module Provider::Configurable
extend ActiveSupport::Concern
class_methods do
# Define configuration for this provider
def configure(&block)
@configuration = Configuration.new(provider_key)
@configuration.instance_eval(&block)
Provider::ConfigurationRegistry.register(provider_key, @configuration, self)
end
# Get the configuration for this provider
def configuration
@configuration || Provider::ConfigurationRegistry.get(provider_key)
end
# Get the provider key (derived from class name)
# Example: Provider::PlaidAdapter -> "plaid"
def provider_key
name.demodulize.gsub(/Adapter$/, "").underscore
end
# Get a configuration value
def config_value(field_name)
configuration&.get_value(field_name)
end
# Check if provider is configured (all required fields present)
def configured?
configuration&.configured? || false
end
# Reload provider-specific configuration (override in subclasses if needed)
# This is called after settings are updated in the UI
# Example: reload Rails.application.config values, reinitialize API clients, etc.
def reload_configuration
# Default implementation does nothing
# Override in provider adapters that need to reload configuration
end
end
# Instance methods
def provider_key
self.class.provider_key
end
def configuration
self.class.configuration
end
def config_value(field_name)
self.class.config_value(field_name)
end
def configured?
self.class.configured?
end
# Configuration DSL
class Configuration
attr_reader :provider_key, :fields, :provider_description
def initialize(provider_key)
@provider_key = provider_key
@fields = []
@provider_description = nil
end
# Set the provider-level description (markdown supported)
# @param text [String] The description text for this provider
def description(text)
@provider_description = text
end
# Define a configuration field
# @param name [Symbol] The field name
# @param label [String] Human-readable label
# @param required [Boolean] Whether this field is required
# @param secret [Boolean] Whether this field contains sensitive data (will be masked in UI)
# @param env_key [String] The ENV variable key for this field
# @param default [String] Default value if none provided
# @param description [String] Optional help text
def field(name, label:, required: false, secret: false, env_key: nil, default: nil, description: nil)
@fields << ConfigField.new(
name: name,
label: label,
required: required,
secret: secret,
env_key: env_key,
default: default,
description: description,
provider_key: @provider_key
)
end
# Get value for a field (checks Setting, then ENV, then default)
def get_value(field_name)
field = fields.find { |f| f.name == field_name }
return nil unless field
field.value
end
# Check if all required fields are present
def configured?
fields.select(&:required).all? { |f| f.value.present? }
end
# Get all field values as a hash
def to_h
fields.each_with_object({}) do |field, hash|
hash[field.name] = field.value
end
end
end
# Represents a single configuration field
class ConfigField
attr_reader :name, :label, :required, :secret, :env_key, :default, :description, :provider_key
def initialize(name:, label:, required:, secret:, env_key:, default:, description:, provider_key:)
@name = name
@label = label
@required = required
@secret = secret
@env_key = env_key
@default = default
@description = description
@provider_key = provider_key
end
# Get the setting key for this field
# Example: plaid_client_id
def setting_key
"#{provider_key}_#{name}".to_sym
end
# Get the value for this field (Setting -> ENV -> default)
def value
# First try Setting using dynamic hash-style access
# This works even without explicit field declarations in Setting model
setting_value = Setting[setting_key]
return normalize_value(setting_value) if setting_value.present?
# Then try ENV if env_key is specified
if env_key.present?
env_value = ENV[env_key]
return normalize_value(env_value) if env_value.present?
end
# Finally return default
normalize_value(default)
end
# Check if this field has a value
def present?
value.present?
end
# Validate the current value
# Returns true if valid, false otherwise
def valid?
validate.empty?
end
# Get validation errors for the current value
# Returns an array of error messages
def validate
errors = []
current_value = value
# Required validation
if required && current_value.blank?
errors << "#{label} is required"
end
# Additional validations can be added here in the future:
# - Format validation (regex)
# - Length validation
# - Enum validation
# - Custom validation blocks
errors
end
# Validate and raise an error if invalid
def validate!
errors = validate
raise ArgumentError, "Invalid configuration for #{setting_key}: #{errors.join(", ")}" if errors.any?
true
end
private
# Normalize value by stripping whitespace and converting empty strings to nil
def normalize_value(val)
return nil if val.nil?
normalized = val.to_s.strip
normalized.empty? ? nil : normalized
end
end
end
# Registry to store all provider configurations
module Provider::ConfigurationRegistry
class << self
def register(provider_key, configuration, adapter_class = nil)
registry[provider_key] = configuration
adapter_registry[provider_key] = adapter_class if adapter_class
end
def get(provider_key)
registry[provider_key]
end
def all
registry.values
end
def providers
registry.keys
end
# Get the adapter class for a provider key
def get_adapter_class(provider_key)
adapter_registry[provider_key]
end
private
def registry
@registry ||= {}
end
def adapter_registry
@adapter_registry ||= {}
end
end
end

View File

@@ -1,4 +1,6 @@
class Provider::Factory
class AdapterNotFoundError < StandardError; end
class << self
# Register a provider adapter
# @param provider_type [String] The provider account class name (e.g., "PlaidAccount")
@@ -15,15 +17,9 @@ class Provider::Factory
return nil if provider_account.nil?
provider_type = provider_account.class.name
adapter_class = registry[provider_type]
adapter_class = find_adapter_class(provider_type)
# If not registered, try to load the adapter
if adapter_class.nil?
ensure_adapters_loaded
adapter_class = registry[provider_type]
end
raise ArgumentError, "Unknown provider type: #{provider_type}. Did you forget to register it?" unless adapter_class
raise AdapterNotFoundError, "No adapter registered for provider type: #{provider_type}" unless adapter_class
adapter_class.new(provider_account, account: account)
end
@@ -41,7 +37,35 @@ class Provider::Factory
# @return [Array<String>] List of registered provider type names
def registered_provider_types
ensure_adapters_loaded
registry.keys
registry.keys.sort
end
# Ensures all provider adapters are loaded and registered
# Uses Rails autoloading to discover adapters dynamically
def ensure_adapters_loaded
# Eager load all adapter files to trigger their registration
adapter_files.each do |adapter_name|
adapter_class_name = "Provider::#{adapter_name}"
# Use Rails autoloading (constantize) instead of require
begin
adapter_class_name.constantize
rescue NameError => e
Rails.logger.warn("Failed to load adapter: #{adapter_class_name} - #{e.message}")
end
end
end
# Check if a provider type has a registered adapter
# @param provider_type [String] The provider account class name
# @return [Boolean]
def registered?(provider_type)
find_adapter_class(provider_type).present?
end
# Clear all registered adapters (useful for testing)
def clear_registry!
@registry = {}
end
private
@@ -50,17 +74,28 @@ class Provider::Factory
@registry ||= {}
end
# Ensures all provider adapters are loaded
# This is needed for Rails autoloading in development/test environments
def ensure_adapters_loaded
return if @adapters_loaded
# Find adapter class, attempting to load all adapters if not registered
def find_adapter_class(provider_type)
# Return if already registered
return registry[provider_type] if registry[provider_type]
# Require all adapter files to trigger registration
Dir[Rails.root.join("app/models/provider/*_adapter.rb")].each do |file|
require_dependency file
# Load all adapters to ensure they're registered
# This triggers their self-registration calls
ensure_adapters_loaded
# Check registry again after loading
registry[provider_type]
end
# Discover all adapter files in the provider directory
# Returns adapter class names (e.g., ["PlaidAdapter", "SimplefinAdapter"])
def adapter_files
return [] unless defined?(Rails)
pattern = Rails.root.join("app/models/provider/*_adapter.rb")
Dir[pattern].map do |file|
File.basename(file, ".rb").camelize
end
@adapters_loaded = true
end
end
end

View File

@@ -1,14 +1,72 @@
# PlaidAdapter serves dual purposes:
#
# 1. Configuration Manager (class-level):
# - Manages Rails.application.config.plaid (US region)
# - Exposes 3 configurable fields in "Plaid" section of settings UI
# - PlaidEuAdapter separately manages EU region in "Plaid Eu" section
#
# 2. Instance Adapter (instance-level):
# - Wraps ALL PlaidAccount instances regardless of region (US or EU)
# - The PlaidAccount's plaid_item.plaid_region determines which config to use
# - Delegates to Provider::Registry.plaid_provider_for_region(region)
class Provider::PlaidAdapter < Provider::Base
include Provider::Syncable
include Provider::InstitutionMetadata
include Provider::Configurable
# Register this adapter with the factory
# Register this adapter with the factory for ALL PlaidAccount instances
Provider::Factory.register("PlaidAccount", self)
# Configuration for Plaid US
configure do
description <<~DESC
Setup instructions:
1. Visit the [Plaid Dashboard](https://dashboard.plaid.com/team/keys) to get your API credentials
2. Your Client ID and Secret Key are required to enable Plaid bank sync for US/CA banks
3. For production use, set environment to 'production', for testing use 'sandbox'
DESC
field :client_id,
label: "Client ID",
required: false,
env_key: "PLAID_CLIENT_ID",
description: "Your Plaid Client ID from the Plaid Dashboard"
field :secret,
label: "Secret Key",
required: false,
secret: true,
env_key: "PLAID_SECRET",
description: "Your Plaid Secret from the Plaid Dashboard"
field :environment,
label: "Environment",
required: false,
env_key: "PLAID_ENV",
default: "sandbox",
description: "Plaid environment: sandbox, development, or production"
end
def provider_name
"plaid"
end
# Reload Plaid US configuration when settings are updated
def self.reload_configuration
client_id = config_value(:client_id).presence || ENV["PLAID_CLIENT_ID"]
secret = config_value(:secret).presence || ENV["PLAID_SECRET"]
environment = config_value(:environment).presence || ENV["PLAID_ENV"] || "sandbox"
if client_id.present? && secret.present?
Rails.application.config.plaid = Plaid::Configuration.new
Rails.application.config.plaid.server_index = Plaid::Configuration::Environment[environment]
Rails.application.config.plaid.api_key["PLAID-CLIENT-ID"] = client_id
Rails.application.config.plaid.api_key["PLAID-SECRET"] = secret
else
Rails.application.config.plaid = nil
end
end
def sync_path
Rails.application.routes.url_helpers.sync_plaid_item_path(item)
end

View File

@@ -0,0 +1,61 @@
# PlaidEuAdapter is a configuration-only manager for Plaid EU credentials.
#
# It does NOT register as a provider type because:
# - There's no separate "PlaidEuAccount" model
# - All PlaidAccounts (regardless of region) use PlaidAdapter as their instance adapter
#
# This class only manages Rails.application.config.plaid_eu, which
# Provider::Registry.plaid_provider_for_region(:eu) uses to create Provider::Plaid instances.
#
# This separation into a distinct adapter class provides:
# - Clear UI separation: "Plaid" vs "Plaid Eu" sections in settings
# - Better UX: Users only configure the region they need
class Provider::PlaidEuAdapter
include Provider::Configurable
# Configuration for Plaid EU
configure do
description <<~DESC
Setup instructions:
1. Visit the [Plaid Dashboard](https://dashboard.plaid.com/team/keys) to get your API credentials
2. Your Client ID and Secret Key are required to enable Plaid bank sync for European banks
3. For production use, set environment to 'production', for testing use 'sandbox'
DESC
field :client_id,
label: "Client ID",
required: false,
env_key: "PLAID_EU_CLIENT_ID",
description: "Your Plaid Client ID from the Plaid Dashboard for EU region"
field :secret,
label: "Secret Key",
required: false,
secret: true,
env_key: "PLAID_EU_SECRET",
description: "Your Plaid Secret from the Plaid Dashboard for EU region"
field :environment,
label: "Environment",
required: false,
env_key: "PLAID_EU_ENV",
default: "sandbox",
description: "Plaid environment: sandbox, development, or production"
end
# Reload Plaid EU configuration when settings are updated
def self.reload_configuration
client_id = config_value(:client_id).presence || ENV["PLAID_EU_CLIENT_ID"]
secret = config_value(:secret).presence || ENV["PLAID_EU_SECRET"]
environment = config_value(:environment).presence || ENV["PLAID_EU_ENV"] || "sandbox"
if client_id.present? && secret.present?
Rails.application.config.plaid_eu = Plaid::Configuration.new
Rails.application.config.plaid_eu.server_index = Plaid::Configuration::Environment[environment]
Rails.application.config.plaid_eu.api_key["PLAID-CLIENT-ID"] = client_id
Rails.application.config.plaid_eu.api_key["PLAID-SECRET"] = secret
else
Rails.application.config.plaid_eu = nil
end
end
end

View File

@@ -1,10 +1,27 @@
class Provider::SimplefinAdapter < Provider::Base
include Provider::Syncable
include Provider::InstitutionMetadata
include Provider::Configurable
# Register this adapter with the factory
Provider::Factory.register("SimplefinAccount", self)
# Configuration for SimpleFIN
configure do
description <<~DESC
Setup instructions:
1. Visit [SimpleFIN Bridge](https://bridge.simplefin.org/simplefin/create) to get a setup token
2. This token is optional and only needed if you want to provide a default setup token for users
DESC
field :setup_token,
label: "Setup Token",
required: false,
secret: true,
env_key: "SIMPLEFIN_SETUP_TOKEN",
description: "Optional: SimpleFIN setup token from your SimpleFIN Bridge account (one-time use)"
end
def provider_name
"simplefin"
end

View File

@@ -4,12 +4,18 @@ class Setting < RailsSettings::Base
cache_prefix { "v1" }
# Third-party API keys
field :twelve_data_api_key, type: :string, default: ENV["TWELVE_DATA_API_KEY"]
field :openai_access_token, type: :string, default: ENV["OPENAI_ACCESS_TOKEN"]
field :openai_uri_base, type: :string, default: ENV["OPENAI_URI_BASE"]
field :openai_model, type: :string, default: ENV["OPENAI_MODEL"]
field :brand_fetch_client_id, type: :string, default: ENV["BRAND_FETCH_CLIENT_ID"]
# Single hash field for all dynamic provider credentials and other dynamic settings
# This allows unlimited dynamic fields without declaring them upfront
field :dynamic_fields, type: :hash, default: {}
# Onboarding and app settings
ONBOARDING_STATES = %w[open closed invite_only].freeze
DEFAULT_ONBOARDING_STATE = begin
env_value = ENV["ONBOARDING_STATE"].to_s.presence || "open"
@@ -42,6 +48,56 @@ class Setting < RailsSettings::Base
self.require_invite_for_signup = state == "invite_only"
self.raw_onboarding_state = state
end
# Support dynamic field access via bracket notation
# First checks if it's a declared field, then falls back to dynamic_fields hash
def [](key)
key_str = key.to_s
# Check if it's a declared field first
if respond_to?(key_str)
public_send(key_str)
else
# Fall back to dynamic_fields hash
dynamic_fields[key_str]
end
end
def []=(key, value)
key_str = key.to_s
# If it's a declared field, use the setter
if respond_to?("#{key_str}=")
public_send("#{key_str}=", value)
else
# Otherwise, store in dynamic_fields hash
current_dynamic = dynamic_fields.dup
current_dynamic[key_str] = value
self.dynamic_fields = current_dynamic
end
end
# Check if a dynamic field exists (useful to distinguish nil value vs missing key)
def key?(key)
key_str = key.to_s
respond_to?(key_str) || dynamic_fields.key?(key_str)
end
# Delete a dynamic field
def delete(key)
key_str = key.to_s
return nil if respond_to?(key_str) # Can't delete declared fields
current_dynamic = dynamic_fields.dup
value = current_dynamic.delete(key_str)
self.dynamic_fields = current_dynamic
value
end
# List all dynamic field keys (excludes declared fields)
def dynamic_keys
dynamic_fields.keys
end
end
# Validates OpenAI configuration requires model when custom URI base is set

View File

@@ -28,6 +28,7 @@ nav_sections = [
{ label: "LLM Usage", path: settings_llm_usage_path, icon: "activity" },
{ label: t(".api_keys_label"), path: settings_api_key_path, icon: "key" },
{ label: t(".self_hosting_label"), path: settings_hosting_path, icon: "database", if: self_hosted? },
{ label: "Providers", path: settings_providers_path, icon: "plug" },
{ label: t(".imports_label"), path: imports_path, icon: "download" },
{ label: "SimpleFin", path: simplefin_items_path, icon: "building-2" }
]

View File

@@ -0,0 +1,83 @@
<%
# Parameters:
# - configuration: Provider::Configurable::Configuration object
%>
<div class="space-y-4">
<div>
<% if configuration.provider_description.present? %>
<div class="text-sm text-secondary mb-4 prose prose-sm">
<%= markdown(configuration.provider_description).html_safe %>
</div>
<% end %>
<% env_configured = configuration.fields.any? { |f| f.env_key && ENV[f.env_key].present? } %>
<% if env_configured %>
<p class="text-sm text-secondary">
Configuration can be set via environment variables or overridden below.
</p>
<% end %>
<% if configuration.fields.any? { |f| f.description.present? } %>
<p class="text-secondary text-sm mb-4">Field descriptions:</p>
<ul class="text-sm text-secondary mb-4 list-disc ml-6 space-y-2">
<% configuration.fields.each do |field| %>
<% if field.description.present? %>
<li><strong><%= field.label %>:</strong> <%= field.description %></li>
<% end %>
<% end %>
</ul>
<% end %>
</div>
<%= styled_form_with model: Setting.new,
url: settings_providers_path,
method: :patch,
data: {
controller: "auto-submit-form",
"auto-submit-form-trigger-event-value": "blur"
} do |form| %>
<div class="space-y-4">
<% configuration.fields.each do |field| %>
<%
env_value = ENV[field.env_key] if field.env_key
# Use dynamic hash-style access - works without explicit field declaration
setting_value = Setting[field.setting_key]
# Show the setting value if it exists, otherwise show ENV value
# This allows users to see what they've overridden
current_value = setting_value.presence || env_value
# Mask secret values if they exist
display_value = if field.secret && current_value.present?
"********"
else
current_value
end
# Determine input type
input_type = field.secret ? "password" : "text"
# Don't disable fields - allow overriding ENV variables
disabled = false
%>
<%= form.text_field field.setting_key,
label: field.label,
type: input_type,
placeholder: field.default || (field.required ? "" : "Optional"),
value: display_value,
disabled: disabled,
data: { "auto-submit-form-target": "auto" } %>
<% end %>
</div>
<% end %>
<% # Show configuration status %>
<% if configuration.configured? %>
<div class="flex items-center gap-2 mt-4">
<div class="w-2 h-2 bg-green-500 rounded-full"></div>
<p class="text-sm text-secondary">Configured and ready to use</p>
</div>
<% end %>
</div>

View File

@@ -0,0 +1,15 @@
<%= content_for :page_title, "Bank Sync Providers" %>
<div class="space-y-8">
<div>
<p class="text-secondary mb-4">
Configure credentials for third-party bank sync providers. Settings configured here will override environment variables.
</p>
</div>
<% @provider_configurations.each do |config| %>
<%= settings_section title: config.provider_key.titleize do %>
<%= render "settings/providers/provider_form", configuration: config %>
<% end %>
<% end %>
</div>

View File

@@ -1,18 +1,15 @@
Rails.application.configure do
# Initialize Plaid configuration to nil
config.plaid = nil
config.plaid_eu = nil
if ENV["PLAID_CLIENT_ID"].present? && ENV["PLAID_SECRET"].present?
config.plaid = Plaid::Configuration.new
config.plaid.server_index = Plaid::Configuration::Environment[ENV["PLAID_ENV"] || "sandbox"]
config.plaid.api_key["PLAID-CLIENT-ID"] = ENV["PLAID_CLIENT_ID"]
config.plaid.api_key["PLAID-SECRET"] = ENV["PLAID_SECRET"]
end
if ENV["PLAID_EU_CLIENT_ID"].present? && ENV["PLAID_EU_SECRET"].present?
config.plaid_eu = Plaid::Configuration.new
config.plaid_eu.server_index = Plaid::Configuration::Environment[ENV["PLAID_ENV"] || "sandbox"]
config.plaid_eu.api_key["PLAID-CLIENT-ID"] = ENV["PLAID_EU_CLIENT_ID"]
config.plaid_eu.api_key["PLAID-SECRET"] = ENV["PLAID_EU_SECRET"]
end
end
# Load Plaid configuration from adapters after initialization
Rails.application.config.after_initialize do
# Ensure provider adapters are loaded
Provider::Factory.ensure_adapters_loaded
# Reload configurations from settings/ENV
Provider::PlaidAdapter.reload_configuration # US region
Provider::PlaidEuAdapter.reload_configuration # EU region
end

View File

@@ -76,6 +76,7 @@ Rails.application.routes.draw do
resource :llm_usage, only: :show
resource :guides, only: :show
resource :bank_sync, only: :show, controller: "bank_sync"
resource :providers, only: %i[show update]
end
resource :subscription, only: %i[new show create] do

View File

@@ -3,13 +3,14 @@ require "test_helper"
class Provider::RegistryTest < ActiveSupport::TestCase
test "providers filters out nil values when provider is not configured" do
# Ensure OpenAI is not configured
Setting.stubs(:openai_access_token).returns(nil)
ENV.stubs(:fetch).with("OPENAI_ACCESS_TOKEN", nil).returns(nil)
ClimateControl.modify("OPENAI_ACCESS_TOKEN" => nil) do
Setting.stubs(:openai_access_token).returns(nil)
registry = Provider::Registry.for_concept(:llm)
registry = Provider::Registry.for_concept(:llm)
# Should return empty array instead of [nil]
assert_equal [], registry.providers
# Should return empty array instead of [nil]
assert_equal [], registry.providers
end
end
test "providers returns configured providers" do
@@ -34,13 +35,14 @@ class Provider::RegistryTest < ActiveSupport::TestCase
test "get_provider returns nil when provider not configured" do
# Ensure OpenAI is not configured
Setting.stubs(:openai_access_token).returns(nil)
ENV.stubs(:[]).with("OPENAI_ACCESS_TOKEN").returns(nil)
ClimateControl.modify("OPENAI_ACCESS_TOKEN" => nil) do
Setting.stubs(:openai_access_token).returns(nil)
registry = Provider::Registry.for_concept(:llm)
registry = Provider::Registry.for_concept(:llm)
# Should return nil when provider method exists but returns nil
assert_nil registry.get_provider(:openai)
# Should return nil when provider method exists but returns nil
assert_nil registry.get_provider(:openai)
end
end
test "openai provider falls back to Setting when ENV is empty string" do