Files
sure/app/models/enable_banking_account/processor.rb
Carlos Lindo ef61466e87 fix(enable-banking): preserve manual card limit when provider reports zero (#2841)
* Preserve manual card limit when provider reports zero

* Test non-positive provider credit limits

* Address credit limit review feedback
2026-08-01 08:44:30 +02:00

181 lines
7.6 KiB
Ruby

class EnableBankingAccount::Processor
class ProcessingError < StandardError; end
include CurrencyNormalizable
attr_reader :enable_banking_account
def initialize(enable_banking_account)
@enable_banking_account = enable_banking_account
end
def process
unless enable_banking_account.current_account.present?
Rails.logger.info "EnableBankingAccount::Processor - No linked account for enable_banking_account #{enable_banking_account.id}, skipping processing"
return
end
Rails.logger.info "EnableBankingAccount::Processor - Processing enable_banking_account #{enable_banking_account.id} (uid #{enable_banking_account.uid})"
begin
process_account!
rescue StandardError => e
Rails.logger.error "EnableBankingAccount::Processor - Failed to process account #{enable_banking_account.id}: #{e.message}"
Rails.logger.error "Backtrace: #{e.backtrace.join("\n")}"
report_exception(e, "account")
raise
end
process_transactions
end
private
def process_account!
if enable_banking_account.current_account.blank?
Rails.logger.error("Enable Banking account #{enable_banking_account.id} has no associated Account")
return
end
account = enable_banking_account.current_account
balance = enable_banking_account.current_balance
# A nil current_balance means this sync's balance fetch did not succeed:
# upsert_enable_banking_snapshot! clears it and only a successful
# balance fetch repopulates it. Coercing nil to 0 here would persist a
# zero balance (and a zero current_anchor valuation) onto a healthy
# account whenever the provider has a transient failure, so leave the
# account untouched instead.
if balance.nil?
Rails.logger.warn("EnableBankingAccount::Processor - No balance available for enable_banking_account #{enable_banking_account.id} (balance fetch failed or not yet run), skipping account update")
return
end
available_credit = nil
skip_balance_update = false
# For liability accounts, ensure balance sign is correct.
# For CreditCards, we expect the main balance to reflect the absolute outstanding debt
# rather than available credit, to ensure net worth calculations handle the liability accurately.
# Any available credit metrics (from limits) are instead stored safely as metadata on the Accountable.
# Loans and CreditCards must always represent their outstanding balance as an absolute
# positive debt amount, regardless of the API's reported sign, to ensure the BalanceSheet
# calculates net worth accurately.
if account.accountable_type == "Loan" || account.accountable_type == "CreditCard"
# Standardize the raw balance to an absolute positive debt
balance = balance.abs
if account.accountable_type == "CreditCard"
balance, available_credit, skip_balance_update = interpret_credit_card_balance(account, balance)
end
end
currency = parse_currency(enable_banking_account.currency) || account.currency || "EUR"
# Wrap both writes in a transaction so a failure on either rolls back both.
ActiveRecord::Base.transaction do
if account.accountable.present? && account.accountable.respond_to?(:available_credit=)
account.accountable.update!(available_credit: available_credit)
end
if skip_balance_update
account.update!(currency: currency)
else
account.update!(currency: currency, cash_balance: balance)
# Use set_current_balance to create a current_anchor valuation entry.
# This enables Balance::ReverseCalculator, which works backward from the
# bank-reported balance — eliminating spurious cash adjustment spikes.
result = account.set_current_balance(balance)
raise ProcessingError, "Failed to set current balance: #{result.error}" unless result.success?
end
end
# TODO: pass explicit window_start_date to sync_later to avoid full history recalculation on every sync
# Currently relies on set_current_balance's implicit sync trigger; window params would require refactor
end
# Interprets the reported credit card balance based on the
# treat_balance_as_available_credit flag.
# Returns [balance, available_credit, skip_balance_update].
def interpret_credit_card_balance(account, reported_balance)
if enable_banking_account.treat_balance_as_available_credit?
# In this mode the accountable's available_credit field holds the credit
# limit: the API-provided one, or a user-entered value when the API
# omits it. Writing the limit back (never the reported balance) keeps
# the field stable across syncs so a manual limit is never clobbered.
credit_limit = positive_credit_limit(enable_banking_account.credit_limit) ||
positive_credit_limit(account.accountable&.available_credit)
unless account.accountable.present?
capture_debug_log("CreditCard accountable missing for account", account)
end
if credit_limit.present?
# The API returns the available credit as the current balance, so the
# outstanding debt is derived from the credit limit.
# Use .max(0) to prevent synthetic debt on overpaid cards.
outstanding_debt = [ credit_limit - reported_balance, 0 ].max
[ outstanding_debt, credit_limit, false ]
else
# No credit limit from the API or the card's available credit field.
# The reported balance is available credit, so the outstanding debt is
# unknown. Keep the existing account balance instead of recording
# available credit as debt.
capture_debug_log("Cannot compute debt from available credit because no credit limit is set (API or manual)", account)
[ nil, nil, true ]
end
else
# Default behavior: API returns outstanding debt
provider_credit_limit = positive_credit_limit(enable_banking_account.credit_limit)
available_credit = if provider_credit_limit
[ provider_credit_limit - reported_balance, 0 ].max
else
# In this mode available_credit is the actual remaining credit, not a
# limit, so zero is valid and must be preserved intentionally.
account.accountable&.available_credit
end
[ reported_balance, available_credit, false ]
end
end
# Credit limits must be positive. Non-positive provider or manual values
# mean that no usable limit is known.
def positive_credit_limit(value)
value if value&.positive?
end
def capture_debug_log(message, account)
DebugLogEntry.capture(
category: "sync",
level: "warn",
message: message,
source: "EnableBankingAccount::Processor",
provider_key: "enable_banking",
account: account,
account_provider: account.account_providers.find_by(provider_type: "EnableBankingAccount"),
metadata: {
enable_banking_account_id: enable_banking_account.id,
enable_banking_item_id: enable_banking_account.enable_banking_item_id
}
)
end
def process_transactions
EnableBankingAccount::Transactions::Processor.new(enable_banking_account).process
rescue => e
report_exception(e, "transactions")
end
def report_exception(error, context)
Sentry.capture_exception(error) do |scope|
scope.set_tags(
enable_banking_account_id: enable_banking_account.id,
context: context
)
end
end
end