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

Setup instructions:

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

Field descriptions:

+
    +
  • API Key: Your My Bank API key (required)
  • +
  • Base URL: Your My Bank base URL (optional, defaults to https://api.mybank.com)
  • +
  • Refresh Token: Your My Bank refresh token (required)
  • +
+
+ + <% error_msg = local_assigns[:error_message] || @error_message %> + <% if error_msg.present? %> +
+ <%= error_msg %> +
+ <% end %> + + <% + # Get or initialize a my_bank_item for this family + my_bank_item = Current.family.my_bank_items.first_or_initialize(name: "My Bank Connection") + is_new_record = my_bank_item.new_record? + %> + + <%= styled_form_with model: my_bank_item, + url: is_new_record ? my_bank_items_path : my_bank_item_path(my_bank_item), + scope: :my_bank_item, + method: is_new_record ? :post : :patch, + data: { turbo: true }, + class: "space-y-3" do |form| %> + <%= form.text_field :api_key, + label: "API Key", + type: :password %> + + <%= form.text_field :base_url, + label: "Base URL (Optional)", + placeholder: "https://api.mybank.com (default)", + value: my_bank_item.base_url %> + + <%= form.text_field :refresh_token, + label: "Refresh Token", + type: :password %> + +
+ <%= form.submit is_new_record ? "Save Configuration" : "Update Configuration", + class: "inline-flex items-center justify-center rounded-lg px-4 py-2 text-sm font-medium text-white bg-gray-900 hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-gray-900 focus:ring-offset-2 transition-colors" %> +
+ <% end %> + + <% items = local_assigns[:my_bank_items] || @my_bank_items || Current.family.my_bank_items.where.not(api_key: nil) %> + <% if items&.any? %> +
+
+

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

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

My Bank Integration

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

+ Need help? Visit My Bank Help Center +

+
+
+``` + +--- + +## Examples + +### Example 1: Simple API Key Provider + +```bash +rails g provider:family coinbase api_key:text:secret +``` + +Result: Basic provider with just an API key field. + +### Example 2: OAuth Provider + +```bash +rails g provider:family stripe \ + client_id:string:secret \ + client_secret:string:secret \ + access_token:text:secret \ + refresh_token:text:secret +``` + +Then customize the adapter to implement OAuth flow. + +### Example 3: Complex Provider + +```bash +rails g provider:family enterprise_bank \ + api_key:text:secret \ + environment:string \ + base_url:string \ + webhook_secret:text:secret \ + rate_limit:integer +``` + +Then add custom validations and logic in the model: + +```ruby +class EnterpriseBankItem < ApplicationRecord + # ... (basic setup) + + validates :environment, inclusion: { in: %w[sandbox production] } + validates :rate_limit, numericality: { greater_than: 0 }, allow_nil: true + + def effective_rate_limit + rate_limit || 100 # Default to 100 requests/minute + end +end +``` + +--- + +## Tips & Best Practices + +### 1. Always Run Migrations + +```bash +rails db:migrate +``` + +### 2. Test in Console + +```ruby +# Check if adapter is registered +Provider::Factory.adapters +# => { ... "MyBankAccount" => Provider::MyBankAdapter, ... } + +# Test provider building +family = Family.first +item = family.my_bank_items.create!(name: "Test", api_key: "test_key", refresh_token: "test_refresh") +provider = Provider::MyBankAdapter.build_provider(family: family) +``` + +### 3. Use Proper Encryption + +Always check that encryption is set up: + +```ruby +# In your model +if Rails.application.credentials.active_record_encryption.present? + encrypts :api_key, :refresh_token, deterministic: true +else + Rails.logger.warn "ActiveRecord encryption not configured for #{self.name}" +end +``` + +### 4. Implement Proper Error Handling + +```ruby +def self.build_provider(family:) + return nil unless family.present? + + item = family.my_bank_items.where.not(api_key: nil).first + return nil unless item&.credentials_configured? + + begin + Provider::MyBank.new(item.api_key) + rescue Provider::MyBank::ConfigurationError => e + Rails.logger.error("MyBank provider configuration error: #{e.message}") + nil + end +end +``` + +### 5. Add Integration Tests + +```ruby +# test/models/provider/my_bank_adapter_test.rb +class Provider::MyBankAdapterTest < ActiveSupport::TestCase + test "builds provider with valid credentials" do + family = families(:family_one) + item = family.my_bank_items.create!( + name: "Test Bank", + api_key: "test_key" + ) + + provider = Provider::MyBankAdapter.build_provider(family: family) + assert_not_nil provider + assert_instance_of Provider::MyBank, provider + end + + test "returns nil without credentials" do + family = families(:family_one) + provider = Provider::MyBankAdapter.build_provider(family: family) + assert_nil provider + end +end +``` + +--- + +## Troubleshooting + +### Panel Not Showing + +1. Check that the provider is excluded in `settings/providers_controller.rb`: + ```ruby + @provider_configurations = Provider::ConfigurationRegistry.all.reject do |config| + config.provider_key.to_s.casecmp("my_bank").zero? + end + ``` + +2. Check that the instance variable is set: + ```ruby + @my_bank_items = Current.family.my_bank_items.ordered.select(:id) + ``` + +3. Check that the section exists in `settings/providers/show.html.erb`: + ```erb + <%= settings_section title: "My Bank" do %> + + <%= render "settings/providers/my_bank_panel" %> + + <% end %> + ``` + +### Form Not Submitting + +1. Check routes are properly added: + ```bash + rails routes | grep my_bank + ``` + +2. Check turbo frame ID matches: + - View: `` + - Controller: Uses `"my-bank-providers-panel"` in turbo_stream.replace + +### Encryption Not Working + +1. Check credentials are configured: + ```bash + rails credentials:edit + ``` + +2. Add encryption keys if missing: + ```yaml + active_record_encryption: + primary_key: (generate with: rails db:encryption:init) + deterministic_key: (generate with: rails db:encryption:init) + key_derivation_salt: (generate with: rails db:encryption:init) + ``` + +3. Or use environment variables: + ```bash + export ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY="..." + export ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY="..." + export ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT="..." + ``` + +--- + +## Advanced: Creating a Provider SDK + +For complex providers, consider creating a separate SDK class: + +```ruby +# app/models/provider/my_bank.rb +class Provider::MyBank + class AuthenticationError < StandardError; end + class RateLimitError < StandardError; end + + def initialize(api_key, base_url: "https://api.mybank.com") + @api_key = api_key + @base_url = base_url + @client = HTTP.headers( + "Authorization" => "Bearer #{api_key}", + "User-Agent" => "MyApp/1.0" + ) + end + + def get_accounts + response = @client.get("#{@base_url}/accounts") + handle_response(response) + end + + def get_transactions(account_id, start_date: nil, end_date: nil) + params = { account_id: account_id } + params[:start_date] = start_date.iso8601 if start_date + params[:end_date] = end_date.iso8601 if end_date + + response = @client.get("#{@base_url}/transactions", params: params) + handle_response(response) + end + + private + + def handle_response(response) + case response.code + when 200...300 + JSON.parse(response.body, symbolize_names: true) + when 401, 403 + raise AuthenticationError, "Invalid API key" + when 429 + raise RateLimitError, "Rate limit exceeded" + else + raise StandardError, "API error: #{response.code} #{response.body}" + end + end +end +``` + +--- + +## Summary + +The per-family `Provider` Rails generator system provides: + - ✅ **Fast development** - Generate in seconds, not hour + - ✅ **Consistency** - All providers follow the same pattern + - ✅ **Maintainability** - Clear structure and conventions + - ✅ **Flexibility** - Easy to customize for complex needs + - ✅ **Security** - Built-in encryption for sensitive fields + - ✅ **Documentation** - Self-documenting with descriptions + +Use it whenever you need to add a new provider where each family needs their own credentials. diff --git a/lib/generators/provider/family/family_generator.rb b/lib/generators/provider/family/family_generator.rb new file mode 100644 index 000000000..f652794a6 --- /dev/null +++ b/lib/generators/provider/family/family_generator.rb @@ -0,0 +1,429 @@ +require "rails/generators" +require "rails/generators/active_record" + +# Generator for creating per-family provider integrations +# +# Usage: +# rails g provider:family NAME field:type:secret field:type ... +# +# Examples: +# rails g provider:family lunchflow api_key:text:secret base_url:string +# rails g provider:family my_bank access_token:text:secret refresh_token:text:secret +# +# Field format: +# name:type[:secret] +# - name: Field name (e.g., api_key) +# - type: Database column type (text, string, integer, boolean) +# - secret: Optional flag indicating this field should be encrypted +# +# This generates: +# - Migration creating complete provider_items and provider_accounts tables +# - Models for items, accounts, and provided concern +# - Adapter class +# - Manual panel view for provider settings +# - Simple controller for CRUD operations +# - Routes +class Provider::FamilyGenerator < Rails::Generators::NamedBase + include Rails::Generators::Migration + + source_root File.expand_path("templates", __dir__) + + argument :fields, type: :array, default: [], banner: "field:type[:secret] field:type[:secret]" + + class_option :skip_migration, type: :boolean, default: false, desc: "Skip generating migration" + class_option :skip_routes, type: :boolean, default: false, desc: "Skip adding routes" + class_option :skip_view, type: :boolean, default: false, desc: "Skip generating view" + class_option :skip_controller, type: :boolean, default: false, desc: "Skip generating controller" + class_option :skip_adapter, type: :boolean, default: false, desc: "Skip generating adapter" + + def validate_fields + if parsed_fields.empty? + say "Warning: No fields specified. You'll need to add them manually later.", :yellow + end + + # Validate field types + parsed_fields.each do |field| + unless %w[text string integer boolean].include?(field[:type]) + raise Thor::Error, "Invalid field type '#{field[:type]}' for #{field[:name]}. Must be one of: text, string, integer, boolean" + end + end + end + + def generate_migration + return if options[:skip_migration] + + migration_template "migration.rb.tt", + "db/migrate/create_#{table_name}_and_accounts.rb", + migration_version: migration_version + end + + def create_adapter + return if options[:skip_adapter] + + adapter_path = "app/models/provider/#{file_name}_adapter.rb" + + if File.exist?(adapter_path) + say "Adapter already exists: #{adapter_path}", :skip + else + # Create new adapter + template "adapter.rb.tt", adapter_path + say "Created new adapter: #{adapter_path}", :green + end + end + + def create_models + # Create item model + item_model_path = "app/models/#{file_name}_item.rb" + if File.exist?(item_model_path) + say "Item model already exists: #{item_model_path}", :skip + else + template "item_model.rb.tt", item_model_path + say "Created item model: #{item_model_path}", :green + end + + # Create account model + account_model_path = "app/models/#{file_name}_account.rb" + if File.exist?(account_model_path) + say "Account model already exists: #{account_model_path}", :skip + else + template "account_model.rb.tt", account_model_path + say "Created account model: #{account_model_path}", :green + end + + # Create Provided concern + provided_concern_path = "app/models/#{file_name}_item/provided.rb" + if File.exist?(provided_concern_path) + say "Provided concern already exists: #{provided_concern_path}", :skip + else + template "provided_concern.rb.tt", provided_concern_path + say "Created Provided concern: #{provided_concern_path}", :green + end + + # Create Unlinking concern + unlinking_concern_path = "app/models/#{file_name}_item/unlinking.rb" + if File.exist?(unlinking_concern_path) + say "Unlinking concern already exists: #{unlinking_concern_path}", :skip + else + template "unlinking_concern.rb.tt", unlinking_concern_path + say "Created Unlinking concern: #{unlinking_concern_path}", :green + end + + # Create Family Connectable concern + connectable_concern_path = "app/models/family/#{file_name}_connectable.rb" + if File.exist?(connectable_concern_path) + say "Connectable concern already exists: #{connectable_concern_path}", :skip + else + template "connectable_concern.rb.tt", connectable_concern_path + say "Created Connectable concern: #{connectable_concern_path}", :green + end + end + + def update_family_model + family_model_path = "app/models/family.rb" + return unless File.exist?(family_model_path) + + content = File.read(family_model_path) + connectable_module = "#{class_name}Connectable" + + # Check if already included + if content.include?(connectable_module) + say "Family model already includes #{connectable_module}", :skip + else + # Insert a new include line after the class declaration + # This approach is more robust than trying to append to an existing include line + lines = content.lines + class_line_index = nil + + lines.each_with_index do |line, index| + if line =~ /^\s*class\s+Family\s*<\s*ApplicationRecord/ + class_line_index = index + break + end + end + + if class_line_index + # Find the indentation used in the file (check next non-empty line) + indentation = " " # default + ((class_line_index + 1)...lines.length).each do |i| + if lines[i] =~ /^(\s+)\S/ + indentation = ::Regexp.last_match(1) + break + end + end + + # Insert include line right after the class declaration + new_include_line = "#{indentation}include #{connectable_module}\n" + lines.insert(class_line_index + 1, new_include_line) + + File.write(family_model_path, lines.join) + say "Added #{connectable_module} to Family model", :green + else + say "Could not find class declaration in Family model, please add manually: include #{connectable_module}", :yellow + end + end + end + + def create_panel_view + return if options[:skip_view] + + # Create a simple manual panel view + template "panel.html.erb.tt", + "app/views/settings/providers/_#{file_name}_panel.html.erb" + end + + def create_controller + return if options[:skip_controller] + + controller_path = "app/controllers/#{file_name}_items_controller.rb" + + if File.exist?(controller_path) + say "Controller already exists: #{controller_path}", :skip + else + # Create new controller + template "controller.rb.tt", controller_path + say "Created new controller: #{controller_path}", :green + end + end + + def add_routes + return if options[:skip_routes] + + route_content = <<~RUBY.strip + resources :#{file_name}_items, only: [:index, :new, :create, :show, :edit, :update, :destroy] do + collection do + get :preload_accounts + get :select_accounts + post :link_accounts + get :select_existing_account + post :link_existing_account + end + + member do + post :sync + get :setup_accounts + post :complete_account_setup + end + end + RUBY + + # Check if routes already exist + routes_file = "config/routes.rb" + if File.read(routes_file).include?("resources :#{file_name}_items") + say "Routes already exist for :#{file_name}_items", :skip + else + route route_content + say "Added routes for :#{file_name}_items", :green + end + end + + def update_settings_controller + controller_path = "app/controllers/settings/providers_controller.rb" + return unless File.exist?(controller_path) + + content = File.read(controller_path) + new_condition = "config.provider_key.to_s.casecmp(\"#{file_name}\").zero?" + + # Check if provider is already excluded + if content.include?(new_condition) + say "Settings controller already excludes #{file_name}", :skip + return + end + + # Add to the rejection list in prepare_show_context + # Look for the end of the reject block and insert before it + if content.include?("reject do |config|") + # Find the reject block's end and insert our condition before it + # The block ends with "end" on its own line after the conditions + lines = content.lines + reject_block_start = nil + reject_block_end = nil + + lines.each_with_index do |line, index| + if line.include?("Provider::ConfigurationRegistry.all.reject do |config|") + reject_block_start = index + elsif reject_block_start && line.strip == "end" && reject_block_end.nil? + reject_block_end = index + break + end + end + + if reject_block_start && reject_block_end + # Find the last condition line (the one before 'end') + last_condition_index = reject_block_end - 1 + + # Get indentation from the last condition line + last_condition_line = lines[last_condition_index] + indentation = last_condition_line[/^\s*/] + + # Append our condition with || to the last condition line + # Remove trailing whitespace/newline, add || and new condition + lines[last_condition_index] = last_condition_line.rstrip + " || \\\n#{indentation}#{new_condition}\n" + + File.write(controller_path, lines.join) + say "Added #{file_name} to provider exclusion list", :green + else + say "Could not find reject block boundaries in settings controller", :yellow + end + elsif content.include?("@provider_configurations = Provider::ConfigurationRegistry.all") + # No reject block exists yet, create one + gsub_file controller_path, + "@provider_configurations = Provider::ConfigurationRegistry.all\n", + "@provider_configurations = Provider::ConfigurationRegistry.all.reject do |config|\n #{new_condition}\n end\n" + say "Created provider exclusion block with #{file_name}", :green + else + say "Could not find provider_configurations assignment in settings controller", :yellow + end + + # Re-read content after potential modifications + content = File.read(controller_path) + + # Add instance variable for items + items_var = "@#{file_name}_items" + unless content.include?(items_var) + # Find the last @*_items assignment line and insert after it + lines = content.lines + last_items_index = nil + + lines.each_with_index do |line, index| + if line =~ /@\w+_items = Current\.family\.\w+_items/ + last_items_index = index + end + end + + if last_items_index + # Get indentation from the found line + indentation = lines[last_items_index][/^\s*/] + new_line = "#{indentation}#{items_var} = Current.family.#{file_name}_items.ordered.select(:id)\n" + lines.insert(last_items_index + 1, new_line) + File.write(controller_path, lines.join) + say "Added #{items_var} instance variable", :green + else + say "Could not find existing @*_items assignments, please add manually: #{items_var} = Current.family.#{file_name}_items.ordered.select(:id)", :yellow + end + end + end + + def update_providers_view + return if options[:skip_view] + + view_path = "app/views/settings/providers/show.html.erb" + return unless File.exist?(view_path) + + content = File.read(view_path) + + # Check if section already exists + if content.include?("\"#{file_name}-providers-panel\"") + say "Providers view already has #{class_name} section", :skip + else + # Add section before the last closing div (at end of file) + section_content = <<~ERB + + <%%= settings_section title: "#{class_name}", collapsible: true, open: false do %> + + <%%= render "settings/providers/#{file_name}_panel" %> + + <%% end %> + ERB + + # Insert before the final
at the end of file + insert_into_file view_path, section_content, before: /^<\/div>\s*\z/ + say "Added #{class_name} section to providers view", :green + end + end + + def show_summary + say "\n" + "=" * 80, :green + say "Successfully generated per-family provider: #{class_name}", :green + say "=" * 80, :green + + say "\nGenerated files:", :cyan + say " 📋 Migration: db/migrate/xxx_create_#{table_name}_and_accounts.rb" + say " 📦 Models:" + say " - app/models/#{file_name}_item.rb" + say " - app/models/#{file_name}_account.rb" + say " - app/models/#{file_name}_item/provided.rb" + say " - app/models/#{file_name}_item/unlinking.rb" + say " - app/models/family/#{file_name}_connectable.rb" + say " 🔌 Adapter: app/models/provider/#{file_name}_adapter.rb" + say " 🎮 Controller: app/controllers/#{file_name}_items_controller.rb" + say " 🖼️ View: app/views/settings/providers/_#{file_name}_panel.html.erb" + say " 🛣️ Routes: Updated config/routes.rb" + say " ⚙️ Settings: Updated controllers, views, and Family model" + + if parsed_fields.any? + say "\nCredential fields:", :cyan + parsed_fields.each do |field| + secret_flag = field[:secret] ? " 🔒 (encrypted)" : "" + default_flag = field[:default] ? " [default: #{field[:default]}]" : "" + say " - #{field[:name]}: #{field[:type]}#{secret_flag}#{default_flag}" + end + end + + say "\nDatabase tables created:", :cyan + say " - #{table_name} (stores per-family credentials)" + say " - #{file_name}_accounts (stores individual account data)" + + say "\nNext steps:", :yellow + say " 1. Run migrations:" + say " rails db:migrate" + say "" + say " 2. Implement the provider SDK in:" + say " app/models/provider/#{file_name}.rb" + say "" + say " 3. Update #{class_name}Item::Provided concern:" + say " app/models/#{file_name}_item/provided.rb" + say " Implement the #{file_name}_provider method" + say "" + say " 4. Customize the adapter's build_provider method:" + say " app/models/provider/#{file_name}_adapter.rb" + say "" + say " 5. Add any custom business logic:" + say " - Import methods in #{class_name}Item" + say " - Processing logic for accounts" + say " - Sync strategies" + say "" + say " 6. Test the integration:" + say " Visit /settings/providers and configure credentials" + say "" + say " 📚 See docs/PER_FAMILY_PROVIDER_GUIDE.md for detailed documentation" + end + + # Required for Rails::Generators::Migration + def self.next_migration_number(dirname) + ActiveRecord::Generators::Base.next_migration_number(dirname) + end + + private + + def table_name + "#{file_name}_items" + end + + def migration_version + "[#{Rails::VERSION::MAJOR}.#{Rails::VERSION::MINOR}]" + end + + def parsed_fields + @parsed_fields ||= fields.map do |field_def| + # Handle default values with colons (like URLs) by extracting them first + # Format: field:type[:secret][:default=value] + default_match = field_def.match(/default=(.+)$/) + default_value = nil + if default_match + default_value = default_match[1] + # Remove the default part for further parsing + field_def = field_def.sub(/:?default=.+$/, "") + end + + parts = field_def.split(":") + field = { + name: parts[0], + type: parts[1] || "string", + secret: parts.include?("secret"), + default: default_value + } + + field + end + end +end diff --git a/lib/generators/provider/family/templates/account_model.rb.tt b/lib/generators/provider/family/templates/account_model.rb.tt new file mode 100644 index 000000000..d61f00758 --- /dev/null +++ b/lib/generators/provider/family/templates/account_model.rb.tt @@ -0,0 +1,52 @@ +class <%= class_name %>Account < ApplicationRecord + include CurrencyNormalizable + + belongs_to :<%= file_name %>_item + + # New association through account_providers + has_one :account_provider, as: :provider, dependent: :destroy + has_one :account, through: :account_provider, source: :account + has_one :linked_account, through: :account_provider, source: :account + + validates :name, :currency, presence: true + + # Helper to get account using account_providers system + def current_account + account + end + + def upsert_<%= file_name %>_snapshot!(account_snapshot) + # Convert to symbol keys or handle both string and symbol keys + snapshot = account_snapshot.with_indifferent_access + + # Map <%= class_name %> field names to our field names + # TODO: Customize this mapping based on your provider's API response + update!( + current_balance: snapshot[:balance] || snapshot[:current_balance], + currency: parse_currency(snapshot[:currency]) || "USD", + name: snapshot[:name], + account_id: snapshot[:id]&.to_s, + account_status: snapshot[:status], + provider: snapshot[:provider], + institution_metadata: { + name: snapshot[:institution_name], + logo: snapshot[:institution_logo] + }.compact, + raw_payload: account_snapshot + ) + end + + def upsert_<%= file_name %>_transactions_snapshot!(transactions_snapshot) + assign_attributes( + raw_transactions_payload: transactions_snapshot + ) + + save! + end + + private + + def log_invalid_currency(currency_value) + Rails.logger.warn("Invalid currency code '#{currency_value}' for <%= class_name %> account #{id}, defaulting to USD") + end +end diff --git a/lib/generators/provider/family/templates/adapter.rb.tt b/lib/generators/provider/family/templates/adapter.rb.tt new file mode 100644 index 000000000..fd7075034 --- /dev/null +++ b/lib/generators/provider/family/templates/adapter.rb.tt @@ -0,0 +1,114 @@ +class Provider::<%= class_name %>Adapter < Provider::Base + include Provider::Syncable + include Provider::InstitutionMetadata + + # Register this adapter with the factory + Provider::Factory.register("<%= class_name %>Account", self) + + # Define which account types this provider supports + def self.supported_account_types + %w[Depository CreditCard Loan] + end + + # Returns connection configurations for this provider + def self.connection_configs(family:) + return [] unless family.can_connect_<%= file_name %>? + + [ { + key: "<%= file_name %>", + name: "<%= class_name.titleize %>", + description: "Connect to your bank via <%= class_name.titleize %>", + can_connect: true, + new_account_path: ->(accountable_type, return_to) { + Rails.application.routes.url_helpers.select_accounts_<%= file_name %>_items_path( + accountable_type: accountable_type, + return_to: return_to + ) + }, + existing_account_path: ->(account_id) { + Rails.application.routes.url_helpers.select_existing_account_<%= file_name %>_items_path( + account_id: account_id + ) + } + } ] + end + + def provider_name + "<%= file_name %>" + end + + # Build a <%= class_name %> provider instance with family-specific credentials + # @param family [Family] The family to get credentials for (required) + # @return [Provider::<%= class_name %>, nil] Returns nil if credentials are not configured + def self.build_provider(family: nil) + return nil unless family.present? + + # Get family-specific credentials +<% first_secret_field = parsed_fields.find { |f| f[:secret] }&.dig(:name) -%> +<% if first_secret_field -%> + <%= file_name %>_item = family.<%= file_name %>_items.where.not(<%= first_secret_field %>: nil).first +<% else -%> + <%= file_name %>_item = family.<%= file_name %>_items.first +<% end -%> + return nil unless <%= file_name %>_item&.credentials_configured? + + # TODO: Implement provider initialization +<% if first_secret_field -%> + # Provider::<%= class_name %>.new( + # <%= file_name %>_item.<%= first_secret_field %><%= parsed_fields.select { |f| f[:default] }.any? ? ",\n # " + parsed_fields.select { |f| f[:default] }.map { |f| "#{f[:name]}: #{file_name}_item.effective_#{f[:name]}" }.join(",\n # ") : "" %> + # ) +<% else -%> + # Provider::<%= class_name %>.new(<%= file_name %>_item) +<% end -%> + raise NotImplementedError, "Implement Provider::<%= class_name %>.new in #{__FILE__}" + end + + def sync_path + Rails.application.routes.url_helpers.sync_<%= file_name %>_item_path(item) + end + + def item + provider_account.<%= file_name %>_item + end + + def can_delete_holdings? + false + end + + def institution_domain + metadata = provider_account.institution_metadata + return nil unless metadata.present? + + domain = metadata["domain"] + url = metadata["url"] + + # Derive domain from URL if missing + if domain.blank? && url.present? + begin + domain = URI.parse(url).host&.gsub(/^www\./, "") + rescue URI::InvalidURIError + Rails.logger.warn("Invalid institution URL for <%= class_name %> account #{provider_account.id}: #{url}") + end + end + + domain + end + + def institution_name + metadata = provider_account.institution_metadata + return nil unless metadata.present? + + metadata["name"] || item&.institution_name + end + + def institution_url + metadata = provider_account.institution_metadata + return nil unless metadata.present? + + metadata["url"] || item&.institution_url + end + + def institution_color + item&.institution_color + end +end diff --git a/lib/generators/provider/family/templates/connectable_concern.rb.tt b/lib/generators/provider/family/templates/connectable_concern.rb.tt new file mode 100644 index 000000000..d504ad074 --- /dev/null +++ b/lib/generators/provider/family/templates/connectable_concern.rb.tt @@ -0,0 +1,37 @@ +module Family::<%= class_name %>Connectable + extend ActiveSupport::Concern + + included do + has_many :<%= file_name %>_items, dependent: :destroy + end + + def can_connect_<%= file_name %>? + # Families can configure their own <%= class_name %> credentials + true + end + +<% + # Build method parameters: required params (secrets) come first, then optional params + required_params = parsed_fields.select { |f| f[:secret] }.map { |f| "#{f[:name]}:" } + optional_params = parsed_fields.reject { |f| f[:secret] }.map { |f| "#{f[:name]}: nil" } + all_params = (required_params + optional_params + ["item_name: nil"]).join(", ") +-%> + def create_<%= file_name %>_item!(<%= all_params %>) + <%= file_name %>_item = <%= file_name %>_items.create!( + name: item_name || "<%= class_name.titleize %> Connection"<%= parsed_fields.map { |f| ",\n #{f[:name]}: #{f[:name]}" }.join("") %> + ) + + <%= file_name %>_item.sync_later + + <%= file_name %>_item + end + + def has_<%= file_name %>_credentials? +<% primary_secret = parsed_fields.find { |f| f[:secret] }&.dig(:name) -%> +<% if primary_secret -%> + <%= file_name %>_items.where.not(<%= primary_secret %>: nil).exists? +<% else -%> + <%= file_name %>_items.exists? +<% end -%> + end +end diff --git a/lib/generators/provider/family/templates/controller.rb.tt b/lib/generators/provider/family/templates/controller.rb.tt new file mode 100644 index 000000000..cd9334d9c --- /dev/null +++ b/lib/generators/provider/family/templates/controller.rb.tt @@ -0,0 +1,153 @@ +class <%= class_name %>ItemsController < ApplicationController + before_action :set_<%= file_name %>_item, only: [:show, :edit, :update, :destroy, :sync, :setup_accounts, :complete_account_setup] + + def index + @<%= table_name %> = Current.family.<%= table_name %>.ordered + end + + def show + end + + def new + @<%= file_name %>_item = Current.family.<%= table_name %>.build + end + + def edit + end + + def create + @<%= file_name %>_item = Current.family.<%= table_name %>.build(<%= file_name %>_item_params) + @<%= file_name %>_item.name ||= "<%= class_name %> Connection" + + if @<%= file_name %>_item.save + if turbo_frame_request? + flash.now[:notice] = t(".success", default: "Successfully configured <%= class_name %>.") + @<%= table_name %> = Current.family.<%= table_name %>.ordered + render turbo_stream: [ + turbo_stream.replace( + "<%= file_name %>-providers-panel", + partial: "settings/providers/<%= file_name %>_panel", + locals: { <%= file_name %>_items: @<%= table_name %> } + ), + *flash_notification_stream_items + ] + else + redirect_to settings_providers_path, notice: t(".success"), status: :see_other + end + else + @error_message = @<%= file_name %>_item.errors.full_messages.join(", ") + + if turbo_frame_request? + render turbo_stream: turbo_stream.replace( + "<%= file_name %>-providers-panel", + partial: "settings/providers/<%= file_name %>_panel", + locals: { error_message: @error_message } + ), status: :unprocessable_entity + else + redirect_to settings_providers_path, alert: @error_message, status: :unprocessable_entity + end + end + end + + def update + if @<%= file_name %>_item.update(<%= file_name %>_item_params) + if turbo_frame_request? + flash.now[:notice] = t(".success", default: "Successfully updated <%= class_name %> configuration.") + @<%= table_name %> = Current.family.<%= table_name %>.ordered + render turbo_stream: [ + turbo_stream.replace( + "<%= file_name %>-providers-panel", + partial: "settings/providers/<%= file_name %>_panel", + locals: { <%= file_name %>_items: @<%= table_name %> } + ), + *flash_notification_stream_items + ] + else + redirect_to settings_providers_path, notice: t(".success"), status: :see_other + end + else + @error_message = @<%= file_name %>_item.errors.full_messages.join(", ") + + if turbo_frame_request? + render turbo_stream: turbo_stream.replace( + "<%= file_name %>-providers-panel", + partial: "settings/providers/<%= file_name %>_panel", + locals: { error_message: @error_message } + ), status: :unprocessable_entity + else + redirect_to settings_providers_path, alert: @error_message, status: :unprocessable_entity + end + end + end + + def destroy + @<%= file_name %>_item.destroy_later + redirect_to settings_providers_path, notice: t(".success", default: "Scheduled <%= class_name %> connection for deletion.") + end + + def sync + unless @<%= file_name %>_item.syncing? + @<%= file_name %>_item.sync_later + end + + respond_to do |format| + format.html { redirect_back_or_to accounts_path } + format.json { head :ok } + end + end + + # Collection actions for account linking flow + # TODO: Implement these when you have the provider SDK ready + + def preload_accounts + # TODO: Fetch accounts from provider API and cache them + redirect_to settings_providers_path, alert: "Not implemented yet" + end + + def select_accounts + # TODO: Show UI to select which accounts to link + @accountable_type = params[:accountable_type] + @return_to = params[:return_to] + redirect_to settings_providers_path, alert: "Not implemented yet" + end + + def link_accounts + # TODO: Link selected accounts + redirect_to settings_providers_path, alert: "Not implemented yet" + end + + def select_existing_account + # TODO: Show UI to link an existing account to provider + @account_id = params[:account_id] + redirect_to settings_providers_path, alert: "Not implemented yet" + end + + def link_existing_account + # TODO: Link an existing account to a provider account + redirect_to settings_providers_path, alert: "Not implemented yet" + end + + def setup_accounts + # TODO: Show account setup UI + redirect_to settings_providers_path, alert: "Not implemented yet" + end + + def complete_account_setup + # TODO: Complete the account setup process + redirect_to settings_providers_path, alert: "Not implemented yet" + end + + private + + def set_<%= file_name %>_item + @<%= file_name %>_item = Current.family.<%= table_name %>.find(params[:id]) + end + + def <%= file_name %>_item_params + params.require(:<%= file_name %>_item).permit( + :name, + :sync_start_date<% parsed_fields.each do |field| %>, + :<%= field[:name] %><% end %> + ) + end +end diff --git a/lib/generators/provider/family/templates/item_model.rb.tt b/lib/generators/provider/family/templates/item_model.rb.tt new file mode 100644 index 000000000..37709516f --- /dev/null +++ b/lib/generators/provider/family/templates/item_model.rb.tt @@ -0,0 +1,187 @@ +class <%= class_name %>Item < ApplicationRecord + include Syncable, Provided, Unlinking + + enum :status, { good: "good", requires_update: "requires_update" }, default: :good + + # Helper to detect if ActiveRecord Encryption is configured for this app + def self.encryption_ready? + creds_ready = Rails.application.credentials.active_record_encryption.present? + env_ready = ENV["ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY"].present? && + ENV["ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY"].present? && + ENV["ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT"].present? + creds_ready || env_ready + end + + # Encrypt sensitive credentials if ActiveRecord encryption is configured + if encryption_ready? +<% parsed_fields.select { |f| f[:secret] }.each do |field| -%> + encrypts :<%= field[:name] %>, deterministic: true +<% end -%> + end + + validates :name, presence: true +<% parsed_fields.select { |f| f[:secret] }.each do |field| -%> + validates :<%= field[:name] %>, presence: true, on: :create +<% end -%> + + belongs_to :family + has_one_attached :logo + + has_many :<%= file_name %>_accounts, dependent: :destroy + has_many :accounts, through: :<%= file_name %>_accounts + + scope :active, -> { where(scheduled_for_deletion: false) } + scope :ordered, -> { order(created_at: :desc) } + scope :needs_update, -> { where(status: :requires_update) } + + def destroy_later + update!(scheduled_for_deletion: true) + DestroyJob.perform_later(self) + end + + # TODO: Implement data import from provider API + # This method should fetch the latest data from the provider and import it. + # May need provider-specific validation (e.g., session validity checks). + # See LunchflowItem#import_latest_lunchflow_data or EnableBankingItem#import_latest_enable_banking_data for examples. + def import_latest_<%= file_name %>_data + provider = <%= file_name %>_provider + unless provider + Rails.logger.error "<%= class_name %>Item #{id} - Cannot import: provider is not configured" + raise StandardError.new("<%= class_name %> provider is not configured") + end + + # TODO: Add any provider-specific validation here (e.g., session checks) + <%= class_name %>Item::Importer.new(self, <%= file_name %>_provider: provider).import + rescue => e + Rails.logger.error "<%= class_name %>Item #{id} - Failed to import data: #{e.message}" + raise + end + + # TODO: Implement account processing logic + # This method processes linked accounts after data import. + # Customize based on your provider's data structure and processing needs. + def process_accounts + return [] if <%= file_name %>_accounts.empty? + + results = [] + <%= file_name %>_accounts.joins(:account).merge(Account.visible).each do |<%= file_name %>_account| + begin + result = <%= class_name %>Account::Processor.new(<%= file_name %>_account).process + results << { <%= file_name %>_account_id: <%= file_name %>_account.id, success: true, result: result } + rescue => e + Rails.logger.error "<%= class_name %>Item #{id} - Failed to process account #{<%= file_name %>_account.id}: #{e.message}" + results << { <%= file_name %>_account_id: <%= file_name %>_account.id, success: false, error: e.message } + end + end + + results + end + + # TODO: Customize sync scheduling if needed + # This method schedules sync jobs for all linked accounts. + def schedule_account_syncs(parent_sync: nil, window_start_date: nil, window_end_date: nil) + return [] if accounts.empty? + + results = [] + accounts.visible.each do |account| + begin + account.sync_later( + parent_sync: parent_sync, + window_start_date: window_start_date, + window_end_date: window_end_date + ) + results << { account_id: account.id, success: true } + rescue => e + Rails.logger.error "<%= class_name %>Item #{id} - Failed to schedule sync for account #{account.id}: #{e.message}" + results << { account_id: account.id, success: false, error: e.message } + end + end + + results + end + + def upsert_<%= file_name %>_snapshot!(accounts_snapshot) + assign_attributes( + raw_payload: accounts_snapshot + ) + + save! + end + + def has_completed_initial_setup? + # Setup is complete if we have any linked accounts + accounts.any? + end + + # TODO: Customize sync status summary if needed + # Some providers use latest_sync.sync_stats, others use count methods directly. + # See SimplefinItem#sync_status_summary or EnableBankingItem#sync_status_summary for examples. + def sync_status_summary + total_accounts = total_accounts_count + linked_count = linked_accounts_count + unlinked_count = unlinked_accounts_count + + if total_accounts == 0 + "No accounts found" + elsif unlinked_count == 0 + "#{linked_count} #{'account'.pluralize(linked_count)} synced" + else + "#{linked_count} synced, #{unlinked_count} need setup" + end + end + + def linked_accounts_count + <%= file_name %>_accounts.joins(:account_provider).count + end + + def unlinked_accounts_count + <%= file_name %>_accounts.left_joins(:account_provider).where(account_providers: { id: nil }).count + end + + def total_accounts_count + <%= file_name %>_accounts.count + end + + def institution_display_name + institution_name.presence || institution_domain.presence || name + end + + # TODO: Customize based on how your provider stores institution data + # SimpleFin uses org_data, others use institution_metadata. + # Adjust the field name and key lookups as needed. + def connected_institutions + <%= file_name %>_accounts.includes(:account) + .where.not(institution_metadata: nil) + .map { |acc| acc.institution_metadata } + .uniq { |inst| inst["name"] || inst["institution_name"] } + end + + # TODO: Customize institution summary if your provider has special fields + # EnableBanking uses aspsp_name as a fallback, for example. + def institution_summary + institutions = connected_institutions + case institutions.count + when 0 + "No institutions connected" + when 1 + institutions.first["name"] || institutions.first["institution_name"] || "1 institution" + else + "#{institutions.count} institutions" + end + end + + def credentials_configured? +<% if parsed_fields.select { |f| f[:secret] }.any? -%> + <%= parsed_fields.select { |f| f[:secret] }.map { |f| "#{f[:name]}.present?" }.join(" && ") %> +<% else -%> + true +<% end -%> + end + +<% parsed_fields.select { |f| f[:default] }.each do |field| -%> + def effective_<%= field[:name] %> + <%= field[:name] %>.presence || "<%= field[:default] %>" + end + +<% end -%> +end diff --git a/lib/generators/provider/family/templates/migration.rb.tt b/lib/generators/provider/family/templates/migration.rb.tt new file mode 100644 index 000000000..d61b1ea2b --- /dev/null +++ b/lib/generators/provider/family/templates/migration.rb.tt @@ -0,0 +1,62 @@ +class Create<%= class_name %>ItemsAndAccounts < ActiveRecord::Migration<%= migration_version %> + def change + # Create provider items table (stores per-family connection credentials) + create_table :<%= table_name %>, id: :uuid do |t| + t.references :family, null: false, foreign_key: true, type: :uuid + t.string :name + + # Institution metadata + t.string :institution_id + t.string :institution_name + t.string :institution_domain + t.string :institution_url + t.string :institution_color + + # Status and lifecycle + t.string :status, default: "good" + t.boolean :scheduled_for_deletion, default: false + t.boolean :pending_account_setup, default: false + + # Sync settings + t.datetime :sync_start_date + + # Raw data storage + t.jsonb :raw_payload + t.jsonb :raw_institution_payload + + # Provider-specific credential fields +<% parsed_fields.each do |field| -%> + t.<%= field[:type] %> :<%= field[:name] %> +<% end -%> + + t.timestamps + end + + add_index :<%= table_name %>, :status + + # Create provider accounts table (stores individual account data from provider) + create_table :<%= file_name %>_accounts, id: :uuid do |t| + t.references :<%= file_name %>_item, null: false, foreign_key: true, type: :uuid + + # Account identification + t.string :name + t.string :account_id + + # Account details + t.string :currency + t.decimal :current_balance, precision: 19, scale: 4 + t.string :account_status + t.string :account_type + t.string :provider + + # Metadata and raw data + t.jsonb :institution_metadata + t.jsonb :raw_payload + t.jsonb :raw_transactions_payload + + t.timestamps + end + + add_index :<%= file_name %>_accounts, :account_id + end +end diff --git a/lib/generators/provider/family/templates/panel.html.erb.tt b/lib/generators/provider/family/templates/panel.html.erb.tt new file mode 100644 index 000000000..a3c94e25f --- /dev/null +++ b/lib/generators/provider/family/templates/panel.html.erb.tt @@ -0,0 +1,71 @@ +
+
+

Setup instructions:

+
    +
  1. Visit your <%= class_name.titleize %> dashboard to get your credentials
  2. +
  3. Enter your credentials below and click the Save button
  4. +
  5. After a successful connection, go to the Accounts tab to set up new accounts
  6. +
+ +

Field descriptions:

+ +
+ + <%% error_msg = local_assigns[:error_message] || @error_message %> + <%% if error_msg.present? %> +
+

<%%= error_msg %>

+
+ <%% end %> + + <%% + # Get or initialize a <%= file_name %>_item for this family + # - If family has an item WITH credentials, use it (for updates) + # - If family has an item WITHOUT credentials, use it (to add credentials) + # - If family has no items at all, create a new one + <%= file_name %>_item = Current.family.<%= file_name %>_items.first_or_initialize(name: "<%= class_name.titleize %> Connection") + is_new_record = <%= file_name %>_item.new_record? + %> + + <%%= styled_form_with model: <%= file_name %>_item, + url: is_new_record ? <%= file_name %>_items_path : <%= file_name %>_item_path(<%= file_name %>_item), + scope: :<%= file_name %>_item, + method: is_new_record ? :post : :patch, + data: { turbo: true }, + class: "space-y-3" do |form| %> +<% parsed_fields.each do |field| -%> + <%%= form.<%= %w[text string].include?(field[:type]) ? 'text_field' : 'number_field' %> :<%= field[:name] %>, + label: "<%= field[:name].titleize %><%= field[:default] ? ' (Optional)' : '' %>", +<% if field[:secret] -%> + placeholder: is_new_record ? "Paste <%= field[:name].humanize.downcase %> here" : "Enter new <%= field[:name].humanize.downcase %> to update", + type: :password %> +<% elsif field[:default] -%> + placeholder: "<%= field[:default] %> (default)", + value: <%= file_name %>_item.<%= field[:name] %> %> +<% else -%> + placeholder: is_new_record ? "Enter <%= field[:name].humanize.downcase %>" : "Enter new <%= field[:name].humanize.downcase %> to update", + value: <%= file_name %>_item.<%= field[:name] %> %> +<% end -%> + +<% end -%> +
+ <%%= form.submit is_new_record ? "Save Configuration" : "Update Configuration", + class: "inline-flex items-center justify-center rounded-lg px-4 py-2 text-sm font-medium text-white bg-gray-900 hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-gray-900 focus:ring-offset-2 transition-colors" %> +
+ <%% end %> + + <%% items = local_assigns[:<%= file_name %>_items] || @<%= file_name %>_items || Current.family.<%= file_name %>_items.where.not(<%= parsed_fields.select { |f| f[:secret] }.first&.dig(:name) || 'api_key' %>: [nil, ""]) %> +
+ <%% if items&.any? %> +
+

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

+ <%% else %> +
+

Not configured

+ <%% end %> +
+
diff --git a/lib/generators/provider/family/templates/provided_concern.rb.tt b/lib/generators/provider/family/templates/provided_concern.rb.tt new file mode 100644 index 000000000..c193b0da5 --- /dev/null +++ b/lib/generators/provider/family/templates/provided_concern.rb.tt @@ -0,0 +1,11 @@ +module <%= class_name %>Item::Provided + extend ActiveSupport::Concern + + def <%= file_name %>_provider + return nil unless credentials_configured? + + # TODO: Implement provider instantiation + # Provider::<%= class_name %>.new(<%= parsed_fields.select { |f| f[:secret] }.first&.dig(:name) || 'api_key' %><%= parsed_fields.select { |f| f[:default] }.any? ? ", " + parsed_fields.select { |f| f[:default] }.map { |f| "#{f[:name]}: effective_#{f[:name]}" }.join(", ") : "" %>) + raise NotImplementedError, "Implement <%= file_name %>_provider method in #{__FILE__}" + end +end diff --git a/lib/generators/provider/family/templates/unlinking_concern.rb.tt b/lib/generators/provider/family/templates/unlinking_concern.rb.tt new file mode 100644 index 000000000..d2c3bb1c7 --- /dev/null +++ b/lib/generators/provider/family/templates/unlinking_concern.rb.tt @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +module <%= class_name %>Item::Unlinking + # Concern that encapsulates unlinking logic for a <%= class_name %> item. + extend ActiveSupport::Concern + + # Idempotently remove all connections between this <%= class_name %> item and local accounts. + # - Detaches any AccountProvider links for each <%= class_name %>Account + # - Detaches Holdings that point at the AccountProvider links + # Returns a per-account result payload for observability + def unlink_all!(dry_run: false) + results = [] + + <%= file_name %>_accounts.find_each do |provider_account| + links = AccountProvider.where(provider_type: "<%= class_name %>Account", provider_id: provider_account.id).to_a + link_ids = links.map(&:id) + result = { + provider_account_id: provider_account.id, + name: provider_account.name, + provider_link_ids: link_ids + } + results << result + + next if dry_run + + begin + ActiveRecord::Base.transaction do + # Detach holdings for any provider links found + if link_ids.any? + Holding.where(account_provider_id: link_ids).update_all(account_provider_id: nil) + end + + # Destroy all provider links + links.each do |ap| + ap.destroy! + end + end + rescue StandardError => e + Rails.logger.warn( + "<%= class_name %>Item Unlinker: failed to fully unlink provider account ##{provider_account.id} (links=#{link_ids.inspect}): #{e.class} - #{e.message}" + ) + # Record error for observability; continue with other accounts + result[:error] = e.message + end + end + + results + end +end diff --git a/lib/generators/provider/global/global_generator.rb b/lib/generators/provider/global/global_generator.rb new file mode 100644 index 000000000..838b137ca --- /dev/null +++ b/lib/generators/provider/global/global_generator.rb @@ -0,0 +1,242 @@ +require "rails/generators" +require "rails/generators/active_record" + +# Generator for creating global provider integrations +# +# Usage: +# rails g provider:global NAME field:type[:secret] field:type ... +# +# Examples: +# rails g provider:global plaid client_id:string:secret secret:string:secret environment:string +# rails g provider:global openai api_key:string:secret model:string +# +# Field format: +# name:type[:secret][:default=value] +# - name: Field name (e.g., api_key) +# - type: Database column type (text, string, integer, boolean) +# - secret: Optional flag indicating this field should be masked in UI +# - default: Optional default value (e.g., default=sandbox) +# +# This generates: +# - Migration creating provider_items and provider_accounts tables (WITHOUT credential fields) +# - Models for items, accounts, and provided concern +# - Adapter class with Provider::Configurable (credentials stored globally in settings table) +# +# Key difference from provider:family: +# - Credentials stored in `settings` table (global, shared by all families) +# - Item/account tables store connections per family (but not credentials) +# - No controller/view/routes needed (configuration via /settings/providers) +class Provider::GlobalGenerator < Rails::Generators::NamedBase + include Rails::Generators::Migration + + source_root File.expand_path("templates", __dir__) + + argument :fields, type: :array, default: [], banner: "field:type[:secret][:default=value] field:type[:secret]" + + class_option :skip_migration, type: :boolean, default: false, desc: "Skip generating migration" + class_option :skip_models, type: :boolean, default: false, desc: "Skip generating models" + class_option :skip_adapter, type: :boolean, default: false, desc: "Skip generating adapter" + + def validate_fields + if parsed_fields.empty? + raise Thor::Error, "At least one credential field is required. Example: api_key:text:secret" + end + + # Validate field types + parsed_fields.each do |field| + unless %w[text string integer boolean].include?(field[:type]) + raise Thor::Error, "Invalid field type '#{field[:type]}' for #{field[:name]}. Must be one of: text, string, integer, boolean" + end + end + end + + def generate_migration + return if options[:skip_migration] + + migration_template "global_migration.rb.tt", + "db/migrate/create_#{table_name}_and_accounts.rb", + migration_version: migration_version + end + + def create_models + return if options[:skip_models] + + # Create item model + item_model_path = "app/models/#{file_name}_item.rb" + if File.exist?(item_model_path) + say "Item model already exists: #{item_model_path}", :skip + else + template "global_item_model.rb.tt", item_model_path + say "Created item model: #{item_model_path}", :green + end + + # Create account model + account_model_path = "app/models/#{file_name}_account.rb" + if File.exist?(account_model_path) + say "Account model already exists: #{account_model_path}", :skip + else + template "global_account_model.rb.tt", account_model_path + say "Created account model: #{account_model_path}", :green + end + + # Create Provided concern + provided_concern_path = "app/models/#{file_name}_item/provided.rb" + if File.exist?(provided_concern_path) + say "Provided concern already exists: #{provided_concern_path}", :skip + else + template "global_provided_concern.rb.tt", provided_concern_path + say "Created Provided concern: #{provided_concern_path}", :green + end + end + + def create_adapter + return if options[:skip_adapter] + + adapter_path = "app/models/provider/#{file_name}_adapter.rb" + + if File.exist?(adapter_path) + say "Adapter already exists: #{adapter_path}", :skip + else + template "global_adapter.rb.tt", adapter_path + say "Created adapter: #{adapter_path}", :green + end + end + + def show_summary + say "\n" + "=" * 80, :green + say "Successfully generated global provider: #{class_name}", :green + say "=" * 80, :green + + say "\nGenerated files:", :cyan + say " 📋 Migration: db/migrate/xxx_create_#{table_name}_and_accounts.rb" + say " 📦 Models:" + say " - app/models/#{file_name}_item.rb" + say " - app/models/#{file_name}_account.rb" + say " - app/models/#{file_name}_item/provided.rb" + say " 🔌 Adapter: app/models/provider/#{file_name}_adapter.rb" + + if parsed_fields.any? + say "\nGlobal credential fields (stored in settings table):", :cyan + parsed_fields.each do |field| + secret_flag = field[:secret] ? " 🔒 (secret, masked in UI)" : "" + default_flag = field[:default] ? " [default: #{field[:default]}]" : "" + env_flag = " [ENV: #{field[:env_key]}]" + say " - #{field[:name]}: #{field[:type]}#{secret_flag}#{default_flag}#{env_flag}" + end + end + + say "\nDatabase tables created:", :cyan + say " - #{table_name} (stores per-family connections, NO credentials)" + say " - #{file_name}_accounts (stores individual account data)" + + say "\n⚠️ Global Provider Pattern:", :yellow + say " - Credentials stored GLOBALLY in 'settings' table" + say " - All families share the same credentials" + say " - Configuration UI auto-generated at /settings/providers" + say " - Only available in self-hosted mode" + + say "\nNext steps:", :yellow + say " 1. Run migrations:" + say " rails db:migrate" + say "" + say " 2. Implement the provider SDK in:" + say " app/models/provider/#{file_name}.rb" + say "" + say " 3. Update #{class_name}Item::Provided concern:" + say " app/models/#{file_name}_item/provided.rb" + say " Implement the #{file_name}_provider method" + say "" + say " 4. Customize the adapter:" + say " app/models/provider/#{file_name}_adapter.rb" + say " - Update configure block descriptions" + say " - Implement reload_configuration if needed" + say " - Implement build_provider method" + say "" + say " 5. Configure credentials:" + say " Visit /settings/providers (self-hosted mode only)" + say " Or set ENV variables:" + parsed_fields.each do |field| + say " export #{field[:env_key]}=\"your_value\"" + end + say "" + say " 6. Add item creation flow:" + say " - Users connect their #{class_name} account" + say " - Creates #{class_name}Item with family association" + say " - Syncs accounts using global credentials" + say "" + say " 📚 See PROVIDER_ARCHITECTURE.md for global provider documentation" + end + + # Required for Rails::Generators::Migration + def self.next_migration_number(dirname) + ActiveRecord::Generators::Base.next_migration_number(dirname) + end + + private + + def table_name + "#{file_name}_items" + end + + def migration_version + "[#{Rails::VERSION::MAJOR}.#{Rails::VERSION::MINOR}]" + end + + def parsed_fields + @parsed_fields ||= fields.map do |field_def| + parts = field_def.split(":") + name = parts[0] + type = parts[1] || "string" + secret = parts.include?("secret") + default = extract_default(parts) + + { + name: name, + type: type, + secret: secret, + default: default, + env_key: "#{file_name.upcase}_#{name.upcase}" + } + end + end + + def extract_default(parts) + default_part = parts.find { |p| p.start_with?("default=") } + default_part&.sub("default=", "") + end + + def configure_block_content + return "" if parsed_fields.empty? + + fields_code = parsed_fields.map do |field| + field_attrs = [ + "label: \"#{field[:name].titleize}\"", + ("required: true" if field[:secret]), + ("secret: true" if field[:secret]), + "env_key: \"#{field[:env_key]}\"", + ("default: \"#{field[:default]}\"" if field[:default]), + "description: \"Your #{class_name} #{field[:name].humanize.downcase}\"" + ].compact.join(",\n ") + + " field :#{field[:name]},\n #{field_attrs}\n" + end.join("\n") + + <<~RUBY + + configure do + description <<~DESC + Setup instructions for #{class_name}: + 1. Visit your #{class_name} dashboard to get your credentials + 2. Enter your credentials below + 3. These credentials will be used by all families (global configuration) + + **Note:** This is a global configuration for self-hosted mode only. + In managed mode, credentials are configured by the platform operator. + DESC + + #{fields_code} + end + + RUBY + end +end diff --git a/lib/generators/provider/global/templates/global_account_model.rb.tt b/lib/generators/provider/global/templates/global_account_model.rb.tt new file mode 100644 index 000000000..69eae09a5 --- /dev/null +++ b/lib/generators/provider/global/templates/global_account_model.rb.tt @@ -0,0 +1,52 @@ +class <%= class_name %>Account < ApplicationRecord + include CurrencyNormalizable + + belongs_to :<%= file_name %>_item + + # Association through account_providers for linking to internal accounts + has_one :account_provider, as: :provider, dependent: :destroy + has_one :account, through: :account_provider, source: :account + has_one :linked_account, through: :account_provider, source: :account + + validates :name, :currency, presence: true + + # Helper to get account using account_providers system + def current_account + account + end + + def upsert_<%= file_name %>_snapshot!(account_snapshot) + # Convert to symbol keys or handle both string and symbol keys + snapshot = account_snapshot.with_indifferent_access + + # Map <%= class_name %> field names to our field names + # TODO: Customize this mapping based on your provider's API response + update!( + current_balance: snapshot[:balance] || snapshot[:current_balance], + currency: parse_currency(snapshot[:currency]) || "USD", + name: snapshot[:name], + account_id: snapshot[:id]&.to_s, + account_status: snapshot[:status], + provider: snapshot[:provider], + institution_metadata: { + name: snapshot[:institution_name], + logo: snapshot[:institution_logo] + }.compact, + raw_payload: account_snapshot + ) + end + + def upsert_<%= file_name %>_transactions_snapshot!(transactions_snapshot) + assign_attributes( + raw_transactions_payload: transactions_snapshot + ) + + save! + end + + private + + def log_invalid_currency(currency_value) + Rails.logger.warn("Invalid currency code '#{currency_value}' for <%= class_name %> account #{id}, defaulting to USD") + end +end diff --git a/lib/generators/provider/global/templates/global_adapter.rb.tt b/lib/generators/provider/global/templates/global_adapter.rb.tt new file mode 100644 index 000000000..ed526d6aa --- /dev/null +++ b/lib/generators/provider/global/templates/global_adapter.rb.tt @@ -0,0 +1,105 @@ +class Provider::<%= class_name %>Adapter < Provider::Base + include Provider::Syncable + include Provider::InstitutionMetadata + include Provider::Configurable + + # Register this adapter with the factory + Provider::Factory.register("<%= class_name %>Account", self) +<%= configure_block_content %> + def provider_name + "<%= file_name %>" + end + + # Thread-safe lazy loading of <%= class_name %> configuration + def self.ensure_configuration_loaded + # Fast path: return immediately if already loaded (no lock needed) + return if Rails.application.config.<%= file_name %>.present? + + # Slow path: acquire lock and reload if still needed + @config_mutex ||= Mutex.new + @config_mutex.synchronize do + return if Rails.application.config.<%= file_name %>.present? + reload_configuration + end + end + + # Reload <%= class_name %> configuration when settings are updated + def self.reload_configuration +<% parsed_fields.each do |field| -%> + <%= field[:name] %> = config_value(:<%= field[:name] %>).presence || ENV["<%= field[:env_key] %>"]<% if field[:default] %> || "<%= field[:default] %>"<% end %> +<% end -%> + +<% first_required_field = parsed_fields.find { |f| f[:secret] } -%> +<% if first_required_field -%> + if <%= first_required_field[:name] %>.present? +<% end -%> + Rails.application.config.<%= file_name %> = OpenStruct.new( +<% parsed_fields.each_with_index do |field, index| -%> + <%= field[:name] %>: <%= field[:name] %><%= index < parsed_fields.length - 1 ? ',' : '' %> +<% end -%> + ) +<% if first_required_field -%> + else + Rails.application.config.<%= file_name %> = nil + end +<% end -%> + end + + # Build a <%= class_name %> provider instance using GLOBAL credentials + # @return [Provider::<%= class_name %>, nil] Returns nil if credentials are not configured + def self.build_provider +<% first_secret_field = parsed_fields.find { |f| f[:secret] } -%> +<% if first_secret_field -%> + <%= first_secret_field[:name] %> = config_value(:<%= first_secret_field[:name] %>) + return nil unless <%= first_secret_field[:name] %>.present? +<% end -%> + + # TODO: Implement provider initialization + # Provider::<%= class_name %>.new( +<% parsed_fields.each_with_index do |field, index| -%> + # <%= field[:name] %>: config_value(:<%= field[:name] %>)<%= index < parsed_fields.length - 1 ? ',' : '' %> +<% end -%> + # ) + raise NotImplementedError, "Implement build_provider in #{__FILE__}" + end + + def sync_path + Rails.application.routes.url_helpers.sync_<%= file_name %>_item_path(item) + end + + def item + provider_account.<%= file_name %>_item + end + + def can_delete_holdings? + false + end + + def institution_domain + # TODO: Implement institution domain extraction + metadata = provider_account.institution_metadata + return nil unless metadata.present? + + metadata["domain"] + end + + def institution_name + # TODO: Implement institution name extraction + metadata = provider_account.institution_metadata + return nil unless metadata.present? + + metadata["name"] || item&.institution_name + end + + def institution_url + # TODO: Implement institution URL extraction + metadata = provider_account.institution_metadata + return nil unless metadata.present? + + metadata["url"] || item&.institution_url + end + + def institution_color + item&.institution_color + end +end diff --git a/lib/generators/provider/global/templates/global_item_model.rb.tt b/lib/generators/provider/global/templates/global_item_model.rb.tt new file mode 100644 index 000000000..acd300921 --- /dev/null +++ b/lib/generators/provider/global/templates/global_item_model.rb.tt @@ -0,0 +1,33 @@ +class <%= class_name %>Item < ApplicationRecord + include Syncable, Provided + + enum :status, { good: "good", requires_update: "requires_update" }, default: :good + + validates :name, presence: true + + belongs_to :family + has_one_attached :logo + + has_many :<%= file_name %>_accounts, dependent: :destroy + has_many :accounts, through: :<%= file_name %>_accounts + + scope :active, -> { where(scheduled_for_deletion: false) } + scope :ordered, -> { order(created_at: :desc) } + scope :needs_update, -> { where(status: :requires_update) } + + def destroy_later + update!(scheduled_for_deletion: true) + DestroyJob.perform_later(self) + end + + # NOTE: This is a GLOBAL provider + # Credentials are configured globally in /settings/providers (self-hosted mode) + # or via environment variables + # This model stores the per-family connection, but not credentials + + # TODO: Implement provider-specific methods + # - import_latest_<%= file_name %>_data + # - process_accounts + # - schedule_account_syncs + # - See <%= class_name %>Item::Provided for provider instantiation +end diff --git a/lib/generators/provider/global/templates/global_migration.rb.tt b/lib/generators/provider/global/templates/global_migration.rb.tt new file mode 100644 index 000000000..b34a36938 --- /dev/null +++ b/lib/generators/provider/global/templates/global_migration.rb.tt @@ -0,0 +1,61 @@ +class Create<%= class_name %>ItemsAndAccounts < ActiveRecord::Migration<%= migration_version %> + def change + # Create provider items table (stores per-family connections) + # NOTE: Credentials are stored GLOBALLY in the 'settings' table via Provider::Configurable + # This table only stores connection metadata per family + create_table :<%= table_name %>, id: :uuid do |t| + t.references :family, null: false, foreign_key: true, type: :uuid + t.string :name + + # Institution metadata + t.string :institution_id + t.string :institution_name + t.string :institution_domain + t.string :institution_url + t.string :institution_color + + # Status and lifecycle + t.string :status, default: "good" + t.boolean :scheduled_for_deletion, default: false + t.boolean :pending_account_setup, default: false + + # Sync settings + t.datetime :sync_start_date + + # Raw data storage + t.jsonb :raw_payload + t.jsonb :raw_institution_payload + + # NO credential fields here - they're in the settings table! + + t.timestamps + end + + add_index :<%= table_name %>, :status + + # Create provider accounts table (stores individual account data from provider) + create_table :<%= file_name %>_accounts, id: :uuid do |t| + t.references :<%= file_name %>_item, null: false, foreign_key: true, type: :uuid + + # Account identification + t.string :name + t.string :account_id + + # Account details + t.string :currency + t.decimal :current_balance, precision: 19, scale: 4 + t.string :account_status + t.string :account_type + t.string :provider + + # Metadata and raw data + t.jsonb :institution_metadata + t.jsonb :raw_payload + t.jsonb :raw_transactions_payload + + t.timestamps + end + + add_index :<%= file_name %>_accounts, :account_id + end +end diff --git a/lib/generators/provider/global/templates/global_provided_concern.rb.tt b/lib/generators/provider/global/templates/global_provided_concern.rb.tt new file mode 100644 index 000000000..aab460551 --- /dev/null +++ b/lib/generators/provider/global/templates/global_provided_concern.rb.tt @@ -0,0 +1,10 @@ +module <%= class_name %>Item::Provided + extend ActiveSupport::Concern + + # Returns a <%= class_name %> provider instance using GLOBAL credentials + # Credentials are configured in /settings/providers (self-hosted) or ENV variables + def <%= file_name %>_provider + # Use the adapter's build_provider method which reads from global settings + Provider::<%= class_name %>Adapter.build_provider + end +end