mirror of
https://github.com/we-promise/sure.git
synced 2026-07-29 05:02:14 +00:00
* 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>
173 lines
7.3 KiB
Ruby
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
|