Provider generator (#364)

* Move provider config to family

* Update schema.rb

* Add provier generator

* Add table creation also

* FIX generator namespace

* Add support for global providers also

* Remove over-engineered stuff

* FIX parser

* FIX linter

* Some generator fixes

* Update generator with fixes

* Update item_model.rb.tt

* Add missing linkable concern

* Add missing routes

* Update adapter.rb.tt

* Update connectable_concern.rb.tt

* Update unlinking_concern.rb.tt

* Update family_generator.rb

* Update family_generator.rb

* Delete .claude/settings.local.json

Signed-off-by: soky srm <sokysrm@gmail.com>

* Move docs under API related folder

* Rename Rails generator doc

* Light edits to LLM generated doc

* Small Lunch Flow config panel regressions.

---------

Signed-off-by: soky srm <sokysrm@gmail.com>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
This commit is contained in:
soky srm
2025-12-08 22:52:30 +01:00
committed by GitHub
parent 88952e4714
commit 5d6c1bc280
18 changed files with 2604 additions and 4 deletions

View File

@@ -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 @@
</div>
<% 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) %>
<div class="flex items-center gap-2">
<% if items&.any? %>
<div class="w-2 h-2 bg-success rounded-full"></div>

View File

@@ -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 <PROVIDER_NAME> field:type[:secret] ...
# Global Provider (all families share site-wide credentials)
rails g provider:global <PROVIDER_NAME> 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 <PROVIDER_NAME> 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 <PROVIDER_NAME> 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
<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:
```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
<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
```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 %>
<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:
```bash
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:
```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.

View File

