">
<% if has_blank_name %>
<%= t(".no_name_placeholder") %>
<% else %>
diff --git a/app/views/reports/_comparison_chart.html.erb b/app/views/reports/_comparison_chart.html.erb
deleted file mode 100644
index c48531eb9..000000000
--- a/app/views/reports/_comparison_chart.html.erb
+++ /dev/null
@@ -1,250 +0,0 @@
-<%
- currency = Current.family.currency
-
- # Helper to calculate percentage change and determine if it's good or bad
- def comparison_class(current, previous, inverse: false)
- return "text-primary" if previous.zero?
-
- change = current - previous
- is_positive_change = change > 0
-
- # For expenses, lower is better (inverse logic)
- is_good = inverse ? !is_positive_change : is_positive_change
-
- is_good ? "text-green-600" : "text-gray-600"
- end
-
- def percentage_change(current, previous)
- return 0 if previous.zero?
- ((current - previous) / previous.abs * 100).round(1)
- end
-%>
-
-
-
-
- <%= t("reports.comparison.title") %>
-
-
- <%= t("reports.comparison.currency", symbol: comparison_data[:currency_symbol]) %>
-
-
-
-
- <%# Income Comparison %>
-
-
-
- <%= icon("trending-up", class: "w-4 h-4 text-success") %>
- <%= t("reports.comparison.income") %>
-
-
-
-
-
-
- <%= Money.new(comparison_data[:current][:income], currency).format %>
-
- <% change = percentage_change(comparison_data[:current][:income], comparison_data[:previous][:income]) %>
- <% if change != 0 %>
- <% income_improved = comparison_data[:current][:income] > comparison_data[:previous][:income] %>
-
-
- <%= change >= 0 ? "+" : "" %><%= change %>%
-
-
- <%= icon(income_improved ? "trending-up" : "trending-down", class: "w-3 h-3") %>
- <%= t(income_improved ? "reports.comparison.status.improved" : "reports.comparison.status.decreased") %>
-
-
- <% end %>
-
-
- <%= t("reports.comparison.previous") %>: <%= Money.new(comparison_data[:previous][:income], currency).format %>
-
-
-
- <%# Overlapping bars %>
-
- <%
- current_income_abs = comparison_data[:current][:income].to_f.abs
- previous_income_abs = comparison_data[:previous][:income].to_f.abs
- max_income = [current_income_abs, previous_income_abs].max
-
- if max_income > 0
- current_width = [3, (current_income_abs / max_income * 100)].max
- previous_width = [3, (previous_income_abs / max_income * 100)].max
- else
- current_width = 0
- previous_width = 0
- end
-
- # Income: green if increased, gray/primary if decreased
- income_increased = comparison_data[:current][:income] >= comparison_data[:previous][:income]
- income_bar_color = income_increased ? "bg-green-500" : "bg-gray-600"
- income_bg_color = income_increased ? "bg-green-200" : "bg-gray-300"
- %>
- <% if previous_width > 0 || current_width > 0 %>
- <%# Previous period bar (background) %>
- <% if previous_width > 0 %>
-
- <% end %>
- <%# Current period bar (foreground) %>
- <% if current_width > 0 %>
-
- <% end %>
- <% else %>
-
- <%= t("reports.comparison.no_data") %>
-
- <% end %>
-
-
-
- <%# Expenses Comparison %>
-
-
-
- <%= icon("trending-down", class: "w-4 h-4 text-danger") %>
- <%= t("reports.comparison.expenses") %>
-
-
-
-
-
-
- <%= Money.new(comparison_data[:current][:expenses], currency).format %>
-
- <% change = percentage_change(comparison_data[:current][:expenses], comparison_data[:previous][:expenses]) %>
- <% if change != 0 %>
- <% expenses_improved = comparison_data[:current][:expenses] < comparison_data[:previous][:expenses] %>
-
-
- <%= change >= 0 ? "+" : "" %><%= change %>%
-
-
- <%= icon(expenses_improved ? "trending-down" : "trending-up", class: "w-3 h-3") %>
- <%= t(expenses_improved ? "reports.comparison.status.reduced" : "reports.comparison.status.increased") %>
-
-
- <% end %>
-
-
- <%= t("reports.comparison.previous") %>: <%= Money.new(comparison_data[:previous][:expenses], currency).format %>
-
-
-
- <%# Overlapping bars %>
-
- <%
- current_expenses_abs = comparison_data[:current][:expenses].to_f.abs
- previous_expenses_abs = comparison_data[:previous][:expenses].to_f.abs
- max_expenses = [current_expenses_abs, previous_expenses_abs].max
-
- if max_expenses > 0
- current_width = [3, (current_expenses_abs / max_expenses * 100)].max
- previous_width = [3, (previous_expenses_abs / max_expenses * 100)].max
- else
- current_width = 0
- previous_width = 0
- end
-
- # Expenses: green if decreased (inverse logic), gray/primary if increased
- expenses_decreased = comparison_data[:current][:expenses] <= comparison_data[:previous][:expenses]
- expenses_bar_color = expenses_decreased ? "bg-green-500" : "bg-gray-600"
- expenses_bg_color = expenses_decreased ? "bg-green-200" : "bg-gray-300"
- %>
- <% if previous_width > 0 || current_width > 0 %>
- <%# Previous period bar (background) %>
- <% if previous_width > 0 %>
-
- <% end %>
- <%# Current period bar (foreground) %>
- <% if current_width > 0 %>
-
- <% end %>
- <% else %>
-
- <%= t("reports.comparison.no_data") %>
-
- <% end %>
-
-
-
- <%# Net Savings Comparison %>
-
-
-
- <%= icon("piggy-bank", class: "w-4 h-4 text-primary") %>
- <%= t("reports.comparison.net_savings") %>
-
-
-
-
-
-
- <%= Money.new(comparison_data[:current][:net], currency).format %>
-
- <% change = percentage_change(comparison_data[:current][:net], comparison_data[:previous][:net]) %>
- <% if change != 0 %>
- <% net_improved = comparison_data[:current][:net] > comparison_data[:previous][:net] %>
-
-
- <%= change >= 0 ? "+" : "" %><%= change %>%
-
-
- <%= icon(net_improved ? "trending-up" : "trending-down", class: "w-3 h-3") %>
- <%= t(net_improved ? "reports.comparison.status.improved" : "reports.comparison.status.decreased") %>
-
-
- <% end %>
-
-
- <%= t("reports.comparison.previous") %>: <%= Money.new(comparison_data[:previous][:net], currency).format %>
-
-
-
- <%# Overlapping bars %>
-
- <%
- current_net_abs = comparison_data[:current][:net].to_f.abs
- previous_net_abs = comparison_data[:previous][:net].to_f.abs
- max_net = [current_net_abs, previous_net_abs].max
-
- if max_net > 0
- current_width = [3, (current_net_abs / max_net * 100)].max
- previous_width = [3, (previous_net_abs / max_net * 100)].max
- else
- current_width = 0
- previous_width = 0
- end
-
- # Net Savings: green if improved (increased), gray/primary if got worse
- net_improved = comparison_data[:current][:net] >= comparison_data[:previous][:net]
- net_bar_color = net_improved ? "bg-green-500" : "bg-gray-600"
- net_bg_color = net_improved ? "bg-green-200" : "bg-gray-300"
- %>
- <% if previous_width > 0 || current_width > 0 %>
- <%# Previous period bar (background) %>
- <% if previous_width > 0 %>
-
- <% end %>
- <%# Current period bar (foreground) %>
- <% if current_width > 0 %>
-
- <% end %>
- <% else %>
-
- <%= t("reports.comparison.no_data") %>
-
- <% end %>
-
-
-
-
diff --git a/app/views/reports/index.html.erb b/app/views/reports/index.html.erb
index dd659bf87..6a75e28d4 100644
--- a/app/views/reports/index.html.erb
+++ b/app/views/reports/index.html.erb
@@ -87,15 +87,6 @@
} %>
- <%# Comparison Chart %>
-
- <%= render partial: "reports/comparison_chart", locals: {
- comparison_data: @comparison_data,
- period_type: @period_type,
- start_date: @start_date
- } %>
-
-
<%# Trends & Insights %>
<%= render partial: "reports/trends_insights", locals: {
diff --git a/app/views/settings/providers/_provider_form.html.erb b/app/views/settings/providers/_provider_form.html.erb
index 8f83a8f2e..34054ead5 100644
--- a/app/views/settings/providers/_provider_form.html.erb
+++ b/app/views/settings/providers/_provider_form.html.erb
@@ -73,7 +73,7 @@
<% end %>
- <% # Show configuration status %>
+ <%# Show configuration status %>
<% if configuration.configured? %>
diff --git a/config/initializers/plaid.rb b/config/initializers/plaid.rb
index f5c47fd2b..a8b54e6be 100644
--- a/config/initializers/plaid.rb
+++ b/config/initializers/plaid.rb
@@ -6,10 +6,16 @@ end
# Load Plaid configuration from adapters after initialization
Rails.application.config.after_initialize do
+ # Skip if database is not ready (e.g., during db:create)
+ next unless ActiveRecord::Base.connection.table_exists?("settings")
+
# Ensure provider adapters are loaded
Provider::Factory.ensure_adapters_loaded
# Reload configurations from settings/ENV
Provider::PlaidAdapter.reload_configuration # US region
Provider::PlaidEuAdapter.reload_configuration # EU region
+rescue ActiveRecord::NoDatabaseError, PG::ConnectionBad
+ # Database doesn't exist yet, skip initialization
+ nil
end
diff --git a/config/locales/views/reports/en.yml b/config/locales/views/reports/en.yml
index 3b4e952e2..87dd04b9a 100644
--- a/config/locales/views/reports/en.yml
+++ b/config/locales/views/reports/en.yml
@@ -24,20 +24,6 @@ en:
income_minus_expenses: Income minus expenses
of_budget_used: of budget used
no_budget_data: No budget data for this period
- comparison:
- title: Period Comparison
- currency: "Currency: %{symbol}"
- income: Income
- expenses: Expenses
- net_savings: Net Savings
- current: Current Period
- previous: Previous Period
- no_data: No data available
- status:
- improved: Improved
- decreased: Decreased
- reduced: Reduced
- increased: Increased
budget_performance:
title: Budget Performance
spent: Spent
diff --git a/db/migrate/20251111084519_remove_orphaned_lunchflow_accounts.rb b/db/migrate/20251111084519_remove_orphaned_lunchflow_accounts.rb
new file mode 100644
index 000000000..80a210854
--- /dev/null
+++ b/db/migrate/20251111084519_remove_orphaned_lunchflow_accounts.rb
@@ -0,0 +1,28 @@
+class RemoveOrphanedLunchflowAccounts < ActiveRecord::Migration[7.2]
+ def up
+ # Find all LunchflowAccount records that don't have an associated account_provider
+ # These are "orphaned" accounts that were created during sync but never actually
+ # imported/linked by the user due to old behavior that saved all accounts
+ orphaned_accounts = LunchflowAccount.left_outer_joins(:account_provider)
+ .where(account_providers: { id: nil })
+
+ orphaned_count = orphaned_accounts.count
+
+ if orphaned_count > 0
+ Rails.logger.info "Removing #{orphaned_count} orphaned LunchflowAccount records (not linked via account_provider)"
+
+ # Delete orphaned accounts
+ orphaned_accounts.destroy_all
+
+ Rails.logger.info "Successfully removed #{orphaned_count} orphaned LunchflowAccount records"
+ else
+ Rails.logger.info "No orphaned LunchflowAccount records found to remove"
+ end
+ end
+
+ def down
+ # Cannot restore orphaned accounts that were deleted
+ # These were unused accounts from old behavior that shouldn't have been created
+ Rails.logger.info "Cannot restore orphaned LunchflowAccount records (data migration is irreversible)"
+ end
+end
diff --git a/db/migrate/20251111094448_migrate_dynamic_fields_to_individual_entries.rb b/db/migrate/20251111094448_migrate_dynamic_fields_to_individual_entries.rb
new file mode 100644
index 000000000..2e4931859
--- /dev/null
+++ b/db/migrate/20251111094448_migrate_dynamic_fields_to_individual_entries.rb
@@ -0,0 +1,35 @@
+class MigrateDynamicFieldsToIndividualEntries < ActiveRecord::Migration[7.2]
+ def up
+ # Find the dynamic_fields setting record
+ dynamic_fields_record = Setting.find_by(var: "dynamic_fields")
+ return unless dynamic_fields_record
+
+ # Parse the hash and create individual entries
+ dynamic_fields_hash = dynamic_fields_record.value || {}
+ dynamic_fields_hash.each do |key, value|
+ Setting.create!(
+ var: "dynamic:#{key}",
+ value: value
+ )
+ end
+
+ # Delete the old dynamic_fields record
+ dynamic_fields_record.destroy
+ end
+
+ def down
+ # Collect all dynamic: entries back into a hash
+ dynamic_fields_hash = {}
+ Setting.where("var LIKE ?", "dynamic:%").find_each do |record|
+ key = record.var.sub(/^dynamic:/, "")
+ dynamic_fields_hash[key] = record.value
+ record.destroy
+ end
+
+ # Recreate the dynamic_fields record with the hash
+ Setting.create!(
+ var: "dynamic_fields",
+ value: dynamic_fields_hash
+ ) if dynamic_fields_hash.any?
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 86a1ec71d..4743b4f02 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_10_104411) do
+ActiveRecord::Schema[7.2].define(version: 2025_11_11_094448) do
# These are extensions that must be enabled in order to support this database
enable_extension "pgcrypto"
enable_extension "plpgsql"
@@ -687,6 +687,10 @@ ActiveRecord::Schema[7.2].define(version: 2025_11_10_104411) do
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.string "name"
+ t.boolean "manual", default: false, null: false
+ 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_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"
diff --git a/test/controllers/reports_controller_test.rb b/test/controllers/reports_controller_test.rb
index c2e7a8cb2..4c454bf6a 100644
--- a/test/controllers/reports_controller_test.rb
+++ b/test/controllers/reports_controller_test.rb
@@ -76,14 +76,6 @@ class ReportsControllerTest < ActionDispatch::IntegrationTest
assert_select "h3", text: I18n.t("reports.summary.net_savings")
end
- test "index builds comparison data" do
- get reports_path(period_type: :monthly)
- assert_response :ok
- assert_select "h2", text: I18n.t("reports.comparison.title")
- assert_select "h3", text: I18n.t("reports.comparison.income")
- assert_select "h3", text: I18n.t("reports.comparison.expenses")
- end
-
test "index builds trends data" do
get reports_path(period_type: :monthly)
assert_response :ok
diff --git a/test/controllers/settings/providers_controller_test.rb b/test/controllers/settings/providers_controller_test.rb
index 7f97d6de0..9443a5032 100644
--- a/test/controllers/settings/providers_controller_test.rb
+++ b/test/controllers/settings/providers_controller_test.rb
@@ -41,10 +41,10 @@ class Settings::ProvidersControllerTest < ActionDispatch::IntegrationTest
end
test "updates dynamic provider fields using batch update" do
- # plaid_client_id is a dynamic field, so it should go through dynamic_fields hash
+ # plaid_client_id is a dynamic field, stored as an individual entry
with_self_hosting do
# Clear any existing plaid settings
- Setting.dynamic_fields = {}
+ Setting["plaid_client_id"] = nil
patch settings_providers_url, params: {
setting: { plaid_client_id: "test_client_id" }
@@ -52,14 +52,16 @@ class Settings::ProvidersControllerTest < ActionDispatch::IntegrationTest
assert_redirected_to settings_providers_url
assert_equal "test_client_id", Setting["plaid_client_id"]
- assert_equal "test_client_id", Setting.dynamic_fields["plaid_client_id"]
end
end
test "batches multiple dynamic fields from same provider atomically" do
- # Test that multiple fields from Plaid are updated together in one write operation
+ # Test that multiple fields from Plaid are updated as individual entries
with_self_hosting do
- Setting.dynamic_fields = {}
+ # Clear existing fields
+ Setting["plaid_client_id"] = nil
+ Setting["plaid_secret"] = nil
+ Setting["plaid_environment"] = nil
patch settings_providers_url, params: {
setting: {
@@ -71,7 +73,7 @@ class Settings::ProvidersControllerTest < ActionDispatch::IntegrationTest
assert_redirected_to settings_providers_url
- # All three should be present in dynamic_fields
+ # All three should be present as individual entries
assert_equal "new_client_id", Setting["plaid_client_id"]
assert_equal "new_secret", Setting["plaid_secret"]
assert_equal "production", Setting["plaid_environment"]
@@ -79,9 +81,14 @@ class Settings::ProvidersControllerTest < ActionDispatch::IntegrationTest
end
test "batches dynamic fields from multiple providers atomically" do
- # Test that fields from different providers are all batched together
+ # Test that fields from different providers are stored as individual entries
with_self_hosting do
- Setting.dynamic_fields = {}
+ # Clear existing fields
+ Setting["plaid_client_id"] = nil
+ 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: {
@@ -108,10 +115,8 @@ class Settings::ProvidersControllerTest < ActionDispatch::IntegrationTest
# Test that updating some fields doesn't overwrite other existing fields
with_self_hosting do
# Set initial fields
- Setting.dynamic_fields = {
- "existing_field_1" => "value1",
- "plaid_client_id" => "old_client_id"
- }
+ Setting["existing_field_1"] = "value1"
+ Setting["plaid_client_id"] = "old_client_id"
# Update one field and add a new one
patch settings_providers_url, params: {
@@ -162,7 +167,7 @@ class Settings::ProvidersControllerTest < ActionDispatch::IntegrationTest
# Set initial values
Setting["plaid_client_id"] = "old_value"
assert_equal "old_value", Setting["plaid_client_id"]
- assert Setting.dynamic_fields.key?("plaid_client_id")
+ assert Setting.key?("plaid_client_id")
patch settings_providers_url, params: {
setting: { plaid_client_id: " " } # Blank string with spaces
@@ -170,18 +175,18 @@ class Settings::ProvidersControllerTest < ActionDispatch::IntegrationTest
assert_redirected_to settings_providers_url
assert_nil Setting["plaid_client_id"]
- # Key should be removed from hash, not just set to nil
- refute Setting.dynamic_fields.key?("plaid_client_id"),
- "nil values should delete the key from dynamic_fields"
+ # Entry should be removed, not just set to nil
+ refute Setting.key?("plaid_client_id"),
+ "nil values should delete the entry"
end
end
test "handles sequential updates to different dynamic fields safely" do
# This test simulates what would happen if two requests tried to update
- # different dynamic fields sequentially. With the batch update approach,
- # all changes should be preserved.
+ # different dynamic fields sequentially. With individual entries,
+ # all changes should be preserved without conflicts.
with_self_hosting do
- Setting.dynamic_fields = { "existing_field" => "existing_value" }
+ Setting["existing_field"] = "existing_value"
# Simulate first request updating plaid fields
patch settings_providers_url, params: {
@@ -266,8 +271,8 @@ class Settings::ProvidersControllerTest < ActionDispatch::IntegrationTest
test "logs errors when update fails" do
with_self_hosting do
# Test that errors during update are properly logged and handled gracefully
- # We'll force an error by making the dynamic_fields= setter raise
- Setting.expects(:dynamic_fields=).raises(StandardError.new("Database error")).once
+ # We'll force an error by making the []= method raise
+ Setting.expects(:[]=).with("plaid_client_id", "test").raises(StandardError.new("Database error")).once
# Mock logger to verify error is logged
Rails.logger.expects(:error).with(regexp_matches(/Failed to update provider settings.*Database error/)).once
diff --git a/test/models/period_test.rb b/test/models/period_test.rb
index b7e3e4f5d..fa1221eb9 100644
--- a/test/models/period_test.rb
+++ b/test/models/period_test.rb
@@ -45,25 +45,11 @@ class PeriodTest < ActiveSupport::TestCase
assert_equal "Custom Period", period.label
end
- test "comparison_label returns correct label for known period" do
- period = Period.from_key("last_30_days")
- assert_equal "vs. last month", period.comparison_label
- end
-
- test "comparison_label returns date range for unknown period" do
- start_date = Date.current - 15.days
- end_date = Date.current
- period = Period.new(start_date: start_date, end_date: end_date)
- expected = "#{start_date.strftime("%b %d, %Y")} to #{end_date.strftime("%b %d, %Y")}"
- assert_equal expected, period.comparison_label
- end
-
test "all_time period can be created" do
period = Period.from_key("all_time")
assert_equal "all_time", period.key
assert_equal "All Time", period.label
assert_equal "All", period.label_short
- assert_equal "vs. beginning", period.comparison_label
end
test "all_time period uses family's oldest entry date" do
diff --git a/test/models/setting_test.rb b/test/models/setting_test.rb
index bb0ffe8df..cdadb0458 100644
--- a/test/models/setting_test.rb
+++ b/test/models/setting_test.rb
@@ -7,6 +7,11 @@ class SettingTest < ActiveSupport::TestCase
Setting.openai_model = nil
end
+ teardown do
+ # Clean up dynamic fields after each test
+ Setting.where("var LIKE ?", "dynamic:%").destroy_all
+ end
+
test "validate_openai_config! passes when both uri base and model are set" do
assert_nothing_raised do
Setting.validate_openai_config!(uri_base: "https://api.example.com", model: "gpt-4")
@@ -61,4 +66,69 @@ class SettingTest < ActiveSupport::TestCase
Setting.validate_openai_config!(uri_base: "", model: nil)
end
end
+
+ # Dynamic field tests
+ test "can set and get dynamic fields" do
+ Setting["custom_key"] = "custom_value"
+ assert_equal "custom_value", Setting["custom_key"]
+ end
+
+ test "can set and get multiple dynamic fields independently" do
+ Setting["key1"] = "value1"
+ Setting["key2"] = "value2"
+ Setting["key3"] = "value3"
+
+ assert_equal "value1", Setting["key1"]
+ assert_equal "value2", Setting["key2"]
+ assert_equal "value3", Setting["key3"]
+ end
+
+ test "setting nil value deletes dynamic field" do
+ Setting["temp_key"] = "temp_value"
+ assert_equal "temp_value", Setting["temp_key"]
+
+ Setting["temp_key"] = nil
+ assert_nil Setting["temp_key"]
+ end
+
+ test "can delete dynamic field" do
+ Setting["delete_key"] = "delete_value"
+ assert_equal "delete_value", Setting["delete_key"]
+
+ value = Setting.delete("delete_key")
+ assert_equal "delete_value", value
+ assert_nil Setting["delete_key"]
+ end
+
+ test "key? returns true for existing dynamic field" do
+ Setting["exists_key"] = "exists_value"
+ assert Setting.key?("exists_key")
+ end
+
+ test "key? returns false for non-existing dynamic field" do
+ assert_not Setting.key?("nonexistent_key")
+ end
+
+ test "dynamic_keys returns all dynamic field keys" do
+ Setting["dynamic1"] = "value1"
+ Setting["dynamic2"] = "value2"
+
+ keys = Setting.dynamic_keys
+ assert_includes keys, "dynamic1"
+ assert_includes keys, "dynamic2"
+ end
+
+ test "declared fields take precedence over dynamic fields" do
+ # Try to set a declared field using bracket notation
+ Setting["openai_model"] = "custom-model"
+ assert_equal "custom-model", Setting["openai_model"]
+ assert_equal "custom-model", Setting.openai_model
+ end
+
+ test "cannot delete declared fields" do
+ Setting.openai_model = "test-model"
+ result = Setting.delete("openai_model")
+ assert_nil result
+ assert_equal "test-model", Setting.openai_model
+ end
end