Files
sure/app/models/enable_banking_account/processor.rb
Markus Laaksonen c4ac6a365f feat(sync): add toggle to parse Enable Banking CC balance as available credit (#2512)
* feat(sync): allow EnableBanking credit card balances to be parsed as available credit

* feat(ui): expose Enable Banking balance interpretation toggle on credit card form

Adds a toggle to the credit card edit dialog, shown only when the account
is linked to an Enable Banking provider account. Toggling persists
treat_balance_as_available_credit and enqueues an item sync so the balance
is reinterpreted immediately.

* fix(sync): keep existing balance when credit limit is missing for available-credit cards

When treat_balance_as_available_credit is on and the API omits credit_limit,
the reported balance is known to be available credit, so recording it as
debt fabricates a liability. Skip the balance write in that case and only
update the available credit metadata.

Also extract the credit card branching into a helper and include
provider_key, account_provider and metadata in the debug log entries so
support can filter /settings/debug by the affected connection.

* refactor(ui): move provider lookup to Account and label the toggle for assistive tech

Adds Account#provider_account_for so the view no longer queries
account_providers directly, and wires aria-labelledby from the toggle to
its visible label.

* fix(ui): apply Enable Banking setting only after a successful account update

Runs the provider flag update after super and only on the redirect path,
so a failed account save no longer persists the flag or enqueues a sync.
Also permits the enable_banking params so malformed input is filtered
instead of raising.

* test(sync): extract relink helper in Enable Banking processor test

* feat(sync): fall back to manually set available credit as the credit limit

When the toggle is on, the accountable's available_credit field now holds
the credit limit (API-provided, or user-entered when the API omits it) and
is never overwritten with the reported balance. This lets users whose bank
reports available credit without a credit limit compute the outstanding
debt by entering their limit in the Available credit field, while keeping
the field stable across syncs so a stale reported balance can never be
mistaken for a limit.

---------

Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
2026-07-21 09:15:24 +02:00

173 lines
7.3 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 = enable_banking_account.credit_limit.presence ||
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
available_credit = if enable_banking_account.credit_limit.present?
[ enable_banking_account.credit_limit - reported_balance, 0 ].max
elsif account.accountable&.available_credit.present?
# No limit from API, but we have stored available_credit metadata
account.accountable.available_credit
end
[ reported_balance, available_credit, false ]
end
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