# Rails `Provider` Generator Guide This guide explains how to use the `Provider` generators, which make it easy to add new integrations with either **global** or **per-family** scope credentials. ## Table of Contents 1. [Quick Start](#quick-start) 2. [Global vs Per-Family: Which to Use?](#global-vs-per-family-which-to-use) 3. [Provider:Family Generator](#providerfamily-generator) 4. [Provider:Global Generator](#providerglobal-generator) 5. [Comparison Table](#comparison-table) 6. [Examples](#examples) --- ## Quick Start ### Two Generators Available ```bash # Per-Family Provider (each family has separate credentials) rails g provider:family field:type[:secret] ... # Global Provider (all families share site-wide credentials) rails g provider:global field:type[:secret] ... ``` ### Quick Examples ```bash # Per-family: Each family has their own Lunchflow API key rails g provider:family lunchflow api_key:text:secret base_url:string # Global: All families share the same Plaid credentials rails g provider:global plaid \ client_id:string:secret \ secret:string:secret \ environment:string:default=sandbox ``` --- ## Global vs Per-Family: Which to Use? ### Use `provider:global` When: - ✅ One set of credentials serves the entire application - ✅ Provider charges per-application (not per-customer) - ✅ You control the API account (self-hosted or managed mode) - ✅ All families can safely share access - ✅ Examples: Plaid, OpenAI, exchange rate services ### Use `provider:family` When: - ✅ Each family/customer needs their own credentials - ✅ Provider charges per-customer - ✅ Users bring their own API keys - ✅ Data isolation required between families - ✅ Examples: Lunch Flow, SimpleFIN, YNAB, personal bank APIs --- ## Provider:Family Generator ### Usage ```bash rails g provider:family field:type[:secret][:default=value] ... ``` ### Example: Adding a MyBank Provider ```bash rails g provider:family my_bank \ api_key:text:secret \ base_url:string:default=https://api.mybank.com \ refresh_token:text:secret ``` ### What Gets Generated This single command generates: - ✅ Migration for `my_bank_items` and `my_bank_accounts` tables **with credential fields** - ✅ Models: `MyBankItem`, `MyBankAccount`, and `MyBankItem::Provided` concern - ✅ Adapter class for provider integration - ✅ Simple manual panel view for provider settings - ✅ Controller with CRUD actions and Turbo Stream support - ✅ Routes - ✅ Updates to settings controller and view ### Key Characteristics - **Credentials**: Stored in `my_bank_items` table (encrypted) - **Isolation**: Each family has completely separate credentials - **UI**: Manual form panel at `/settings/providers` - **Configuration**: Per-family, self-service --- ## Provider:Global Generator ### Usage ```bash rails g provider:global field:type[:secret][:default=value] ... ``` ### Example: Adding a Plaid Provider ```bash rails g provider:global plaid \ client_id:string:secret \ secret:string:secret \ environment:string:default=sandbox ``` ### What Gets Generated This single command generates: - ✅ Migration for `plaid_items` and `plaid_accounts` tables **without credential fields** - ✅ Models: `PlaidItem`, `PlaidAccount`, and `PlaidItem::Provided` concern - ✅ Adapter with `Provider::Configurable` - ❌ No controller (credentials managed globally) - ❌ No view (UI auto-generated by `Provider::Configurable`) - ❌ No routes (no CRUD needed) ### Key Characteristics - **Credentials**: Stored in `settings` table (global, not encrypted) - **Sharing**: All families use the same credentials - **UI**: Auto-generated at `/settings/providers` (self-hosted mode only) - **Configuration**: ENV variables or admin settings ### Important Notes - Credentials are shared by **all families** - use only for trusted services - Only available in **self-hosted mode** (admin-only access) - No per-family credential management needed - Simpler implementation (fewer files generated) --- ## Comparison Table | Aspect | `provider:family` | `provider:global` | |--------|------------------|------------------| | **Credentials storage** | `provider_items` table (per-family) | `settings` table (global) | | **Credential encryption** | ✅ Yes (ActiveRecord encryption) | ❌ No (plaintext in settings) | | **Family isolation** | ✅ Complete (each family has own credentials) | ❌ None (all families share) | | **Files generated** | 9+ files | 5 files | | **Migration includes credentials** | ✅ Yes | ❌ No | | **Controller** | ✅ Yes (simple CRUD) | ❌ No | | **View** | ✅ Manual form panel | ❌ Auto-generated | | **Routes** | ✅ Yes | ❌ No | | **UI location** | `/settings/providers` (always) | `/settings/providers` (self-hosted only) | | **ENV variable support** | ❌ No (per-family can't use ENV) | ✅ Yes (fallback) | | **Use case** | User brings own API key | Platform provides API access | | **Examples** | Lunch Flow, SimpleFIN, YNAB | Plaid, OpenAI, TwelveData | --- ## What Gets Generated (Detailed) ### 1. Migration **File:** `db/migrate/xxx_create_my_bank_tables_and_accounts.rb` Creates two complete tables with all necessary fields: ```ruby class CreateMyBankTablesAndAccounts < ActiveRecord::Migration[7.2] def change # Create provider items table (stores per-family connection credentials) create_table :my_bank_items, 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 t.text :api_key t.string :base_url t.text :refresh_token t.timestamps end add_index :my_bank_items, :family_id add_index :my_bank_items, :status # Create provider accounts table (stores individual account data from provider) create_table :my_bank_accounts, id: :uuid do |t| t.references :my_bank_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 :my_bank_accounts, :account_id add_index :my_bank_accounts, :my_bank_item_id end end ``` ### 2. Models **File:** `app/models/my_bank_item.rb` The item model stores per-family connection credentials: ```ruby class MyBankItem < ApplicationRecord include Syncable, Provided enum :status, { good: "good", requires_update: "requires_update" }, default: :good # Encryption for secret fields if Rails.application.credentials.active_record_encryption.present? encrypts :api_key, :refresh_token, deterministic: true end validates :name, presence: true validates :api_key, presence: true, on: :create validates :refresh_token, presence: true, on: :create belongs_to :family has_one_attached :logo has_many :my_bank_accounts, dependent: :destroy has_many :accounts, through: :my_bank_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 def credentials_configured? api_key.present? && refresh_token.present? end def effective_base_url base_url.presence || "https://api.mybank.com" end end ``` **File:** `app/models/my_bank_account.rb` The account model stores individual account data from the provider: ```ruby class MyBankAccount < ApplicationRecord include CurrencyNormalizable belongs_to :my_bank_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 validates :name, :currency, presence: true def upsert_my_bank_snapshot!(account_snapshot) update!( current_balance: account_snapshot[:balance], currency: parse_currency(account_snapshot[:currency]) || "USD", name: account_snapshot[:name], account_id: account_snapshot[:id]&.to_s, account_status: account_snapshot[:status], raw_payload: account_snapshot ) end end ``` **File:** `app/models/my_bank_item/provided.rb` The Provided concern connects the item to its provider SDK: ```ruby module MyBankItem::Provided extend ActiveSupport::Concern def my_bank_provider return nil unless credentials_configured? Provider::MyBank.new( api_key, base_url: effective_base_url, refresh_token: refresh_token ) end end ``` ### 3. Adapter **File:** `app/models/provider/my_bank_adapter.rb` ```ruby class Provider::MyBankAdapter < Provider::Base include Provider::Syncable include Provider::InstitutionMetadata # Register this adapter with the factory Provider::Factory.register("MyBankAccount", self) def provider_name "my_bank" end # Build a My Bank provider instance with family-specific credentials def self.build_provider(family:) return nil unless family.present? # Get family-specific credentials my_bank_item = family.my_bank_items.where.not(api_key: nil).first return nil unless my_bank_item&.credentials_configured? # TODO: Implement provider initialization Provider::MyBank.new( my_bank_item.api_key, base_url: my_bank_item.effective_base_url, refresh_token: my_bank_item.refresh_token ) end def sync_path Rails.application.routes.url_helpers.sync_my_bank_item_path(item) end def item provider_account.my_bank_item end def can_delete_holdings? false end def institution_domain metadata = provider_account.institution_metadata return nil unless metadata.present? metadata["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 ``` ### 4. Panel View **File:** `app/views/settings/providers/_my_bank_panel.html.erb` A simple manual form for configuring My Bank credentials: ```erb

