Files
sure/docs/api/rails_provider_generator.md
soky srm 5d6c1bc280 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>
2025-12-08 22:52:30 +01:00

26 KiB

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
  2. Global vs Per-Family: Which to Use?
  3. Provider:Family Generator
  4. Provider:Global Generator
  5. Comparison Table
  6. Examples

Quick Start

Two Generators Available

# Per-Family Provider (each family has separate credentials)
rails g provider:family <PROVIDER_NAME> field:type[:secret] ...

# Global Provider (all families share site-wide credentials)
rails g provider:global <PROVIDER_NAME> field:type[:secret] ...

Quick Examples

# 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

rails g provider:family <PROVIDER_NAME> field:type[:secret][:default=value] ...

Example: Adding a MyBank Provider

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

rails g provider:global <PROVIDER_NAME> field:type[:secret][:default=value] ...

Example: Adding a Plaid Provider

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:

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:

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:

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:

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

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:

<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 My Bank 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>
      <li><strong>API Key:</strong> Your My Bank API key (required)</li>
      <li><strong>Base URL:</strong> Your My Bank base URL (optional, defaults to https://api.mybank.com)</li>
      <li><strong>Refresh Token:</strong> Your My Bank refresh token (required)</li>
    </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">
      <%= error_msg %>
    </div>
  <% 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 %>

    <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[:my_bank_items] || @my_bank_items || Current.family.my_bank_items.where.not(api_key: nil) %>
  <% if items&.any? %>
    <div class="flex items-center gap-2">
      <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>
    </div>
  <% end %>
</div>

5. Controller

File: app/controllers/my_bank_items_controller.rb

A simple controller with CRUD actions and Turbo Stream support:

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)

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:

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:

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:

<div class="space-y-4">
  <%# Add custom header %>
  <div class="flex items-center gap-3">
    <%= image_tag "my_bank_logo.svg", class: "w-8 h-8" %>
    <h3 class="text-lg font-semibold">My Bank Integration</h3>
  </div>

  <%# The generated form content goes here... %>

  <%# Add custom help section %>
  <div class="mt-4 p-4 bg-subtle rounded-lg">
    <p class="text-sm text-secondary">
      Need help? Visit <a href="https://help.mybank.com" class="link">My Bank Help Center</a>
    </p>
  </div>
</div>

Examples

Example 1: Simple API Key Provider

rails g provider:family coinbase api_key:text:secret

Result: Basic provider with just an API key field.

Example 2: OAuth Provider

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

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:

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

rails db:migrate

2. Test in Console

# 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:

# 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

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

# 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:

    @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:

    @my_bank_items = Current.family.my_bank_items.ordered.select(:id)
    
  3. Check that the section exists in settings/providers/show.html.erb:

    <%= settings_section title: "My Bank" do %>
      <turbo-frame id="my-bank-providers-panel">
        <%= render "settings/providers/my_bank_panel" %>
      </turbo-frame>
    <% end %>
    

Form Not Submitting

  1. Check routes are properly added:

    rails routes | grep my_bank
    
  2. Check turbo frame ID matches:

    • View: <turbo-frame id="my-bank-providers-panel">
    • Controller: Uses "my-bank-providers-panel" in turbo_stream.replace

Encryption Not Working

  1. Check credentials are configured:

    rails credentials:edit
    
  2. Add encryption keys if missing:

    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:

    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:

# 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.