diff --git a/app/controllers/credit_cards_controller.rb b/app/controllers/credit_cards_controller.rb index 4d0f56594..982ce5483 100644 --- a/app/controllers/credit_cards_controller.rb +++ b/app/controllers/credit_cards_controller.rb @@ -9,4 +9,30 @@ class CreditCardsController < ApplicationController :annual_fee, :expiration_date ) + + def update + super + # Only apply provider settings once the account update succeeded (redirect); + # a failed update renders :edit and must not persist the flag. + update_enable_banking_settings if response.redirect? + end + + private + def update_enable_banking_settings + eb_params = params.permit(account: { enable_banking: [ :treat_balance_as_available_credit ] }) + .dig(:account, :enable_banking) + return if eb_params.blank? + + provider_account = @account.provider_account_for("EnableBankingAccount") + return unless provider_account.present? + + provider_account.update!( + treat_balance_as_available_credit: ActiveModel::Type::Boolean.new.cast(eb_params[:treat_balance_as_available_credit]) + ) + + # Re-sync so the balance is reinterpreted right away instead of on the next scheduled sync + if provider_account.saved_change_to_treat_balance_as_available_credit? + provider_account.enable_banking_item.sync_later + end + end end diff --git a/app/models/account/linkable.rb b/app/models/account/linkable.rb index f079331e2..a3d3b5c98 100644 --- a/app/models/account/linkable.rb +++ b/app/models/account/linkable.rb @@ -52,6 +52,11 @@ module Account::Linkable account_provider&.adapter end + # Returns the raw provider account record (e.g. EnableBankingAccount) for a specific provider type + def provider_account_for(provider_type) + account_providers.find_by(provider_type: provider_type)&.provider + end + # Convenience method to get the provider name def provider_name # Try new system first diff --git a/app/models/enable_banking_account/processor.rb b/app/models/enable_banking_account/processor.rb index ef7a1cf4b..20e2218b6 100644 --- a/app/models/enable_banking_account/processor.rb +++ b/app/models/enable_banking_account/processor.rb @@ -51,6 +51,7 @@ class EnableBankingAccount::Processor 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 @@ -61,24 +62,10 @@ class EnableBankingAccount::Processor # 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 + balance = balance.abs 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 + balance, available_credit, skip_balance_update = interpret_credit_card_balance(account, balance) end end @@ -89,19 +76,85 @@ class EnableBankingAccount::Processor if account.accountable.present? && account.accountable.respond_to?(:available_credit=) account.accountable.update!(available_credit: available_credit) end - 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? + 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 diff --git a/app/views/credit_cards/_form.html.erb b/app/views/credit_cards/_form.html.erb index ef3d5d3d4..7209e118b 100644 --- a/app/views/credit_cards/_form.html.erb +++ b/app/views/credit_cards/_form.html.erb @@ -34,5 +34,18 @@ min: 0 %> <% end %> + + <% enable_banking_account = account.provider_account_for("EnableBankingAccount") if account.persisted? %> + <% if enable_banking_account %> +
+
+

<%= t("credit_cards.form.treat_balance_as_available_credit_label") %>

+

<%= t("credit_cards.form.treat_balance_as_available_credit_description") %>

