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 %> +
Unable to connect to Lunch Flow
+<%= error_message %>
+Common Issues:
+API Key Not Configured
+Before you can link Lunch Flow accounts, you need to configure your Lunch Flow API key.
+Setup Steps:
+Setup instructions:
+Field descriptions:
+Configured and ready to use. Visit the Accounts tab to manage and set up accounts.
+Setup instructions:
Field descriptions:
Configured and ready to use. Visit the Accounts tab to manage and set up accounts.
<% else %> -