@@ -0,0 +1,429 @@
require "rails/generators"
require "rails/generators/active_record"
# Generator for creating per-family provider integrations
#
# Usage:
# rails g provider:family NAME field:type:secret field:type ...
#
# Examples:
# rails g provider:family lunchflow api_key:text:secret base_url:string
# rails g provider:family my_bank access_token:text:secret refresh_token:text:secret
#
# Field format:
# name:type[:secret]
# - name: Field name (e.g., api_key)
# - type: Database column type (text, string, integer, boolean)
# - secret: Optional flag indicating this field should be encrypted
#
# This generates:
# - Migration creating complete provider_items and provider_accounts tables
# - Models for items, accounts, and provided concern
# - Adapter class
# - Manual panel view for provider settings
# - Simple controller for CRUD operations
# - Routes
class Provider::FamilyGenerator < Rails::Generators::NamedBase
include Rails::Generators::Migration
source_root File.expand_path("templates", __dir__)
argument :fields, type: :array, default: [], banner: "field:type[:secret] field:type[:secret]"
class_option :skip_migration, type: :boolean, default: false, desc: "Skip generating migration"
class_option :skip_routes, type: :boolean, default: false, desc: "Skip adding routes"
class_option :skip_view, type: :boolean, default: false, desc: "Skip generating view"
class_option :skip_controller, type: :boolean, default: false, desc: "Skip generating controller"
class_option :skip_adapter, type: :boolean, default: false, desc: "Skip generating adapter"
def validate_fields
if parsed_fields.empty?
say "Warning: No fields specified. You'll need to add them manually later.", :yellow
end
# Validate field types
parsed_fields.each do |field|
unless %w[text string integer boolean].include?(field[:type])
raise Thor::Error, "Invalid field type '#{field[:type]}' for #{field[:name]}. Must be one of: text, string, integer, boolean"
end
end
end
def generate_migration
return if options[:skip_migration]
migration_template "migration.rb.tt",
"db/migrate/create_#{table_name}_and_accounts.rb",
migration_version: migration_version
end
def create_adapter
return if options[:skip_adapter]
adapter_path = "app/models/provider/#{file_name}_adapter.rb"
if File.exist?(adapter_path)
say "Adapter already exists: #{adapter_path}", :skip
else
# Create new adapter
template "adapter.rb.tt", adapter_path
say "Created new adapter: #{adapter_path}", :green
end
end
def create_models
# Create item model
item_model_path = "app/models/#{file_name}_item.rb"
if File.exist?(item_model_path)
say "Item model already exists: #{item_model_path}", :skip
else
template "item_model.rb.tt", item_model_path
say "Created item model: #{item_model_path}", :green
end
# Create account model
account_model_path = "app/models/#{file_name}_account.rb"
if File.exist?(account_model_path)
say "Account model already exists: #{account_model_path}", :skip
else
template "account_model.rb.tt", account_model_path
say "Created account model: #{account_model_path}", :green
end
# Create Provided concern
provided_concern_path = "app/models/#{file_name}_item/provided.rb"
if File.exist?(provided_concern_path)
say "Provided concern already exists: #{provided_concern_path}", :skip
else
template "provided_concern.rb.tt", provided_concern_path
say "Created Provided concern: #{provided_concern_path}", :green
end
# Create Unlinking concern
unlinking_concern_path = "app/models/#{file_name}_item/unlinking.rb"
if File.exist?(unlinking_concern_path)
say "Unlinking concern already exists: #{unlinking_concern_path}", :skip
else
template "unlinking_concern.rb.tt", unlinking_concern_path
say "Created Unlinking concern: #{unlinking_concern_path}", :green
end
# Create Family Connectable concern
connectable_concern_path = "app/models/family/#{file_name}_connectable.rb"
if File.exist?(connectable_concern_path)
say "Connectable concern already exists: #{connectable_concern_path}", :skip
else
template "connectable_concern.rb.tt", connectable_concern_path
say "Created Connectable concern: #{connectable_concern_path}", :green
end
end
def update_family_model
family_model_path = "app/models/family.rb"
return unless File.exist?(family_model_path)
content = File.read(family_model_path)
connectable_module = "#{class_name}Connectable"
# Check if already included
if content.include?(connectable_module)
say "Family model already includes #{connectable_module}", :skip
else
# Insert a new include line after the class declaration
# This approach is more robust than trying to append to an existing include line
lines = content.lines
class_line_index = nil
lines.each_with_index do |line, index|
if line =~ /^\s*class\s+Family\s*<\s*ApplicationRecord/
class_line_index = index
break
end
end
if class_line_index
# Find the indentation used in the file (check next non-empty line)
indentation = " " # default
((class_line_index + 1)...lines.length).each do |i|
if lines[i] =~ /^(\s+)\S/
indentation = ::Regexp.last_match(1)
break
end
end
# Insert include line right after the class declaration
new_include_line = "#{indentation}include #{connectable_module}\n"
lines.insert(class_line_index + 1, new_include_line)
File.write(family_model_path, lines.join)
say "Added #{connectable_module} to Family model", :green
else
say "Could not find class declaration in Family model, please add manually: include #{connectable_module}", :yellow
end
end
end
def create_panel_view
return if options[:skip_view]
# Create a simple manual panel view
template "panel.html.erb.tt",
"app/views/settings/providers/_#{file_name}_panel.html.erb"
end
def create_controller
return if options[:skip_controller]
controller_path = "app/controllers/#{file_name}_items_controller.rb"
if File.exist?(controller_path)
say "Controller already exists: #{controller_path}", :skip
else
# Create new controller
template "controller.rb.tt", controller_path
say "Created new controller: #{controller_path}", :green
end
end
def add_routes
return if options[:skip_routes]
route_content = <<~RUBY.strip
resources :#{file_name}_items, only: [:index, :new, :create, :show, :edit, :update, :destroy] do
collection do
get :preload_accounts
get :select_accounts
post :link_accounts
get :select_existing_account
post :link_existing_account
end
member do
post :sync
get :setup_accounts
post :complete_account_setup
end
end
RUBY
# Check if routes already exist
routes_file = "config/routes.rb"
if File.read(routes_file).include?("resources :#{file_name}_items")
say "Routes already exist for :#{file_name}_items", :skip
else
route route_content
say "Added routes for :#{file_name}_items", :green
end
end
def update_settings_controller
controller_path = "app/controllers/settings/providers_controller.rb"
return unless File.exist?(controller_path)
content = File.read(controller_path)
new_condition = "config.provider_key.to_s.casecmp(\"#{file_name}\").zero?"
# Check if provider is already excluded
if content.include?(new_condition)
say "Settings controller already excludes #{file_name}", :skip
return
end
# Add to the rejection list in prepare_show_context
# Look for the end of the reject block and insert before it
if content.include?("reject do |config|")
# Find the reject block's end and insert our condition before it
# The block ends with "end" on its own line after the conditions
lines = content.lines
reject_block_start = nil
reject_block_end = nil
lines.each_with_index do |line, index|
if line.include?("Provider::ConfigurationRegistry.all.reject do |config|")
reject_block_start = index
elsif reject_block_start && line.strip == "end" && reject_block_end.nil?
reject_block_end = index
break
end
end
if reject_block_start && reject_block_end
# Find the last condition line (the one before 'end')
last_condition_index = reject_block_end - 1
# Get indentation from the last condition line
last_condition_line = lines[last_condition_index]
indentation = last_condition_line[/^\s*/]
# Append our condition with || to the last condition line
# Remove trailing whitespace/newline, add || and new condition
lines[last_condition_index] = last_condition_line.rstrip + " || \\\n#{indentation}#{new_condition}\n"
File.write(controller_path, lines.join)
say "Added #{file_name} to provider exclusion list", :green
else
say "Could not find reject block boundaries in settings controller", :yellow
end
elsif content.include?("@provider_configurations = Provider::ConfigurationRegistry.all")
# No reject block exists yet, create one
gsub_file controller_path,
"@provider_configurations = Provider::ConfigurationRegistry.all\n",
"@provider_configurations = Provider::ConfigurationRegistry.all.reject do |config|\n #{new_condition}\n end\n"
say "Created provider exclusion block with #{file_name}", :green
else
say "Could not find provider_configurations assignment in settings controller", :yellow
end
# Re-read content after potential modifications
content = File.read(controller_path)
# Add instance variable for items
items_var = "@#{file_name}_items"
unless content.include?(items_var)
# Find the last @*_items assignment line and insert after it
lines = content.lines
last_items_index = nil
lines.each_with_index do |line, index|
if line =~ /@\w+_items = Current\.family\.\w+_items/
last_items_index = index
end
end
if last_items_index
# Get indentation from the found line
indentation = lines[last_items_index][/^\s*/]
new_line = "#{indentation}#{items_var} = Current.family.#{file_name}_items.ordered.select(:id)\n"
lines.insert(last_items_index + 1, new_line)
File.write(controller_path, lines.join)
say "Added #{items_var} instance variable", :green
else
say "Could not find existing @*_items assignments, please add manually: #{items_var} = Current.family.#{file_name}_items.ordered.select(:id)", :yellow
end
end
end
def update_providers_view
return if options[:skip_view]
view_path = "app/views/settings/providers/show.html.erb"
return unless File.exist?(view_path)
content = File.read(view_path)
# Check if section already exists
if content.include?("\"#{file_name}-providers-panel\"")
say "Providers view already has #{class_name} section", :skip
else
# Add section before the last closing div (at end of file)
section_content = <<~ERB
<%%= settings_section title: "#{class_name}", collapsible: true, open: false do %>
<turbo-frame id="#{file_name}-providers-panel">
<%%= render "settings/providers/#{file_name}_panel" %>
</turbo-frame>
<%% end %>
ERB
# Insert before the final </div> at the end of file
insert_into_file view_path, section_content, before: /^<\/div>\s*\z/
say "Added #{class_name} section to providers view", :green
end
end
def show_summary
say "\n" + "=" * 80, :green
say "Successfully generated per-family provider: #{class_name}", :green
say "=" * 80, :green
say "\nGenerated files:", :cyan
say " 📋 Migration: db/migrate/xxx_create_#{table_name}_and_accounts.rb"
say " 📦 Models:"
say " - app/models/#{file_name}_item.rb"
say " - app/models/#{file_name}_account.rb"
say " - app/models/#{file_name}_item/provided.rb"
say " - app/models/#{file_name}_item/unlinking.rb"
say " - app/models/family/#{file_name}_connectable.rb"
say " 🔌 Adapter: app/models/provider/#{file_name}_adapter.rb"
say " 🎮 Controller: app/controllers/#{file_name}_items_controller.rb"
say " 🖼️ View: app/views/settings/providers/_#{file_name}_panel.html.erb"
say " 🛣️ Routes: Updated config/routes.rb"
say " ⚙️ Settings: Updated controllers, views, and Family model"
if parsed_fields.any?
say "\nCredential fields:", :cyan
parsed_fields.each do |field|
secret_flag = field[:secret] ? " 🔒 (encrypted)" : ""
default_flag = field[:default] ? " [default: #{field[:default]}]" : ""
say " - #{field[:name]}: #{field[:type]}#{secret_flag}#{default_flag}"
end
end
say "\nDatabase tables created:", :cyan
say " - #{table_name} (stores per-family credentials)"
say " - #{file_name}_accounts (stores individual account data)"
say "\nNext steps:", :yellow
say " 1. Run migrations:"
say " rails db:migrate"
say ""
say " 2. Implement the provider SDK in:"
say " app/models/provider/#{file_name}.rb"
say ""
say " 3. Update #{class_name}Item::Provided concern:"
say " app/models/#{file_name}_item/provided.rb"
say " Implement the #{file_name}_provider method"
say ""
say " 4. Customize the adapter's build_provider method:"
say " app/models/provider/#{file_name}_adapter.rb"
say ""
say " 5. Add any custom business logic:"
say " - Import methods in #{class_name}Item"
say " - Processing logic for accounts"
say " - Sync strategies"
say ""
say " 6. Test the integration:"
say " Visit /settings/providers and configure credentials"
say ""
say " 📚 See docs/PER_FAMILY_PROVIDER_GUIDE.md for detailed documentation"
end
# Required for Rails::Generators::Migration
def self.next_migration_number(dirname)
ActiveRecord::Generators::Base.next_migration_number(dirname)
end
private
def table_name
"#{file_name}_items"
end
def migration_version
"[#{Rails::VERSION::MAJOR}.#{Rails::VERSION::MINOR}]"
end
def parsed_fields
@parsed_fields ||= fields.map do |field_def|
# Handle default values with colons (like URLs) by extracting them first
# Format: field:type[:secret][:default=value]
default_match = field_def.match(/default=(.+)$/)
default_value = nil
if default_match
default_value = default_match[1]
# Remove the default part for further parsing
field_def = field_def.sub(/:?default=.+$/, "")
end
parts = field_def.split(":")
field = {
name: parts[0],
type: parts[1] || "string",
secret: parts.include?("secret"),
default: default_value
}
field
end
end
end

