Remove plaid initialiser (#317)

* Remove plaid initialiser

The initializer can be safely removed because:
  - Config is lazily loaded via Provider::Registry
  - reload_configuration is called after settings updates
  - All calling code handles nil configs gracefully
  - Initial nil state is fine - config loads on first use

* Fix for missing config

* Actually don't pollute application.rb

* Add currency loading for balances

* Fix race condition on lazy load

* Allow loans to be imported in lunch flow also

* Fix currency processor
This commit is contained in:
soky srm
2025-11-12 16:01:19 +01:00
committed by GitHub
parent fad241c416
commit e8f935bc6f
14 changed files with 134 additions and 27 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -49,7 +49,8 @@ class SimplefinAccount::Processor
account.update!(
balance: balance,
cash_balance: cash_balance
cash_balance: cash_balance,
currency: simplefin_account.currency
)
end

View File

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

View File

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

View File

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

View File

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

View File

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