diff --git a/app/views/settings/providers/_lunchflow_panel.html.erb b/app/views/settings/providers/_lunchflow_panel.html.erb
index 35d24c53d..1c1eee30d 100644
--- a/app/views/settings/providers/_lunchflow_panel.html.erb
+++ b/app/views/settings/providers/_lunchflow_panel.html.erb
@@ -23,9 +23,6 @@
<%
# Get or initialize a lunchflow_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
lunchflow_item = Current.family.lunchflow_items.first_or_initialize(name: "Lunch Flow Connection")
is_new_record = lunchflow_item.new_record?
%>
@@ -52,7 +49,7 @@
<% end %>
- <% items = local_assigns[:lunchflow_items] || @lunchflow_items || Current.family.lunchflow_items.where.not(api_key: [nil, ""]) %>
+ <% items = local_assigns[:lunchflow_items] || @lunchflow_items || Current.family.lunchflow_items.where.not(api_key: nil) %>
<% if items&.any? %>
diff --git a/docs/api/rails_provider_generator.md b/docs/api/rails_provider_generator.md
new file mode 100644
index 000000000..6700798e0
--- /dev/null
+++ b/docs/api/rails_provider_generator.md
@@ -0,0 +1,935 @@
+# 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:
+
+ - Visit your My Bank dashboard to get your credentials
+ - Enter your credentials below and click the Save button
+ - 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 %>
+
+
+```
+
+---
+
+## 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.
diff --git a/lib/generators/provider/family/family_generator.rb b/lib/generators/provider/family/family_generator.rb
new file mode 100644
index 000000000..f652794a6
--- /dev/null
+++ b/lib/generators/provider/family/family_generator.rb
@@ -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 %>
+
+ <%%= render "settings/providers/#{file_name}_panel" %>
+
+ <%% end %>
+ ERB
+
+ # Insert before the final 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
diff --git a/lib/generators/provider/family/templates/account_model.rb.tt b/lib/generators/provider/family/templates/account_model.rb.tt
new file mode 100644
index 000000000..d61f00758
--- /dev/null
+++ b/lib/generators/provider/family/templates/account_model.rb.tt
@@ -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
diff --git a/lib/generators/provider/family/templates/adapter.rb.tt b/lib/generators/provider/family/templates/adapter.rb.tt
new file mode 100644
index 000000000..fd7075034
--- /dev/null
+++ b/lib/generators/provider/family/templates/adapter.rb.tt
@@ -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
diff --git a/lib/generators/provider/family/templates/connectable_concern.rb.tt b/lib/generators/provider/family/templates/connectable_concern.rb.tt
new file mode 100644
index 000000000..d504ad074
--- /dev/null
+++ b/lib/generators/provider/family/templates/connectable_concern.rb.tt
@@ -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
diff --git a/lib/generators/provider/family/templates/controller.rb.tt b/lib/generators/provider/family/templates/controller.rb.tt
new file mode 100644
index 000000000..cd9334d9c
--- /dev/null
+++ b/lib/generators/provider/family/templates/controller.rb.tt
@@ -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
diff --git a/lib/generators/provider/family/templates/item_model.rb.tt b/lib/generators/provider/family/templates/item_model.rb.tt
new file mode 100644
index 000000000..37709516f
--- /dev/null
+++ b/lib/generators/provider/family/templates/item_model.rb.tt
@@ -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
diff --git a/lib/generators/provider/family/templates/migration.rb.tt b/lib/generators/provider/family/templates/migration.rb.tt
new file mode 100644
index 000000000..d61b1ea2b
--- /dev/null
+++ b/lib/generators/provider/family/templates/migration.rb.tt
@@ -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
diff --git a/lib/generators/provider/family/templates/panel.html.erb.tt b/lib/generators/provider/family/templates/panel.html.erb.tt
new file mode 100644
index 000000000..a3c94e25f
--- /dev/null
+++ b/lib/generators/provider/family/templates/panel.html.erb.tt
@@ -0,0 +1,71 @@
+
+
+
Setup instructions:
+
+ - Visit your <%= class_name.titleize %> dashboard to get your credentials
+ - Enter your credentials below and click the Save button
+ - After a successful connection, go to the Accounts tab to set up new accounts
+
+
+
Field descriptions:
+
+<% parsed_fields.each do |field| -%>
+ - <%= field[:name].titleize %>: Your <%= class_name.titleize %> <%= field[:name].humanize.downcase %><%= field[:secret] ? ' (required)' : '' %><%= field[:default] ? " (optional, defaults to #{field[:default]})" : '' %>
+<% end -%>
+
+
+
+ <%% error_msg = local_assigns[:error_message] || @error_message %>
+ <%% if error_msg.present? %>
+
+ <%% 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 -%>
+
+ <%%= 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[:<%= 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, ""]) %>
+
+ <%% if items&.any? %>
+
+
Configured and ready to use. Visit the Accounts tab to manage and set up accounts.
+ <%% else %>
+
+
Not configured
+ <%% end %>
+
+
diff --git a/lib/generators/provider/family/templates/provided_concern.rb.tt b/lib/generators/provider/family/templates/provided_concern.rb.tt
new file mode 100644
index 000000000..c193b0da5
--- /dev/null
+++ b/lib/generators/provider/family/templates/provided_concern.rb.tt
@@ -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
diff --git a/lib/generators/provider/family/templates/unlinking_concern.rb.tt b/lib/generators/provider/family/templates/unlinking_concern.rb.tt
new file mode 100644
index 000000000..d2c3bb1c7
--- /dev/null
+++ b/lib/generators/provider/family/templates/unlinking_concern.rb.tt
@@ -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
diff --git a/lib/generators/provider/global/global_generator.rb b/lib/generators/provider/global/global_generator.rb
new file mode 100644
index 000000000..838b137ca
--- /dev/null
+++ b/lib/generators/provider/global/global_generator.rb
@@ -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
diff --git a/lib/generators/provider/global/templates/global_account_model.rb.tt b/lib/generators/provider/global/templates/global_account_model.rb.tt
new file mode 100644
index 000000000..69eae09a5
--- /dev/null
+++ b/lib/generators/provider/global/templates/global_account_model.rb.tt
@@ -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
diff --git a/lib/generators/provider/global/templates/global_adapter.rb.tt b/lib/generators/provider/global/templates/global_adapter.rb.tt
new file mode 100644
index 000000000..ed526d6aa
--- /dev/null
+++ b/lib/generators/provider/global/templates/global_adapter.rb.tt
@@ -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
diff --git a/lib/generators/provider/global/templates/global_item_model.rb.tt b/lib/generators/provider/global/templates/global_item_model.rb.tt
new file mode 100644
index 000000000..acd300921
--- /dev/null
+++ b/lib/generators/provider/global/templates/global_item_model.rb.tt
@@ -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
diff --git a/lib/generators/provider/global/templates/global_migration.rb.tt b/lib/generators/provider/global/templates/global_migration.rb.tt
new file mode 100644
index 000000000..b34a36938
--- /dev/null
+++ b/lib/generators/provider/global/templates/global_migration.rb.tt
@@ -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
diff --git a/lib/generators/provider/global/templates/global_provided_concern.rb.tt b/lib/generators/provider/global/templates/global_provided_concern.rb.tt
new file mode 100644
index 000000000..aab460551
--- /dev/null
+++ b/lib/generators/provider/global/templates/global_provided_concern.rb.tt
@@ -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