Provider generator (#364)

* Move provider config to family

* Update schema.rb

* Add provier generator

* Add table creation also

* FIX generator namespace

* Add support for global providers also

* Remove over-engineered stuff

* FIX parser

* FIX linter

* Some generator fixes

* Update generator with fixes

* Update item_model.rb.tt

* Add missing linkable concern

* Add missing routes

* Update adapter.rb.tt

* Update connectable_concern.rb.tt

* Update unlinking_concern.rb.tt

* Update family_generator.rb

* Update family_generator.rb

* Delete .claude/settings.local.json

Signed-off-by: soky srm <sokysrm@gmail.com>

* Move docs under API related folder

* Rename Rails generator doc

* Light edits to LLM generated doc

* Small Lunch Flow config panel regressions.

---------

Signed-off-by: soky srm <sokysrm@gmail.com>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
This commit is contained in:
soky srm
2025-12-08 22:52:30 +01:00
committed by GitHub
parent 88952e4714
commit 5d6c1bc280
18 changed files with 2604 additions and 4 deletions

View File

@@ -0,0 +1,242 @@
require "rails/generators"
require "rails/generators/active_record"
# Generator for creating global provider integrations
#
# Usage:
# rails g provider:global NAME field:type[:secret] field:type ...
#
# Examples:
# rails g provider:global plaid client_id:string:secret secret:string:secret environment:string
# rails g provider:global openai api_key:string:secret model:string
#
# Field format:
# name:type[:secret][:default=value]
# - name: Field name (e.g., api_key)
# - type: Database column type (text, string, integer, boolean)
# - secret: Optional flag indicating this field should be masked in UI
# - default: Optional default value (e.g., default=sandbox)
#
# This generates:
# - Migration creating provider_items and provider_accounts tables (WITHOUT credential fields)
# - Models for items, accounts, and provided concern
# - Adapter class with Provider::Configurable (credentials stored globally in settings table)
#
# Key difference from provider:family:
# - Credentials stored in `settings` table (global, shared by all families)
# - Item/account tables store connections per family (but not credentials)
# - No controller/view/routes needed (configuration via /settings/providers)
class Provider::GlobalGenerator < Rails::Generators::NamedBase
include Rails::Generators::Migration
source_root File.expand_path("templates", __dir__)
argument :fields, type: :array, default: [], banner: "field:type[:secret][:default=value] field:type[:secret]"
class_option :skip_migration, type: :boolean, default: false, desc: "Skip generating migration"
class_option :skip_models, type: :boolean, default: false, desc: "Skip generating models"
class_option :skip_adapter, type: :boolean, default: false, desc: "Skip generating adapter"
def validate_fields
if parsed_fields.empty?
raise Thor::Error, "At least one credential field is required. Example: api_key:text:secret"
end
# Validate field types
parsed_fields.each do |field|
unless %w[text string integer boolean].include?(field[:type])
raise Thor::Error, "Invalid field type '#{field[:type]}' for #{field[:name]}. Must be one of: text, string, integer, boolean"
end
end
end
def generate_migration
return if options[:skip_migration]
migration_template "global_migration.rb.tt",
"db/migrate/create_#{table_name}_and_accounts.rb",
migration_version: migration_version
end
def create_models
return if options[:skip_models]
# Create item model
item_model_path = "app/models/#{file_name}_item.rb"
if File.exist?(item_model_path)
say "Item model already exists: #{item_model_path}", :skip
else
template "global_item_model.rb.tt", item_model_path
say "Created item model: #{item_model_path}", :green
end
# Create account model
account_model_path = "app/models/#{file_name}_account.rb"
if File.exist?(account_model_path)
say "Account model already exists: #{account_model_path}", :skip
else
template "global_account_model.rb.tt", account_model_path
say "Created account model: #{account_model_path}", :green
end
# Create Provided concern
provided_concern_path = "app/models/#{file_name}_item/provided.rb"
if File.exist?(provided_concern_path)
say "Provided concern already exists: #{provided_concern_path}", :skip
else
template "global_provided_concern.rb.tt", provided_concern_path
say "Created Provided concern: #{provided_concern_path}", :green
end
end
def create_adapter
return if options[:skip_adapter]
adapter_path = "app/models/provider/#{file_name}_adapter.rb"
if File.exist?(adapter_path)
say "Adapter already exists: #{adapter_path}", :skip
else
template "global_adapter.rb.tt", adapter_path
say "Created adapter: #{adapter_path}", :green
end
end
def show_summary
say "\n" + "=" * 80, :green
say "Successfully generated global provider: #{class_name}", :green
say "=" * 80, :green
say "\nGenerated files:", :cyan
say " 📋 Migration: db/migrate/xxx_create_#{table_name}_and_accounts.rb"
say " 📦 Models:"
say " - app/models/#{file_name}_item.rb"
say " - app/models/#{file_name}_account.rb"
say " - app/models/#{file_name}_item/provided.rb"
say " 🔌 Adapter: app/models/provider/#{file_name}_adapter.rb"
if parsed_fields.any?
say "\nGlobal credential fields (stored in settings table):", :cyan
parsed_fields.each do |field|
secret_flag = field[:secret] ? " 🔒 (secret, masked in UI)" : ""
default_flag = field[:default] ? " [default: #{field[:default]}]" : ""
env_flag = " [ENV: #{field[:env_key]}]"
say " - #{field[:name]}: #{field[:type]}#{secret_flag}#{default_flag}#{env_flag}"
end
end
say "\nDatabase tables created:", :cyan
say " - #{table_name} (stores per-family connections, NO credentials)"
say " - #{file_name}_accounts (stores individual account data)"
say "\n⚠️ Global Provider Pattern:", :yellow
say " - Credentials stored GLOBALLY in 'settings' table"
say " - All families share the same credentials"
say " - Configuration UI auto-generated at /settings/providers"
say " - Only available in self-hosted mode"
say "\nNext steps:", :yellow
say " 1. Run migrations:"
say " rails db:migrate"
say ""
say " 2. Implement the provider SDK in:"
say " app/models/provider/#{file_name}.rb"
say ""
say " 3. Update #{class_name}Item::Provided concern:"
say " app/models/#{file_name}_item/provided.rb"
say " Implement the #{file_name}_provider method"
say ""
say " 4. Customize the adapter:"
say " app/models/provider/#{file_name}_adapter.rb"
say " - Update configure block descriptions"
say " - Implement reload_configuration if needed"
say " - Implement build_provider method"
say ""
say " 5. Configure credentials:"
say " Visit /settings/providers (self-hosted mode only)"
say " Or set ENV variables:"
parsed_fields.each do |field|
say " export #{field[:env_key]}=\"your_value\""
end
say ""
say " 6. Add item creation flow:"
say " - Users connect their #{class_name} account"
say " - Creates #{class_name}Item with family association"
say " - Syncs accounts using global credentials"
say ""
say " 📚 See PROVIDER_ARCHITECTURE.md for global provider documentation"
end
# Required for Rails::Generators::Migration
def self.next_migration_number(dirname)
ActiveRecord::Generators::Base.next_migration_number(dirname)
end
private
def table_name
"#{file_name}_items"
end
def migration_version
"[#{Rails::VERSION::MAJOR}.#{Rails::VERSION::MINOR}]"
end
def parsed_fields
@parsed_fields ||= fields.map do |field_def|
parts = field_def.split(":")
name = parts[0]
type = parts[1] || "string"
secret = parts.include?("secret")
default = extract_default(parts)
{
name: name,
type: type,
secret: secret,
default: default,
env_key: "#{file_name.upcase}_#{name.upcase}"
}
end
end
def extract_default(parts)
default_part = parts.find { |p| p.start_with?("default=") }
default_part&.sub("default=", "")
end
def configure_block_content
return "" if parsed_fields.empty?
fields_code = parsed_fields.map do |field|
field_attrs = [
"label: \"#{field[:name].titleize}\"",
("required: true" if field[:secret]),
("secret: true" if field[:secret]),
"env_key: \"#{field[:env_key]}\"",
("default: \"#{field[:default]}\"" if field[:default]),
"description: \"Your #{class_name} #{field[:name].humanize.downcase}\""
].compact.join(",\n ")
" field :#{field[:name]},\n #{field_attrs}\n"
end.join("\n")
<<~RUBY
configure do
description <<~DESC
Setup instructions for #{class_name}:
1. Visit your #{class_name} dashboard to get your credentials
2. Enter your credentials below
3. These credentials will be used by all families (global configuration)
**Note:** This is a global configuration for self-hosted mode only.
In managed mode, credentials are configured by the platform operator.
DESC
#{fields_code}
end
RUBY
end
end

View File

@@ -0,0 +1,52 @@
class <%= class_name %>Account < ApplicationRecord
include CurrencyNormalizable
belongs_to :<%= file_name %>_item
# Association through account_providers for linking to internal accounts
has_one :account_provider, as: :provider, dependent: :destroy
has_one :account, through: :account_provider, source: :account
has_one :linked_account, through: :account_provider, source: :account
validates :name, :currency, presence: true
# Helper to get account using account_providers system
def current_account
account
end
def upsert_<%= file_name %>_snapshot!(account_snapshot)
# Convert to symbol keys or handle both string and symbol keys
snapshot = account_snapshot.with_indifferent_access
# Map <%= class_name %> field names to our field names
# TODO: Customize this mapping based on your provider's API response
update!(
current_balance: snapshot[:balance] || snapshot[:current_balance],
currency: parse_currency(snapshot[:currency]) || "USD",
name: snapshot[:name],
account_id: snapshot[:id]&.to_s,
account_status: snapshot[:status],
provider: snapshot[:provider],
institution_metadata: {
name: snapshot[:institution_name],
logo: snapshot[:institution_logo]
}.compact,
raw_payload: account_snapshot
)
end
def upsert_<%= file_name %>_transactions_snapshot!(transactions_snapshot)
assign_attributes(
raw_transactions_payload: transactions_snapshot
)
save!
end
private
def log_invalid_currency(currency_value)
Rails.logger.warn("Invalid currency code '#{currency_value}' for <%= class_name %> account #{id}, defaulting to USD")
end
end

View File

@@ -0,0 +1,105 @@
class Provider::<%= class_name %>Adapter < Provider::Base
include Provider::Syncable
include Provider::InstitutionMetadata
include Provider::Configurable
# Register this adapter with the factory
Provider::Factory.register("<%= class_name %>Account", self)
<%= configure_block_content %>
def provider_name
"<%= file_name %>"
end
# Thread-safe lazy loading of <%= class_name %> configuration
def self.ensure_configuration_loaded
# Fast path: return immediately if already loaded (no lock needed)
return if Rails.application.config.<%= file_name %>.present?
# Slow path: acquire lock and reload if still needed
@config_mutex ||= Mutex.new
@config_mutex.synchronize do
return if Rails.application.config.<%= file_name %>.present?
reload_configuration
end
end
# Reload <%= class_name %> configuration when settings are updated
def self.reload_configuration
<% parsed_fields.each do |field| -%>
<%= field[:name] %> = config_value(:<%= field[:name] %>).presence || ENV["<%= field[:env_key] %>"]<% if field[:default] %> || "<%= field[:default] %>"<% end %>
<% end -%>
<% first_required_field = parsed_fields.find { |f| f[:secret] } -%>
<% if first_required_field -%>
if <%= first_required_field[:name] %>.present?
<% end -%>
Rails.application.config.<%= file_name %> = OpenStruct.new(
<% parsed_fields.each_with_index do |field, index| -%>
<%= field[:name] %>: <%= field[:name] %><%= index < parsed_fields.length - 1 ? ',' : '' %>
<% end -%>
)
<% if first_required_field -%>
else
Rails.application.config.<%= file_name %> = nil
end
<% end -%>
end
# Build a <%= class_name %> provider instance using GLOBAL credentials
# @return [Provider::<%= class_name %>, nil] Returns nil if credentials are not configured
def self.build_provider
<% first_secret_field = parsed_fields.find { |f| f[:secret] } -%>
<% if first_secret_field -%>
<%= first_secret_field[:name] %> = config_value(:<%= first_secret_field[:name] %>)
return nil unless <%= first_secret_field[:name] %>.present?
<% end -%>
# TODO: Implement provider initialization
# Provider::<%= class_name %>.new(
<% parsed_fields.each_with_index do |field, index| -%>
# <%= field[:name] %>: config_value(:<%= field[:name] %>)<%= index < parsed_fields.length - 1 ? ',' : '' %>
<% end -%>
# )
raise NotImplementedError, "Implement build_provider in #{__FILE__}"
end
def sync_path
Rails.application.routes.url_helpers.sync_<%= file_name %>_item_path(item)
end
def item
provider_account.<%= file_name %>_item
end
def can_delete_holdings?
false
end
def institution_domain
# TODO: Implement institution domain extraction
metadata = provider_account.institution_metadata
return nil unless metadata.present?
metadata["domain"]
end
def institution_name
# TODO: Implement institution name extraction
metadata = provider_account.institution_metadata
return nil unless metadata.present?
metadata["name"] || item&.institution_name
end
def institution_url
# TODO: Implement institution URL extraction
metadata = provider_account.institution_metadata
return nil unless metadata.present?
metadata["url"] || item&.institution_url
end
def institution_color
item&.institution_color
end
end

View File

@@ -0,0 +1,33 @@
class <%= class_name %>Item < ApplicationRecord
include Syncable, Provided
enum :status, { good: "good", requires_update: "requires_update" }, default: :good
validates :name, presence: true
belongs_to :family
has_one_attached :logo
has_many :<%= file_name %>_accounts, dependent: :destroy
has_many :accounts, through: :<%= file_name %>_accounts
scope :active, -> { where(scheduled_for_deletion: false) }
scope :ordered, -> { order(created_at: :desc) }
scope :needs_update, -> { where(status: :requires_update) }
def destroy_later
update!(scheduled_for_deletion: true)
DestroyJob.perform_later(self)
end
# NOTE: This is a GLOBAL provider
# Credentials are configured globally in /settings/providers (self-hosted mode)
# or via environment variables
# This model stores the per-family connection, but not credentials
# TODO: Implement provider-specific methods
# - import_latest_<%= file_name %>_data
# - process_accounts
# - schedule_account_syncs
# - See <%= class_name %>Item::Provided for provider instantiation
end

View File

@@ -0,0 +1,61 @@
class Create<%= class_name %>ItemsAndAccounts < ActiveRecord::Migration<%= migration_version %>
def change
# Create provider items table (stores per-family connections)
# NOTE: Credentials are stored GLOBALLY in the 'settings' table via Provider::Configurable
# This table only stores connection metadata per family
create_table :<%= table_name %>, id: :uuid do |t|
t.references :family, null: false, foreign_key: true, type: :uuid
t.string :name
# Institution metadata
t.string :institution_id
t.string :institution_name
t.string :institution_domain
t.string :institution_url
t.string :institution_color
# Status and lifecycle
t.string :status, default: "good"
t.boolean :scheduled_for_deletion, default: false
t.boolean :pending_account_setup, default: false
# Sync settings
t.datetime :sync_start_date
# Raw data storage
t.jsonb :raw_payload
t.jsonb :raw_institution_payload
# NO credential fields here - they're in the settings table!
t.timestamps
end
add_index :<%= table_name %>, :status
# Create provider accounts table (stores individual account data from provider)
create_table :<%= file_name %>_accounts, id: :uuid do |t|
t.references :<%= file_name %>_item, null: false, foreign_key: true, type: :uuid
# Account identification
t.string :name
t.string :account_id
# Account details
t.string :currency
t.decimal :current_balance, precision: 19, scale: 4
t.string :account_status
t.string :account_type
t.string :provider
# Metadata and raw data
t.jsonb :institution_metadata
t.jsonb :raw_payload
t.jsonb :raw_transactions_payload
t.timestamps
end
add_index :<%= file_name %>_accounts, :account_id
end
end

View File

@@ -0,0 +1,10 @@
module <%= class_name %>Item::Provided
extend ActiveSupport::Concern
# Returns a <%= class_name %> provider instance using GLOBAL credentials
# Credentials are configured in /settings/providers (self-hosted) or ENV variables
def <%= file_name %>_provider
# Use the adapter's build_provider method which reads from global settings
Provider::<%= class_name %>Adapter.build_provider
end
end