View File

@@ -0,0 +1,52 @@
class <%= class_name %>Account < ApplicationRecord
include CurrencyNormalizable
belongs_to :<%= file_name %>_item
# New association through account_providers
has_one :account_provider, as: :provider, dependent: :destroy
has_one :account, through: :account_provider, source: :account
has_one :linked_account, through: :account_provider, source: :account
validates :name, :currency, presence: true
# Helper to get account using account_providers system
def current_account
account
end
def upsert_<%= file_name %>_snapshot!(account_snapshot)
# Convert to symbol keys or handle both string and symbol keys
snapshot = account_snapshot.with_indifferent_access
# Map <%= class_name %> field names to our field names
# TODO: Customize this mapping based on your provider's API response
update!(
current_balance: snapshot[:balance] || snapshot[:current_balance],
currency: parse_currency(snapshot[:currency]) || "USD",
name: snapshot[:name],
account_id: snapshot[:id]&.to_s,
account_status: snapshot[:status],
provider: snapshot[:provider],
institution_metadata: {
name: snapshot[:institution_name],
logo: snapshot[:institution_logo]
}.compact,
raw_payload: account_snapshot
)
end
def upsert_<%= file_name %>_transactions_snapshot!(transactions_snapshot)
assign_attributes(
raw_transactions_payload: transactions_snapshot
)
save!
end
private
def log_invalid_currency(currency_value)
Rails.logger.warn("Invalid currency code '#{currency_value}' for <%= class_name %> account #{id}, defaulting to USD")
end
end

View File

