Lunchflow settings family (#363)

* 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 <juanjo.mata@gmail.com>
This commit is contained in:
soky srm
2025-11-22 02:14:29 +01:00
committed by GitHub
parent 983fb177fc
commit be0b20dfd9
25 changed files with 532 additions and 127 deletions

View File

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

View File

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

View File

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

View File

@@ -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();
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 %>
<div class="space-y-4">
<div class="flex items-start gap-3">
<%= icon("alert-circle", class: "text-destructive w-5 h-5 shrink-0 mt-0.5") %>
<div class="text-sm">
<p class="font-medium text-primary mb-2">Unable to connect to Lunch Flow</p>
<p class="text-secondary"><%= error_message %></p>
</div>
</div>
<div class="bg-surface rounded-lg p-4 space-y-2 text-sm">
<p class="font-medium text-primary">Common Issues:</p>
<ul class="list-disc list-inside space-y-1 text-secondary">
<li><strong>Invalid API Key:</strong> Check your API key in Provider Settings</li>
<li><strong>Expired Credentials:</strong> Generate a new API key from Lunch Flow</li>
<li><strong>Network Issue:</strong> Check your internet connection</li>
<li><strong>Service Down:</strong> Lunch Flow API may be temporarily unavailable</li>
</ul>
</div>
<div class="mt-4">
<%= 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 %>
</div>
</div>
<% end %>
<% end %>
<% end %>

View File

@@ -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 %>
<div class="space-y-4">
<div class="flex items-start gap-3">
<%= icon("alert-circle", class: "text-warning w-5 h-5 shrink-0 mt-0.5") %>
<div class="text-sm text-secondary">
<p class="font-medium text-primary mb-2">API Key Not Configured</p>
<p>Before you can link Lunch Flow accounts, you need to configure your Lunch Flow API key.</p>
</div>
</div>
<div class="bg-surface rounded-lg p-4 space-y-2 text-sm">
<p class="font-medium text-primary">Setup Steps:</p>
<ol class="list-decimal list-inside space-y-1 text-secondary">
<li>Go to <strong>Settings → Bank Sync Providers</strong></li>
<li>Find the <strong>Lunch Flow</strong> section</li>
<li>Enter your Lunch Flow API key</li>
<li>Return here to link your accounts</li>
</ol>
</div>
<div class="mt-4">
<%= 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 %>
</div>
</div>
<% end %>
<% end %>
<% end %>

View File

@@ -0,0 +1,62 @@
<div class="space-y-4">
<div class="prose prose-sm text-secondary">
<p class="text-primary font-medium">Setup instructions:</p>
<ol>
<li>Visit <a href="https://www.lunchflow.app" target="_blank" rel="noopener noreferrer" class="link">Lunch Flow</a> to get your API key</li>
<li>Paste your API key below and click the Save button</li>
<li>After a successful connection, go to the Accounts tab to set up new accounts and link them to your existing ones</li>
</ol>
<p class="text-primary font-medium">Field descriptions:</p>
<ul>
<li><strong>API Key:</strong> Your Lunch Flow API key for authentication (required)</li>
<li><strong>Base URL:</strong> Base URL for Lunch Flow API (optional, defaults to https://lunchflow.app/api/v1)</li>
</ul>
</div>
<% error_msg = local_assigns[:error_message] || @error_message %>
<% if error_msg.present? %>
<div class="p-2 rounded-md bg-destructive/10 text-destructive text-sm">
<%= error_msg %>
</div>
<% end %>
<%
# Get or initialize a 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 %>
<div class="flex justify-end">
<%= form.submit is_new_record ? "Save Configuration" : "Update Configuration",
class: "inline-flex items-center justify-center rounded-lg px-4 py-2 text-sm font-medium text-white bg-gray-900 hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-gray-900 focus:ring-offset-2 transition-colors" %>
</div>
<% end %>
<% items = local_assigns[:lunchflow_items] || @lunchflow_items || Current.family.lunchflow_items.where.not(api_key: nil) %>
<% if items&.any? %>
<div class="flex items-center gap-2">
<div class="w-2 h-2 bg-success rounded-full"></div>
<p class="text-sm text-secondary">Configured and ready to use. Visit the <a href="<%= accounts_path %>" class="link">Accounts</a> tab to manage and set up accounts.</p>
</div>
<% end %>
</div>

View File

@@ -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| %>
<div class="space-y-4">
<% 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 %>
<div class="flex justify-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" %>
</div>
</div>
<% end %>

View File

@@ -2,14 +2,14 @@
<div class="prose prose-sm text-secondary">
<p class="text-primary font-medium">Setup instructions:</p>
<ol>
<li>Visit <a href="https://beta-bridge.simplefin.org" target="_blank" rel="noopener noreferrer" class="link">SimpleFin Bridge</a> to get your one-time setup token</li>
<li>Paste the token below to enable SimpleFin bank data sync</li>
<li>Visit <a href="https://beta-bridge.simplefin.org" target="_blank" rel="noopener noreferrer" class="link">SimpleFIN Bridge</a> to get your one-time setup token</li>
<li>Paste the token below and click the Save button to enable SimpleFIN bank data sync</li>
<li>After a successful connection, go to the Accounts tab to set up new accounts and link them to your existing ones</li>
</ol>
<p class="text-primary font-medium">Field descriptions:</p>
<ul>
<li><strong>Setup Token:</strong> Your SimpleFin one-time setup token from SimpleFin Bridge (consumed on first use)</li>
<li><strong>Setup Token:</strong> Your SimpleFIN one-time setup token from SimpleFIN Bridge (consumed on first use)</li>
</ul>
</div>
@@ -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 %>
<div class="flex justify-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" %>
</div>
<% end %>
<% if @simplefin_items&.any? %>
@@ -38,6 +42,6 @@
<p class="text-sm text-secondary">Configured and ready to use. Visit the <a href="<%= accounts_path %>" class="link">Accounts</a> tab to manage and set up accounts.</p>
</div>
<% else %>
<div class="text-sm text-secondary">No SimpleFin connections yet.</div>
<div class="text-sm text-secondary">No SimpleFIN connections yet.</div>
<% end %>
</div>

View File

@@ -14,7 +14,13 @@
<% end %>
<% end %>
<%= settings_section title: "Simplefin" do %>
<%= settings_section title: "Lunch Flow" do %>
<turbo-frame id="lunchflow-providers-panel">
<%= render "settings/providers/lunchflow_panel" %>
</turbo-frame>
<% end %>
<%= settings_section title: "SimpleFIN" do %>
<turbo-frame id="simplefin-providers-panel">
<%= render "settings/providers/simplefin_panel" %>
</turbo-frame>

View File

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

View File

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

11
db/schema.rb generated
View File

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

View File

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

View File

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