Files
sure/app/models/account.rb
T
Aland BabanandAland Baban 0cdab9a0bc Add first-class Trade Republic support (#3168)
* Add Trade Republic provider integration

Introduce authenticated web and QR login, resilient account synchronization, deterministic financial imports, account discovery, and provider diagnostics. Keep login state encrypted, PINs transient, and incomplete provider responses non-destructive.

* Address Trade Republic review findings

Keep QR-authenticated sessions syncable, preserve historical holding snapshots, correct dividend direction, handle unpriced positions safely, localize repair feedback, and align provider controls with the design system.

* Add Trade Republic translations for supported locales

* Restore German Trade Republic account labels

* Resolve remaining Trade Republic review findings

* Resolve remaining Trade Republic review findings

* Address latest Trade Republic review feedback

* Refactor Trade Republic panel buttons to use DS::Button component and add integration tests

* Fix 100x money inflation and missing positions locale key in TR views

Money.new takes major units, so multiplying by 100 displayed EUR 12.34
as EUR 1234 in the holdings category cards and expense summary. Also
add the pluralized holdings.index.positions key that t(".positions")
resolves to (previously only defined at the unused holdings.positions
root level), across all 18 locales.

* fix(db): repair merge artifacts in schema and migrations

- Remove duplicated icon/progress_basis columns on goals in schema.rb
- Renumber Trade Republic migrations to unique versions (clashed with
  main's 20260824120000_add_lifecycle_to_goals)
- Bump schema version to match latest migration

* Address remaining Trade Republic review feedback

* fix(trade-republic): address open PR #3168 review findings\n\n- Reject authenticated sessions without a securities account number so a\n  blank account does not mark the item connected on a broken session.\n- Derive a missing trade amount from |quantity| x price, and a missing\n  price from the resolved amount, without changing the signed import amount.\n- Regenerate db/schema.rb so the Trade Republic item/account tables and\n  indexes are present; a fresh test database was otherwise missing the\n  tables even though the migrations were marked up.\n

* fix(trade-republic): localize activity labels in ActivitiesProcessor (i18n)

* fix(trade-republic): localize activity labels in ActivitiesProcessor (i18n)

* fix(trade-republic): add activity labels i18n keys to all locale files

* Fix Trade Republic PR review follow-ups

* fix(trade-republic): i18n-aware category guard and ignore generated graphify cache

- Category matcher skipped core deposit/withdrawal labels; guard now compares
  against translated values so German etc skip correctly
- Remove committed graphify-out cache and ignore dir

* Protect holdings from malformed snapshots

* Consolidate Trade Republic migrations

* Address final Trade Republic review comments

* Address final Trade Republic review comments

- Remove hard-coded category matcher (merchant keyword taxonomy) and leave Trade Republic transactions uncategorized when no structured category exists; rely on Sure rules/AI
- Revert shared ProviderImportAdapter# import_trade extra: param; handle Trade Republic trade metadata locally in ActivitiesProcessor via post-import Trade extra merge (preserve existing extra, deep_merge)
- Preserve Trade Republic product distinctions (cash, brokerage/private_markets/interest_products/crypto_wallet via portfolio categories) without collapsing account kinds

---------

Co-authored-by: Aland Baban <snow@iBananaMac.fritz.box>
2026-09-03 00:24:49 +02:00

757 lines
27 KiB
Ruby

class Account < ApplicationRecord
include AASM, Syncable, Monetizable, Chartable, Linkable, Enrichable, Anchorable, Reconcileable, TaxTreatable
before_validation :assign_default_owner, if: -> { owner_id.blank? }
before_destroy :capture_account_statement_ids_to_move
before_destroy :cleanup_transfers
after_destroy_commit :move_account_statements_to_inbox
validates :name, :balance, :currency, presence: true
validate :owner_belongs_to_family, if: -> { owner_id.present? && family_id.present? }
belongs_to :family
belongs_to :owner, class_name: "User", optional: true
belongs_to :import, optional: true
has_many :account_shares, dependent: :destroy
has_many :shared_users, through: :account_shares, source: :user
has_many :import_mappings, as: :mappable, dependent: :destroy, class_name: "Import::Mapping"
has_many :entries, dependent: :destroy
has_many :transactions, through: :entries, source: :entryable, source_type: "Transaction"
has_many :valuations, through: :entries, source: :entryable, source_type: "Valuation"
has_many :trades, through: :entries, source: :entryable, source_type: "Trade"
has_many :holdings, dependent: :destroy
has_many :balances, dependent: :destroy
has_many :recurring_transactions, dependent: :destroy
has_many :goal_accounts, dependent: :destroy
has_many :goals, through: :goal_accounts
has_many :goal_pledges, dependent: :destroy
# Inverse for recurring transfers where this account is the destination.
# Account#recurring_transactions only matches account_id; without this
# association, destroying the destination account would hit the FK
# cascade silently and the AR cache wouldn't reflect the deletion.
has_many :inbound_recurring_transfers,
class_name: "RecurringTransaction",
foreign_key: :destination_account_id,
dependent: :destroy
monetize :balance, :cash_balance
enum :classification, { asset: "asset", liability: "liability" }, validate: { allow_nil: true }
VISIBLE_STATUSES = %w[draft active].freeze
HISTORICAL_STATUSES = (VISIBLE_STATUSES + %w[disabled]).freeze
scope :visible, -> { where(status: VISIBLE_STATUSES) }
scope :historical, -> { where(status: HISTORICAL_STATUSES) }
# Accounts whose data should be included in financial reports, dashboards,
# and exports. Excludes accounts where the user has opted to suppress them.
scope :included_in_reports, -> { where(exclude_from_reports: false) }
scope :assets, -> { where(classification: "asset") }
scope :liabilities, -> { where(classification: "liability") }
scope :alphabetically, -> { order(:name) }
scope :manual, -> {
left_joins(:account_providers)
.where(account_providers: { id: nil })
.where(plaid_account_id: nil, simplefin_account_id: nil)
}
scope :visible_manual, -> {
visible.manual
}
scope :listable_manual, -> {
manual.where.not(status: :pending_deletion)
}
# All accounts a user can access (owned + shared with them)
scope :accessible_by, ->(user) {
left_joins(:account_shares)
.where("accounts.owner_id = :uid OR account_shares.user_id = :uid", uid: user.id)
.distinct
}
# Accounts a user can write to (owned or shared with full_control)
scope :writable_by, ->(user) {
left_joins(:account_shares)
.where("accounts.owner_id = :uid OR (account_shares.user_id = :uid AND account_shares.permission = 'full_control')", uid: user.id)
.distinct
}
# Accounts that count in a user's financial calculations
scope :included_in_finances_for, ->(user) {
left_joins(:account_shares)
.where(
"accounts.owner_id = :uid OR " \
"(account_shares.user_id = :uid AND account_shares.include_in_finances = true)",
uid: user.id
)
.distinct
}
has_one_attached :logo, dependent: :purge_later
# No dependent: option; before_destroy captures IDs, after_destroy_commit moves statements back to inbox.
has_many :account_statements
delegated_type :accountable, types: Accountable::TYPES, dependent: :destroy
delegate :subtype, to: :accountable, allow_nil: true
# Writer for subtype that delegates to the accountable, allowing forms to set
# subtype directly on the account.
#
# On create the accountable is not built yet, and the chosen subtype is easy to
# drop because of mass-assignment ordering. Two cases:
#
# 1. `subtype` is applied while `accountable_type` is already known — build
# the accountable from the delegated type so the value lands on it. The
# later `accountable_attributes` assignment (update_only) then updates that
# same record instead of building a new one.
# 2. `subtype` is applied *before* `accountable_type` — this is the real
# controller path: strong-params `permit` preserves filter order, and
# `account_params` lists `:subtype` before `:accountable_type`, so the
# writer runs while the type (and thus `accountable_class`) is still
# unknown. We can't build the accountable yet, so stash the value and
# apply it from `accountable_type=` once the type is set.
def subtype=(value)
self.accountable = accountable_class.new if accountable.nil? && accountable_type.present?
if accountable
accountable.subtype = value
else
@deferred_subtype = value
end
end
# Applies a subtype that arrived before the type was known (see `subtype=`
# case 2). `super` resolves `accountable_type`/`accountable_class` first, then
# the re-entrant `subtype=` builds the accountable and assigns the value.
def accountable_type=(value)
super
if defined?(@deferred_subtype)
pending = @deferred_subtype
remove_instance_variable(:@deferred_subtype)
self.subtype = pending
end
end
accepts_nested_attributes_for :accountable, update_only: true
# Account state machine
aasm column: :status, timestamps: true do
state :active, initial: true
state :draft
state :disabled
state :pending_deletion
event :activate do
transitions from: [ :draft, :disabled ], to: :active
end
event :disable do
transitions from: [ :draft, :active ], to: :disabled
end
event :enable do
transitions from: :disabled, to: :active
end
event :mark_for_deletion do
transitions from: [ :draft, :active, :disabled ], to: :pending_deletion
end
end
class << self
def human_attribute_name(attribute, options = {})
options = { moniker: Current.family&.moniker_label || "Family" }.merge(options)
super(attribute, options)
end
def create_and_sync(attributes, skip_initial_sync: false, opening_balance_date: nil)
attributes[:accountable_attributes] ||= {} # Ensure accountable is created, even if empty
# Default cash_balance to balance unless explicitly provided (e.g., Crypto sets it to 0)
attrs = attributes.dup
attrs[:cash_balance] = attrs[:balance] unless attrs.key?(:cash_balance)
account = new(attrs)
# Presence is read from the raw value: a blank form field arrives as ""
# and would convert to a very present-looking 0.
raw_initial_balance = attributes.dig(:accountable_attributes, :initial_balance)
initial_balance = raw_initial_balance.to_d if raw_initial_balance.present?
transaction do
account.save!
manager = Account::OpeningBalanceManager.new(account)
result = manager.set_opening_balance(
balance: initial_balance || account.balance,
date: opening_balance_date
)
raise result.error if result.error
# When the opening balance differs from the entered current balance
# (a loan created with its original principal), the opening anchor is
# the account's only entry — the initial sync would recalculate
# today's balance back to it, silently discarding what the user just
# typed. Anchor today's balance too so both survive.
#
# Only when the opening anchor is on an earlier day: the opening date
# is user-supplied and may be today, and a same-day reconciliation
# would be matched to the opening anchor by date and overwrite it.
# On its own date the opening balance wins.
if initial_balance && initial_balance != account.balance && manager.opening_date < Date.current
# An explicit reconciliation, not CurrentBalanceManager: for cash
# accounts its transaction-adjustment strategy computes a zero delta
# here (account.balance already holds the entered value) and would
# only rewrite the opening anchor, leaving today's balance unanchored
# for the first sync.
reconciliation = Account::ReconciliationManager.new(account).reconcile_balance(
balance: account.balance,
date: Date.current
)
raise reconciliation.error_message unless reconciliation.success?
end
account.auto_share_with_family! if account.family.share_all_by_default?
end
# Skip initial sync for linked accounts - the provider sync will handle balance creation
# after the correct currency is known
account.sync_later unless skip_initial_sync
account
end
def create_from_simplefin_account(simplefin_account, account_type, subtype = nil)
# Respect user choice when provided; otherwise infer a sensible default
# Require an explicit account_type; do not infer on the backend
if account_type.blank? || account_type.to_s == "unknown"
raise ArgumentError, "account_type is required when creating an account from SimpleFIN"
end
# Get the balance from SimpleFin
balance = simplefin_account.current_balance || simplefin_account.available_balance || 0
# SimpleFin returns negative balances for credit cards (liabilities)
# But Sure expects positive balances for liabilities
if account_type == "CreditCard" || account_type == "Loan"
balance = balance.abs
end
# Calculate cash balance correctly for investment accounts
cash_balance = balance
if account_type == "Investment"
begin
calculator = SimplefinAccount::Investments::BalanceCalculator.new(simplefin_account)
calculated = calculator.cash_balance
cash_balance = calculated unless calculated.nil?
rescue => e
Rails.logger.warn(
"Investment cash_balance calculation failed for " \
"SimpleFin account #{simplefin_account.id}: #{e.class} - #{e.message}"
)
# Fallback to zero as suggested
cash_balance = 0
end
end
family = simplefin_account.simplefin_item.family
attributes = {
family: family,
name: simplefin_account.name,
balance: balance,
cash_balance: cash_balance,
currency: simplefin_account.currency,
accountable_type: account_type,
accountable_attributes: build_simplefin_accountable_attributes(simplefin_account, account_type, subtype),
simplefin_account_id: simplefin_account.id
}
# Skip initial sync - provider sync will handle balance creation with correct currency
create_and_sync(attributes, skip_initial_sync: true)
end
def create_from_enable_banking_account(enable_banking_account, account_type, subtype = nil)
# Get the balance from Enable Banking
balance = enable_banking_account.current_balance || 0
# Enable Banking may return negative balances for liabilities
# Sure expects positive balances for liabilities
if account_type == "CreditCard" || account_type == "Loan"
balance = balance.abs
end
cash_balance = balance
family = enable_banking_account.enable_banking_item.family
attributes = {
family: family,
name: enable_banking_account.name,
balance: balance,
cash_balance: cash_balance,
currency: enable_banking_account.currency || "EUR"
}
accountable_attributes = {}
accountable_attributes[:subtype] = subtype if subtype.present?
# Skip initial sync - provider sync will handle balance creation with correct currency
create_and_sync(
attributes.merge(
accountable_type: account_type,
accountable_attributes: accountable_attributes
),
skip_initial_sync: true
)
end
def create_from_wise_account(wise_account)
family = wise_account.wise_item.family
create_and_sync(
{
family: family,
name: wise_account.name || "Wise #{wise_account.currency}",
balance: wise_account.current_balance || 0,
cash_balance: wise_account.current_balance || 0,
currency: wise_account.currency,
accountable_type: "Depository",
accountable_attributes: { subtype: wise_account.account_subtype }
},
skip_initial_sync: true
)
end
def create_from_coinbase_account(coinbase_account)
# All Coinbase accounts are crypto exchange accounts
family = coinbase_account.coinbase_item.family
# Extract native balance and currency from Coinbase (e.g., USD, EUR, GBP)
native_balance = coinbase_account.raw_payload&.dig("native_balance", "amount").to_d
native_currency = coinbase_account.raw_payload&.dig("native_balance", "currency") || family.currency
attributes = {
family: family,
name: coinbase_account.name,
balance: native_balance,
cash_balance: 0, # No cash - all value is in holdings
currency: native_currency,
accountable_type: "Crypto",
accountable_attributes: {
subtype: "exchange",
tax_treatment: "taxable"
}
}
# Skip initial sync - provider sync will handle balance/holdings creation
create_and_sync(attributes, skip_initial_sync: true)
end
def create_from_binance_account(binance_account)
account = create_from_crypto_exchange_account(binance_account, family: binance_account.binance_item.family)
account.set_opening_anchor_balance(balance: 0)
account
end
def create_from_ibkr_account(ibkr_account)
family = ibkr_account.ibkr_item.family
default_name = if ibkr_account.ibkr_account_id.present?
"Interactive Brokers (#{ibkr_account.ibkr_account_id})"
else
"Interactive Brokers"
end
attributes = {
family: family,
name: default_name,
balance: 0,
cash_balance: 0,
currency: ibkr_account.currency.presence || family.currency,
accountable_type: "Investment",
accountable_attributes: {
subtype: "brokerage"
}
}
# Capture the created account in a variable
create_and_sync(attributes, skip_initial_sync: true)
end
def create_from_trading212_account(trading212_account)
family = trading212_account.trading212_item.family
attributes = {
family: family,
name: trading212_account.name.presence || "Trading 212",
balance: 0,
cash_balance: 0,
currency: trading212_account.currency.presence || family.currency,
accountable_type: "Investment",
accountable_attributes: {
subtype: "brokerage"
}
}
create_and_sync(attributes, skip_initial_sync: true)
end
def create_from_trade_republic_account(trade_republic_account)
family = trade_republic_account.trade_republic_item.family
is_cash = trade_republic_account.cash?
attributes = {
family: family,
name: trade_republic_account.name.presence || (is_cash ? "Trade Republic Cash" : "Trade Republic Portfolio"),
balance: 0,
cash_balance: 0,
currency: trade_republic_account.currency.presence || family.currency,
accountable_type: is_cash ? "Depository" : "Investment",
accountable_attributes: {
subtype: is_cash ? "checking" : "brokerage"
}
}
create_and_sync(attributes, skip_initial_sync: true)
end
def create_from_kraken_account(kraken_account)
create_from_crypto_exchange_account(kraken_account, family: kraken_account.kraken_item.family)
end
# Self-custody assets are wallets, not exchanges: no trade entry by hand,
# and no cash side. The balance is written by the provider sync, which is
# the only thing that knows what the chain says.
def create_from_onchain_wallet_account(onchain_wallet_account)
family = onchain_wallet_account.onchain_wallet_item.family
create_and_sync(
{
family: family,
name: onchain_wallet_account.display_name,
balance: 0,
cash_balance: 0,
currency: onchain_wallet_account.currency.presence || family.currency,
accountable_type: "Crypto",
accountable_attributes: {
subtype: "wallet",
tax_treatment: "taxable"
}
},
skip_initial_sync: true
)
end
private
def create_from_crypto_exchange_account(provider_account, family:)
attributes = {
family: family,
name: provider_account.name,
balance: (provider_account.current_balance || 0).to_d,
cash_balance: 0,
currency: provider_account.currency.presence || family.currency,
accountable_type: "Crypto",
accountable_attributes: {
subtype: "exchange",
tax_treatment: "taxable"
}
}
create_and_sync(attributes, skip_initial_sync: true)
end
def build_simplefin_accountable_attributes(simplefin_account, account_type, subtype)
attributes = {}
attributes[:subtype] = subtype if subtype.present?
# Set account-type-specific attributes from SimpleFin data
case account_type
when "CreditCard"
# For credit cards, available_balance often represents available credit
if simplefin_account.available_balance.present? && simplefin_account.available_balance > 0
attributes[:available_credit] = simplefin_account.available_balance
end
when "Loan"
# For loans, we might get additional data from the raw_payload
# This is where loan-specific information could be extracted if available
# Currently we don't have specific loan fields from SimpleFin protocol
end
attributes
end
end
def institution_name
read_attribute(:institution_name).presence || provider&.institution_name
end
def institution_domain
read_attribute(:institution_domain).presence || provider&.institution_domain
end
def manual_crypto_exchange?
accountable_type == "Crypto" &&
accountable&.subtype == "exchange" &&
manual?
end
# True when the account has no live sync provider attached. Mirrors the
# `Account.manual` scope so per-instance checks don't drift from the query.
def manual?
account_providers.none? &&
plaid_account_id.blank? &&
simplefin_account_id.blank?
end
# Default GoalPledge kind for this account. Manual accounts get
# `manual_save` (resolves on the next valuation), live-synced accounts
# get `transfer` (resolves when the synced deposit posts). Keeps the
# decision in one place so the new-pledge controller / preview helper
# can't disagree on what they're going to save.
def default_pledge_kind
# Investment accounts never use manual_save: a positive valuation delta on a
# brokerage is usually a market move, not a deposit, and would false-match a
# pledge. They resolve on transfer (cash-inflow) entries only.
manual? && !investment? ? "manual_save" : "transfer"
end
# Total fixed earmark this account currently has reserved across every goal
# still holding its money (unallocated/whole-balance links reserve no fixed
# slice). Mirrors Budget#allocated_spending. Scoped to Goal::RELEASED_STATES
# so this and Goal.pooled_allocations_for never disagree — if they did,
# free_to_earmark would contradict the figures the goals themselves show.
def goal_earmarked_total
GoalAccount.joins(:goal)
.where(account_id: id)
.where.not(allocated_amount: nil)
.where.not(goals: { state: Goal::RELEASED_STATES })
.sum(:allocated_amount)
.to_d
end
# Headroom left to earmark toward goals before fixed allocations exceed the
# balance. Negative means the account is over-earmarked. Intended to back a
# non-blocking over-allocation warning (UI is a follow-up). Mirrors
# Budget#available_to_allocate.
def free_to_earmark
balance.to_d - goal_earmarked_total
end
def logo_url
if institution_domain.present? && Setting.brand_fetch_client_id.present?
logo_size = Setting.brand_fetch_logo_size
"https://cdn.brandfetch.io/#{institution_domain}/icon/fallback/lettermark/w/#{logo_size}/h/#{logo_size}?c=#{Setting.brand_fetch_client_id}"
elsif provider&.logo_url.present?
provider.logo_url
elsif logo.attached?
Rails.application.routes.url_helpers.rails_blob_path(logo, only_path: true)
end
end
def destroy_later
transaction do
mark_for_deletion!
DestroyJob.perform_later(self)
end
end
# Override destroy to handle error recovery for accounts
def destroy
super
rescue => e
# If destruction fails, transition back to disabled state
# This provides a cleaner recovery path than the generic scheduled_for_deletion flag
disable! if may_disable?
raise e
end
def current_holdings
if (provider_snapshot_date = latest_provider_holdings_snapshot_date)
holdings
.where.not(account_provider_id: nil)
.where(date: provider_snapshot_date)
.where.not(qty: 0)
.order(amount: :desc)
else
holdings
.where(currency: currency)
.where.not(qty: 0)
.where(
id: holdings.select("DISTINCT ON (security_id) id")
.where(currency: currency)
.order(:security_id, date: :desc)
)
.order(amount: :desc)
end
end
def latest_provider_holdings_snapshot_date
holdings.where.not(account_provider_id: nil).maximum(:date)
end
def start_date
first_entry_date = entries.minimum(:date) || Date.current
first_entry_date - 1.day
end
def lock_saved_attributes!
super
accountable.lock_saved_attributes!
end
def first_valuation
entries.valuations.order(:date).first
end
def first_valuation_amount
first_valuation&.amount_money || balance_money
end
# Get short version of the subtype label
def short_subtype_label
accountable_class.short_subtype_label_for(subtype) || accountable_class.display_name
end
# Get long version of the subtype label
def long_subtype_label
accountable_class.long_subtype_label_for(subtype) || accountable_class.display_name
end
def supports_default?
depository? || credit_card?
end
def eligible_for_transaction_default?
supports_default? && active? && !linked?
end
# Determines if this account supports manual trade entry
# Investment accounts always support trades; Crypto only if subtype is "exchange"
def supports_trades?
return true if investment?
return accountable.supports_trades? if crypto? && accountable.respond_to?(:supports_trades?)
false
end
def traded_standard_securities
Security.where(id: holdings.select(:security_id))
.standard
.distinct
.order(:ticker)
end
# The balance type determines which "component" of balance is being tracked.
# This is primarily used for balance related calculations and updates.
#
# "Cash" = "Liquid"
# "Non-cash" = "Illiquid"
# "Investment" = A mix of both, including brokerage cash (liquid) and holdings (illiquid)
def balance_type
case accountable_type
when "Depository", "CreditCard"
:cash
when "Property", "Vehicle", "OtherAsset", "Loan", "OtherLiability"
:non_cash
when "Investment", "Crypto"
:investment
else
raise "Unknown account type: #{accountable_type}"
end
end
def owned_by?(user)
user.present? && owner_id == user.id
end
def shared_with?(user)
return false if user.nil?
owned_by?(user) ||
if account_shares.loaded?
account_shares.any? { |s| s.user_id == user.id }
else
account_shares.exists?(user: user)
end
end
def shared?
account_shares.any?
end
def permission_for(user)
return :owner if owned_by?(user)
account_shares.find_by(user: user)&.permission&.to_sym
end
def share_with!(user, permission: "read_only", include_in_finances: true)
account_shares.create!(user: user, permission: permission, include_in_finances: include_in_finances)
end
def unshare_with!(user)
account_shares.where(user: user).destroy_all
end
def auto_share_with_family!
# Guests get read_only, everyone else read_write. This mirrors
# Family#auto_share_existing_accounts_with so a guest's permission on an
# account is the same whether they joined before or after it was created.
records = family.users.where.not(id: owner_id).pluck(:id, :role).map do |user_id, role|
{ account_id: id, user_id: user_id,
permission: role == "guest" ? "read_only" : "read_write",
include_in_finances: true, created_at: Time.current, updated_at: Time.current }
end
AccountShare.insert_all(records, unique_by: %i[account_id user_id]) if records.any?
end
private
def assign_default_owner
return if owner.present?
if Current.user.present? && Current.user.family_id == family_id
self.owner = Current.user
else
self.owner =
family&.users&.where(role: "admin")&.order(:created_at)&.first ||
family&.users&.where(role: "super_admin")&.order(:created_at)&.first ||
family&.users&.order(:created_at)&.first
end
end
def owner_belongs_to_family
owner_user = User.lock.find_by(id: owner_id)
return if owner_user&.family_id == family_id
errors.add(:owner, :invalid, message: "must belong to the same family as the account")
end
def capture_account_statement_ids_to_move
@statement_ids_to_move = account_statements.ids
end
def move_account_statements_to_inbox
statement_ids = Array(@statement_ids_to_move).compact
return if statement_ids.empty?
# Bypass callbacks deliberately: the account was destroyed, so linked statements need a direct inbox move.
AccountStatement.where(id: statement_ids).update_all(
account_id: nil,
review_status: "unmatched",
match_confidence: nil,
updated_at: Time.current
)
end
def cleanup_transfers
transaction_ids = entries.where(entryable_type: "Transaction").pluck(:entryable_id)
transfers = Transfer.where(inflow_transaction_id: transaction_ids).or(Transfer.where(outflow_transaction_id: transaction_ids))
.includes(inflow_transaction: { entry: { account: :family } }, outflow_transaction: { entry: { account: :family } })
transfers.find_each(&:destroy!)
end
end