@@ -0,0 +1,114 @@
class Provider::<%= class_name %>Adapter < Provider::Base
include Provider::Syncable
include Provider::InstitutionMetadata
# Register this adapter with the factory
Provider::Factory.register("<%= class_name %>Account", self)
# Define which account types this provider supports
def self.supported_account_types
%w[Depository CreditCard Loan]
end
# Returns connection configurations for this provider
def self.connection_configs(family:)
return [] unless family.can_connect_<%= file_name %>?
[ {
key: "<%= file_name %>",
name: "<%= class_name.titleize %>",
description: "Connect to your bank via <%= class_name.titleize %>",
can_connect: true,
new_account_path: ->(accountable_type, return_to) {
Rails.application.routes.url_helpers.select_accounts_<%= file_name %>_items_path(
accountable_type: accountable_type,
return_to: return_to
)
},
existing_account_path: ->(account_id) {
Rails.application.routes.url_helpers.select_existing_account_<%= file_name %>_items_path(
account_id: account_id
)
}
} ]
end
def provider_name
"<%= file_name %>"
end
# Build a <%= class_name %> provider instance with family-specific credentials
# @param family [Family] The family to get credentials for (required)
# @return [Provider::<%= class_name %>, nil] Returns nil if credentials are not configured
def self.build_provider(family: nil)
return nil unless family.present?
# Get family-specific credentials
<% first_secret_field = parsed_fields.find { |f| f[:secret] }&.dig(:name) -%>
<% if first_secret_field -%>
<%= file_name %>_item = family.<%= file_name %>_items.where.not(<%= first_secret_field %>: nil).first
<% else -%>
<%= file_name %>_item = family.<%= file_name %>_items.first
<% end -%>
return nil unless <%= file_name %>_item&.credentials_configured?
# TODO: Implement provider initialization
<% if first_secret_field -%>
# Provider::<%= class_name %>.new(
# <%= file_name %>_item.<%= first_secret_field %><%= parsed_fields.select { |f| f[:default] }.any? ? ",\n # " + parsed_fields.select { |f| f[:default] }.map { |f| "#{f[:name]}: #{file_name}_item.effective_#{f[:name]}" }.join(",\n # ") : "" %>
# )
<% else -%>
# Provider::<%= class_name %>.new(<%= file_name %>_item)
<% end -%>
raise NotImplementedError, "Implement Provider::<%= class_name %>.new in #{__FILE__}"
end
def sync_path
Rails.application.routes.url_helpers.sync_<%= file_name %>_item_path(item)
end
def item
provider_account.<%= file_name %>_item
end
def can_delete_holdings?
false
end
def institution_domain
metadata = provider_account.institution_metadata
return nil unless metadata.present?
domain = metadata["domain"]
url = metadata["url"]
# Derive domain from URL if missing
if domain.blank? && url.present?
begin
domain = URI.parse(url).host&.gsub(/^www\./, "")
rescue URI::InvalidURIError
Rails.logger.warn("Invalid institution URL for <%= class_name %> account #{provider_account.id}: #{url}")
end
end
domain
end
def institution_name
metadata = provider_account.institution_metadata
return nil unless metadata.present?
metadata["name"] || item&.institution_name
end
def institution_url
metadata = provider_account.institution_metadata
return nil unless metadata.present?
metadata["url"] || item&.institution_url
end
def institution_color
item&.institution_color
end
end

View File

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

View File

@@ -0,0 +1,153 @@
class <%= class_name %>ItemsController < ApplicationController
before_action :set_<%= file_name %>_item, only: [:show, :edit, :update, :destroy, :sync, :setup_accounts, :complete_account_setup]
def index
@<%= table_name %> = Current.family.<%= table_name %>.ordered
end
def show
end
def new
@<%= file_name %>_item = Current.family.<%= table_name %>.build
end
def edit
end
def create
@<%= file_name %>_item = Current.family.<%= table_name %>.build(<%= file_name %>_item_params)
@<%= file_name %>_item.name ||= "<%= class_name %> Connection"
if @<%= file_name %>_item.save
if turbo_frame_request?
flash.now[:notice] = t(".success", default: "Successfully configured <%= class_name %>.")
@<%= table_name %> = Current.family.<%= table_name %>.ordered
render turbo_stream: [
turbo_stream.replace(
"<%= file_name %>-providers-panel",
partial: "settings/providers/<%= file_name %>_panel",
locals: { <%= file_name %>_items: @<%= table_name %> }
),
*flash_notification_stream_items
]
else
redirect_to settings_providers_path, notice: t(".success"), status: :see_other
end
else
@error_message = @<%= file_name %>_item.errors.full_messages.join(", ")
if turbo_frame_request?
render turbo_stream: turbo_stream.replace(
"<%= file_name %>-providers-panel",
partial: "settings/providers/<%= file_name %>_panel",
locals: { error_message: @error_message }
), status: :unprocessable_entity
else
redirect_to settings_providers_path, alert: @error_message, status: :unprocessable_entity
end
end
end
def update
if @<%= file_name %>_item.update(<%= file_name %>_item_params)
if turbo_frame_request?
flash.now[:notice] = t(".success", default: "Successfully updated <%= class_name %> configuration.")
@<%= table_name %> = Current.family.<%= table_name %>.ordered
render turbo_stream: [
turbo_stream.replace(
"<%= file_name %>-providers-panel",
partial: "settings/providers/<%= file_name %>_panel",
locals: { <%= file_name %>_items: @<%= table_name %> }
),
*flash_notification_stream_items
]
else
redirect_to settings_providers_path, notice: t(".success"), status: :see_other
end
else
@error_message = @<%= file_name %>_item.errors.full_messages.join(", ")
if turbo_frame_request?
render turbo_stream: turbo_stream.replace(
"<%= file_name %>-providers-panel",
partial: "settings/providers/<%= file_name %>_panel",
locals: { error_message: @error_message }
), status: :unprocessable_entity
else
redirect_to settings_providers_path, alert: @error_message, status: :unprocessable_entity
end
end
end
def destroy
@<%= file_name %>_item.destroy_later
redirect_to settings_providers_path, notice: t(".success", default: "Scheduled <%= class_name %> connection for deletion.")
end
def sync
unless @<%= file_name %>_item.syncing?
@<%= file_name %>_item.sync_later
end
respond_to do |format|
format.html { redirect_back_or_to accounts_path }
format.json { head :ok }
end
end
# Collection actions for account linking flow
# TODO: Implement these when you have the provider SDK ready
def preload_accounts
# TODO: Fetch accounts from provider API and cache them
redirect_to settings_providers_path, alert: "Not implemented yet"
end
def select_accounts
# TODO: Show UI to select which accounts to link
@accountable_type = params[:accountable_type]
@return_to = params[:return_to]
redirect_to settings_providers_path, alert: "Not implemented yet"
end
def link_accounts
# TODO: Link selected accounts
redirect_to settings_providers_path, alert: "Not implemented yet"
end
def select_existing_account
# TODO: Show UI to link an existing account to provider
@account_id = params[:account_id]
redirect_to settings_providers_path, alert: "Not implemented yet"
end
def link_existing_account
# TODO: Link an existing account to a provider account
redirect_to settings_providers_path, alert: "Not implemented yet"
end
def setup_accounts
# TODO: Show account setup UI
redirect_to settings_providers_path, alert: "Not implemented yet"
end
def complete_account_setup
# TODO: Complete the account setup process
redirect_to settings_providers_path, alert: "Not implemented yet"
end
private
def set_<%= file_name %>_item
@<%= file_name %>_item = Current.family.<%= table_name %>.find(params[:id])
end
def <%= file_name %>_item_params
params.require(:<%= file_name %>_item).permit(
:name,
:sync_start_date<% parsed_fields.each do |field| %>,
:<%= field[:name] %><% end %>
)
end
end

