mirror of
https://github.com/we-promise/sure.git
synced 2026-04-09 07:14:47 +00:00
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:
108
app/controllers/settings/providers_controller.rb
Normal file
108
app/controllers/settings/providers_controller.rb
Normal 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
|
||||
286
app/models/provider/configurable.rb
Normal file
286
app/models/provider/configurable.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
61
app/models/provider/plaid_eu_adapter.rb
Normal file
61
app/models/provider/plaid_eu_adapter.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" }
|
||||
]
|
||||
|
||||
83
app/views/settings/providers/_provider_form.html.erb
Normal file
83
app/views/settings/providers/_provider_form.html.erb
Normal 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>
|
||||
15
app/views/settings/providers/show.html.erb
Normal file
15
app/views/settings/providers/show.html.erb
Normal 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>
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user