+
+ <%= form.fields_for :enable_banking, enable_banking_account do |eb_form| %> + <%= eb_form.toggle :treat_balance_as_available_credit, "aria-labelledby": "enable_banking_balance_toggle_label" %> + <% end %> +
+ <% end %> <% end %> diff --git a/config/locales/views/credit_cards/en.yml b/config/locales/views/credit_cards/en.yml index ac5871982..486b4ab34 100644 --- a/config/locales/views/credit_cards/en.yml +++ b/config/locales/views/credit_cards/en.yml @@ -13,6 +13,12 @@ en: expiration_date: Expiration date minimum_payment: Minimum payment minimum_payment_placeholder: '100' + treat_balance_as_available_credit_description: Enable if your bank reports + this card's balance as remaining available credit instead of the amount + owed. The debt is then derived from the credit limit reported by your + bank, or from the Available credit field if your bank does not provide + one. + treat_balance_as_available_credit_label: Balance is available credit new: title: Enter credit card details overview: diff --git a/db/migrate/20260627101954_add_balance_reversal_to_enable_banking_accounts.rb b/db/migrate/20260627101954_add_balance_reversal_to_enable_banking_accounts.rb new file mode 100644 index 000000000..fa08c2213 --- /dev/null +++ b/db/migrate/20260627101954_add_balance_reversal_to_enable_banking_accounts.rb @@ -0,0 +1,5 @@ +class AddBalanceReversalToEnableBankingAccounts < ActiveRecord::Migration[7.2] + def change + add_column :enable_banking_accounts, :treat_balance_as_available_credit, :boolean, default: false, null: false + end +end diff --git a/db/schema.rb b/db/schema.rb index dd8ecc9d7..7d4095647 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -579,6 +579,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_07_14_120000) do t.string "product" t.decimal "credit_limit", precision: 19, scale: 4 t.jsonb "identification_hashes", default: [] + t.boolean "treat_balance_as_available_credit", default: false, null: false t.index ["account_id"], name: "index_enable_banking_accounts_on_account_id" t.index ["enable_banking_item_id"], name: "index_enable_banking_accounts_on_enable_banking_item_id" t.index ["identification_hashes"], name: "index_enable_banking_accounts_on_identification_hashes", using: :gin diff --git a/test/controllers/credit_cards_controller_test.rb b/test/controllers/credit_cards_controller_test.rb index 1b11ce558..7fd20f99a 100644 --- a/test/controllers/credit_cards_controller_test.rb +++ b/test/controllers/credit_cards_controller_test.rb @@ -92,4 +92,103 @@ class CreditCardsControllerTest < ActionDispatch::IntegrationTest assert_equal "Credit card account updated", flash[:notice] assert_enqueued_with(job: SyncJob) end + + test "updates enable banking balance interpretation flag when linked" do + enable_banking_account = create_linked_enable_banking_account + + get edit_credit_card_path(@account) + assert_response :success + assert_match "treat_balance_as_available_credit", response.body + assert_match I18n.t("credit_cards.form.treat_balance_as_available_credit_label"), response.body + + patch credit_card_path(@account), params: { + account: { + name: @account.name, + accountable_type: "CreditCard", + enable_banking: { treat_balance_as_available_credit: "1" } + } + } + + assert_redirected_to @account + assert enable_banking_account.reload.treat_balance_as_available_credit? + end + + test "clears enable banking balance interpretation flag when toggled off" do + enable_banking_account = create_linked_enable_banking_account + enable_banking_account.update!(treat_balance_as_available_credit: true) + + patch credit_card_path(@account), params: { + account: { + name: @account.name, + accountable_type: "CreditCard", + enable_banking: { treat_balance_as_available_credit: "0" } + } + } + + assert_redirected_to @account + assert_not enable_banking_account.reload.treat_balance_as_available_credit? + end + + test "does not persist enable banking flag when the account update fails" do + enable_banking_account = create_linked_enable_banking_account + + patch credit_card_path(@account), params: { + account: { + name: "", + accountable_type: "CreditCard", + enable_banking: { treat_balance_as_available_credit: "1" } + } + } + + assert_response :unprocessable_entity + assert_not enable_banking_account.reload.treat_balance_as_available_credit? + end + + test "handles malformed enable banking params without error" do + create_linked_enable_banking_account + + patch credit_card_path(@account), params: { + account: { + name: @account.name, + accountable_type: "CreditCard", + enable_banking: "bogus" + } + } + + assert_redirected_to @account + end + + test "ignores enable banking params for accounts without an enable banking link" do + patch credit_card_path(@account), params: { + account: { + name: "Still works", + accountable_type: "CreditCard", + enable_banking: { treat_balance_as_available_credit: "1" } + } + } + + assert_redirected_to @account + assert_equal "Still works", @account.reload.name + end + + private + def create_linked_enable_banking_account + enable_banking_item = EnableBankingItem.create!( + family: @account.family, + name: "Test EB", + country_code: "FR", + application_id: "app_id", + client_certificate: "cert" + ) + enable_banking_account = EnableBankingAccount.create!( + enable_banking_item: enable_banking_item, + name: "Linked card", + uid: "hash_cc", + currency: "EUR", + current_balance: 900.00, + credit_limit: 1000.00 + ) + AccountProvider.create!(account: @account, provider: enable_banking_account) + enable_banking_account + end end diff --git a/test/models/enable_banking_account/processor_test.rb b/test/models/enable_banking_account/processor_test.rb index 474bf2b9d..5a1ae5a0d 100644 --- a/test/models/enable_banking_account/processor_test.rb +++ b/test/models/enable_banking_account/processor_test.rb @@ -55,47 +55,140 @@ class EnableBankingAccount::ProcessorTest < ActiveSupport::TestCase 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, - credit_limit: 1000.00 + current_balance: 900.00, + credit_limit: 1000.00, + treat_balance_as_available_credit: true ) - AccountProvider.find_by(provider: @enable_banking_account)&.destroy - AccountProvider.create!(account: cc_account, provider: @enable_banking_account) + relink_provider_to(cc_account) EnableBankingAccount::Processor.new(@enable_banking_account).process - assert_equal 450.0, cc_account.reload.cash_balance + assert_equal 100.0, cc_account.reload.cash_balance if cc_account.accountable.respond_to?(:available_credit) - assert_equal 550.0, cc_account.accountable.reload.available_credit + assert_equal 1000.0, cc_account.accountable.reload.available_credit end end - test "sets CC balance as absolute debt and keeps stored available_credit when limit absent" do + test "when treat_balance_as_available_credit is true and card is overpaid, floors debt at zero" do cc_account = accounts(:credit_card) - cc_account.accountable.update!(available_credit: 1000.0) - @enable_banking_account.update!(current_balance: 300.00, credit_limit: nil) - - AccountProvider.find_by(provider: @enable_banking_account)&.destroy - AccountProvider.create!(account: cc_account, provider: @enable_banking_account) + @enable_banking_account.update!( + current_balance: 1050.00, # overpaid by 50 + credit_limit: 1000.00, + treat_balance_as_available_credit: true + ) + relink_provider_to(cc_account) EnableBankingAccount::Processor.new(@enable_banking_account).process - assert_equal 300.0, cc_account.reload.cash_balance + assert_equal 0.0, cc_account.reload.cash_balance + if cc_account.accountable.respond_to?(:available_credit) + assert_equal 1000.0, cc_account.accountable.reload.available_credit + end + end + + test "when treat_balance_as_available_credit is true and API limit absent, derives debt from manually set available_credit" do + cc_account = accounts(:credit_card) + + cc_account.accountable.update!(available_credit: 1000.0) + + @enable_banking_account.update!( + current_balance: 900.00, + credit_limit: nil, + treat_balance_as_available_credit: true + ) + + relink_provider_to(cc_account) + + EnableBankingAccount::Processor.new(@enable_banking_account).process + + # The manually configured available_credit acts as the credit limit and + # must survive the sync unchanged. + assert_equal 100.0, cc_account.reload.cash_balance assert_equal 1000.0, cc_account.accountable.reload.available_credit end + test "when treat_balance_as_available_credit is true but no limit is known, keeps existing balance" do + cc_account = accounts(:credit_card) + + cc_account.accountable.update!(available_credit: nil) + + @enable_banking_account.update!( + current_balance: 900.00, + credit_limit: nil, + treat_balance_as_available_credit: true + ) + + relink_provider_to(cc_account) + + balance_before = cc_account.cash_balance + + EnableBankingAccount::Processor.new(@enable_banking_account).process + + # The reported balance is available credit and there's no limit to reverse + # from, so the debt is unknown. The existing balance must not be overwritten, + # and available_credit must stay blank so a later sync can't mistake a + # stale reported balance for a credit limit. + assert_equal balance_before, cc_account.reload.cash_balance + assert_nil cc_account.accountable.reload.available_credit + end + + test "when treat_balance_as_available_credit is false, treats balance as absolute debt natively" do + cc_account = accounts(:credit_card) + + # API sends current_balance as debt (e.g. 100) and credit limit (e.g. 1000) + # Debt should remain 100, available credit becomes 900 + @enable_banking_account.update!( + current_balance: 100.00, + credit_limit: 1000.00, + treat_balance_as_available_credit: false + ) + relink_provider_to(cc_account) + + EnableBankingAccount::Processor.new(@enable_banking_account).process + + assert_equal 100.0, cc_account.reload.cash_balance + if cc_account.accountable.respond_to?(:available_credit) + assert_equal 900.0, cc_account.accountable.reload.available_credit + end + end + 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) @enable_banking_account.update!(current_balance: 300.00, credit_limit: nil) - AccountProvider.find_by(provider: @enable_banking_account)&.destroy - AccountProvider.create!(account: cc_account, provider: @enable_banking_account) + relink_provider_to(cc_account) EnableBankingAccount::Processor.new(@enable_banking_account).process assert_equal 300.0, cc_account.reload.cash_balance end + + test "treat_balance_as_available_credit flag is a no-op on Loan accounts" do + loan_account = accounts(:loan) + + # Even with the flag set to true, loans should only ever process as absolute debt + @enable_banking_account.update!( + current_balance: 50000.00, + credit_limit: 100000.00, + treat_balance_as_available_credit: true + ) + + relink_provider_to(loan_account) + + EnableBankingAccount::Processor.new(@enable_banking_account).process + + # Balance should match the absolute incoming balance, no credit limit math applied + assert_equal 50000.0, loan_account.reload.cash_balance + end + + private + def relink_provider_to(account) + AccountProvider.find_by(provider: @enable_banking_account)&.destroy + AccountProvider.create!(account: account, provider: @enable_banking_account) + end end