View File

@@ -0,0 +1,187 @@
class <%= class_name %>Item < ApplicationRecord
include Syncable, Provided, Unlinking
enum :status, { good: "good", requires_update: "requires_update" }, default: :good
# Helper to detect if ActiveRecord Encryption is configured for this app
def self.encryption_ready?
creds_ready = Rails.application.credentials.active_record_encryption.present?
env_ready = ENV["ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY"].present? &&
ENV["ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY"].present? &&
ENV["ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT"].present?
creds_ready || env_ready
end
# Encrypt sensitive credentials if ActiveRecord encryption is configured
if encryption_ready?
<% parsed_fields.select { |f| f[:secret] }.each do |field| -%>
encrypts :<%= field[:name] %>, deterministic: true
<% end -%>
end
validates :name, presence: true
<% parsed_fields.select { |f| f[:secret] }.each do |field| -%>
validates :<%= field[:name] %>, presence: true, on: :create
<% end -%>
belongs_to :family
has_one_attached :logo
has_many :<%= file_name %>_accounts, dependent: :destroy
has_many :accounts, through: :<%= file_name %>_accounts
scope :active, -> { where(scheduled_for_deletion: false) }
scope :ordered, -> { order(created_at: :desc) }
scope :needs_update, -> { where(status: :requires_update) }
def destroy_later
update!(scheduled_for_deletion: true)
DestroyJob.perform_later(self)
end
# TODO: Implement data import from provider API
# This method should fetch the latest data from the provider and import it.
# May need provider-specific validation (e.g., session validity checks).
# See LunchflowItem#import_latest_lunchflow_data or EnableBankingItem#import_latest_enable_banking_data for examples.
def import_latest_<%= file_name %>_data
provider = <%= file_name %>_provider
unless provider
Rails.logger.error "<%= class_name %>Item #{id} - Cannot import: provider is not configured"
raise StandardError.new("<%= class_name %> provider is not configured")
end
# TODO: Add any provider-specific validation here (e.g., session checks)
<%= class_name %>Item::Importer.new(self, <%= file_name %>_provider: provider).import
rescue => e
Rails.logger.error "<%= class_name %>Item #{id} - Failed to import data: #{e.message}"
raise
end
# TODO: Implement account processing logic
# This method processes linked accounts after data import.
# Customize based on your provider's data structure and processing needs.
def process_accounts
return [] if <%= file_name %>_accounts.empty?
results = []
<%= file_name %>_accounts.joins(:account).merge(Account.visible).each do |<%= file_name %>_account|
begin
result = <%= class_name %>Account::Processor.new(<%= file_name %>_account).process
results << { <%= file_name %>_account_id: <%= file_name %>_account.id, success: true, result: result }
rescue => e
Rails.logger.error "<%= class_name %>Item #{id} - Failed to process account #{<%= file_name %>_account.id}: #{e.message}"
results << { <%= file_name %>_account_id: <%= file_name %>_account.id, success: false, error: e.message }
end
end
results
end
# TODO: Customize sync scheduling if needed
# This method schedules sync jobs for all linked accounts.
def schedule_account_syncs(parent_sync: nil, window_start_date: nil, window_end_date: nil)
return [] if accounts.empty?
results = []
accounts.visible.each do |account|
begin
account.sync_later(
parent_sync: parent_sync,
window_start_date: window_start_date,
window_end_date: window_end_date
)
results << { account_id: account.id, success: true }
rescue => e
Rails.logger.error "<%= class_name %>Item #{id} - Failed to schedule sync for account #{account.id}: #{e.message}"
results << { account_id: account.id, success: false, error: e.message }
end
end
results
end
def upsert_<%= file_name %>_snapshot!(accounts_snapshot)
assign_attributes(
raw_payload: accounts_snapshot
)
save!
end
def has_completed_initial_setup?
# Setup is complete if we have any linked accounts
accounts.any?
end
# TODO: Customize sync status summary if needed
# Some providers use latest_sync.sync_stats, others use count methods directly.
# See SimplefinItem#sync_status_summary or EnableBankingItem#sync_status_summary for examples.
def sync_status_summary
total_accounts = total_accounts_count
linked_count = linked_accounts_count
unlinked_count = unlinked_accounts_count
if total_accounts == 0
"No accounts found"
elsif unlinked_count == 0
"#{linked_count} #{'account'.pluralize(linked_count)} synced"
else
"#{linked_count} synced, #{unlinked_count} need setup"
end
end
def linked_accounts_count
<%= file_name %>_accounts.joins(:account_provider).count
end
def unlinked_accounts_count
<%= file_name %>_accounts.left_joins(:account_provider).where(account_providers: { id: nil }).count
end
def total_accounts_count
<%= file_name %>_accounts.count
end
def institution_display_name
institution_name.presence || institution_domain.presence || name
end
# TODO: Customize based on how your provider stores institution data
# SimpleFin uses org_data, others use institution_metadata.
# Adjust the field name and key lookups as needed.
def connected_institutions
<%= file_name %>_accounts.includes(:account)
.where.not(institution_metadata: nil)
.map { |acc| acc.institution_metadata }
.uniq { |inst| inst["name"] || inst["institution_name"] }
end
# TODO: Customize institution summary if your provider has special fields
# EnableBanking uses aspsp_name as a fallback, for example.
def institution_summary
institutions = connected_institutions
case institutions.count
when 0
"No institutions connected"
when 1
institutions.first["name"] || institutions.first["institution_name"] || "1 institution"
else
"#{institutions.count} institutions"
end
end
def credentials_configured?
<% if parsed_fields.select { |f| f[:secret] }.any? -%>
<%= parsed_fields.select { |f| f[:secret] }.map { |f| "#{f[:name]}.present?" }.join(" && ") %>
<% else -%>
true
<% end -%>
end
<% parsed_fields.select { |f| f[:default] }.each do |field| -%>
def effective_<%= field[:name] %>
<%= field[:name] %>.presence || "<%= field[:default] %>"
end
<% end -%>
end

