diff --git a/app/models/concerns/currency_normalizable.rb b/app/models/concerns/currency_normalizable.rb new file mode 100644 index 000000000..3d1f62331 --- /dev/null +++ b/app/models/concerns/currency_normalizable.rb @@ -0,0 +1,50 @@ +# Provides currency normalization and validation for provider data imports +# +# This concern provides a shared method to parse and normalize currency codes +# from external providers (Plaid, SimpleFIN, LunchFlow), ensuring: +# - Consistent uppercase formatting (e.g., "eur" -> "EUR") +# - Validation of 3-letter ISO currency codes +# - Proper handling of nil, empty, and invalid values +# +# Usage: +# include CurrencyNormalizable +# currency = parse_currency(api_data[:currency]) +module CurrencyNormalizable + extend ActiveSupport::Concern + + private + + # Parse and normalize a currency code from provider data + # + # @param currency_value [String, nil] Raw currency value from provider API + # @return [String, nil] Normalized uppercase 3-letter currency code, or nil if invalid + # + # @example + # parse_currency("usd") # => "USD" + # parse_currency("EUR") # => "EUR" + # parse_currency(" gbp ") # => "GBP" + # parse_currency("invalid") # => nil (logs warning) + # parse_currency(nil) # => nil + # parse_currency("") # => nil + def parse_currency(currency_value) + # Handle nil, empty string, or whitespace-only strings + return nil if currency_value.blank? + + # Normalize to uppercase 3-letter code + normalized = currency_value.to_s.strip.upcase + + # Validate it's a reasonable currency code (3 letters) + if normalized.match?(/\A[A-Z]{3}\z/) + normalized + else + log_invalid_currency(currency_value) + nil + end + end + + # Log warning for invalid currency codes + # Override this method in including classes to provide context-specific logging + def log_invalid_currency(currency_value) + Rails.logger.warn("Invalid currency code '#{currency_value}', defaulting to fallback") + end +end diff --git a/app/models/lunchflow_account.rb b/app/models/lunchflow_account.rb index cb5fb1019..01087450d 100644 --- a/app/models/lunchflow_account.rb +++ b/app/models/lunchflow_account.rb @@ -1,4 +1,6 @@ class LunchflowAccount < ApplicationRecord + include CurrencyNormalizable + belongs_to :lunchflow_item # New association through account_providers @@ -21,7 +23,7 @@ class LunchflowAccount < ApplicationRecord # Lunchflow API returns: { id, name, institution_name, institution_logo, provider, currency, status } update!( current_balance: nil, # Balance not provided by accounts endpoint - currency: snapshot[:currency] || "USD", + currency: parse_currency(snapshot[:currency]) || "USD", name: snapshot[:name], account_id: snapshot[:id].to_s, account_status: snapshot[:status], @@ -41,4 +43,10 @@ class LunchflowAccount < ApplicationRecord save! end + + private + + def log_invalid_currency(currency_value) + Rails.logger.warn("Invalid currency code '#{currency_value}' for LunchFlow account #{id}, defaulting to USD") + end end diff --git a/app/models/lunchflow_account/processor.rb b/app/models/lunchflow_account/processor.rb index a696e3ba2..793426474 100644 --- a/app/models/lunchflow_account/processor.rb +++ b/app/models/lunchflow_account/processor.rb @@ -1,4 +1,6 @@ class LunchflowAccount::Processor + include CurrencyNormalizable + attr_reader :lunchflow_account def initialize(lunchflow_account) @@ -37,14 +39,20 @@ class LunchflowAccount::Processor account = lunchflow_account.current_account balance = lunchflow_account.current_balance || 0 - # For credit cards and loans, ensure positive balances + # For liability accounts (credit cards and loans), ensure positive balances + # LunchFlow may return negative values for liabilities, but Sure expects positive if account.accountable_type == "CreditCard" || account.accountable_type == "Loan" balance = balance.abs end + # Normalize currency with fallback chain: parsed lunchflow currency -> existing account currency -> USD + currency = parse_currency(lunchflow_account.currency) || account.currency || "USD" + + # Update account balance account.update!( balance: balance, - cash_balance: balance + cash_balance: balance, + currency: currency ) end diff --git a/app/models/lunchflow_entry/processor.rb b/app/models/lunchflow_entry/processor.rb index 971705e9c..11a7801dd 100644 --- a/app/models/lunchflow_entry/processor.rb +++ b/app/models/lunchflow_entry/processor.rb @@ -1,6 +1,7 @@ require "digest/md5" class LunchflowEntry::Processor + include CurrencyNormalizable # lunchflow_transaction is the raw hash fetched from Lunchflow API and converted to JSONB # Transaction structure: { id, accountId, amount, currency, date, merchant, description } def initialize(lunchflow_transaction, lunchflow_account:) @@ -122,7 +123,11 @@ class LunchflowEntry::Processor end def currency - data[:currency].presence || account&.currency || "USD" + parse_currency(data[:currency]) || account&.currency || "USD" + end + + def log_invalid_currency(currency_value) + Rails.logger.warn("Invalid currency code '#{currency_value}' in LunchFlow transaction #{external_id}, falling back to account currency") end def date diff --git a/app/models/provider/plaid_adapter.rb b/app/models/provider/plaid_adapter.rb index 0e60a4e86..098447dc0 100644 --- a/app/models/provider/plaid_adapter.rb +++ b/app/models/provider/plaid_adapter.rb @@ -17,6 +17,10 @@ class Provider::PlaidAdapter < Provider::Base # Register this adapter with the factory for ALL PlaidAccount instances Provider::Factory.register("PlaidAccount", self) + # Mutex for thread-safe configuration loading + # Initialized at class load time to avoid race conditions on mutex creation + @config_mutex = Mutex.new + # Configuration for Plaid US configure do description <<~DESC @@ -51,6 +55,21 @@ class Provider::PlaidAdapter < Provider::Base "plaid" end + # Thread-safe lazy loading of Plaid US configuration + # Ensures configuration is loaded exactly once even under concurrent access + def self.ensure_configuration_loaded + # Fast path: return immediately if already loaded (no lock needed) + return if Rails.application.config.plaid.present? + + # Slow path: acquire lock and reload if still needed + @config_mutex.synchronize do + # Double-check after acquiring lock (another thread may have loaded it) + return if Rails.application.config.plaid.present? + + reload_configuration + end + end + # Reload Plaid US configuration when settings are updated def self.reload_configuration client_id = config_value(:client_id).presence || ENV["PLAID_CLIENT_ID"] diff --git a/app/models/provider/plaid_eu_adapter.rb b/app/models/provider/plaid_eu_adapter.rb index 36c2eae4e..9d2273721 100644 --- a/app/models/provider/plaid_eu_adapter.rb +++ b/app/models/provider/plaid_eu_adapter.rb @@ -13,6 +13,10 @@ class Provider::PlaidEuAdapter include Provider::Configurable + # Mutex for thread-safe configuration loading + # Initialized at class load time to avoid race conditions on mutex creation + @config_mutex = Mutex.new + # Configuration for Plaid EU configure do description <<~DESC @@ -43,6 +47,21 @@ class Provider::PlaidEuAdapter description: "Plaid environment: sandbox, development, or production" end + # Thread-safe lazy loading of Plaid EU configuration + # Ensures configuration is loaded exactly once even under concurrent access + def self.ensure_configuration_loaded + # Fast path: return immediately if already loaded (no lock needed) + return if Rails.application.config.plaid_eu.present? + + # Slow path: acquire lock and reload if still needed + @config_mutex.synchronize do + # Double-check after acquiring lock (another thread may have loaded it) + return if Rails.application.config.plaid_eu.present? + + reload_configuration + end + end + # Reload Plaid EU configuration when settings are updated def self.reload_configuration client_id = config_value(:client_id).presence || ENV["PLAID_EU_CLIENT_ID"] diff --git a/app/models/provider/plaid_sandbox.rb b/app/models/provider/plaid_sandbox.rb index e4d7a3476..23c2a4551 100644 --- a/app/models/provider/plaid_sandbox.rb +++ b/app/models/provider/plaid_sandbox.rb @@ -40,6 +40,8 @@ class Provider::PlaidSandbox < Provider::Plaid def create_client raise "Plaid sandbox is not supported in production" if Rails.env.production? + Provider::PlaidAdapter.ensure_configuration_loaded + api_client = Plaid::ApiClient.new( Rails.application.config.plaid ) diff --git a/app/models/provider/registry.rb b/app/models/provider/registry.rb index f98fb1e5e..aa7a443c5 100644 --- a/app/models/provider/registry.rb +++ b/app/models/provider/registry.rb @@ -41,6 +41,7 @@ class Provider::Registry end def plaid_us + Provider::PlaidAdapter.ensure_configuration_loaded config = Rails.application.config.plaid return nil unless config.present? @@ -49,6 +50,7 @@ class Provider::Registry end def plaid_eu + Provider::PlaidEuAdapter.ensure_configuration_loaded config = Rails.application.config.plaid_eu return nil unless config.present? diff --git a/app/models/simplefin_account/processor.rb b/app/models/simplefin_account/processor.rb index f645f33d2..c97124c6a 100644 --- a/app/models/simplefin_account/processor.rb +++ b/app/models/simplefin_account/processor.rb @@ -49,7 +49,8 @@ class SimplefinAccount::Processor account.update!( balance: balance, - cash_balance: cash_balance + cash_balance: cash_balance, + currency: simplefin_account.currency ) end diff --git a/app/models/simplefin_entry/processor.rb b/app/models/simplefin_entry/processor.rb index 73779c3ae..c7156f1a2 100644 --- a/app/models/simplefin_entry/processor.rb +++ b/app/models/simplefin_entry/processor.rb @@ -1,6 +1,7 @@ require "digest/md5" class SimplefinEntry::Processor + include CurrencyNormalizable # simplefin_transaction is the raw hash fetched from SimpleFin API and converted to JSONB def initialize(simplefin_transaction, simplefin_account:) @simplefin_transaction = simplefin_transaction @@ -77,7 +78,11 @@ class SimplefinEntry::Processor end def currency - data[:currency] || account.currency + parse_currency(data[:currency]) || account.currency + end + + def log_invalid_currency(currency_value) + Rails.logger.warn("Invalid currency code '#{currency_value}' in SimpleFIN transaction #{external_id}, falling back to account currency") end def date diff --git a/app/views/loans/new.html.erb b/app/views/loans/new.html.erb index 74d66496a..8ca62d369 100644 --- a/app/views/loans/new.html.erb +++ b/app/views/loans/new.html.erb @@ -3,6 +3,7 @@ path: new_loan_path(return_to: params[:return_to]), show_us_link: @show_us_link, show_eu_link: @show_eu_link, + show_lunchflow_link: @show_lunchflow_link, accountable_type: "Loan" %> <% else %> <%= render DS::Dialog.new do |dialog| %> diff --git a/config/initializers/plaid.rb b/config/initializers/plaid.rb deleted file mode 100644 index a8b54e6be..000000000 --- a/config/initializers/plaid.rb +++ /dev/null @@ -1,21 +0,0 @@ -Rails.application.configure do - # Initialize Plaid configuration to nil - config.plaid = nil - config.plaid_eu = nil -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/initializers/plaid_config.rb b/config/initializers/plaid_config.rb new file mode 100644 index 000000000..e69fcbc12 --- /dev/null +++ b/config/initializers/plaid_config.rb @@ -0,0 +1,7 @@ +# Plaid configuration attributes +# These are initialized to nil and loaded lazily on first access by Provider::Registry +# Configuration is loaded from database settings or ENV variables via the adapter's reload_configuration method +Rails.application.configure do + config.plaid = nil + config.plaid_eu = nil +end diff --git a/lib/tasks/data_migration.rake b/lib/tasks/data_migration.rake index 5d53a09b8..e2b4578a6 100644 --- a/lib/tasks/data_migration.rake +++ b/lib/tasks/data_migration.rake @@ -3,6 +3,7 @@ namespace :data_migration do # 2025-02-07: EU Plaid items need to be moved over to a new webhook URL so that we can # instantiate the correct Plaid client for verification based on which Plaid instance it comes from task eu_plaid_webhooks: :environment do + Provider::PlaidEuAdapter.ensure_configuration_loaded provider = Provider::Plaid.new(Rails.application.config.plaid_eu, region: :eu) eu_items = PlaidItem.where(plaid_region: "eu")