mirror of
https://github.com/we-promise/sure.git
synced 2026-07-17 23:35:23 +00:00
* feat(goals): earmark a portion of an account toward a goal Goals currently count each linked account's whole balance, so an account shared across goals double-counts and one account can't fund several goals in distinct slices. Add a per-account earmark — the "GoalBacking" the v1 model already foreshadowed (goal.rb). - goal_accounts.allocated_amount (nullable). NULL = "dedicate the whole balance" (the v1 default: no backfill, existing goals unchanged); a set amount reserves a fixed slice. - Goal#current_balance is now the single chokepoint computing each account's backing under a family-wide shared pool: fixed earmarks take their slice, an unallocated link takes the remainder, and when fixed earmarks exceed the balance every slice is scaled down pro-rata so the goals' shares can never sum past the account balance (no double-counting). - Account#free_to_earmark / #goal_earmarked_total (mirror Budget's available_to_allocate) back a soft, non-blocking over-allocation hint. - GoalsController threads a goal[allocations] hash through create/update. Phase 1 of the goals earmarking work; investment-backed goals follow. * feat(goals): earmark UI on the goal form + backing-aware funding breakdown - Goal form: a per-account "earmark amount" input (blank = whole balance) next to each funding-account checkbox, prefilled from the saved allocation on edit. - Goal#account_backing exposes a single linked account's share so the funding-accounts breakdown shows each account's earmarked contribution and percent instead of its whole balance — keeping the show page consistent with the (now allocation-aware) progress ring. - English strings for the earmark controls and the "earmarked of balance" breakdown line. * fix(goals): address review on the earmark shared-pool math - Overdrawn (<= 0 balance) accounts now back nothing on both the fixed and whole-balance paths. The fixed path previously produced negative backing and let a goal claim money the account doesn't hold. - An archived goal reads its OWN earmark from its own goal_accounts instead of the shared pool (which excludes archived goals), so it no longer mis-reports the whole account balance for itself. - goals#index injects one family-wide earmark pool into every card (Goal.pooled_allocations_for) instead of querying once per goal (N+1), and preloads goal_accounts. - The projection chart scales its whole-account historical series by the backing ratio so the saved line meets current_balance at "today" rather than dropping off a cliff for earmarked goals. - Honest comments: free_to_earmark no longer claims a form warning that doesn't exist yet; pace documents its deliberate whole-account basis. * fix(goals): widen the earmark input so the 'Whole balance' placeholder isn't clipped * fix(goals): address review on #2490 - autosave: true on goal_accounts so earmark edits to already-linked accounts persist through goal.save! (Rails only auto-saves newly built children, so changing/clearing an existing earmark was silently dropped). + test. - Reset the balance/progress memos on AASM transitions, not just the status memos, so a same-instance render after complete!/archive! isn't stale. + test. - backing_ratio is 0 (not 1) when the linked-account total is non-positive, so the projection saved series ends at 0 to match the forced-zero current_balance. - Localize the funding-row subtype label via goals.form.subtypes.*. - Add the earmark strings to zh-CN (the maintained second locale; goals has no ca locale, so Catalan keeps falling back to en like the rest of goals). * feat(goals): investment-backed goals (Phase 2) Goals can now be funded by investment accounts, not just depository. - Relax linked_accounts_must_be_depository -> _must_be_fundable (depository || investment); the funding picker + counts include investment accounts. - Add goals.progress_basis ('balance' | 'contributions', default 'balance'). Investment-backed goals default to 'contributions' so a market swing doesn't move the goal: current_balance = value - cumulative market gain (Sum of balances.net_market_flows); depository accounts have zero net_market_flows, so they're unchanged. Goal#market_value_money shows what it's worth today next to the contributed figure on the show page. - Pledge false-match guard: investment accounts never use manual_save / valuation-delta matching (a market move isn't a deposit) - they resolve on transfer (cash-inflow) entries only. Guarded in both Account#default_pledge_kind and GoalPledge#matches?. - Add a `reopen` AASM event (completed -> active) + route/action/menu item so a manually-completed goal whose value later dips can be reopened. Stacked on the earmarking branch (#2490). Full suite green; +6 goal tests. * fix(goals): address Phase 2 review — allocation-aware contributions, N+1, basis-on-update - Contributions basis now goes through the same earmark/shared-pool logic as the balance basis: backing_balance_for -> backing_share_for(account, base), where base is the live balance (balance basis) or net contributions (contributions basis). Earmarks are respected and shared accounts no longer double-count on contributions goals; market_value_money stays consistent. - Batch the per-account net_market_flows sum (Goal.market_flows_for) and inject it on index like pooled_allocations, killing the N+1 for contributions goals. - Default the basis on update too (not just create), so adding an investment account to an existing depository goal flips it to contributions instead of silently tracking market value. - Fix the stale reconciliation_manager comment (renamed validation) and the orphaned zh-CN must_be_depository key. * fix(goals): address review on #2491 - before_save (not before_validation) for the progress_basis default, so a goal can be inspected via valid? without its basis flipping as a side effect (jjmata). - Pledge copy keys off default_pledge_kind, not manual?, so a manual investment account — which pledges via transfer — shows the transfer prompt instead of the "update your manual balance" flow (codex). pledge_action_label_key and the pledge modal's per-account helper flag both use it. - Add the Phase 2 strings (reopen success/invalid_transition, show.reopen, ring.market_value) to zh-CN. --------- Signed-off-by: Juan José Mata <juanjo.mata@gmail.com> Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
662 lines
23 KiB
Ruby
662 lines
23 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)
|
|
initial_balance = attributes.dig(:accountable_attributes, :initial_balance)&.to_d
|
|
|
|
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
|
|
|
|
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_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_kraken_account(kraken_account)
|
|
create_from_crypto_exchange_account(kraken_account, family: kraken_account.kraken_item.family)
|
|
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
|
|
# non-archived goal (unallocated/whole-balance links reserve no fixed
|
|
# slice). Mirrors Budget#allocated_spending.
|
|
def goal_earmarked_total
|
|
GoalAccount.joins(:goal)
|
|
.where(account_id: id)
|
|
.where.not(allocated_amount: nil)
|
|
.where.not(goals: { state: "archived" })
|
|
.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
|
|
|
|
# Total fixed earmark this account currently has reserved across every
|
|
# non-archived goal (unallocated/whole-balance links reserve no fixed
|
|
# slice). Mirrors Budget#allocated_spending.
|
|
def goal_earmarked_total
|
|
GoalAccount.joins(:goal)
|
|
.where(account_id: id)
|
|
.where.not(allocated_amount: nil)
|
|
.where.not(goals: { state: "archived" })
|
|
.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!
|
|
records = family.users.where.not(id: owner_id).pluck(:id).map do |user_id|
|
|
{ account_id: id, user_id: user_id, permission: "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&.find_by(role: %w[admin super_admin]) || family&.users&.order(:created_at)&.first
|
|
end
|
|
end
|
|
|
|
def owner_belongs_to_family
|
|
return if User.where(id: owner_id, family_id: family_id).exists?
|
|
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))
|
|
|
|
transfers.find_each(&:destroy!)
|
|
end
|
|
end
|