View File

@@ -0,0 +1,62 @@
class Create<%= class_name %>ItemsAndAccounts < ActiveRecord::Migration<%= migration_version %>
def change
# Create provider items table (stores per-family connection credentials)
create_table :<%= table_name %>, id: :uuid do |t|
t.references :family, null: false, foreign_key: true, type: :uuid
t.string :name
# Institution metadata
t.string :institution_id
t.string :institution_name
t.string :institution_domain
t.string :institution_url
t.string :institution_color
# Status and lifecycle
t.string :status, default: "good"
t.boolean :scheduled_for_deletion, default: false
t.boolean :pending_account_setup, default: false
# Sync settings
t.datetime :sync_start_date
# Raw data storage
t.jsonb :raw_payload
t.jsonb :raw_institution_payload
# Provider-specific credential fields
<% parsed_fields.each do |field| -%>
t.<%= field[:type] %> :<%= field[:name] %>
<% end -%>
t.timestamps
end
add_index :<%= table_name %>, :status
# Create provider accounts table (stores individual account data from provider)
create_table :<%= file_name %>_accounts, id: :uuid do |t|
t.references :<%= file_name %>_item, null: false, foreign_key: true, type: :uuid
# Account identification
t.string :name
t.string :account_id
# Account details
t.string :currency
t.decimal :current_balance, precision: 19, scale: 4
t.string :account_status
t.string :account_type
t.string :provider
# Metadata and raw data
t.jsonb :institution_metadata
t.jsonb :raw_payload
t.jsonb :raw_transactions_payload
t.timestamps
end
add_index :<%= file_name %>_accounts, :account_id
end
end

View File

@@ -0,0 +1,71 @@
<div class="space-y-4">
<div class="prose prose-sm text-secondary">
<p class="text-primary font-medium">Setup instructions:</p>
<ol>
<li>Visit your <%= class_name.titleize %> dashboard to get your credentials</li>
<li>Enter your credentials below and click the Save button</li>
<li>After a successful connection, go to the Accounts tab to set up new accounts</li>
</ol>
<p class="text-primary font-medium">Field descriptions:</p>
<ul>
<% parsed_fields.each do |field| -%>
<li><strong><%= field[:name].titleize %>:</strong> Your <%= class_name.titleize %> <%= field[:name].humanize.downcase %><%= field[:secret] ? ' (required)' : '' %><%= field[:default] ? " (optional, defaults to #{field[:default]})" : '' %></li>
<% end -%>
</ul>
</div>
<%% error_msg = local_assigns[:error_message] || @error_message %>
<%% if error_msg.present? %>
<div class="p-2 rounded-md bg-destructive/10 text-destructive text-sm overflow-hidden">
<p class="line-clamp-3" title="<%%= error_msg %>"><%%= error_msg %></p>
</div>
<%% end %>
<%%
# Get or initialize a <%= file_name %>_item for this family
# - If family has an item WITH credentials, use it (for updates)
# - If family has an item WITHOUT credentials, use it (to add credentials)
# - If family has no items at all, create a new one
<%= file_name %>_item = Current.family.<%= file_name %>_items.first_or_initialize(name: "<%= class_name.titleize %> Connection")
is_new_record = <%= file_name %>_item.new_record?
%>
<%%= styled_form_with model: <%= file_name %>_item,
url: is_new_record ? <%= file_name %>_items_path : <%= file_name %>_item_path(<%= file_name %>_item),
scope: :<%= file_name %>_item,
method: is_new_record ? :post : :patch,
data: { turbo: true },
class: "space-y-3" do |form| %>
<% parsed_fields.each do |field| -%>
<%%= form.<%= %w[text string].include?(field[:type]) ? 'text_field' : 'number_field' %> :<%= field[:name] %>,
label: "<%= field[:name].titleize %><%= field[:default] ? ' (Optional)' : '' %>",
<% if field[:secret] -%>
placeholder: is_new_record ? "Paste <%= field[:name].humanize.downcase %> here" : "Enter new <%= field[:name].humanize.downcase %> to update",
type: :password %>
<% elsif field[:default] -%>
placeholder: "<%= field[:default] %> (default)",
value: <%= file_name %>_item.<%= field[:name] %> %>
<% else -%>
placeholder: is_new_record ? "Enter <%= field[:name].humanize.downcase %>" : "Enter new <%= field[:name].humanize.downcase %> to update",
value: <%= file_name %>_item.<%= field[:name] %> %>
<% end -%>
<% end -%>
<div class="flex justify-end">
<%%= form.submit is_new_record ? "Save Configuration" : "Update Configuration",
class: "inline-flex items-center justify-center rounded-lg px-4 py-2 text-sm font-medium text-white bg-gray-900 hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-gray-900 focus:ring-offset-2 transition-colors" %>
</div>
<%% end %>
<%% items = local_assigns[:<%= file_name %>_items] || @<%= file_name %>_items || Current.family.<%= file_name %>_items.where.not(<%= parsed_fields.select { |f| f[:secret] }.first&.dig(:name) || 'api_key' %>: [nil, ""]) %>
<div class="flex items-center gap-2">
<%% if items&.any? %>
<div class="w-2 h-2 bg-success rounded-full"></div>
<p class="text-sm text-secondary">Configured and ready to use. Visit the <a href="<%%= accounts_path %>" class="link">Accounts</a> tab to manage and set up accounts.</p>
<%% else %>
<div class="w-2 h-2 bg-gray-400 rounded-full"></div>
<p class="text-sm text-secondary">Not configured</p>
<%% end %>
</div>
</div>

