Files
sure/app/models/account/linkable.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

93 lines
3.0 KiB
Ruby

module Account::Linkable
extend ActiveSupport::Concern
included do
# New generic provider association
has_many :account_providers, dependent: :destroy
# Legacy provider associations - kept for backward compatibility during migration
belongs_to :plaid_account, optional: true
belongs_to :simplefin_account, optional: true
# SQL-level mirror of `linked?`. Use this for set-based checks (e.g. bulk
# `EXISTS`) so both definitions stay in sync. If `linked?` adds a new
# provider source, update this scope too.
scope :linked, -> {
left_outer_joins(:account_providers)
.where(
"account_providers.id IS NOT NULL OR accounts.plaid_account_id IS NOT NULL OR accounts.simplefin_account_id IS NOT NULL"
)
.distinct
}
end
# A "linked" account gets transaction and balance data from a third party like Plaid or SimpleFin
def linked?
account_providers.any? || plaid_account.present? || simplefin_account.present?
end
# An "offline" or "unlinked" account is one where the user tracks values and
# adds transactions manually, without the help of a data provider
def unlinked?
!linked?
end
alias_method :manual?, :unlinked?
# Returns the primary provider adapter for this account
# If multiple providers exist, returns the first one
def provider
return nil unless linked?
@provider ||= account_providers.first&.adapter
end
# Returns all provider adapters for this account
def providers
@providers ||= account_providers.map(&:adapter).compact
end
# Returns the provider adapter for a specific provider type
def provider_for(provider_type)
account_provider = account_providers.find_by(provider_type: provider_type)
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
return provider&.provider_name if provider.present?
# Fall back to legacy system
return "plaid" if plaid_account.present?
return "simplefin" if simplefin_account.present?
nil
end
# Check if account is linked to a specific provider
def linked_to?(provider_type)
account_providers.exists?(provider_type: provider_type)
end
# Whether this account's provider applies the category matcher to imported
# transactions. Only Plaid honors `enable_category_matcher` today; extend this
# when other providers (e.g. SimpleFIN) wire up category matching.
def supports_category_matcher?
plaid_account.present? || linked_to?("PlaidAccount")
end
# Check if holdings can be deleted
# If account has multiple providers, returns true only if ALL providers allow deletion
# This prevents deleting holdings that would be recreated on next sync
def can_delete_holdings?
return true if unlinked?
providers.all?(&:can_delete_holdings?)
end
end