mirror of
https://github.com/we-promise/sure.git
synced 2026-04-09 15:24:48 +00:00
Provider generator (#364)
* Move provider config to family * Update schema.rb * Add provier generator * Add table creation also * FIX generator namespace * Add support for global providers also * Remove over-engineered stuff * FIX parser * FIX linter * Some generator fixes * Update generator with fixes * Update item_model.rb.tt * Add missing linkable concern * Add missing routes * Update adapter.rb.tt * Update connectable_concern.rb.tt * Update unlinking_concern.rb.tt * Update family_generator.rb * Update family_generator.rb * Delete .claude/settings.local.json Signed-off-by: soky srm <sokysrm@gmail.com> * Move docs under API related folder * Rename Rails generator doc * Light edits to LLM generated doc * Small Lunch Flow config panel regressions. --------- Signed-off-by: soky srm <sokysrm@gmail.com> Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
This commit is contained in:
@@ -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>
|
||||
|
||||
935
docs/api/rails_provider_generator.md
Normal file
935
docs/api/rails_provider_generator.md
Normal 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.
|
||||
429
lib/generators/provider/family/family_generator.rb
Normal file
429
lib/generators/provider/family/family_generator.rb
Normal 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
|
||||
52
lib/generators/provider/family/templates/account_model.rb.tt
Normal file
52
lib/generators/provider/family/templates/account_model.rb.tt
Normal 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
|
||||
114
lib/generators/provider/family/templates/adapter.rb.tt
Normal file
114
lib/generators/provider/family/templates/adapter.rb.tt
Normal 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
|
||||
@@ -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
|
||||
153
lib/generators/provider/family/templates/controller.rb.tt
Normal file
153
lib/generators/provider/family/templates/controller.rb.tt
Normal 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
|
||||
187
lib/generators/provider/family/templates/item_model.rb.tt
Normal file
187
lib/generators/provider/family/templates/item_model.rb.tt
Normal 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
|
||||
62
lib/generators/provider/family/templates/migration.rb.tt
Normal file
62
lib/generators/provider/family/templates/migration.rb.tt
Normal 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
|
||||
71
lib/generators/provider/family/templates/panel.html.erb.tt
Normal file
71
lib/generators/provider/family/templates/panel.html.erb.tt
Normal 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>
|
||||
@@ -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
|
||||
@@ -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
|
||||
242
lib/generators/provider/global/global_generator.rb
Normal file
242
lib/generators/provider/global/global_generator.rb
Normal file
@@ -0,0 +1,242 @@
|
||||
require "rails/generators"
|
||||
require "rails/generators/active_record"
|
||||
|
||||
# Generator for creating global provider integrations
|
||||
#
|
||||
# Usage:
|
||||
# rails g provider:global NAME field:type[:secret] field:type ...
|
||||
#
|
||||
# Examples:
|
||||
# rails g provider:global plaid client_id:string:secret secret:string:secret environment:string
|
||||
# rails g provider:global openai api_key:string:secret model:string
|
||||
#
|
||||
# Field format:
|
||||
# name:type[:secret][:default=value]
|
||||
# - name: Field name (e.g., api_key)
|
||||
# - type: Database column type (text, string, integer, boolean)
|
||||
# - secret: Optional flag indicating this field should be masked in UI
|
||||
# - default: Optional default value (e.g., default=sandbox)
|
||||
#
|
||||
# This generates:
|
||||
# - Migration creating provider_items and provider_accounts tables (WITHOUT credential fields)
|
||||
# - Models for items, accounts, and provided concern
|
||||
# - Adapter class with Provider::Configurable (credentials stored globally in settings table)
|
||||
#
|
||||
# Key difference from provider:family:
|
||||
# - Credentials stored in `settings` table (global, shared by all families)
|
||||
# - Item/account tables store connections per family (but not credentials)
|
||||
# - No controller/view/routes needed (configuration via /settings/providers)
|
||||
class Provider::GlobalGenerator < Rails::Generators::NamedBase
|
||||
include Rails::Generators::Migration
|
||||
|
||||
source_root File.expand_path("templates", __dir__)
|
||||
|
||||
argument :fields, type: :array, default: [], banner: "field:type[:secret][:default=value] field:type[:secret]"
|
||||
|
||||
class_option :skip_migration, type: :boolean, default: false, desc: "Skip generating migration"
|
||||
class_option :skip_models, type: :boolean, default: false, desc: "Skip generating models"
|
||||
class_option :skip_adapter, type: :boolean, default: false, desc: "Skip generating adapter"
|
||||
|
||||
def validate_fields
|
||||
if parsed_fields.empty?
|
||||
raise Thor::Error, "At least one credential field is required. Example: api_key:text:secret"
|
||||
end
|
||||
|
||||
# Validate field types
|
||||
parsed_fields.each do |field|
|
||||
unless %w[text string integer boolean].include?(field[:type])
|
||||
raise Thor::Error, "Invalid field type '#{field[:type]}' for #{field[:name]}. Must be one of: text, string, integer, boolean"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def generate_migration
|
||||
return if options[:skip_migration]
|
||||
|
||||
migration_template "global_migration.rb.tt",
|
||||
"db/migrate/create_#{table_name}_and_accounts.rb",
|
||||
migration_version: migration_version
|
||||
end
|
||||
|
||||
def create_models
|
||||
return if options[:skip_models]
|
||||
|
||||
# Create item model
|
||||
item_model_path = "app/models/#{file_name}_item.rb"
|
||||
if File.exist?(item_model_path)
|
||||
say "Item model already exists: #{item_model_path}", :skip
|
||||
else
|
||||
template "global_item_model.rb.tt", item_model_path
|
||||
say "Created item model: #{item_model_path}", :green
|
||||
end
|
||||
|
||||
# Create account model
|
||||
account_model_path = "app/models/#{file_name}_account.rb"
|
||||
if File.exist?(account_model_path)
|
||||
say "Account model already exists: #{account_model_path}", :skip
|
||||
else
|
||||
template "global_account_model.rb.tt", account_model_path
|
||||
say "Created account model: #{account_model_path}", :green
|
||||
end
|
||||
|
||||
# Create Provided concern
|
||||
provided_concern_path = "app/models/#{file_name}_item/provided.rb"
|
||||
if File.exist?(provided_concern_path)
|
||||
say "Provided concern already exists: #{provided_concern_path}", :skip
|
||||
else
|
||||
template "global_provided_concern.rb.tt", provided_concern_path
|
||||
say "Created Provided concern: #{provided_concern_path}", :green
|
||||
end
|
||||
end
|
||||
|
||||
def create_adapter
|
||||
return if options[:skip_adapter]
|
||||
|
||||
adapter_path = "app/models/provider/#{file_name}_adapter.rb"
|
||||
|
||||
if File.exist?(adapter_path)
|
||||
say "Adapter already exists: #{adapter_path}", :skip
|
||||
else
|
||||
template "global_adapter.rb.tt", adapter_path
|
||||
say "Created adapter: #{adapter_path}", :green
|
||||
end
|
||||
end
|
||||
|
||||
def show_summary
|
||||
say "\n" + "=" * 80, :green
|
||||
say "Successfully generated global provider: #{class_name}", :green
|
||||
say "=" * 80, :green
|
||||
|
||||
say "\nGenerated files:", :cyan
|
||||
say " 📋 Migration: db/migrate/xxx_create_#{table_name}_and_accounts.rb"
|
||||
say " 📦 Models:"
|
||||
say " - app/models/#{file_name}_item.rb"
|
||||
say " - app/models/#{file_name}_account.rb"
|
||||
say " - app/models/#{file_name}_item/provided.rb"
|
||||
say " 🔌 Adapter: app/models/provider/#{file_name}_adapter.rb"
|
||||
|
||||
if parsed_fields.any?
|
||||
say "\nGlobal credential fields (stored in settings table):", :cyan
|
||||
parsed_fields.each do |field|
|
||||
secret_flag = field[:secret] ? " 🔒 (secret, masked in UI)" : ""
|
||||
default_flag = field[:default] ? " [default: #{field[:default]}]" : ""
|
||||
env_flag = " [ENV: #{field[:env_key]}]"
|
||||
say " - #{field[:name]}: #{field[:type]}#{secret_flag}#{default_flag}#{env_flag}"
|
||||
end
|
||||
end
|
||||
|
||||
say "\nDatabase tables created:", :cyan
|
||||
say " - #{table_name} (stores per-family connections, NO credentials)"
|
||||
say " - #{file_name}_accounts (stores individual account data)"
|
||||
|
||||
say "\n⚠️ Global Provider Pattern:", :yellow
|
||||
say " - Credentials stored GLOBALLY in 'settings' table"
|
||||
say " - All families share the same credentials"
|
||||
say " - Configuration UI auto-generated at /settings/providers"
|
||||
say " - Only available in self-hosted mode"
|
||||
|
||||
say "\nNext steps:", :yellow
|
||||
say " 1. Run migrations:"
|
||||
say " rails db:migrate"
|
||||
say ""
|
||||
say " 2. Implement the provider SDK in:"
|
||||
say " app/models/provider/#{file_name}.rb"
|
||||
say ""
|
||||
say " 3. Update #{class_name}Item::Provided concern:"
|
||||
say " app/models/#{file_name}_item/provided.rb"
|
||||
say " Implement the #{file_name}_provider method"
|
||||
say ""
|
||||
say " 4. Customize the adapter:"
|
||||
say " app/models/provider/#{file_name}_adapter.rb"
|
||||
say " - Update configure block descriptions"
|
||||
say " - Implement reload_configuration if needed"
|
||||
say " - Implement build_provider method"
|
||||
say ""
|
||||
say " 5. Configure credentials:"
|
||||
say " Visit /settings/providers (self-hosted mode only)"
|
||||
say " Or set ENV variables:"
|
||||
parsed_fields.each do |field|
|
||||
say " export #{field[:env_key]}=\"your_value\""
|
||||
end
|
||||
say ""
|
||||
say " 6. Add item creation flow:"
|
||||
say " - Users connect their #{class_name} account"
|
||||
say " - Creates #{class_name}Item with family association"
|
||||
say " - Syncs accounts using global credentials"
|
||||
say ""
|
||||
say " 📚 See PROVIDER_ARCHITECTURE.md for global provider documentation"
|
||||
end
|
||||
|
||||
# Required for Rails::Generators::Migration
|
||||
def self.next_migration_number(dirname)
|
||||
ActiveRecord::Generators::Base.next_migration_number(dirname)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def table_name
|
||||
"#{file_name}_items"
|
||||
end
|
||||
|
||||
def migration_version
|
||||
"[#{Rails::VERSION::MAJOR}.#{Rails::VERSION::MINOR}]"
|
||||
end
|
||||
|
||||
def parsed_fields
|
||||
@parsed_fields ||= fields.map do |field_def|
|
||||
parts = field_def.split(":")
|
||||
name = parts[0]
|
||||
type = parts[1] || "string"
|
||||
secret = parts.include?("secret")
|
||||
default = extract_default(parts)
|
||||
|
||||
{
|
||||
name: name,
|
||||
type: type,
|
||||
secret: secret,
|
||||
default: default,
|
||||
env_key: "#{file_name.upcase}_#{name.upcase}"
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def extract_default(parts)
|
||||
default_part = parts.find { |p| p.start_with?("default=") }
|
||||
default_part&.sub("default=", "")
|
||||
end
|
||||
|
||||
def configure_block_content
|
||||
return "" if parsed_fields.empty?
|
||||
|
||||
fields_code = parsed_fields.map do |field|
|
||||
field_attrs = [
|
||||
"label: \"#{field[:name].titleize}\"",
|
||||
("required: true" if field[:secret]),
|
||||
("secret: true" if field[:secret]),
|
||||
"env_key: \"#{field[:env_key]}\"",
|
||||
("default: \"#{field[:default]}\"" if field[:default]),
|
||||
"description: \"Your #{class_name} #{field[:name].humanize.downcase}\""
|
||||
].compact.join(",\n ")
|
||||
|
||||
" field :#{field[:name]},\n #{field_attrs}\n"
|
||||
end.join("\n")
|
||||
|
||||
<<~RUBY
|
||||
|
||||
configure do
|
||||
description <<~DESC
|
||||
Setup instructions for #{class_name}:
|
||||
1. Visit your #{class_name} dashboard to get your credentials
|
||||
2. Enter your credentials below
|
||||
3. These credentials will be used by all families (global configuration)
|
||||
|
||||
**Note:** This is a global configuration for self-hosted mode only.
|
||||
In managed mode, credentials are configured by the platform operator.
|
||||
DESC
|
||||
|
||||
#{fields_code}
|
||||
end
|
||||
|
||||
RUBY
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,52 @@
|
||||
class <%= class_name %>Account < ApplicationRecord
|
||||
include CurrencyNormalizable
|
||||
|
||||
belongs_to :<%= file_name %>_item
|
||||
|
||||
# Association through account_providers for linking to internal accounts
|
||||
has_one :account_provider, as: :provider, dependent: :destroy
|
||||
has_one :account, through: :account_provider, source: :account
|
||||
has_one :linked_account, through: :account_provider, source: :account
|
||||
|
||||
validates :name, :currency, presence: true
|
||||
|
||||
# Helper to get account using account_providers system
|
||||
def current_account
|
||||
account
|
||||
end
|
||||
|
||||
def upsert_<%= file_name %>_snapshot!(account_snapshot)
|
||||
# Convert to symbol keys or handle both string and symbol keys
|
||||
snapshot = account_snapshot.with_indifferent_access
|
||||
|
||||
# Map <%= class_name %> field names to our field names
|
||||
# TODO: Customize this mapping based on your provider's API response
|
||||
update!(
|
||||
current_balance: snapshot[:balance] || snapshot[:current_balance],
|
||||
currency: parse_currency(snapshot[:currency]) || "USD",
|
||||
name: snapshot[:name],
|
||||
account_id: snapshot[:id]&.to_s,
|
||||
account_status: snapshot[:status],
|
||||
provider: snapshot[:provider],
|
||||
institution_metadata: {
|
||||
name: snapshot[:institution_name],
|
||||
logo: snapshot[:institution_logo]
|
||||
}.compact,
|
||||
raw_payload: account_snapshot
|
||||
)
|
||||
end
|
||||
|
||||
def upsert_<%= file_name %>_transactions_snapshot!(transactions_snapshot)
|
||||
assign_attributes(
|
||||
raw_transactions_payload: transactions_snapshot
|
||||
)
|
||||
|
||||
save!
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def log_invalid_currency(currency_value)
|
||||
Rails.logger.warn("Invalid currency code '#{currency_value}' for <%= class_name %> account #{id}, defaulting to USD")
|
||||
end
|
||||
end
|
||||
105
lib/generators/provider/global/templates/global_adapter.rb.tt
Normal file
105
lib/generators/provider/global/templates/global_adapter.rb.tt
Normal file
@@ -0,0 +1,105 @@
|
||||
class Provider::<%= class_name %>Adapter < Provider::Base
|
||||
include Provider::Syncable
|
||||
include Provider::InstitutionMetadata
|
||||
include Provider::Configurable
|
||||
|
||||
# Register this adapter with the factory
|
||||
Provider::Factory.register("<%= class_name %>Account", self)
|
||||
<%= configure_block_content %>
|
||||
def provider_name
|
||||
"<%= file_name %>"
|
||||
end
|
||||
|
||||
# Thread-safe lazy loading of <%= class_name %> configuration
|
||||
def self.ensure_configuration_loaded
|
||||
# Fast path: return immediately if already loaded (no lock needed)
|
||||
return if Rails.application.config.<%= file_name %>.present?
|
||||
|
||||
# Slow path: acquire lock and reload if still needed
|
||||
@config_mutex ||= Mutex.new
|
||||
@config_mutex.synchronize do
|
||||
return if Rails.application.config.<%= file_name %>.present?
|
||||
reload_configuration
|
||||
end
|
||||
end
|
||||
|
||||
# Reload <%= class_name %> configuration when settings are updated
|
||||
def self.reload_configuration
|
||||
<% parsed_fields.each do |field| -%>
|
||||
<%= field[:name] %> = config_value(:<%= field[:name] %>).presence || ENV["<%= field[:env_key] %>"]<% if field[:default] %> || "<%= field[:default] %>"<% end %>
|
||||
<% end -%>
|
||||
|
||||
<% first_required_field = parsed_fields.find { |f| f[:secret] } -%>
|
||||
<% if first_required_field -%>
|
||||
if <%= first_required_field[:name] %>.present?
|
||||
<% end -%>
|
||||
Rails.application.config.<%= file_name %> = OpenStruct.new(
|
||||
<% parsed_fields.each_with_index do |field, index| -%>
|
||||
<%= field[:name] %>: <%= field[:name] %><%= index < parsed_fields.length - 1 ? ',' : '' %>
|
||||
<% end -%>
|
||||
)
|
||||
<% if first_required_field -%>
|
||||
else
|
||||
Rails.application.config.<%= file_name %> = nil
|
||||
end
|
||||
<% end -%>
|
||||
end
|
||||
|
||||
# Build a <%= class_name %> provider instance using GLOBAL credentials
|
||||
# @return [Provider::<%= class_name %>, nil] Returns nil if credentials are not configured
|
||||
def self.build_provider
|
||||
<% first_secret_field = parsed_fields.find { |f| f[:secret] } -%>
|
||||
<% if first_secret_field -%>
|
||||
<%= first_secret_field[:name] %> = config_value(:<%= first_secret_field[:name] %>)
|
||||
return nil unless <%= first_secret_field[:name] %>.present?
|
||||
<% end -%>
|
||||
|
||||
# TODO: Implement provider initialization
|
||||
# Provider::<%= class_name %>.new(
|
||||
<% parsed_fields.each_with_index do |field, index| -%>
|
||||
# <%= field[:name] %>: config_value(:<%= field[:name] %>)<%= index < parsed_fields.length - 1 ? ',' : '' %>
|
||||
<% end -%>
|
||||
# )
|
||||
raise NotImplementedError, "Implement build_provider in #{__FILE__}"
|
||||
end
|
||||
|
||||
def sync_path
|
||||
Rails.application.routes.url_helpers.sync_<%= file_name %>_item_path(item)
|
||||
end
|
||||
|
||||
def item
|
||||
provider_account.<%= file_name %>_item
|
||||
end
|
||||
|
||||
def can_delete_holdings?
|
||||
false
|
||||
end
|
||||
|
||||
def institution_domain
|
||||
# TODO: Implement institution domain extraction
|
||||
metadata = provider_account.institution_metadata
|
||||
return nil unless metadata.present?
|
||||
|
||||
metadata["domain"]
|
||||
end
|
||||
|
||||
def institution_name
|
||||
# TODO: Implement institution name extraction
|
||||
metadata = provider_account.institution_metadata
|
||||
return nil unless metadata.present?
|
||||
|
||||
metadata["name"] || item&.institution_name
|
||||
end
|
||||
|
||||
def institution_url
|
||||
# TODO: Implement institution URL extraction
|
||||
metadata = provider_account.institution_metadata
|
||||
return nil unless metadata.present?
|
||||
|
||||
metadata["url"] || item&.institution_url
|
||||
end
|
||||
|
||||
def institution_color
|
||||
item&.institution_color
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,33 @@
|
||||
class <%= class_name %>Item < ApplicationRecord
|
||||
include Syncable, Provided
|
||||
|
||||
enum :status, { good: "good", requires_update: "requires_update" }, default: :good
|
||||
|
||||
validates :name, presence: true
|
||||
|
||||
belongs_to :family
|
||||
has_one_attached :logo
|
||||
|
||||
has_many :<%= file_name %>_accounts, dependent: :destroy
|
||||
has_many :accounts, through: :<%= file_name %>_accounts
|
||||
|
||||
scope :active, -> { where(scheduled_for_deletion: false) }
|
||||
scope :ordered, -> { order(created_at: :desc) }
|
||||
scope :needs_update, -> { where(status: :requires_update) }
|
||||
|
||||
def destroy_later
|
||||
update!(scheduled_for_deletion: true)
|
||||
DestroyJob.perform_later(self)
|
||||
end
|
||||
|
||||
# NOTE: This is a GLOBAL provider
|
||||
# Credentials are configured globally in /settings/providers (self-hosted mode)
|
||||
# or via environment variables
|
||||
# This model stores the per-family connection, but not credentials
|
||||
|
||||
# TODO: Implement provider-specific methods
|
||||
# - import_latest_<%= file_name %>_data
|
||||
# - process_accounts
|
||||
# - schedule_account_syncs
|
||||
# - See <%= class_name %>Item::Provided for provider instantiation
|
||||
end
|
||||
@@ -0,0 +1,61 @@
|
||||
class Create<%= class_name %>ItemsAndAccounts < ActiveRecord::Migration<%= migration_version %>
|
||||
def change
|
||||
# Create provider items table (stores per-family connections)
|
||||
# NOTE: Credentials are stored GLOBALLY in the 'settings' table via Provider::Configurable
|
||||
# This table only stores connection metadata per family
|
||||
create_table :<%= table_name %>, id: :uuid do |t|
|
||||
t.references :family, null: false, foreign_key: true, type: :uuid
|
||||
t.string :name
|
||||
|
||||
# Institution metadata
|
||||
t.string :institution_id
|
||||
t.string :institution_name
|
||||
t.string :institution_domain
|
||||
t.string :institution_url
|
||||
t.string :institution_color
|
||||
|
||||
# Status and lifecycle
|
||||
t.string :status, default: "good"
|
||||
t.boolean :scheduled_for_deletion, default: false
|
||||
t.boolean :pending_account_setup, default: false
|
||||
|
||||
# Sync settings
|
||||
t.datetime :sync_start_date
|
||||
|
||||
# Raw data storage
|
||||
t.jsonb :raw_payload
|
||||
t.jsonb :raw_institution_payload
|
||||
|
||||
# NO credential fields here - they're in the settings table!
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_index :<%= table_name %>, :status
|
||||
|
||||
# Create provider accounts table (stores individual account data from provider)
|
||||
create_table :<%= file_name %>_accounts, id: :uuid do |t|
|
||||
t.references :<%= file_name %>_item, null: false, foreign_key: true, type: :uuid
|
||||
|
||||
# Account identification
|
||||
t.string :name
|
||||
t.string :account_id
|
||||
|
||||
# Account details
|
||||
t.string :currency
|
||||
t.decimal :current_balance, precision: 19, scale: 4
|
||||
t.string :account_status
|
||||
t.string :account_type
|
||||
t.string :provider
|
||||
|
||||
# Metadata and raw data
|
||||
t.jsonb :institution_metadata
|
||||
t.jsonb :raw_payload
|
||||
t.jsonb :raw_transactions_payload
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_index :<%= file_name %>_accounts, :account_id
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,10 @@
|
||||
module <%= class_name %>Item::Provided
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
# Returns a <%= class_name %> provider instance using GLOBAL credentials
|
||||
# Credentials are configured in /settings/providers (self-hosted) or ENV variables
|
||||
def <%= file_name %>_provider
|
||||
# Use the adapter's build_provider method which reads from global settings
|
||||
Provider::<%= class_name %>Adapter.build_provider
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user