mirror of
https://github.com/we-promise/sure.git
synced 2026-04-18 11:34:13 +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:
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