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