Setup instructions:

  1. Visit your My Bank dashboard to get your credentials
  2. Enter your credentials below and click the Save button
  3. After a successful connection, go to the Accounts tab to set up new accounts

Field descriptions:

  • API Key: Your My Bank API key (required)
  • Base URL: Your My Bank base URL (optional, defaults to https://api.mybank.com)
  • Refresh Token: Your My Bank refresh token (required)
<% error_msg = local_assigns[:error_message] || @error_message %> <% if error_msg.present? %>
<%= error_msg %>
<% end %> <% # Get or initialize a my_bank_item for this family my_bank_item = Current.family.my_bank_items.first_or_initialize(name: "My Bank Connection") is_new_record = my_bank_item.new_record? %> <%= styled_form_with model: my_bank_item, url: is_new_record ? my_bank_items_path : my_bank_item_path(my_bank_item), scope: :my_bank_item, method: is_new_record ? :post : :patch, data: { turbo: true }, class: "space-y-3" do |form| %> <%= form.text_field :api_key, label: "API Key", type: :password %> <%= form.text_field :base_url, label: "Base URL (Optional)", placeholder: "https://api.mybank.com (default)", value: my_bank_item.base_url %> <%= form.text_field :refresh_token, label: "Refresh Token", type: :password %>
<%= 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" %>
<% end %> <% items = local_assigns[:my_bank_items] || @my_bank_items || Current.family.my_bank_items.where.not(api_key: nil) %> <% if items&.any? %>

Configured and ready to use. Visit the Accounts tab to manage and set up accounts.

<% end %>
``` ### 5. Controller **File:** `app/controllers/my_bank_items_controller.rb` A simple controller with CRUD actions and Turbo Stream support: ```ruby class MyBankItemsController < ApplicationController before_action :set_my_bank_item, only: [:update, :destroy, :sync] def create @my_bank_item = Current.family.my_bank_items.build(my_bank_item_params) @my_bank_item.name ||= "My Bank Connection" if @my_bank_item.save if turbo_frame_request? flash.now[:notice] = t(".success", default: "Successfully configured My Bank.") @my_bank_items = Current.family.my_bank_items.ordered render turbo_stream: [ turbo_stream.replace( "my-bank-providers-panel", partial: "settings/providers/my_bank_panel", locals: { my_bank_items: @my_bank_items } ), *flash_notification_stream_items ] else redirect_to settings_providers_path, notice: t(".success"), status: :see_other end else @error_message = @my_bank_item.errors.full_messages.join(", ") if turbo_frame_request? render turbo_stream: turbo_stream.replace( "my-bank-providers-panel", partial: "settings/providers/my_bank_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 @my_bank_item.update(my_bank_item_params) if turbo_frame_request? flash.now[:notice] = t(".success", default: "Successfully updated My Bank configuration.") @my_bank_items = Current.family.my_bank_items.ordered render turbo_stream: [ turbo_stream.replace( "my-bank-providers-panel", partial: "settings/providers/my_bank_panel", locals: { my_bank_items: @my_bank_items } ), *flash_notification_stream_items ] else redirect_to settings_providers_path, notice: t(".success"), status: :see_other end else @error_message = @my_bank_item.errors.full_messages.join(", ") if turbo_frame_request? render turbo_stream: turbo_stream.replace( "my-bank-providers-panel", partial: "settings/providers/my_bank_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 @my_bank_item.destroy_later redirect_to settings_providers_path, notice: t(".success", default: "Scheduled My Bank connection for deletion.") end def sync unless @my_bank_item.syncing? @my_bank_item.sync_later end respond_to do |format| format.html { redirect_back_or_to accounts_path } format.json { head :ok } end end private def set_my_bank_item @my_bank_item = Current.family.my_bank_items.find(params[:id]) end def my_bank_item_params params.require(:my_bank_item).permit( :name, :sync_start_date, :api_key, :base_url, :refresh_token ) end end ``` ### 6. Routes **File:** `config/routes.rb` (updated) ```ruby resources :my_bank_items, only: [:create, :update, :destroy] do member do post :sync end end ``` ### 7. Settings Updates **File:** `app/controllers/settings/providers_controller.rb` (updated) - Excludes `my_bank` from global provider configurations - Adds `@my_bank_items` instance variable **File:** `app/views/settings/providers/show.html.erb` (updated) - Adds My Bank section with turbo frame --- ## Customization After generation, you'll typically want to customize three files: ### 1. Customize the Adapter Implement the `build_provider` method in `app/models/provider/my_bank_adapter.rb`: ```ruby def self.build_provider(family:) return nil unless family.present? # Get the family's credentials my_bank_item = family.my_bank_items.where.not(api_key: nil).first return nil unless my_bank_item&.credentials_configured? # Initialize your provider SDK with the credentials Provider::MyBank.new( my_bank_item.api_key, base_url: my_bank_item.effective_base_url, refresh_token: my_bank_item.refresh_token ) end ``` ### 2. Update the Model Add custom validations, helper methods, and business logic in `app/models/my_bank_item.rb`: ```ruby class MyBankItem < ApplicationRecord include Syncable, Provided belongs_to :family # Validations (the generator adds basic ones, customize as needed) validates :name, presence: true validates :api_key, presence: true, on: :create validates :refresh_token, presence: true, on: :create validates :base_url, format: { with: URI::DEFAULT_PARSER.make_regexp }, allow_blank: true # Add custom business logic def refresh_oauth_token! # Implement OAuth token refresh logic provider = my_bank_provider return false unless provider new_token = provider.refresh_token!(refresh_token) update(refresh_token: new_token) rescue Provider::MyBank::AuthenticationError update(status: :requires_update) false end # Override effective methods for better defaults def effective_base_url base_url.presence || "https://api.mybank.com" end end ``` ### 3. Customize the View Edit the generated panel view `app/views/settings/providers/_my_bank_panel.html.erb` to add custom content: ```erb
<%# Add custom header %>
<%= image_tag "my_bank_logo.svg", class: "w-8 h-8" %>

My Bank Integration

<%# The generated form content goes here... %> <%# Add custom help section %>

Need help? Visit My Bank Help Center

``` --- ## Examples ### Example 1: Simple API Key Provider ```bash rails g provider:family coinbase api_key:text:secret ``` Result: Basic provider with just an API key field. ### Example 2: OAuth Provider ```bash rails g provider:family stripe \ client_id:string:secret \ client_secret:string:secret \ access_token:text:secret \ refresh_token:text:secret ``` Then customize the adapter to implement OAuth flow. ### Example 3: Complex Provider ```bash rails g provider:family enterprise_bank \ api_key:text:secret \ environment:string \ base_url:string \ webhook_secret:text:secret \ rate_limit:integer ``` Then add custom validations and logic in the model: ```ruby class EnterpriseBankItem < ApplicationRecord # ... (basic setup) validates :environment, inclusion: { in: %w[sandbox production] } validates :rate_limit, numericality: { greater_than: 0 }, allow_nil: true def effective_rate_limit rate_limit || 100 # Default to 100 requests/minute end end ``` --- ## Tips & Best Practices ### 1. Always Run Migrations ```bash rails db:migrate ``` ### 2. Test in Console ```ruby # Check if adapter is registered Provider::Factory.adapters # => { ... "MyBankAccount" => Provider::MyBankAdapter, ... } # Test provider building family = Family.first item = family.my_bank_items.create!(name: "Test", api_key: "test_key", refresh_token: "test_refresh") provider = Provider::MyBankAdapter.build_provider(family: family) ``` ### 3. Use Proper Encryption Always check that encryption is set up: ```ruby # In your model if Rails.application.credentials.active_record_encryption.present? encrypts :api_key, :refresh_token, deterministic: true else Rails.logger.warn "ActiveRecord encryption not configured for #{self.name}" end ``` ### 4. Implement Proper Error Handling ```ruby def self.build_provider(family:) return nil unless family.present? item = family.my_bank_items.where.not(api_key: nil).first return nil unless item&.credentials_configured? begin Provider::MyBank.new(item.api_key) rescue Provider::MyBank::ConfigurationError => e Rails.logger.error("MyBank provider configuration error: #{e.message}") nil end end ``` ### 5. Add Integration Tests ```ruby # test/models/provider/my_bank_adapter_test.rb class Provider::MyBankAdapterTest < ActiveSupport::TestCase test "builds provider with valid credentials" do family = families(:family_one) item = family.my_bank_items.create!( name: "Test Bank", api_key: "test_key" ) provider = Provider::MyBankAdapter.build_provider(family: family) assert_not_nil provider assert_instance_of Provider::MyBank, provider end test "returns nil without credentials" do family = families(:family_one) provider = Provider::MyBankAdapter.build_provider(family: family) assert_nil provider end end ``` --- ## Troubleshooting ### Panel Not Showing 1. Check that the provider is excluded in `settings/providers_controller.rb`: ```ruby @provider_configurations = Provider::ConfigurationRegistry.all.reject do |config| config.provider_key.to_s.casecmp("my_bank").zero? end ``` 2. Check that the instance variable is set: ```ruby @my_bank_items = Current.family.my_bank_items.ordered.select(:id) ``` 3. Check that the section exists in `settings/providers/show.html.erb`: ```erb <%= settings_section title: "My Bank" do %> <%= render "settings/providers/my_bank_panel" %> <% end %> ``` ### Form Not Submitting 1. Check routes are properly added: ```bash rails routes | grep my_bank ``` 2. Check turbo frame ID matches: - View: `` - Controller: Uses `"my-bank-providers-panel"` in turbo_stream.replace ### Encryption Not Working 1. Check credentials are configured: ```bash rails credentials:edit ``` 2. Add encryption keys if missing: ```yaml active_record_encryption: primary_key: (generate with: rails db:encryption:init) deterministic_key: (generate with: rails db:encryption:init) key_derivation_salt: (generate with: rails db:encryption:init) ``` 3. Or use environment variables: ```bash export ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY="..." export ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY="..." export ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT="..." ``` --- ## Advanced: Creating a Provider SDK For complex providers, consider creating a separate SDK class: ```ruby # app/models/provider/my_bank.rb class Provider::MyBank class AuthenticationError < StandardError; end class RateLimitError < StandardError; end def initialize(api_key, base_url: "https://api.mybank.com") @api_key = api_key @base_url = base_url @client = HTTP.headers( "Authorization" => "Bearer #{api_key}", "User-Agent" => "MyApp/1.0" ) end def get_accounts response = @client.get("#{@base_url}/accounts") handle_response(response) end def get_transactions(account_id, start_date: nil, end_date: nil) params = { account_id: account_id } params[:start_date] = start_date.iso8601 if start_date params[:end_date] = end_date.iso8601 if end_date response = @client.get("#{@base_url}/transactions", params: params) handle_response(response) end private def handle_response(response) case response.code when 200...300 JSON.parse(response.body, symbolize_names: true) when 401, 403 raise AuthenticationError, "Invalid API key" when 429 raise RateLimitError, "Rate limit exceeded" else raise StandardError, "API error: #{response.code} #{response.body}" end end end ``` --- ## Summary The per-family `Provider` Rails generator system provides: - ✅ **Fast development** - Generate in seconds, not hour - ✅ **Consistency** - All providers follow the same pattern - ✅ **Maintainability** - Clear structure and conventions - ✅ **Flexibility** - Easy to customize for complex needs - ✅ **Security** - Built-in encryption for sensitive fields - ✅ **Documentation** - Self-documenting with descriptions Use it whenever you need to add a new provider where each family needs their own credentials.