From be0b20dfd9958353f681381e43d5e4f115e40401 Mon Sep 17 00:00:00 2001 From: soky srm Date: Sat, 22 Nov 2025 02:14:29 +0100 Subject: [PATCH] Lunchflow settings family (#363) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Move provider config to family * remove global settings * Remove turbo auto submit * Fix flash location * Fix mssing syncer for lunchflow * Update schema.rb * FIX tests and encryption config * FIX make rabbit happy * FIX run migration in SQL * FIX turbo frame modal * Branding fixes * FIX rabbit * OCD with product names * More OCD * No other console.log|warn in codebase --------- Co-authored-by: Juan José Mata --- app/controllers/lunchflow_items_controller.rb | 145 ++++++++++++++++-- .../settings/providers_controller.rb | 9 +- app/controllers/simplefin_items_controller.rb | 14 +- .../lunchflow_preload_controller.js | 16 +- app/models/family/lunchflow_connectable.rb | 20 ++- app/models/family/syncer.rb | 2 +- app/models/lunchflow_item.rb | 23 +++ app/models/lunchflow_item/importer.rb | 4 +- app/models/lunchflow_item/provided.rb | 4 +- app/models/plaid_item.rb | 15 +- app/models/provider/lunchflow.rb | 4 +- app/models/provider/lunchflow_adapter.rb | 50 ++---- app/models/provider/simplefin_adapter.rb | 17 -- app/models/simplefin_item.rb | 12 +- app/views/lunchflow_items/_api_error.html.erb | 35 +++++ .../lunchflow_items/_setup_required.html.erb | 34 ++++ .../providers/_lunchflow_panel.html.erb | 62 ++++++++ .../providers/_provider_form.html.erb | 14 +- .../providers/_simplefin_panel.html.erb | 20 ++- app/views/settings/providers/show.html.erb | 8 +- ...0028_add_credentials_to_lunchflow_items.rb | 6 + ...lobal_lunchflow_credentials_to_families.rb | 41 +++++ db/schema.rb | 11 +- lib/tasks/data_migration.rake | 76 +++++++++ .../settings/providers_controller_test.rb | 17 +- 25 files changed, 532 insertions(+), 127 deletions(-) create mode 100644 app/views/lunchflow_items/_api_error.html.erb create mode 100644 app/views/lunchflow_items/_setup_required.html.erb create mode 100644 app/views/settings/providers/_lunchflow_panel.html.erb create mode 100644 db/migrate/20251121120028_add_credentials_to_lunchflow_items.rb create mode 100644 db/migrate/20251121140453_migrate_global_lunchflow_credentials_to_families.rb diff --git a/app/controllers/lunchflow_items_controller.rb b/app/controllers/lunchflow_items_controller.rb index 9ba1d2646..98c6e0e0e 100644 --- a/app/controllers/lunchflow_items_controller.rb +++ b/app/controllers/lunchflow_items_controller.rb @@ -12,6 +12,12 @@ class LunchflowItemsController < ApplicationController # Preload Lunchflow accounts in background (async, non-blocking) def preload_accounts begin + # Check if family has credentials + unless Current.family.has_lunchflow_credentials? + render json: { success: false, error: "no_credentials", has_accounts: false } + return + end + cache_key = "lunchflow_accounts_#{Current.family.id}" # Check if already cached @@ -23,7 +29,7 @@ class LunchflowItemsController < ApplicationController end # Fetch from API - lunchflow_provider = Provider::LunchflowAdapter.build_provider + lunchflow_provider = Provider::LunchflowAdapter.build_provider(family: Current.family) unless lunchflow_provider.present? render json: { success: false, error: "no_api_key", has_accounts: false } @@ -39,16 +45,32 @@ class LunchflowItemsController < ApplicationController render json: { success: true, has_accounts: available_accounts.any?, cached: false } rescue Provider::Lunchflow::LunchflowError => e Rails.logger.error("Lunchflow preload error: #{e.message}") - render json: { success: false, error: e.message, has_accounts: false } + # API error (bad key, network issue, etc) - keep button visible, show error when clicked + render json: { success: false, error: "api_error", error_message: e.message, has_accounts: nil } rescue StandardError => e Rails.logger.error("Unexpected error preloading Lunchflow accounts: #{e.class}: #{e.message}") - render json: { success: false, error: "unexpected_error", has_accounts: false } + # Unexpected error - keep button visible, show error when clicked + render json: { success: false, error: "unexpected_error", error_message: e.message, has_accounts: nil } end end # Fetch available accounts from Lunchflow API and show selection UI def select_accounts begin + # Check if family has Lunchflow credentials configured + unless Current.family.has_lunchflow_credentials? + if turbo_frame_request? + # Render setup modal for turbo frame requests + render partial: "lunchflow_items/setup_required", layout: false + else + # Redirect for regular requests + redirect_to settings_providers_path, + alert: t(".no_credentials_configured", + default: "Please configure your Lunch Flow API key first in Provider Settings.") + end + return + end + cache_key = "lunchflow_accounts_#{Current.family.id}" # Try to get cached accounts first @@ -56,10 +78,11 @@ class LunchflowItemsController < ApplicationController # If not cached, fetch from API if @available_accounts.nil? - lunchflow_provider = Provider::LunchflowAdapter.build_provider + lunchflow_provider = Provider::LunchflowAdapter.build_provider(family: Current.family) unless lunchflow_provider.present? - redirect_to new_account_path, alert: t(".no_api_key") + redirect_to settings_providers_path, alert: t(".no_api_key", + default: "Lunch Flow API key not found. Please configure it in Provider Settings.") return end @@ -71,6 +94,13 @@ class LunchflowItemsController < ApplicationController Rails.cache.write(cache_key, @available_accounts, expires_in: 5.minutes) end + # Filter out already linked accounts + lunchflow_item = Current.family.lunchflow_items.first + if lunchflow_item + linked_account_ids = lunchflow_item.lunchflow_accounts.joins(:account_provider).pluck(:account_id) + @available_accounts = @available_accounts.reject { |acc| linked_account_ids.include?(acc[:id].to_s) } + end + @accountable_type = params[:accountable_type] || "Depository" @return_to = safe_return_to_path @@ -81,7 +111,19 @@ class LunchflowItemsController < ApplicationController render layout: false rescue Provider::Lunchflow::LunchflowError => e - redirect_to new_account_path, alert: t(".api_error", message: e.message) + Rails.logger.error("Lunch flow API error in select_accounts: #{e.message}") + @error_message = e.message + @return_path = safe_return_to_path + render partial: "lunchflow_items/api_error", + locals: { error_message: @error_message, return_path: @return_path }, + layout: false + rescue StandardError => e + Rails.logger.error("Unexpected error in select_accounts: #{e.class}: #{e.message}") + @error_message = "An unexpected error occurred. Please try again later." + @return_path = safe_return_to_path + render partial: "lunchflow_items/api_error", + locals: { error_message: @error_message, return_path: @return_path }, + layout: false end end @@ -102,7 +144,7 @@ class LunchflowItemsController < ApplicationController ) # Fetch account details from API - lunchflow_provider = Provider::LunchflowAdapter.build_provider + lunchflow_provider = Provider::LunchflowAdapter.build_provider(family: Current.family) unless lunchflow_provider.present? redirect_to new_account_path, alert: t(".no_api_key") return @@ -210,6 +252,20 @@ class LunchflowItemsController < ApplicationController return end + # Check if family has Lunchflow credentials configured + unless Current.family.has_lunchflow_credentials? + if turbo_frame_request? + # Render setup modal for turbo frame requests + render partial: "lunchflow_items/setup_required", layout: false + else + # Redirect for regular requests + redirect_to settings_providers_path, + alert: t(".no_credentials_configured", + default: "Please configure your Lunch Flow API key first in Provider Settings.") + end + return + end + begin cache_key = "lunchflow_accounts_#{Current.family.id}" @@ -218,10 +274,11 @@ class LunchflowItemsController < ApplicationController # If not cached, fetch from API if @available_accounts.nil? - lunchflow_provider = Provider::LunchflowAdapter.build_provider + lunchflow_provider = Provider::LunchflowAdapter.build_provider(family: Current.family) unless lunchflow_provider.present? - redirect_to accounts_path, alert: t(".no_api_key") + redirect_to settings_providers_path, alert: t(".no_api_key", + default: "Lunch Flow API key not found. Please configure it in Provider Settings.") return end @@ -254,7 +311,17 @@ class LunchflowItemsController < ApplicationController render layout: false rescue Provider::Lunchflow::LunchflowError => e - redirect_to accounts_path, alert: t(".api_error", message: e.message) + Rails.logger.error("Lunch flow API error in select_existing_account: #{e.message}") + @error_message = e.message + render partial: "lunchflow_items/api_error", + locals: { error_message: @error_message, return_path: accounts_path }, + layout: false + rescue StandardError => e + Rails.logger.error("Unexpected error in select_existing_account: #{e.class}: #{e.message}") + @error_message = "An unexpected error occurred. Please try again later." + render partial: "lunchflow_items/api_error", + locals: { error_message: @error_message, return_path: accounts_path }, + layout: false end end @@ -283,7 +350,7 @@ class LunchflowItemsController < ApplicationController ) # Fetch account details from API - lunchflow_provider = Provider::LunchflowAdapter.build_provider + lunchflow_provider = Provider::LunchflowAdapter.build_provider(family: Current.family) unless lunchflow_provider.present? redirect_to accounts_path, alert: t(".no_api_key") return @@ -338,16 +405,38 @@ class LunchflowItemsController < ApplicationController def create @lunchflow_item = Current.family.lunchflow_items.build(lunchflow_params) - @lunchflow_item.name = "Lunch Flow Connection" + @lunchflow_item.name ||= "Lunch Flow Connection" if @lunchflow_item.save # Trigger initial sync to fetch accounts @lunchflow_item.sync_later - redirect_to accounts_path, notice: t(".success") + if turbo_frame_request? + flash.now[:notice] = t(".success") + @lunchflow_items = Current.family.lunchflow_items.ordered + render turbo_stream: [ + turbo_stream.replace( + "lunchflow-providers-panel", + partial: "settings/providers/lunchflow_panel", + locals: { lunchflow_items: @lunchflow_items } + ), + *flash_notification_stream_items + ] + else + redirect_to accounts_path, notice: t(".success"), status: :see_other + end else @error_message = @lunchflow_item.errors.full_messages.join(", ") - render :new, status: :unprocessable_entity + + if turbo_frame_request? + render turbo_stream: turbo_stream.replace( + "lunchflow-providers-panel", + partial: "settings/providers/lunchflow_panel", + locals: { error_message: @error_message } + ), status: :unprocessable_entity + else + render :new, status: :unprocessable_entity + end end end @@ -356,10 +445,32 @@ class LunchflowItemsController < ApplicationController def update if @lunchflow_item.update(lunchflow_params) - redirect_to accounts_path, notice: t(".success") + if turbo_frame_request? + flash.now[:notice] = t(".success") + @lunchflow_items = Current.family.lunchflow_items.ordered + render turbo_stream: [ + turbo_stream.replace( + "lunchflow-providers-panel", + partial: "settings/providers/lunchflow_panel", + locals: { lunchflow_items: @lunchflow_items } + ), + *flash_notification_stream_items + ] + else + redirect_to accounts_path, notice: t(".success"), status: :see_other + end else @error_message = @lunchflow_item.errors.full_messages.join(", ") - render :edit, status: :unprocessable_entity + + if turbo_frame_request? + render turbo_stream: turbo_stream.replace( + "lunchflow-providers-panel", + partial: "settings/providers/lunchflow_panel", + locals: { error_message: @error_message } + ), status: :unprocessable_entity + else + render :edit, status: :unprocessable_entity + end end end @@ -385,7 +496,7 @@ class LunchflowItemsController < ApplicationController end def lunchflow_params - params.require(:lunchflow_item).permit(:name, :sync_start_date) + params.require(:lunchflow_item).permit(:name, :sync_start_date, :api_key, :base_url) end # Sanitize return_to parameter to prevent XSS attacks diff --git a/app/controllers/settings/providers_controller.rb b/app/controllers/settings/providers_controller.rb index d4a93ca87..19b9116c2 100644 --- a/app/controllers/settings/providers_controller.rb +++ b/app/controllers/settings/providers_controller.rb @@ -120,11 +120,14 @@ class Settings::ProvidersController < ApplicationController # Prepares instance vars needed by the show view and partials def prepare_show_context - # Load all provider configurations (exclude SimpleFin, which has its own unified panel below) + # Load all provider configurations (exclude SimpleFin and Lunchflow, which have their own family-specific panels below) Provider::Factory.ensure_adapters_loaded - @provider_configurations = Provider::ConfigurationRegistry.all.reject { |config| config.provider_key.to_s.casecmp("simplefin").zero? } + @provider_configurations = Provider::ConfigurationRegistry.all.reject do |config| + config.provider_key.to_s.casecmp("simplefin").zero? || config.provider_key.to_s.casecmp("lunchflow").zero? + end - # Providers page only needs to know whether any SimpleFin connections exist + # Providers page only needs to know whether any SimpleFin/Lunchflow connections exist @simplefin_items = Current.family.simplefin_items.ordered.select(:id) + @lunchflow_items = Current.family.lunchflow_items.ordered.select(:id) end end diff --git a/app/controllers/simplefin_items_controller.rb b/app/controllers/simplefin_items_controller.rb index ac15f555c..bc80ec0d7 100644 --- a/app/controllers/simplefin_items_controller.rb +++ b/app/controllers/simplefin_items_controller.rb @@ -93,12 +93,16 @@ class SimplefinItemsController < ApplicationController ) if turbo_frame_request? + flash.now[:notice] = t(".success") @simplefin_items = Current.family.simplefin_items.ordered - render turbo_stream: turbo_stream.replace( - "simplefin-providers-panel", - partial: "settings/providers/simplefin_panel", - locals: { simplefin_items: @simplefin_items } - ) + render turbo_stream: [ + turbo_stream.replace( + "simplefin-providers-panel", + partial: "settings/providers/simplefin_panel", + locals: { simplefin_items: @simplefin_items } + ), + *flash_notification_stream_items + ] else redirect_to accounts_path, notice: t(".success"), status: :see_other end diff --git a/app/javascript/controllers/lunchflow_preload_controller.js b/app/javascript/controllers/lunchflow_preload_controller.js index 6a328d0c9..38d05cd33 100644 --- a/app/javascript/controllers/lunchflow_preload_controller.js +++ b/app/javascript/controllers/lunchflow_preload_controller.js @@ -52,13 +52,23 @@ export default class extends Controller { if (this.hasLinkTarget) { this.hideLoading(); } - } else if (!data.has_accounts) { - // No accounts available, hide the link entirely + } else if (data.error === "no_credentials") { + // No credentials configured - keep link visible so user can see setup message + if (this.hasLinkTarget) { + this.hideLoading(); + } + } else if (data.has_accounts === false) { + // Credentials configured and API works, but no accounts available - hide the link if (this.hasLinkTarget) { this.linkTarget.style.display = "none"; } + } else if (data.has_accounts === null || data.error === "api_error" || data.error === "unexpected_error") { + // API error (bad credentials, network issue, etc) - keep link visible, user will see error when clicked + if (this.hasLinkTarget) { + this.hideLoading(); + } } else { - // Error occurred + // Other error - keep link visible if (this.hasLinkTarget) { this.hideLoading(); } diff --git a/app/models/family/lunchflow_connectable.rb b/app/models/family/lunchflow_connectable.rb index bfc2e2047..972c61e35 100644 --- a/app/models/family/lunchflow_connectable.rb +++ b/app/models/family/lunchflow_connectable.rb @@ -6,7 +6,23 @@ module Family::LunchflowConnectable end def can_connect_lunchflow? - # Check if the API key is configured - Provider::LunchflowAdapter.configured? + # Families can now configure their own Lunchflow credentials + true + end + + def create_lunchflow_item!(api_key:, base_url: nil, item_name: nil) + lunchflow_item = lunchflow_items.create!( + name: item_name || "Lunch Flow Connection", + api_key: api_key, + base_url: base_url + ) + + lunchflow_item.sync_later + + lunchflow_item + end + + def has_lunchflow_credentials? + lunchflow_items.where.not(api_key: nil).exists? end end diff --git a/app/models/family/syncer.rb b/app/models/family/syncer.rb index 2f120f81d..a91bbdbcd 100644 --- a/app/models/family/syncer.rb +++ b/app/models/family/syncer.rb @@ -26,6 +26,6 @@ class Family::Syncer private def child_syncables - family.plaid_items + family.simplefin_items.active + family.accounts.manual + family.plaid_items + family.simplefin_items.active + family.lunchflow_items.active + family.accounts.manual end end diff --git a/app/models/lunchflow_item.rb b/app/models/lunchflow_item.rb index 960b380b8..584605473 100644 --- a/app/models/lunchflow_item.rb +++ b/app/models/lunchflow_item.rb @@ -3,7 +3,22 @@ class LunchflowItem < ApplicationRecord 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 (credentials OR env vars) + if encryption_ready? + encrypts :api_key, deterministic: true + end + validates :name, presence: true + validates :api_key, presence: true, on: :create belongs_to :family has_one_attached :logo @@ -146,4 +161,12 @@ class LunchflowItem < ApplicationRecord "#{institutions.count} institutions" end end + + def credentials_configured? + api_key.present? + end + + def effective_base_url + base_url.presence || "https://lunchflow.app/api/v1" + end end diff --git a/app/models/lunchflow_item/importer.rb b/app/models/lunchflow_item/importer.rb index 56104c157..54310e66e 100644 --- a/app/models/lunchflow_item/importer.rb +++ b/app/models/lunchflow_item/importer.rb @@ -100,10 +100,10 @@ class LunchflowItem::Importer Rails.logger.error "LunchflowItem::Importer - Failed to update item status: #{update_error.message}" end end - Rails.logger.error "LunchflowItem::Importer - Lunchflow API error: #{e.message}" + Rails.logger.error "LunchflowItem::Importer - Lunch flow API error: #{e.message}" return nil rescue JSON::ParserError => e - Rails.logger.error "LunchflowItem::Importer - Failed to parse Lunchflow API response: #{e.message}" + Rails.logger.error "LunchflowItem::Importer - Failed to parse Lunch flow API response: #{e.message}" return nil rescue => e Rails.logger.error "LunchflowItem::Importer - Unexpected error fetching accounts: #{e.class} - #{e.message}" diff --git a/app/models/lunchflow_item/provided.rb b/app/models/lunchflow_item/provided.rb index 2daa5e5d3..82d7c140d 100644 --- a/app/models/lunchflow_item/provided.rb +++ b/app/models/lunchflow_item/provided.rb @@ -2,6 +2,8 @@ module LunchflowItem::Provided extend ActiveSupport::Concern def lunchflow_provider - Provider::LunchflowAdapter.build_provider + return nil unless credentials_configured? + + Provider::Lunchflow.new(api_key, base_url: effective_base_url) end end diff --git a/app/models/plaid_item.rb b/app/models/plaid_item.rb index 85c4b5ca0..c60c8421c 100644 --- a/app/models/plaid_item.rb +++ b/app/models/plaid_item.rb @@ -4,11 +4,22 @@ class PlaidItem < ApplicationRecord enum :plaid_region, { us: "us", eu: "eu" } enum :status, { good: "good", requires_update: "requires_update" }, default: :good - if Rails.application.credentials.active_record_encryption.present? + # 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 (credentials OR env vars) + if encryption_ready? encrypts :access_token, deterministic: true end - validates :name, :access_token, presence: true + validates :name, presence: true + validates :access_token, presence: true, on: :create before_destroy :remove_plaid_item diff --git a/app/models/provider/lunchflow.rb b/app/models/provider/lunchflow.rb index 17668507c..dfd5f5109 100644 --- a/app/models/provider/lunchflow.rb +++ b/app/models/provider/lunchflow.rb @@ -1,7 +1,7 @@ class Provider::Lunchflow include HTTParty - headers "User-Agent" => "Sure Finance Lunchflow Client" + headers "User-Agent" => "Sure Finance Lunch Flow Client" default_options.merge!(verify: true, ssl_verify_mode: OpenSSL::SSL::VERIFY_PEER, timeout: 120) attr_reader :api_key, :base_url @@ -94,7 +94,7 @@ class Provider::Lunchflow JSON.parse(response.body, symbolize_names: true) when 400 Rails.logger.error "Lunch Flow API: Bad request - #{response.body}" - raise LunchflowError.new("Bad request to Lunchflow API: #{response.body}", :bad_request) + raise LunchflowError.new("Bad request to Lunch Flow API: #{response.body}", :bad_request) when 401 raise LunchflowError.new("Invalid API key", :unauthorized) when 403 diff --git a/app/models/provider/lunchflow_adapter.rb b/app/models/provider/lunchflow_adapter.rb index 17b342a12..8cd2bb69d 100644 --- a/app/models/provider/lunchflow_adapter.rb +++ b/app/models/provider/lunchflow_adapter.rb @@ -1,55 +1,29 @@ class Provider::LunchflowAdapter < Provider::Base include Provider::Syncable include Provider::InstitutionMetadata - include Provider::Configurable # Register this adapter with the factory Provider::Factory.register("LunchflowAccount", self) - # Configuration for Lunch Flow - configure do - description <<~DESC - Setup instructions: - 1. Visit [Lunch Flow](https://www.lunchflow.app) to get your API key - 2. Enter your API key below to enable Lunch Flow bank data sync - 3. Choose the appropriate environment (production or staging) - DESC - - field :api_key, - label: "API Key", - required: true, - secret: true, - env_key: "LUNCHFLOW_API_KEY", - description: "Your Lunch Flow API key for authentication" - - field :base_url, - label: "Base URL", - required: false, - env_key: "LUNCHFLOW_BASE_URL", - default: "https://lunchflow.app/api/v1", - description: "Base URL for Lunch Flow API" - end - def provider_name "lunchflow" end - # Build a Lunch Flow provider instance with configured credentials + # Build a Lunch Flow provider instance with family-specific credentials + # Lunchflow is now fully per-family - no global credentials supported + # @param family [Family] The family to get credentials for (required) # @return [Provider::Lunchflow, nil] Returns nil if API key is not configured - def self.build_provider - api_key = config_value(:api_key) - return nil unless api_key.present? + def self.build_provider(family: nil) + return nil unless family.present? - base_url = config_value(:base_url).presence || "https://lunchflow.app/api/v1" - Provider::Lunchflow.new(api_key, base_url: base_url) - end + # Get family-specific credentials + lunchflow_item = family.lunchflow_items.where.not(api_key: nil).first + return nil unless lunchflow_item&.credentials_configured? - # Reload Lunchflow configuration when settings are updated - def self.reload_configuration - # Lunch Flow doesn't need to configure Rails.application.config like Plaid does - # The configuration is read dynamically via config_value(:api_key) and config_value(:base_url) - # This method exists to be called by the settings controller after updates - # No action needed here since values are fetched on-demand + Provider::Lunchflow.new( + lunchflow_item.api_key, + base_url: lunchflow_item.effective_base_url + ) end def sync_path diff --git a/app/models/provider/simplefin_adapter.rb b/app/models/provider/simplefin_adapter.rb index 2a89fc89a..9cd758347 100644 --- a/app/models/provider/simplefin_adapter.rb +++ b/app/models/provider/simplefin_adapter.rb @@ -1,27 +1,10 @@ class Provider::SimplefinAdapter < Provider::Base include Provider::Syncable include Provider::InstitutionMetadata - include Provider::Configurable # Register this adapter with the factory Provider::Factory.register("SimplefinAccount", self) - # Configuration for SimpleFIN - configure do - description <<~DESC - Setup instructions: - 1. Visit [SimpleFIN Bridge](https://bridge.simplefin.org/simplefin/create) to get a setup token - 2. This token is optional and only needed if you want to provide a default setup token for users - DESC - - field :setup_token, - label: "Setup Token", - required: false, - secret: true, - env_key: "SIMPLEFIN_SETUP_TOKEN", - description: "Optional: SimpleFIN setup token from your SimpleFIN Bridge account (one-time use)" - end - def provider_name "simplefin" end diff --git a/app/models/simplefin_item.rb b/app/models/simplefin_item.rb index e6044b9d8..cfad9c19d 100644 --- a/app/models/simplefin_item.rb +++ b/app/models/simplefin_item.rb @@ -7,10 +7,6 @@ class SimplefinItem < ApplicationRecord # Virtual attribute for the setup token form field attr_accessor :setup_token - if Rails.application.credentials.active_record_encryption.present? - encrypts :access_url, deterministic: true - end - # Helper to detect if ActiveRecord Encryption is configured for this app def self.encryption_ready? creds_ready = Rails.application.credentials.active_record_encryption.present? @@ -20,7 +16,13 @@ class SimplefinItem < ApplicationRecord creds_ready || env_ready end - validates :name, :access_url, presence: true + # Encrypt sensitive credentials if ActiveRecord encryption is configured (credentials OR env vars) + if encryption_ready? + encrypts :access_url, deterministic: true + end + + validates :name, presence: true + validates :access_url, presence: true, on: :create before_destroy :remove_simplefin_item diff --git a/app/views/lunchflow_items/_api_error.html.erb b/app/views/lunchflow_items/_api_error.html.erb new file mode 100644 index 000000000..1a1b49b3e --- /dev/null +++ b/app/views/lunchflow_items/_api_error.html.erb @@ -0,0 +1,35 @@ +<%# locals: (error_message:, return_path:) %> +<%= turbo_frame_tag "modal" do %> + <%= render DS::Dialog.new do |dialog| %> + <% dialog.with_header(title: "Lunch Flow Connection Error") %> + <% dialog.with_body do %> +
+
+ <%= icon("alert-circle", class: "text-destructive w-5 h-5 shrink-0 mt-0.5") %> +
+

Unable to connect to Lunch Flow

+

<%= error_message %>

+
+
+ +
+

Common Issues:

+
    +
  • Invalid API Key: Check your API key in Provider Settings
  • +
  • Expired Credentials: Generate a new API key from Lunch Flow
  • +
  • Network Issue: Check your internet connection
  • +
  • Service Down: Lunch Flow API may be temporarily unavailable
  • +
+
+ +
+ <%= link_to settings_providers_path, + 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", + data: { turbo: false } do %> + Check Provider Settings + <% end %> +
+
+ <% end %> + <% end %> +<% end %> diff --git a/app/views/lunchflow_items/_setup_required.html.erb b/app/views/lunchflow_items/_setup_required.html.erb new file mode 100644 index 000000000..6a717dcae --- /dev/null +++ b/app/views/lunchflow_items/_setup_required.html.erb @@ -0,0 +1,34 @@ +<%= turbo_frame_tag "modal" do %> + <%= render DS::Dialog.new do |dialog| %> + <% dialog.with_header(title: "Lunch Flow Setup Required") %> + <% dialog.with_body do %> +
+
+ <%= icon("alert-circle", class: "text-warning w-5 h-5 shrink-0 mt-0.5") %> +
+

API Key Not Configured

+

Before you can link Lunch Flow accounts, you need to configure your Lunch Flow API key.

+
+
+ +
+

Setup Steps:

+
    +
  1. Go to Settings → Bank Sync Providers
  2. +
  3. Find the Lunch Flow section
  4. +
  5. Enter your Lunch Flow API key
  6. +
  7. Return here to link your accounts
  8. +
+
+ +
+ <%= link_to settings_providers_path, + 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", + data: { turbo: false } do %> + Go to Provider Settings + <% end %> +
+
+ <% end %> + <% end %> +<% end %> diff --git a/app/views/settings/providers/_lunchflow_panel.html.erb b/app/views/settings/providers/_lunchflow_panel.html.erb new file mode 100644 index 000000000..c9380ba24 --- /dev/null +++ b/app/views/settings/providers/_lunchflow_panel.html.erb @@ -0,0 +1,62 @@ +
+
+

Setup instructions:

+
    +
  1. Visit Lunch Flow to get your API key
  2. +
  3. Paste your API key below and click the Save button
  4. +
  5. After a successful connection, go to the Accounts tab to set up new accounts and link them to your existing ones
  6. +
+ +

Field descriptions:

+
    +
  • API Key: Your Lunch Flow API key for authentication (required)
  • +
  • Base URL: Base URL for Lunch Flow API (optional, defaults to https://lunchflow.app/api/v1)
  • +
+
+ + <% error_msg = local_assigns[:error_message] || @error_message %> + <% if error_msg.present? %> +
+ <%= error_msg %> +
+ <% end %> + + <% + # 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? + %> + + <%= styled_form_with model: lunchflow_item, + url: is_new_record ? lunchflow_items_path : lunchflow_item_path(lunchflow_item), + scope: :lunchflow_item, + method: is_new_record ? :post : :patch, + data: { turbo: true }, + class: "space-y-3" do |form| %> + <%= form.text_field :api_key, + label: "API Key", + placeholder: is_new_record ? "Paste API key here" : "Enter new API key to update", + type: :password %> + + <%= form.text_field :base_url, + label: "Base URL (Optional)", + placeholder: "https://lunchflow.app/api/v1 (default)", + value: lunchflow_item.base_url %> + +
+ <%= 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[:lunchflow_items] || @lunchflow_items || Current.family.lunchflow_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 %> +
diff --git a/app/views/settings/providers/_provider_form.html.erb b/app/views/settings/providers/_provider_form.html.erb index 233acb617..1ffa40282 100644 --- a/app/views/settings/providers/_provider_form.html.erb +++ b/app/views/settings/providers/_provider_form.html.erb @@ -32,11 +32,7 @@ <%= styled_form_with model: Setting.new, url: settings_providers_path, - method: :patch, - data: { - controller: "auto-submit-form", - "auto-submit-form-trigger-event-value": "blur" - } do |form| %> + method: :patch do |form| %>
<% configuration.fields.each do |field| %> <% @@ -67,9 +63,13 @@ type: input_type, placeholder: field.default || (field.required ? "" : "Optional"), value: display_value, - disabled: disabled, - data: { "auto-submit-form-target": "auto" } %> + disabled: disabled %> <% end %> + +
+ <%= form.submit "Save 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 %> diff --git a/app/views/settings/providers/_simplefin_panel.html.erb b/app/views/settings/providers/_simplefin_panel.html.erb index 76553ee9a..089b9a249 100644 --- a/app/views/settings/providers/_simplefin_panel.html.erb +++ b/app/views/settings/providers/_simplefin_panel.html.erb @@ -2,14 +2,14 @@

Setup instructions:

    -
  1. Visit SimpleFin Bridge to get your one-time setup token
  2. -
  3. Paste the token below to enable SimpleFin bank data sync
  4. +
  5. Visit SimpleFIN Bridge to get your one-time setup token
  6. +
  7. Paste the token below and click the Save button to enable SimpleFIN bank data sync
  8. After a successful connection, go to the Accounts tab to set up new accounts and link them to your existing ones

Field descriptions:

    -
  • Setup Token: Your SimpleFin one-time setup token from SimpleFin Bridge (consumed on first use)
  • +
  • Setup Token: Your SimpleFIN one-time setup token from SimpleFIN Bridge (consumed on first use)
@@ -23,13 +23,17 @@ url: simplefin_items_path, scope: :simplefin_item, method: :post, - data: { controller: "auto-submit", action: "keydown.enter->auto-submit#submit blur->auto-submit#submit", turbo: true }, + data: { turbo: true }, class: "space-y-3" do |form| %> <%= form.text_field :setup_token, label: "Setup Token", - placeholder: "Paste SimpleFin setup token and press Enter", - type: :password, - data: { auto_submit_target: "input" } %> + placeholder: "Paste SimpleFIN setup token", + type: :password %> + +
+ <%= form.submit "Save 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 %> <% if @simplefin_items&.any? %> @@ -38,6 +42,6 @@

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

<% else %> -
No SimpleFin connections yet.
+
No SimpleFIN connections yet.
<% end %> diff --git a/app/views/settings/providers/show.html.erb b/app/views/settings/providers/show.html.erb index 59c51b6d4..c04bed239 100644 --- a/app/views/settings/providers/show.html.erb +++ b/app/views/settings/providers/show.html.erb @@ -14,7 +14,13 @@ <% end %> <% end %> - <%= settings_section title: "Simplefin" do %> + <%= settings_section title: "Lunch Flow" do %> + + <%= render "settings/providers/lunchflow_panel" %> + + <% end %> + + <%= settings_section title: "SimpleFIN" do %> <%= render "settings/providers/simplefin_panel" %> diff --git a/db/migrate/20251121120028_add_credentials_to_lunchflow_items.rb b/db/migrate/20251121120028_add_credentials_to_lunchflow_items.rb new file mode 100644 index 000000000..0a2044f60 --- /dev/null +++ b/db/migrate/20251121120028_add_credentials_to_lunchflow_items.rb @@ -0,0 +1,6 @@ +class AddCredentialsToLunchflowItems < ActiveRecord::Migration[7.2] + def change + add_column :lunchflow_items, :api_key, :text + add_column :lunchflow_items, :base_url, :string + end +end diff --git a/db/migrate/20251121140453_migrate_global_lunchflow_credentials_to_families.rb b/db/migrate/20251121140453_migrate_global_lunchflow_credentials_to_families.rb new file mode 100644 index 000000000..9d822433c --- /dev/null +++ b/db/migrate/20251121140453_migrate_global_lunchflow_credentials_to_families.rb @@ -0,0 +1,41 @@ +class MigrateGlobalLunchflowCredentialsToFamilies < ActiveRecord::Migration[7.2] + def up + # Get global Lunchflow credentials from settings table + global_api_key = execute(<<~SQL).to_a.first&.dig("value") + SELECT value FROM settings WHERE var = 'lunchflow_api_key' LIMIT 1 + SQL + + global_base_url = execute(<<~SQL).to_a.first&.dig("value") + SELECT value FROM settings WHERE var = 'lunchflow_base_url' LIMIT 1 + SQL + + # Only proceed if global API key exists + if global_api_key.present? + say "Found global Lunchflow API key, migrating to family-specific configuration..." + + # Update lunchflow_items that don't have credentials yet + rows_updated = execute(<<~SQL).cmd_tuples + UPDATE lunchflow_items + SET api_key = #{connection.quote(global_api_key)}, + base_url = #{connection.quote(global_base_url)} + WHERE api_key IS NULL + SQL + + say "Migrated credentials to #{rows_updated} lunchflow_items" + + # Remove global settings as they're no longer used + execute("DELETE FROM settings WHERE var = 'lunchflow_api_key'") + execute("DELETE FROM settings WHERE var = 'lunchflow_base_url'") + + say "Removed global Lunchflow settings (now per-family)" + else + say "No global Lunchflow credentials found, skipping migration" + end + end + + def down + # This migration is not reversible because we don't know which families + # had credentials before vs. which received them from global settings + say "This migration cannot be reversed - credentials are now per-family" + end +end diff --git a/db/schema.rb b/db/schema.rb index b2f39a2d3..dd63da15c 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2025_11_15_194500) do +ActiveRecord::Schema[7.2].define(version: 2025_11_21_140453) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -39,7 +39,7 @@ ActiveRecord::Schema[7.2].define(version: 2025_11_15_194500) do t.uuid "accountable_id" t.decimal "balance", precision: 19, scale: 4 t.string "currency" - t.virtual "classification", type: :string, as: "\nCASE\n WHEN ((accountable_type)::text = ANY ((ARRAY['Loan'::character varying, 'CreditCard'::character varying, 'OtherLiability'::character varying])::text[])) THEN 'liability'::text\n ELSE 'asset'::text\nEND", stored: true + t.virtual "classification", type: :string, as: "\nCASE\n WHEN ((accountable_type)::text = ANY (ARRAY[('Loan'::character varying)::text, ('CreditCard'::character varying)::text, ('OtherLiability'::character varying)::text])) THEN 'liability'::text\n ELSE 'asset'::text\nEND", stored: true t.uuid "import_id" t.uuid "plaid_account_id" t.decimal "cash_balance", precision: 19, scale: 4, default: "0.0" @@ -503,6 +503,8 @@ ActiveRecord::Schema[7.2].define(version: 2025_11_15_194500) do t.jsonb "raw_institution_payload" t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.text "api_key" + t.string "base_url" t.index ["family_id"], name: "index_lunchflow_items_on_family_id" t.index ["status"], name: "index_lunchflow_items_on_status" end @@ -695,7 +697,8 @@ ActiveRecord::Schema[7.2].define(version: 2025_11_15_194500) do t.decimal "expected_amount_min", precision: 19, scale: 4 t.decimal "expected_amount_max", precision: 19, scale: 4 t.decimal "expected_amount_avg", precision: 19, scale: 4 - t.index ["family_id", "merchant_id", "amount", "currency"], name: "idx_recurring_txns_on_family_merchant_amount_currency", unique: true + t.index ["family_id", "merchant_id", "amount", "currency"], name: "idx_recurring_txns_merchant", unique: true, where: "(merchant_id IS NOT NULL)" + t.index ["family_id", "name", "amount", "currency"], name: "idx_recurring_txns_name", unique: true, where: "((name IS NOT NULL) AND (merchant_id IS NULL))" t.index ["family_id", "status"], name: "index_recurring_transactions_on_family_id_and_status" t.index ["family_id"], name: "index_recurring_transactions_on_family_id" t.index ["merchant_id"], name: "index_recurring_transactions_on_merchant_id" @@ -976,10 +979,12 @@ ActiveRecord::Schema[7.2].define(version: 2025_11_15_194500) do t.datetime "set_onboarding_preferences_at" t.datetime "set_onboarding_goals_at" t.string "default_account_order", default: "name_asc" + t.jsonb "preferences", default: {}, null: false t.index ["email"], name: "index_users_on_email", unique: true t.index ["family_id"], name: "index_users_on_family_id" t.index ["last_viewed_chat_id"], name: "index_users_on_last_viewed_chat_id" t.index ["otp_secret"], name: "index_users_on_otp_secret", unique: true, where: "(otp_secret IS NOT NULL)" + t.index ["preferences"], name: "index_users_on_preferences", using: :gin end create_table "valuations", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| diff --git a/lib/tasks/data_migration.rake b/lib/tasks/data_migration.rake index e2b4578a6..724d01713 100644 --- a/lib/tasks/data_migration.rake +++ b/lib/tasks/data_migration.rake @@ -170,4 +170,80 @@ namespace :data_migration do puts "✅ Balance component migration complete." end + + desc "Migrate global provider settings to family-specific" + # 2025-11-21: Move global Lunchflow API credentials to family-specific lunchflow_items + # Global settings are NO LONGER SUPPORTED as of this migration. + # This improves security and enables proper multi-tenant isolation where each family + # can have their own Lunchflow credentials instead of sharing global ones. + task migrate_provider_settings_to_family: :environment do + puts "==> Migrating global provider settings to family-specific..." + puts "NOTE: Global Lunch flow/SimpleFIN credentials are NO LONGER SUPPORTED after this migration." + puts + + # Check if global Lunchflow API key exists + global_api_key = Setting[:lunchflow_api_key] + global_base_url = Setting[:lunchflow_base_url] + + if global_api_key.blank? + puts "No global Lunchflow API key found. Nothing to migrate." + puts + puts "ℹ️ If you need to configure Lunchflow:" + puts " 1. Go to /settings/providers" + puts " 2. Configure Lunchflow credentials per-family" + puts + puts "✅ Migration complete." + return + end + + puts "Found global Lunchflow API key. Migrating to family-specific settings..." + + families_updated = 0 + families_with_existing = 0 + families_with_items = 0 + + Family.find_each do |family| + # Check if this family has any lunchflow_items + has_lunchflow_items = family.lunchflow_items.exists? + + if has_lunchflow_items + families_with_items += 1 + + # Check if any of the family's lunchflow_items already have credentials + has_credentials = family.lunchflow_items.where.not(api_key: nil).exists? + + if has_credentials + families_with_existing += 1 + puts " Family #{family.id} (#{family.name}): Already has credentials, skipping" + else + # Assign global credentials to the first lunchflow_item + lunchflow_item = family.lunchflow_items.first + lunchflow_item.update!( + api_key: global_api_key, + base_url: global_base_url + ) + families_updated += 1 + puts " Family #{family.id} (#{family.name}): Migrated credentials to lunchflow_item #{lunchflow_item.id}" + end + end + end + + puts + puts "Migration Summary:" + puts " Families with Lunchflow items: #{families_with_items}" + puts " Families with existing credentials: #{families_with_existing}" + puts " Families updated with global credentials: #{families_updated}" + puts + + if families_updated > 0 + puts "✅ Global credentials have been copied to #{families_updated} families." + puts + puts "⚠️ IMPORTANT: You should now remove the global settings:" + puts " rails runner \"Setting[:lunchflow_api_key] = nil; Setting[:lunchflow_base_url] = nil\"" + puts + puts " Global credentials are NO LONGER USED by the application." + end + + puts "✅ Provider settings migration complete." + end end diff --git a/test/controllers/settings/providers_controller_test.rb b/test/controllers/settings/providers_controller_test.rb index bc23696e1..ced1ba20b 100644 --- a/test/controllers/settings/providers_controller_test.rb +++ b/test/controllers/settings/providers_controller_test.rb @@ -87,15 +87,13 @@ class Settings::ProvidersControllerTest < ActionDispatch::IntegrationTest Setting["plaid_secret"] = nil Setting["plaid_eu_client_id"] = nil Setting["plaid_eu_secret"] = nil - Setting["simplefin_setup_token"] = nil patch settings_providers_url, params: { setting: { plaid_client_id: "plaid_client", plaid_secret: "plaid_secret", plaid_eu_client_id: "plaid_eu_client", - plaid_eu_secret: "plaid_eu_secret", - simplefin_setup_token: "simplefin_token" + plaid_eu_secret: "plaid_eu_secret" } } @@ -106,7 +104,6 @@ class Settings::ProvidersControllerTest < ActionDispatch::IntegrationTest assert_equal "plaid_secret", Setting["plaid_secret"] assert_equal "plaid_eu_client", Setting["plaid_eu_client_id"] assert_equal "plaid_eu_secret", Setting["plaid_eu_secret"] - assert_equal "simplefin_token", Setting["simplefin_setup_token"] end end @@ -202,10 +199,10 @@ class Settings::ProvidersControllerTest < ActionDispatch::IntegrationTest assert_equal "client_id_1", Setting["plaid_client_id"] assert_equal "secret_1", Setting["plaid_secret"] - # Simulate second request updating simplefin fields + # Simulate second request updating different plaid fields patch settings_providers_url, params: { setting: { - simplefin_setup_token: "token_1" + plaid_environment: "production" } } @@ -213,7 +210,7 @@ class Settings::ProvidersControllerTest < ActionDispatch::IntegrationTest assert_equal "existing_value", Setting["existing_field"] assert_equal "client_id_1", Setting["plaid_client_id"] assert_equal "secret_1", Setting["plaid_secret"] - assert_equal "token_1", Setting["simplefin_setup_token"] + assert_equal "production", Setting["plaid_environment"] end end @@ -252,14 +249,14 @@ class Settings::ProvidersControllerTest < ActionDispatch::IntegrationTest test "reloads configuration for multiple providers when updated" do with_self_hosting do - # Both providers should have their configuration reloaded + # Both Plaid providers (US and EU) should have their configuration reloaded Provider::PlaidAdapter.expects(:reload_configuration).once - Provider::SimplefinAdapter.expects(:reload_configuration).once + Provider::PlaidEuAdapter.expects(:reload_configuration).once patch settings_providers_url, params: { setting: { plaid_client_id: "plaid_client", - simplefin_setup_token: "simplefin_token" + plaid_eu_client_id: "plaid_eu_client" } }