mirror of
https://github.com/we-promise/sure.git
synced 2026-04-17 02:54:10 +00:00
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:
429
lib/generators/provider/family/family_generator.rb
Normal file
429
lib/generators/provider/family/family_generator.rb
Normal file
@@ -0,0 +1,429 @@
|
||||
require "rails/generators"
|
||||
require "rails/generators/active_record"
|
||||
|
||||
# Generator for creating per-family provider integrations
|
||||
#
|
||||
# Usage:
|
||||
# rails g provider:family NAME field:type:secret field:type ...
|
||||
#
|
||||
# Examples:
|
||||
# rails g provider:family lunchflow api_key:text:secret base_url:string
|
||||
# rails g provider:family my_bank access_token:text:secret refresh_token:text:secret
|
||||
#
|
||||
# Field format:
|
||||
# name:type[:secret]
|
||||
# - name: Field name (e.g., api_key)
|
||||
# - type: Database column type (text, string, integer, boolean)
|
||||
# - secret: Optional flag indicating this field should be encrypted
|
||||
#
|
||||
# This generates:
|
||||
# - Migration creating complete provider_items and provider_accounts tables
|
||||
# - Models for items, accounts, and provided concern
|
||||
# - Adapter class
|
||||
# - Manual panel view for provider settings
|
||||
# - Simple controller for CRUD operations
|
||||
# - Routes
|
||||
class Provider::FamilyGenerator < Rails::Generators::NamedBase
|
||||
include Rails::Generators::Migration
|
||||
|
||||
source_root File.expand_path("templates", __dir__)
|
||||
|
||||
argument :fields, type: :array, default: [], banner: "field:type[:secret] field:type[:secret]"
|
||||
|
||||
class_option :skip_migration, type: :boolean, default: false, desc: "Skip generating migration"
|
||||
class_option :skip_routes, type: :boolean, default: false, desc: "Skip adding routes"
|
||||
class_option :skip_view, type: :boolean, default: false, desc: "Skip generating view"
|
||||
class_option :skip_controller, type: :boolean, default: false, desc: "Skip generating controller"
|
||||
class_option :skip_adapter, type: :boolean, default: false, desc: "Skip generating adapter"
|
||||
|
||||
def validate_fields
|
||||
if parsed_fields.empty?
|
||||
say "Warning: No fields specified. You'll need to add them manually later.", :yellow
|
||||
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 "migration.rb.tt",
|
||||
"db/migrate/create_#{table_name}_and_accounts.rb",
|
||||
migration_version: migration_version
|
||||
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
|
||||
# Create new adapter
|
||||
template "adapter.rb.tt", adapter_path
|
||||
say "Created new adapter: #{adapter_path}", :green
|
||||
end
|
||||
end
|
||||
|
||||
def create_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 "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 "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 "provided_concern.rb.tt", provided_concern_path
|
||||
say "Created Provided concern: #{provided_concern_path}", :green
|
||||
end
|
||||
|
||||
# Create Unlinking concern
|
||||
unlinking_concern_path = "app/models/#{file_name}_item/unlinking.rb"
|
||||
if File.exist?(unlinking_concern_path)
|
||||
say "Unlinking concern already exists: #{unlinking_concern_path}", :skip
|
||||
else
|
||||
template "unlinking_concern.rb.tt", unlinking_concern_path
|
||||
say "Created Unlinking concern: #{unlinking_concern_path}", :green
|
||||
end
|
||||
|
||||
# Create Family Connectable concern
|
||||
connectable_concern_path = "app/models/family/#{file_name}_connectable.rb"
|
||||
if File.exist?(connectable_concern_path)
|
||||
say "Connectable concern already exists: #{connectable_concern_path}", :skip
|
||||
else
|
||||
template "connectable_concern.rb.tt", connectable_concern_path
|
||||
say "Created Connectable concern: #{connectable_concern_path}", :green
|
||||
end
|
||||
end
|
||||
|
||||
def update_family_model
|
||||
family_model_path = "app/models/family.rb"
|
||||
return unless File.exist?(family_model_path)
|
||||
|
||||
content = File.read(family_model_path)
|
||||
connectable_module = "#{class_name}Connectable"
|
||||
|
||||
# Check if already included
|
||||
if content.include?(connectable_module)
|
||||
say "Family model already includes #{connectable_module}", :skip
|
||||
else
|
||||
# Insert a new include line after the class declaration
|
||||
# This approach is more robust than trying to append to an existing include line
|
||||
lines = content.lines
|
||||
class_line_index = nil
|
||||
|
||||
lines.each_with_index do |line, index|
|
||||
if line =~ /^\s*class\s+Family\s*<\s*ApplicationRecord/
|
||||
class_line_index = index
|
||||
break
|
||||
end
|
||||
end
|
||||
|
||||
if class_line_index
|
||||
# Find the indentation used in the file (check next non-empty line)
|
||||
indentation = " " # default
|
||||
((class_line_index + 1)...lines.length).each do |i|
|
||||
if lines[i] =~ /^(\s+)\S/
|
||||
indentation = ::Regexp.last_match(1)
|
||||
break
|
||||
end
|
||||
end
|
||||
|
||||
# Insert include line right after the class declaration
|
||||
new_include_line = "#{indentation}include #{connectable_module}\n"
|
||||
lines.insert(class_line_index + 1, new_include_line)
|
||||
|
||||
File.write(family_model_path, lines.join)
|
||||
say "Added #{connectable_module} to Family model", :green
|
||||
else
|
||||
say "Could not find class declaration in Family model, please add manually: include #{connectable_module}", :yellow
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def create_panel_view
|
||||
return if options[:skip_view]
|
||||
|
||||
# Create a simple manual panel view
|
||||
template "panel.html.erb.tt",
|
||||
"app/views/settings/providers/_#{file_name}_panel.html.erb"
|
||||
end
|
||||
|
||||
def create_controller
|
||||
return if options[:skip_controller]
|
||||
|
||||
controller_path = "app/controllers/#{file_name}_items_controller.rb"
|
||||
|
||||
if File.exist?(controller_path)
|
||||
say "Controller already exists: #{controller_path}", :skip
|
||||
else
|
||||
# Create new controller
|
||||
template "controller.rb.tt", controller_path
|
||||
say "Created new controller: #{controller_path}", :green
|
||||
end
|
||||
end
|
||||
|
||||
def add_routes
|
||||
return if options[:skip_routes]
|
||||
|
||||
route_content = <<~RUBY.strip
|
||||
resources :#{file_name}_items, only: [:index, :new, :create, :show, :edit, :update, :destroy] do
|
||||
collection do
|
||||
get :preload_accounts
|
||||
get :select_accounts
|
||||
post :link_accounts
|
||||
get :select_existing_account
|
||||
post :link_existing_account
|
||||
end
|
||||
|
||||
member do
|
||||
post :sync
|
||||
get :setup_accounts
|
||||
post :complete_account_setup
|
||||
end
|
||||
end
|
||||
RUBY
|
||||
|
||||
# Check if routes already exist
|
||||
routes_file = "config/routes.rb"
|
||||
if File.read(routes_file).include?("resources :#{file_name}_items")
|
||||
say "Routes already exist for :#{file_name}_items", :skip
|
||||
else
|
||||
route route_content
|
||||
say "Added routes for :#{file_name}_items", :green
|
||||
end
|
||||
end
|
||||
|
||||
def update_settings_controller
|
||||
controller_path = "app/controllers/settings/providers_controller.rb"
|
||||
return unless File.exist?(controller_path)
|
||||
|
||||
content = File.read(controller_path)
|
||||
new_condition = "config.provider_key.to_s.casecmp(\"#{file_name}\").zero?"
|
||||
|
||||
# Check if provider is already excluded
|
||||
if content.include?(new_condition)
|
||||
say "Settings controller already excludes #{file_name}", :skip
|
||||
return
|
||||
end
|
||||
|
||||
# Add to the rejection list in prepare_show_context
|
||||
# Look for the end of the reject block and insert before it
|
||||
if content.include?("reject do |config|")
|
||||
# Find the reject block's end and insert our condition before it
|
||||
# The block ends with "end" on its own line after the conditions
|
||||
lines = content.lines
|
||||
reject_block_start = nil
|
||||
reject_block_end = nil
|
||||
|
||||
lines.each_with_index do |line, index|
|
||||
if line.include?("Provider::ConfigurationRegistry.all.reject do |config|")
|
||||
reject_block_start = index
|
||||
elsif reject_block_start && line.strip == "end" && reject_block_end.nil?
|
||||
reject_block_end = index
|
||||
break
|
||||
end
|
||||
end
|
||||
|
||||
if reject_block_start && reject_block_end
|
||||
# Find the last condition line (the one before 'end')
|
||||
last_condition_index = reject_block_end - 1
|
||||
|
||||
# Get indentation from the last condition line
|
||||
last_condition_line = lines[last_condition_index]
|
||||
indentation = last_condition_line[/^\s*/]
|
||||
|
||||
# Append our condition with || to the last condition line
|
||||
# Remove trailing whitespace/newline, add || and new condition
|
||||
lines[last_condition_index] = last_condition_line.rstrip + " || \\\n#{indentation}#{new_condition}\n"
|
||||
|
||||
File.write(controller_path, lines.join)
|
||||
say "Added #{file_name} to provider exclusion list", :green
|
||||
else
|
||||
say "Could not find reject block boundaries in settings controller", :yellow
|
||||
end
|
||||
elsif content.include?("@provider_configurations = Provider::ConfigurationRegistry.all")
|
||||
# No reject block exists yet, create one
|
||||
gsub_file controller_path,
|
||||
"@provider_configurations = Provider::ConfigurationRegistry.all\n",
|
||||
"@provider_configurations = Provider::ConfigurationRegistry.all.reject do |config|\n #{new_condition}\n end\n"
|
||||
say "Created provider exclusion block with #{file_name}", :green
|
||||
else
|
||||
say "Could not find provider_configurations assignment in settings controller", :yellow
|
||||
end
|
||||
|
||||
# Re-read content after potential modifications
|
||||
content = File.read(controller_path)
|
||||
|
||||
# Add instance variable for items
|
||||
items_var = "@#{file_name}_items"
|
||||
unless content.include?(items_var)
|
||||
# Find the last @*_items assignment line and insert after it
|
||||
lines = content.lines
|
||||
last_items_index = nil
|
||||
|
||||
lines.each_with_index do |line, index|
|
||||
if line =~ /@\w+_items = Current\.family\.\w+_items/
|
||||
last_items_index = index
|
||||
end
|
||||
end
|
||||
|
||||
if last_items_index
|
||||
# Get indentation from the found line
|
||||
indentation = lines[last_items_index][/^\s*/]
|
||||
new_line = "#{indentation}#{items_var} = Current.family.#{file_name}_items.ordered.select(:id)\n"
|
||||
lines.insert(last_items_index + 1, new_line)
|
||||
File.write(controller_path, lines.join)
|
||||
say "Added #{items_var} instance variable", :green
|
||||
else
|
||||
say "Could not find existing @*_items assignments, please add manually: #{items_var} = Current.family.#{file_name}_items.ordered.select(:id)", :yellow
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def update_providers_view
|
||||
return if options[:skip_view]
|
||||
|
||||
view_path = "app/views/settings/providers/show.html.erb"
|
||||
return unless File.exist?(view_path)
|
||||
|
||||
content = File.read(view_path)
|
||||
|
||||
# Check if section already exists
|
||||
if content.include?("\"#{file_name}-providers-panel\"")
|
||||
say "Providers view already has #{class_name} section", :skip
|
||||
else
|
||||
# Add section before the last closing div (at end of file)
|
||||
section_content = <<~ERB
|
||||
|
||||
<%%= settings_section title: "#{class_name}", collapsible: true, open: false do %>
|
||||
<turbo-frame id="#{file_name}-providers-panel">
|
||||
<%%= render "settings/providers/#{file_name}_panel" %>
|
||||
</turbo-frame>
|
||||
<%% end %>
|
||||
ERB
|
||||
|
||||
# Insert before the final </div> at the end of file
|
||||
insert_into_file view_path, section_content, before: /^<\/div>\s*\z/
|
||||
say "Added #{class_name} section to providers view", :green
|
||||
end
|
||||
end
|
||||
|
||||
def show_summary
|
||||
say "\n" + "=" * 80, :green
|
||||
say "Successfully generated per-family 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 " - app/models/#{file_name}_item/unlinking.rb"
|
||||
say " - app/models/family/#{file_name}_connectable.rb"
|
||||
say " 🔌 Adapter: app/models/provider/#{file_name}_adapter.rb"
|
||||
say " 🎮 Controller: app/controllers/#{file_name}_items_controller.rb"
|
||||
say " 🖼️ View: app/views/settings/providers/_#{file_name}_panel.html.erb"
|
||||
say " 🛣️ Routes: Updated config/routes.rb"
|
||||
say " ⚙️ Settings: Updated controllers, views, and Family model"
|
||||
|
||||
if parsed_fields.any?
|
||||
say "\nCredential fields:", :cyan
|
||||
parsed_fields.each do |field|
|
||||
secret_flag = field[:secret] ? " 🔒 (encrypted)" : ""
|
||||
default_flag = field[:default] ? " [default: #{field[:default]}]" : ""
|
||||
say " - #{field[:name]}: #{field[:type]}#{secret_flag}#{default_flag}"
|
||||
end
|
||||
end
|
||||
|
||||
say "\nDatabase tables created:", :cyan
|
||||
say " - #{table_name} (stores per-family credentials)"
|
||||
say " - #{file_name}_accounts (stores individual account data)"
|
||||
|
||||
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's build_provider method:"
|
||||
say " app/models/provider/#{file_name}_adapter.rb"
|
||||
say ""
|
||||
say " 5. Add any custom business logic:"
|
||||
say " - Import methods in #{class_name}Item"
|
||||
say " - Processing logic for accounts"
|
||||
say " - Sync strategies"
|
||||
say ""
|
||||
say " 6. Test the integration:"
|
||||
say " Visit /settings/providers and configure credentials"
|
||||
say ""
|
||||
say " 📚 See docs/PER_FAMILY_PROVIDER_GUIDE.md for detailed 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|
|
||||
# Handle default values with colons (like URLs) by extracting them first
|
||||
# Format: field:type[:secret][:default=value]
|
||||
default_match = field_def.match(/default=(.+)$/)
|
||||
default_value = nil
|
||||
if default_match
|
||||
default_value = default_match[1]
|
||||
# Remove the default part for further parsing
|
||||
field_def = field_def.sub(/:?default=.+$/, "")
|
||||
end
|
||||
|
||||
parts = field_def.split(":")
|
||||
field = {
|
||||
name: parts[0],
|
||||
type: parts[1] || "string",
|
||||
secret: parts.include?("secret"),
|
||||
default: default_value
|
||||
}
|
||||
|
||||
field
|
||||
end
|
||||
end
|
||||
end
|
||||
52
lib/generators/provider/family/templates/account_model.rb.tt
Normal file
52
lib/generators/provider/family/templates/account_model.rb.tt
Normal file
@@ -0,0 +1,52 @@
|
||||
class <%= class_name %>Account < ApplicationRecord
|
||||
include CurrencyNormalizable
|
||||
|
||||
belongs_to :<%= file_name %>_item
|
||||
|
||||
# New association through account_providers
|
||||
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
|
||||
114
lib/generators/provider/family/templates/adapter.rb.tt
Normal file
114
lib/generators/provider/family/templates/adapter.rb.tt
Normal file
@@ -0,0 +1,114 @@
|
||||
class Provider::<%= class_name %>Adapter < Provider::Base
|
||||
include Provider::Syncable
|
||||
include Provider::InstitutionMetadata
|
||||
|
||||
# Register this adapter with the factory
|
||||
Provider::Factory.register("<%= class_name %>Account", self)
|
||||
|
||||
# Define which account types this provider supports
|
||||
def self.supported_account_types
|
||||
%w[Depository CreditCard Loan]
|
||||
end
|
||||
|
||||
# Returns connection configurations for this provider
|
||||
def self.connection_configs(family:)
|
||||
return [] unless family.can_connect_<%= file_name %>?
|
||||
|
||||
[ {
|
||||
key: "<%= file_name %>",
|
||||
name: "<%= class_name.titleize %>",
|
||||
description: "Connect to your bank via <%= class_name.titleize %>",
|
||||
can_connect: true,
|
||||
new_account_path: ->(accountable_type, return_to) {
|
||||
Rails.application.routes.url_helpers.select_accounts_<%= file_name %>_items_path(
|
||||
accountable_type: accountable_type,
|
||||
return_to: return_to
|
||||
)
|
||||
},
|
||||
existing_account_path: ->(account_id) {
|
||||
Rails.application.routes.url_helpers.select_existing_account_<%= file_name %>_items_path(
|
||||
account_id: account_id
|
||||
)
|
||||
}
|
||||
} ]
|
||||
end
|
||||
|
||||
def provider_name
|
||||
"<%= file_name %>"
|
||||
end
|
||||
|
||||
# Build a <%= class_name %> provider instance with family-specific credentials
|
||||
# @param family [Family] The family to get credentials for (required)
|
||||
# @return [Provider::<%= class_name %>, nil] Returns nil if credentials are not configured
|
||||
def self.build_provider(family: nil)
|
||||
return nil unless family.present?
|
||||
|
||||
# Get family-specific credentials
|
||||
<% first_secret_field = parsed_fields.find { |f| f[:secret] }&.dig(:name) -%>
|
||||
<% if first_secret_field -%>
|
||||
<%= file_name %>_item = family.<%= file_name %>_items.where.not(<%= first_secret_field %>: nil).first
|
||||
<% else -%>
|
||||
<%= file_name %>_item = family.<%= file_name %>_items.first
|
||||
<% end -%>
|
||||
return nil unless <%= file_name %>_item&.credentials_configured?
|
||||
|
||||
# TODO: Implement provider initialization
|
||||
<% if first_secret_field -%>
|
||||
# Provider::<%= class_name %>.new(
|
||||
# <%= file_name %>_item.<%= first_secret_field %><%= parsed_fields.select { |f| f[:default] }.any? ? ",\n # " + parsed_fields.select { |f| f[:default] }.map { |f| "#{f[:name]}: #{file_name}_item.effective_#{f[:name]}" }.join(",\n # ") : "" %>
|
||||
# )
|
||||
<% else -%>
|
||||
# Provider::<%= class_name %>.new(<%= file_name %>_item)
|
||||
<% end -%>
|
||||
raise NotImplementedError, "Implement Provider::<%= class_name %>.new 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
|
||||
metadata = provider_account.institution_metadata
|
||||
return nil unless metadata.present?
|
||||
|
||||
domain = metadata["domain"]
|
||||
url = metadata["url"]
|
||||
|
||||
# Derive domain from URL if missing
|
||||
if domain.blank? && url.present?
|
||||
begin
|
||||
domain = URI.parse(url).host&.gsub(/^www\./, "")
|
||||
rescue URI::InvalidURIError
|
||||
Rails.logger.warn("Invalid institution URL for <%= class_name %> account #{provider_account.id}: #{url}")
|
||||
end
|
||||
end
|
||||
|
||||
domain
|
||||
end
|
||||
|
||||
def institution_name
|
||||
metadata = provider_account.institution_metadata
|
||||
return nil unless metadata.present?
|
||||
|
||||
metadata["name"] || item&.institution_name
|
||||
end
|
||||
|
||||
def institution_url
|
||||
metadata = provider_account.institution_metadata
|
||||
return nil unless metadata.present?
|
||||
|
||||
metadata["url"] || item&.institution_url
|
||||
end
|
||||
|
||||
def institution_color
|
||||
item&.institution_color
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,37 @@
|
||||
module Family::<%= class_name %>Connectable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
has_many :<%= file_name %>_items, dependent: :destroy
|
||||
end
|
||||
|
||||
def can_connect_<%= file_name %>?
|
||||
# Families can configure their own <%= class_name %> credentials
|
||||
true
|
||||
end
|
||||
|
||||
<%
|
||||
# Build method parameters: required params (secrets) come first, then optional params
|
||||
required_params = parsed_fields.select { |f| f[:secret] }.map { |f| "#{f[:name]}:" }
|
||||
optional_params = parsed_fields.reject { |f| f[:secret] }.map { |f| "#{f[:name]}: nil" }
|
||||
all_params = (required_params + optional_params + ["item_name: nil"]).join(", ")
|
||||
-%>
|
||||
def create_<%= file_name %>_item!(<%= all_params %>)
|
||||
<%= file_name %>_item = <%= file_name %>_items.create!(
|
||||
name: item_name || "<%= class_name.titleize %> Connection"<%= parsed_fields.map { |f| ",\n #{f[:name]}: #{f[:name]}" }.join("") %>
|
||||
)
|
||||
|
||||
<%= file_name %>_item.sync_later
|
||||
|
||||
<%= file_name %>_item
|
||||
end
|
||||
|
||||
def has_<%= file_name %>_credentials?
|
||||
<% primary_secret = parsed_fields.find { |f| f[:secret] }&.dig(:name) -%>
|
||||
<% if primary_secret -%>
|
||||
<%= file_name %>_items.where.not(<%= primary_secret %>: nil).exists?
|
||||
<% else -%>
|
||||
<%= file_name %>_items.exists?
|
||||
<% end -%>
|
||||
end
|
||||
end
|
||||
153
lib/generators/provider/family/templates/controller.rb.tt
Normal file
153
lib/generators/provider/family/templates/controller.rb.tt
Normal file
@@ -0,0 +1,153 @@
|
||||
class <%= class_name %>ItemsController < ApplicationController
|
||||
before_action :set_<%= file_name %>_item, only: [:show, :edit, :update, :destroy, :sync, :setup_accounts, :complete_account_setup]
|
||||
|
||||
def index
|
||||
@<%= table_name %> = Current.family.<%= table_name %>.ordered
|
||||
end
|
||||
|
||||
def show
|
||||
end
|
||||
|
||||
def new
|
||||
@<%= file_name %>_item = Current.family.<%= table_name %>.build
|
||||
end
|
||||
|
||||
def edit
|
||||
end
|
||||
|
||||
def create
|
||||
@<%= file_name %>_item = Current.family.<%= table_name %>.build(<%= file_name %>_item_params)
|
||||
@<%= file_name %>_item.name ||= "<%= class_name %> Connection"
|
||||
|
||||
if @<%= file_name %>_item.save
|
||||
if turbo_frame_request?
|
||||
flash.now[:notice] = t(".success", default: "Successfully configured <%= class_name %>.")
|
||||
@<%= table_name %> = Current.family.<%= table_name %>.ordered
|
||||
render turbo_stream: [
|
||||
turbo_stream.replace(
|
||||
"<%= file_name %>-providers-panel",
|
||||
partial: "settings/providers/<%= file_name %>_panel",
|
||||
locals: { <%= file_name %>_items: @<%= table_name %> }
|
||||
),
|
||||
*flash_notification_stream_items
|
||||
]
|
||||
else
|
||||
redirect_to settings_providers_path, notice: t(".success"), status: :see_other
|
||||
end
|
||||
else
|
||||
@error_message = @<%= file_name %>_item.errors.full_messages.join(", ")
|
||||
|
||||
if turbo_frame_request?
|
||||
render turbo_stream: turbo_stream.replace(
|
||||
"<%= file_name %>-providers-panel",
|
||||
partial: "settings/providers/<%= file_name %>_panel",
|
||||
locals: { error_message: @error_message }
|
||||
), status: :unprocessable_entity
|
||||
else
|
||||
redirect_to settings_providers_path, alert: @error_message, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def update
|
||||
if @<%= file_name %>_item.update(<%= file_name %>_item_params)
|
||||
if turbo_frame_request?
|
||||
flash.now[:notice] = t(".success", default: "Successfully updated <%= class_name %> configuration.")
|
||||
@<%= table_name %> = Current.family.<%= table_name %>.ordered
|
||||
render turbo_stream: [
|
||||
turbo_stream.replace(
|
||||
"<%= file_name %>-providers-panel",
|
||||
partial: "settings/providers/<%= file_name %>_panel",
|
||||
locals: { <%= file_name %>_items: @<%= table_name %> }
|
||||
),
|
||||
*flash_notification_stream_items
|
||||
]
|
||||
else
|
||||
redirect_to settings_providers_path, notice: t(".success"), status: :see_other
|
||||
end
|
||||
else
|
||||
@error_message = @<%= file_name %>_item.errors.full_messages.join(", ")
|
||||
|
||||
if turbo_frame_request?
|
||||
render turbo_stream: turbo_stream.replace(
|
||||
"<%= file_name %>-providers-panel",
|
||||
partial: "settings/providers/<%= file_name %>_panel",
|
||||
locals: { error_message: @error_message }
|
||||
), status: :unprocessable_entity
|
||||
else
|
||||
redirect_to settings_providers_path, alert: @error_message, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
@<%= file_name %>_item.destroy_later
|
||||
redirect_to settings_providers_path, notice: t(".success", default: "Scheduled <%= class_name %> connection for deletion.")
|
||||
end
|
||||
|
||||
def sync
|
||||
unless @<%= file_name %>_item.syncing?
|
||||
@<%= file_name %>_item.sync_later
|
||||
end
|
||||
|
||||
respond_to do |format|
|
||||
format.html { redirect_back_or_to accounts_path }
|
||||
format.json { head :ok }
|
||||
end
|
||||
end
|
||||
|
||||
# Collection actions for account linking flow
|
||||
# TODO: Implement these when you have the provider SDK ready
|
||||
|
||||
def preload_accounts
|
||||
# TODO: Fetch accounts from provider API and cache them
|
||||
redirect_to settings_providers_path, alert: "Not implemented yet"
|
||||
end
|
||||
|
||||
def select_accounts
|
||||
# TODO: Show UI to select which accounts to link
|
||||
@accountable_type = params[:accountable_type]
|
||||
@return_to = params[:return_to]
|
||||
redirect_to settings_providers_path, alert: "Not implemented yet"
|
||||
end
|
||||
|
||||
def link_accounts
|
||||
# TODO: Link selected accounts
|
||||
redirect_to settings_providers_path, alert: "Not implemented yet"
|
||||
end
|
||||
|
||||
def select_existing_account
|
||||
# TODO: Show UI to link an existing account to provider
|
||||
@account_id = params[:account_id]
|
||||
redirect_to settings_providers_path, alert: "Not implemented yet"
|
||||
end
|
||||
|
||||
def link_existing_account
|
||||
# TODO: Link an existing account to a provider account
|
||||
redirect_to settings_providers_path, alert: "Not implemented yet"
|
||||
end
|
||||
|
||||
def setup_accounts
|
||||
# TODO: Show account setup UI
|
||||
redirect_to settings_providers_path, alert: "Not implemented yet"
|
||||
end
|
||||
|
||||
def complete_account_setup
|
||||
# TODO: Complete the account setup process
|
||||
redirect_to settings_providers_path, alert: "Not implemented yet"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_<%= file_name %>_item
|
||||
@<%= file_name %>_item = Current.family.<%= table_name %>.find(params[:id])
|
||||
end
|
||||
|
||||
def <%= file_name %>_item_params
|
||||
params.require(:<%= file_name %>_item).permit(
|
||||
:name,
|
||||
:sync_start_date<% parsed_fields.each do |field| %>,
|
||||
:<%= field[:name] %><% end %>
|
||||
)
|
||||
end
|
||||
end
|
||||
187
lib/generators/provider/family/templates/item_model.rb.tt
Normal file
187
lib/generators/provider/family/templates/item_model.rb.tt
Normal file
@@ -0,0 +1,187 @@
|
||||
class <%= class_name %>Item < ApplicationRecord
|
||||
include Syncable, Provided, Unlinking
|
||||
|
||||
enum :status, { good: "good", requires_update: "requires_update" }, default: :good
|
||||
|
||||
# Helper to detect if ActiveRecord Encryption is configured for this app
|
||||
def self.encryption_ready?
|
||||
creds_ready = Rails.application.credentials.active_record_encryption.present?
|
||||
env_ready = ENV["ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY"].present? &&
|
||||
ENV["ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY"].present? &&
|
||||
ENV["ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT"].present?
|
||||
creds_ready || env_ready
|
||||
end
|
||||
|
||||
# Encrypt sensitive credentials if ActiveRecord encryption is configured
|
||||
if encryption_ready?
|
||||
<% parsed_fields.select { |f| f[:secret] }.each do |field| -%>
|
||||
encrypts :<%= field[:name] %>, deterministic: true
|
||||
<% end -%>
|
||||
end
|
||||
|
||||
validates :name, presence: true
|
||||
<% parsed_fields.select { |f| f[:secret] }.each do |field| -%>
|
||||
validates :<%= field[:name] %>, presence: true, on: :create
|
||||
<% end -%>
|
||||
|
||||
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
|
||||
|
||||
# TODO: Implement data import from provider API
|
||||
# This method should fetch the latest data from the provider and import it.
|
||||
# May need provider-specific validation (e.g., session validity checks).
|
||||
# See LunchflowItem#import_latest_lunchflow_data or EnableBankingItem#import_latest_enable_banking_data for examples.
|
||||
def import_latest_<%= file_name %>_data
|
||||
provider = <%= file_name %>_provider
|
||||
unless provider
|
||||
Rails.logger.error "<%= class_name %>Item #{id} - Cannot import: provider is not configured"
|
||||
raise StandardError.new("<%= class_name %> provider is not configured")
|
||||
end
|
||||
|
||||
# TODO: Add any provider-specific validation here (e.g., session checks)
|
||||
<%= class_name %>Item::Importer.new(self, <%= file_name %>_provider: provider).import
|
||||
rescue => e
|
||||
Rails.logger.error "<%= class_name %>Item #{id} - Failed to import data: #{e.message}"
|
||||
raise
|
||||
end
|
||||
|
||||
# TODO: Implement account processing logic
|
||||
# This method processes linked accounts after data import.
|
||||
# Customize based on your provider's data structure and processing needs.
|
||||
def process_accounts
|
||||
return [] if <%= file_name %>_accounts.empty?
|
||||
|
||||
results = []
|
||||
<%= file_name %>_accounts.joins(:account).merge(Account.visible).each do |<%= file_name %>_account|
|
||||
begin
|
||||
result = <%= class_name %>Account::Processor.new(<%= file_name %>_account).process
|
||||
results << { <%= file_name %>_account_id: <%= file_name %>_account.id, success: true, result: result }
|
||||
rescue => e
|
||||
Rails.logger.error "<%= class_name %>Item #{id} - Failed to process account #{<%= file_name %>_account.id}: #{e.message}"
|
||||
results << { <%= file_name %>_account_id: <%= file_name %>_account.id, success: false, error: e.message }
|
||||
end
|
||||
end
|
||||
|
||||
results
|
||||
end
|
||||
|
||||
# TODO: Customize sync scheduling if needed
|
||||
# This method schedules sync jobs for all linked accounts.
|
||||
def schedule_account_syncs(parent_sync: nil, window_start_date: nil, window_end_date: nil)
|
||||
return [] if accounts.empty?
|
||||
|
||||
results = []
|
||||
accounts.visible.each do |account|
|
||||
begin
|
||||
account.sync_later(
|
||||
parent_sync: parent_sync,
|
||||
window_start_date: window_start_date,
|
||||
window_end_date: window_end_date
|
||||
)
|
||||
results << { account_id: account.id, success: true }
|
||||
rescue => e
|
||||
Rails.logger.error "<%= class_name %>Item #{id} - Failed to schedule sync for account #{account.id}: #{e.message}"
|
||||
results << { account_id: account.id, success: false, error: e.message }
|
||||
end
|
||||
end
|
||||
|
||||
results
|
||||
end
|
||||
|
||||
def upsert_<%= file_name %>_snapshot!(accounts_snapshot)
|
||||
assign_attributes(
|
||||
raw_payload: accounts_snapshot
|
||||
)
|
||||
|
||||
save!
|
||||
end
|
||||
|
||||
def has_completed_initial_setup?
|
||||
# Setup is complete if we have any linked accounts
|
||||
accounts.any?
|
||||
end
|
||||
|
||||
# TODO: Customize sync status summary if needed
|
||||
# Some providers use latest_sync.sync_stats, others use count methods directly.
|
||||
# See SimplefinItem#sync_status_summary or EnableBankingItem#sync_status_summary for examples.
|
||||
def sync_status_summary
|
||||
total_accounts = total_accounts_count
|
||||
linked_count = linked_accounts_count
|
||||
unlinked_count = unlinked_accounts_count
|
||||
|
||||
if total_accounts == 0
|
||||
"No accounts found"
|
||||
elsif unlinked_count == 0
|
||||
"#{linked_count} #{'account'.pluralize(linked_count)} synced"
|
||||
else
|
||||
"#{linked_count} synced, #{unlinked_count} need setup"
|
||||
end
|
||||
end
|
||||
|
||||
def linked_accounts_count
|
||||
<%= file_name %>_accounts.joins(:account_provider).count
|
||||
end
|
||||
|
||||
def unlinked_accounts_count
|
||||
<%= file_name %>_accounts.left_joins(:account_provider).where(account_providers: { id: nil }).count
|
||||
end
|
||||
|
||||
def total_accounts_count
|
||||
<%= file_name %>_accounts.count
|
||||
end
|
||||
|
||||
def institution_display_name
|
||||
institution_name.presence || institution_domain.presence || name
|
||||
end
|
||||
|
||||
# TODO: Customize based on how your provider stores institution data
|
||||
# SimpleFin uses org_data, others use institution_metadata.
|
||||
# Adjust the field name and key lookups as needed.
|
||||
def connected_institutions
|
||||
<%= file_name %>_accounts.includes(:account)
|
||||
.where.not(institution_metadata: nil)
|
||||
.map { |acc| acc.institution_metadata }
|
||||
.uniq { |inst| inst["name"] || inst["institution_name"] }
|
||||
end
|
||||
|
||||
# TODO: Customize institution summary if your provider has special fields
|
||||
# EnableBanking uses aspsp_name as a fallback, for example.
|
||||
def institution_summary
|
||||
institutions = connected_institutions
|
||||
case institutions.count
|
||||
when 0
|
||||
"No institutions connected"
|
||||
when 1
|
||||
institutions.first["name"] || institutions.first["institution_name"] || "1 institution"
|
||||
else
|
||||
"#{institutions.count} institutions"
|
||||
end
|
||||
end
|
||||
|
||||
def credentials_configured?
|
||||
<% if parsed_fields.select { |f| f[:secret] }.any? -%>
|
||||
<%= parsed_fields.select { |f| f[:secret] }.map { |f| "#{f[:name]}.present?" }.join(" && ") %>
|
||||
<% else -%>
|
||||
true
|
||||
<% end -%>
|
||||
end
|
||||
|
||||
<% parsed_fields.select { |f| f[:default] }.each do |field| -%>
|
||||
def effective_<%= field[:name] %>
|
||||
<%= field[:name] %>.presence || "<%= field[:default] %>"
|
||||
end
|
||||
|
||||
<% end -%>
|
||||
end
|
||||
62
lib/generators/provider/family/templates/migration.rb.tt
Normal file
62
lib/generators/provider/family/templates/migration.rb.tt
Normal file
@@ -0,0 +1,62 @@
|
||||
class Create<%= class_name %>ItemsAndAccounts < ActiveRecord::Migration<%= migration_version %>
|
||||
def change
|
||||
# Create provider items table (stores per-family connection credentials)
|
||||
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
|
||||
|
||||
# Provider-specific credential fields
|
||||
<% parsed_fields.each do |field| -%>
|
||||
t.<%= field[:type] %> :<%= field[:name] %>
|
||||
<% end -%>
|
||||
|
||||
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
|
||||
71
lib/generators/provider/family/templates/panel.html.erb.tt
Normal file
71
lib/generators/provider/family/templates/panel.html.erb.tt
Normal file
@@ -0,0 +1,71 @@
|
||||
<div class="space-y-4">
|
||||
<div class="prose prose-sm text-secondary">
|
||||
<p class="text-primary font-medium">Setup instructions:</p>
|
||||
<ol>
|
||||
<li>Visit your <%= class_name.titleize %> dashboard to get your credentials</li>
|
||||
<li>Enter your credentials below and click the Save button</li>
|
||||
<li>After a successful connection, go to the Accounts tab to set up new accounts</li>
|
||||
</ol>
|
||||
|
||||
<p class="text-primary font-medium">Field descriptions:</p>
|
||||
<ul>
|
||||
<% parsed_fields.each do |field| -%>
|
||||
<li><strong><%= field[:name].titleize %>:</strong> Your <%= class_name.titleize %> <%= field[:name].humanize.downcase %><%= field[:secret] ? ' (required)' : '' %><%= field[:default] ? " (optional, defaults to #{field[:default]})" : '' %></li>
|
||||
<% end -%>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<%% error_msg = local_assigns[:error_message] || @error_message %>
|
||||
<%% if error_msg.present? %>
|
||||
<div class="p-2 rounded-md bg-destructive/10 text-destructive text-sm overflow-hidden">
|
||||
<p class="line-clamp-3" title="<%%= error_msg %>"><%%= error_msg %></p>
|
||||
</div>
|
||||
<%% end %>
|
||||
|
||||
<%%
|
||||
# Get or initialize a <%= file_name %>_item for this family
|
||||
# - If family has an item WITH credentials, use it (for updates)
|
||||
# - If family has an item WITHOUT credentials, use it (to add credentials)
|
||||
# - If family has no items at all, create a new one
|
||||
<%= file_name %>_item = Current.family.<%= file_name %>_items.first_or_initialize(name: "<%= class_name.titleize %> Connection")
|
||||
is_new_record = <%= file_name %>_item.new_record?
|
||||
%>
|
||||
|
||||
<%%= styled_form_with model: <%= file_name %>_item,
|
||||
url: is_new_record ? <%= file_name %>_items_path : <%= file_name %>_item_path(<%= file_name %>_item),
|
||||
scope: :<%= file_name %>_item,
|
||||
method: is_new_record ? :post : :patch,
|
||||
data: { turbo: true },
|
||||
class: "space-y-3" do |form| %>
|
||||
<% parsed_fields.each do |field| -%>
|
||||
<%%= form.<%= %w[text string].include?(field[:type]) ? 'text_field' : 'number_field' %> :<%= field[:name] %>,
|
||||
label: "<%= field[:name].titleize %><%= field[:default] ? ' (Optional)' : '' %>",
|
||||
<% if field[:secret] -%>
|
||||
placeholder: is_new_record ? "Paste <%= field[:name].humanize.downcase %> here" : "Enter new <%= field[:name].humanize.downcase %> to update",
|
||||
type: :password %>
|
||||
<% elsif field[:default] -%>
|
||||
placeholder: "<%= field[:default] %> (default)",
|
||||
value: <%= file_name %>_item.<%= field[:name] %> %>
|
||||
<% else -%>
|
||||
placeholder: is_new_record ? "Enter <%= field[:name].humanize.downcase %>" : "Enter new <%= field[:name].humanize.downcase %> to update",
|
||||
value: <%= file_name %>_item.<%= field[:name] %> %>
|
||||
<% end -%>
|
||||
|
||||
<% end -%>
|
||||
<div class="flex justify-end">
|
||||
<%%= form.submit is_new_record ? "Save Configuration" : "Update Configuration",
|
||||
class: "inline-flex items-center justify-center rounded-lg px-4 py-2 text-sm font-medium text-white bg-gray-900 hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-gray-900 focus:ring-offset-2 transition-colors" %>
|
||||
</div>
|
||||
<%% end %>
|
||||
|
||||
<%% items = local_assigns[:<%= file_name %>_items] || @<%= file_name %>_items || Current.family.<%= file_name %>_items.where.not(<%= parsed_fields.select { |f| f[:secret] }.first&.dig(:name) || 'api_key' %>: [nil, ""]) %>
|
||||
<div class="flex items-center gap-2">
|
||||
<%% if items&.any? %>
|
||||
<div class="w-2 h-2 bg-success rounded-full"></div>
|
||||
<p class="text-sm text-secondary">Configured and ready to use. Visit the <a href="<%%= accounts_path %>" class="link">Accounts</a> tab to manage and set up accounts.</p>
|
||||
<%% else %>
|
||||
<div class="w-2 h-2 bg-gray-400 rounded-full"></div>
|
||||
<p class="text-sm text-secondary">Not configured</p>
|
||||
<%% end %>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,11 @@
|
||||
module <%= class_name %>Item::Provided
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
def <%= file_name %>_provider
|
||||
return nil unless credentials_configured?
|
||||
|
||||
# TODO: Implement provider instantiation
|
||||
# Provider::<%= class_name %>.new(<%= parsed_fields.select { |f| f[:secret] }.first&.dig(:name) || 'api_key' %><%= parsed_fields.select { |f| f[:default] }.any? ? ", " + parsed_fields.select { |f| f[:default] }.map { |f| "#{f[:name]}: effective_#{f[:name]}" }.join(", ") : "" %>)
|
||||
raise NotImplementedError, "Implement <%= file_name %>_provider method in #{__FILE__}"
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,49 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module <%= class_name %>Item::Unlinking
|
||||
# Concern that encapsulates unlinking logic for a <%= class_name %> item.
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
# Idempotently remove all connections between this <%= class_name %> item and local accounts.
|
||||
# - Detaches any AccountProvider links for each <%= class_name %>Account
|
||||
# - Detaches Holdings that point at the AccountProvider links
|
||||
# Returns a per-account result payload for observability
|
||||
def unlink_all!(dry_run: false)
|
||||
results = []
|
||||
|
||||
<%= file_name %>_accounts.find_each do |provider_account|
|
||||
links = AccountProvider.where(provider_type: "<%= class_name %>Account", provider_id: provider_account.id).to_a
|
||||
link_ids = links.map(&:id)
|
||||
result = {
|
||||
provider_account_id: provider_account.id,
|
||||
name: provider_account.name,
|
||||
provider_link_ids: link_ids
|
||||
}
|
||||
results << result
|
||||
|
||||
next if dry_run
|
||||
|
||||
begin
|
||||
ActiveRecord::Base.transaction do
|
||||
# Detach holdings for any provider links found
|
||||
if link_ids.any?
|
||||
Holding.where(account_provider_id: link_ids).update_all(account_provider_id: nil)
|
||||
end
|
||||
|
||||
# Destroy all provider links
|
||||
links.each do |ap|
|
||||
ap.destroy!
|
||||
end
|
||||
end
|
||||
rescue StandardError => e
|
||||
Rails.logger.warn(
|
||||
"<%= class_name %>Item Unlinker: failed to fully unlink provider account ##{provider_account.id} (links=#{link_ids.inspect}): #{e.class} - #{e.message}"
|
||||
)
|
||||
# Record error for observability; continue with other accounts
|
||||
result[:error] = e.message
|
||||
end
|
||||
end
|
||||
|
||||
results
|
||||
end
|
||||
end
|
||||
242
lib/generators/provider/global/global_generator.rb
Normal file
242
lib/generators/provider/global/global_generator.rb
Normal 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
|
||||
@@ -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
|
||||
105
lib/generators/provider/global/templates/global_adapter.rb.tt
Normal file
105
lib/generators/provider/global/templates/global_adapter.rb.tt
Normal 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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user