View File

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

View File

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

View File

@@ -0,0 +1,242 @@
require "rails/generators"
require "rails/generators/active_record"
# Generator for creating global provider integrations
#
# Usage:
# rails g provider:global NAME field:type[:secret] field:type ...
#
# Examples:
# rails g provider:global plaid client_id:string:secret secret:string:secret environment:string
# rails g provider:global openai api_key:string:secret model:string
#
# Field format:
# name:type[:secret][:default=value]
# - name: Field name (e.g., api_key)
# - type: Database column type (text, string, integer, boolean)
# - secret: Optional flag indicating this field should be masked in UI
# - default: Optional default value (e.g., default=sandbox)
#
# This generates:
# - Migration creating provider_items and provider_accounts tables (WITHOUT credential fields)
# - Models for items, accounts, and provided concern
# - Adapter class with Provider::Configurable (credentials stored globally in settings table)
#
# Key difference from provider:family:
# - Credentials stored in `settings` table (global, shared by all families)
# - Item/account tables store connections per family (but not credentials)
# - No controller/view/routes needed (configuration via /settings/providers)
class Provider::GlobalGenerator < Rails::Generators::NamedBase
include Rails::Generators::Migration
source_root File.expand_path("templates", __dir__)
argument :fields, type: :array, default: [], banner: "field:type[:secret][:default=value] field:type[:secret]"
class_option :skip_migration, type: :boolean, default: false, desc: "Skip generating migration"
class_option :skip_models, type: :boolean, default: false, desc: "Skip generating models"
class_option :skip_adapter, type: :boolean, default: false, desc: "Skip generating adapter"
def validate_fields
if parsed_fields.empty?
raise Thor::Error, "At least one credential field is required. Example: api_key:text:secret"
end
# Validate field types
parsed_fields.each do |field|
unless %w[text string integer boolean].include?(field[:type])
raise Thor::Error, "Invalid field type '#{field[:type]}' for #{field[:name]}. Must be one of: text, string, integer, boolean"
end
end
end
def generate_migration
return if options[:skip_migration]
migration_template "global_migration.rb.tt",
"db/migrate/create_#{table_name}_and_accounts.rb",
migration_version: migration_version
end
def create_models
return if options[:skip_models]
# Create item model
item_model_path = "app/models/#{file_name}_item.rb"
if File.exist?(item_model_path)
say "Item model already exists: #{item_model_path}", :skip
else
template "global_item_model.rb.tt", item_model_path
say "Created item model: #{item_model_path}", :green
end
# Create account model
account_model_path = "app/models/#{file_name}_account.rb"
if File.exist?(account_model_path)
say "Account model already exists: #{account_model_path}", :skip
else
template "global_account_model.rb.tt", account_model_path
say "Created account model: #{account_model_path}", :green
end
# Create Provided concern
provided_concern_path = "app/models/#{file_name}_item/provided.rb"
if File.exist?(provided_concern_path)
say "Provided concern already exists: #{provided_concern_path}", :skip
else
template "global_provided_concern.rb.tt", provided_concern_path
say "Created Provided concern: #{provided_concern_path}", :green
end
end
def create_adapter
return if options[:skip_adapter]
adapter_path = "app/models/provider/#{file_name}_adapter.rb"
if File.exist?(adapter_path)
say "Adapter already exists: #{adapter_path}", :skip
else
template "global_adapter.rb.tt", adapter_path
say "Created adapter: #{adapter_path}", :green
end
end
def show_summary
say "\n" + "=" * 80, :green
say "Successfully generated global provider: #{class_name}", :green
say "=" * 80, :green
say "\nGenerated files:", :cyan
say " 📋 Migration: db/migrate/xxx_create_#{table_name}_and_accounts.rb"
say " 📦 Models:"
say " - app/models/#{file_name}_item.rb"
say " - app/models/#{file_name}_account.rb"
say " - app/models/#{file_name}_item/provided.rb"
say " 🔌 Adapter: app/models/provider/#{file_name}_adapter.rb"
if parsed_fields.any?
say "\nGlobal credential fields (stored in settings table):", :cyan
parsed_fields.each do |field|
secret_flag = field[:secret] ? " 🔒 (secret, masked in UI)" : ""
default_flag = field[:default] ? " [default: #{field[:default]}]" : ""
env_flag = " [ENV: #{field[:env_key]}]"
say " - #{field[:name]}: #{field[:type]}#{secret_flag}#{default_flag}#{env_flag}"
end
end
say "\nDatabase tables created:", :cyan
say " - #{table_name} (stores per-family connections, NO credentials)"
say " - #{file_name}_accounts (stores individual account data)"
say "\n⚠️ Global Provider Pattern:", :yellow
say " - Credentials stored GLOBALLY in 'settings' table"
say " - All families share the same credentials"
say " - Configuration UI auto-generated at /settings/providers"
say " - Only available in self-hosted mode"
say "\nNext steps:", :yellow
say " 1. Run migrations:"
say " rails db:migrate"
say ""
say " 2. Implement the provider SDK in:"
say " app/models/provider/#{file_name}.rb"
say ""
say " 3. Update #{class_name}Item::Provided concern:"
say " app/models/#{file_name}_item/provided.rb"
say " Implement the #{file_name}_provider method"
say ""
say " 4. Customize the adapter:"
say " app/models/provider/#{file_name}_adapter.rb"
say " - Update configure block descriptions"
say " - Implement reload_configuration if needed"
say " - Implement build_provider method"
say ""
say " 5. Configure credentials:"
say " Visit /settings/providers (self-hosted mode only)"
say " Or set ENV variables:"
parsed_fields.each do |field|
say " export #{field[:env_key]}=\"your_value\""
end
say ""
say " 6. Add item creation flow:"
say " - Users connect their #{class_name} account"
say " - Creates #{class_name}Item with family association"
say " - Syncs accounts using global credentials"
say ""
say " 📚 See PROVIDER_ARCHITECTURE.md for global provider documentation"
end
# Required for Rails::Generators::Migration
def self.next_migration_number(dirname)
ActiveRecord::Generators::Base.next_migration_number(dirname)
end
private
def table_name
"#{file_name}_items"
end
def migration_version
"[#{Rails::VERSION::MAJOR}.#{Rails::VERSION::MINOR}]"
end
def parsed_fields
@parsed_fields ||= fields.map do |field_def|
parts = field_def.split(":")
name = parts[0]
type = parts[1] || "string"
secret = parts.include?("secret")
default = extract_default(parts)
{
name: name,
type: type,
secret: secret,
default: default,
env_key: "#{file_name.upcase}_#{name.upcase}"
}
end
end
def extract_default(parts)
default_part = parts.find { |p| p.start_with?("default=") }
default_part&.sub("default=", "")
end
def configure_block_content
return "" if parsed_fields.empty?
fields_code = parsed_fields.map do |field|
field_attrs = [
"label: \"#{field[:name].titleize}\"",
("required: true" if field[:secret]),
("secret: true" if field[:secret]),
"env_key: \"#{field[:env_key]}\"",
("default: \"#{field[:default]}\"" if field[:default]),
"description: \"Your #{class_name} #{field[:name].humanize.downcase}\""
].compact.join(",\n ")
" field :#{field[:name]},\n #{field_attrs}\n"
end.join("\n")
<<~RUBY
configure do
description <<~DESC
Setup instructions for #{class_name}:
1. Visit your #{class_name} dashboard to get your credentials
2. Enter your credentials below
3. These credentials will be used by all families (global configuration)
**Note:** This is a global configuration for self-hosted mode only.
In managed mode, credentials are configured by the platform operator.
DESC
#{fields_code}
end
RUBY
end
end

