From 112b09f42575526cf036a7b1dbf88cfdb90e919d Mon Sep 17 00:00:00 2001 From: Markus Laaksonen Date: Sat, 27 Jun 2026 07:20:50 +0300 Subject: [PATCH] fix(sync): store EnableBanking credit card debt as balance instead of available credit (#2459) * fix(sync): store EnableBanking credit card debt as balance instead of available credit Previously, the EnableBanking processor forcibly overrode the primary account balance for credit cards to be the available credit (credit_limit - debt) rather than the actual outstanding debt. Since credit cards are modeled as Liability accounts, this caused the balance sheet (net worth) to treat available credit mathematically as a debt. This PR aligns the EnableBanking processor with the Plaid and SimpleFIN processors by storing the absolute debt as the account balance, while tracking the available credit via accountable metadata. Fixes #2458 * docs(sync): update EnableBanking credit card processor documentation Updates the inline processor comments to reflect the new behavior introduced by the previous commit, clarifying that outstanding debt is stored sequentially as the primary balance rather than the UX available credit overriding it. * refactor(sync): clarify balance and available credit calculations in EnableBanking processor Refactors the debt polarity assignments to clarify why liability balances are strictly parsed as absolute positive numbers. Replaces the implicit ordering dependency between the '.abs' conversion and the 'available_credit' math with an explicit 'outstanding_debt' variable to prevent regression by future maintainers. * style: remove trailing whitespace in EnableBanking processor --- .../enable_banking_account/processor.rb | 47 ++++++++++--------- .../enable_banking_account/processor_test.rb | 11 +++-- 2 files changed, 31 insertions(+), 27 deletions(-) diff --git a/app/models/enable_banking_account/processor.rb b/app/models/enable_banking_account/processor.rb index a6b546f0a..1a7f96539 100644 --- a/app/models/enable_banking_account/processor.rb +++ b/app/models/enable_banking_account/processor.rb @@ -41,29 +41,32 @@ class EnableBankingAccount::Processor available_credit = nil # For liability accounts, ensure balance sign is correct. - # DELIBERATE UX DECISION: For CreditCards, we display the available credit (credit_limit - outstanding debt) - # rather than the raw outstanding debt. Do not revert this behavior, as future maintainers should understand - # users expect to see how much credit they have left rather than their debt balance. - # The 'available_credit' calculation overrides the 'balance' variable. - if account.accountable_type == "Loan" - balance = balance.abs - elsif account.accountable_type == "CreditCard" - if enable_banking_account.credit_limit.present? - available = enable_banking_account.credit_limit - balance.abs - available_credit = [ available, 0 ].max - balance = available_credit - unless account.accountable.present? - Rails.logger.warn "EnableBankingAccount::Processor - CreditCard accountable missing for account #{account.id}" + # 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 + outstanding_debt = balance.abs + + # Override the top-level balance variable intended for the account + balance = outstanding_debt + + if account.accountable_type == "CreditCard" + if enable_banking_account.credit_limit.present? + # Compute available credit based on the strictly positive outstanding debt + available = enable_banking_account.credit_limit - outstanding_debt + available_credit = [ available, 0 ].max + unless account.accountable.present? + Rails.logger.warn "EnableBankingAccount::Processor - CreditCard accountable missing for account #{account.id}" + end + elsif account.accountable&.available_credit.present? + # Fallback: no credit_limit from API — compute it using available_credit defined at account level + Rails.logger.info "Using stored available_credit fallback for account #{account.id}" + available_credit = account.accountable.available_credit end - elsif account.accountable&.available_credit.present? - # Fallback: no credit_limit from API — compute it using available_credit defined at account level - Rails.logger.info "Using stored available_credit fallback for account #{account.id}" - available_credit = account.accountable.available_credit - outstanding = balance.abs - balance = [ available_credit - outstanding, 0 ].max - else - # Fallback: no credit_limit from API — display raw outstanding balance - # We cannot derive available credit without knowing the limit; leave balance unchanged. end end diff --git a/test/models/enable_banking_account/processor_test.rb b/test/models/enable_banking_account/processor_test.rb index 6bdbebda1..ab4c41b1b 100644 --- a/test/models/enable_banking_account/processor_test.rb +++ b/test/models/enable_banking_account/processor_test.rb @@ -42,7 +42,7 @@ class EnableBankingAccount::ProcessorTest < ActiveSupport::TestCase assert_nil result end - test "sets CC balance to available_credit when credit_limit is present" do + test "sets CC balance as absolute debt and tracks available_credit when limit is present" do cc_account = accounts(:credit_card) @enable_banking_account.update!( current_balance: 450.00, @@ -53,13 +53,13 @@ class EnableBankingAccount::ProcessorTest < ActiveSupport::TestCase EnableBankingAccount::Processor.new(@enable_banking_account).process - assert_equal 550.0, cc_account.reload.cash_balance + assert_equal 450.0, cc_account.reload.cash_balance if cc_account.accountable.respond_to?(:available_credit) assert_equal 550.0, cc_account.accountable.reload.available_credit end end - test "falls back to stored available_credit when credit_limit is absent" do + test "sets CC balance as absolute debt and keeps stored available_credit when limit absent" do cc_account = accounts(:credit_card) cc_account.accountable.update!(available_credit: 1000.0) @@ -70,10 +70,11 @@ class EnableBankingAccount::ProcessorTest < ActiveSupport::TestCase EnableBankingAccount::Processor.new(@enable_banking_account).process - assert_equal 700.0, cc_account.reload.cash_balance + assert_equal 300.0, cc_account.reload.cash_balance + assert_equal 1000.0, cc_account.accountable.reload.available_credit end - test "sets CC balance to raw outstanding when credit_limit is absent" do + test "sets CC balance to absolute debt when both limit and stored available_credit are absent" do cc_account = accounts(:credit_card) cc_account.accountable.update!(available_credit: nil)