View File

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

View File

@@ -0,0 +1,105 @@
class Provider::<%= class_name %>Adapter < Provider::Base
include Provider::Syncable
include Provider::InstitutionMetadata
include Provider::Configurable
# Register this adapter with the factory
Provider::Factory.register("<%= class_name %>Account", self)
<%= configure_block_content %>
def provider_name
"<%= file_name %>"
end
# Thread-safe lazy loading of <%= class_name %> configuration
def self.ensure_configuration_loaded
# Fast path: return immediately if already loaded (no lock needed)
return if Rails.application.config.<%= file_name %>.present?
# Slow path: acquire lock and reload if still needed
@config_mutex ||= Mutex.new
@config_mutex.synchronize do
return if Rails.application.config.<%= file_name %>.present?
reload_configuration
end
end
# Reload <%= class_name %> configuration when settings are updated
def self.reload_configuration
<% parsed_fields.each do |field| -%>
<%= field[:name] %> = config_value(:<%= field[:name] %>).presence || ENV["<%= field[:env_key] %>"]<% if field[:default] %> || "<%= field[:default] %>"<% end %>
<% end -%>
<% first_required_field = parsed_fields.find { |f| f[:secret] } -%>
<% if first_required_field -%>
if <%= first_required_field[:name] %>.present?
<% end -%>
Rails.application.config.<%= file_name %> = OpenStruct.new(
<% parsed_fields.each_with_index do |field, index| -%>
<%= field[:name] %>: <%= field[:name] %><%= index < parsed_fields.length - 1 ? ',' : '' %>
<% end -%>
)
<% if first_required_field -%>
else
Rails.application.config.<%= file_name %> = nil
end
<% end -%>
end
# Build a <%= class_name %> provider instance using GLOBAL credentials
# @return [Provider::<%= class_name %>, nil] Returns nil if credentials are not configured
def self.build_provider
<% first_secret_field = parsed_fields.find { |f| f[:secret] } -%>
<% if first_secret_field -%>
<%= first_secret_field[:name] %> = config_value(:<%= first_secret_field[:name] %>)
return nil unless <%= first_secret_field[:name] %>.present?
<% end -%>
# TODO: Implement provider initialization
# Provider::<%= class_name %>.new(
<% parsed_fields.each_with_index do |field, index| -%>
# <%= field[:name] %>: config_value(:<%= field[:name] %>)<%= index < parsed_fields.length - 1 ? ',' : '' %>
<% end -%>
# )
raise NotImplementedError, "Implement build_provider in #{__FILE__}"
end
def sync_path
Rails.application.routes.url_helpers.sync_<%= file_name %>_item_path(item)
end
def item
provider_account.<%= file_name %>_item
end
def can_delete_holdings?
false
end
def institution_domain
# TODO: Implement institution domain extraction
metadata = provider_account.institution_metadata
return nil unless metadata.present?
metadata["domain"]
end
def institution_name
# TODO: Implement institution name extraction
metadata = provider_account.institution_metadata
return nil unless metadata.present?
metadata["name"] || item&.institution_name
end
def institution_url
# TODO: Implement institution URL extraction
metadata = provider_account.institution_metadata
return nil unless metadata.present?
metadata["url"] || item&.institution_url
end
def institution_color
item&.institution_color
end
end

View File

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

View File

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

View File

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