mirror of
https://github.com/we-promise/sure.git
synced 2026-09-07 23:54:25 +00:00
* fix(goals): stop two goals from each claiming the same account in full
A GoalAccount with a NULL `allocated_amount` means "dedicate the whole
balance". Two of them on one account each claimed all of it, so the money
was counted twice:
Livret A, 6,000 precaution 6,000 vacances 6,000
progress: 100% progress: 100%
`Goal#backing_share_for` cannot catch this. Its pro-rata haircut only
scales FIXED earmarks, and an unallocated link contributes `nil.to_d` —
zero — to `others_fixed`, so the two links never see each other. The
invariant "shares never sum past the balance" held for every earmark
except the one that claims everything.
Enforce it at the door: GoalAccount now refuses a second whole-balance
link on an account another non-archived goal already claims in full, and
asks for an amount instead. The scope matches
`Goal.pooled_allocations_for` — archived goals are excluded from the
backing math, so they do not block; completed goals still hold their
money, so they do.
Rows written before this guard stay readable and editable. Autosave
revalidates every loaded goal_account on `goal.save`, so validating
untouched links would make a goal that merely holds a legacy overlap
impossible to rename. Only a new link, or one whose amount is being
cleared onto a contested account, is checked.
The goal fixtures encoded exactly the forbidden state — three goals
claiming `depository` in full — so tests that built a fourth whole
claim now use accounts of their own. `build_goal` mirrors the old
balance, leaving every KPI figure unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DJ1npaGEHr6t2HW1rYZdt4
* fix(goals): keep a restored goal from re-claiming an account in full
Addresses review feedback on #3160, raised independently on #3165, #3166 and
#3167 — one bug seen four times, because those branches stack.
Archiving a goal releases the accounts it claimed in full, so another goal can
legitimately claim one while it is away. Restoring it then put two
whole-account links back on the same account and reopened the double-counting
`whole_account_link_must_be_exclusive` closes: that check only fires when a
link is written, and a state change writes none.
A validation rather than an AASM guard. `may_fire_event?` stays true, the save
fails, and GoalsController#perform_transition! already surfaces
`errors.full_messages` — the user reads which goal holds the account instead of
a generic "can't do that in this state".
The conflict lookup now lives in one place, `Goal#whole_account_conflicts_on`,
read both by the door (writing a link) and by the restore, so the two cannot
drift into disagreeing about which goals still hold their money. The
`new_record? || will_save_change_to_allocated_amount?` bound stays on the
validation rather than moving into the shared lookup: it exists so a goal
merely holding a legacy overlap can still be renamed.
`Goal::RELEASED_STATES` replaces the repeated "archived" literal. The old
comment already said this scope had to move with the pool's; the constant makes
that true rather than hoped for, and the restore guard will follow the day the
set grows.
Restores from a released state are guarded; `resume` from `paused` is not. A
paused goal never let go of its accounts, so nothing can legitimately have
claimed one meanwhile, and blocking it would strand a user on a goal they
merely shelved.
Two things this surfaced in the test data:
- The fixtures had three goals each claiming `depository` in full — the exact
state the rule forbids. `test "AASM transitions"` failed on it, a true
positive. Two of them now take a fixed 1,000 slice.
- `current_balance sums linked account balances` asserted the gross balance,
which only held because of that overlap. A whole-account link takes what is
left after other goals' fixed earmarks; the test now says so, and computes it
from the data rather than a constant.
Each guard was confirmed load-bearing by removing it and watching its test
fail. bin/rails test: 6936 runs, 27911 assertions, 0 failures. RuboCop,
erb_lint and Brakeman clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye
* fix(goals): treat moving a whole-account link as the fresh claim it is
Addresses review feedback on #3160.
The exclusivity check was bounded to `new_record? ||
will_save_change_to_allocated_amount?`. A persisted whole-account row whose
`account_id` or `goal_id` changes is neither, so moving one landed it on an
account nobody had checked — the same double-counting hole a restore opened,
through a different door.
The bound is widened rather than dropped. It exists because `Goal has_many
:goal_accounts, autosave: true` revalidates every loaded child on `goal.save`,
so an unguarded check makes a goal that merely holds a legacy overlap
impossible to rename. That reason still holds for every row along for the ride;
it does not hold for a row being moved. A test pins both faces.
bin/rails test: 6939 runs, 27916 assertions, 0 failures. RuboCop and Brakeman
clean. Confirmed load-bearing by narrowing the bound back and watching the move
test fail.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye
* fix(goals): stop a link that is changing goals from conflicting with itself
Addresses review feedback on #3160, on the widening that landed in 00894c16.
Guarding ownership changes was right, but the conflict query excluded the
current record by GOAL, not by row. A link changing `goal_id` still carries the
old one in the database, so the query handed the moving row straight back and
the link was refused as its own conflict — the guard blocked the exact case it
had just been widened to cover.
`whole_account_conflicts_on` now takes the row being written and excludes it by
id. The restore guard passes nothing, which is correct: exclusion by goal
already covers every link the goal being restored owns.
Confirmed load-bearing by dropping the id exclusion and watching the new test
fail. bin/rails test: 6940 runs, 27917 assertions, 0 failures. RuboCop and
Brakeman clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye
* fix(goals): keep the ticked accounts when a creation is rejected
Addresses review feedback on #3160.
`new.html.erb` rendered the form without `currently_linked_account_ids`, whose
default is `[]`, so a rejected create came back with every account unchecked.
The amounts survived — the form reads those off the same built links — which
made it worse rather than better: the user faced an error telling them to enter
an amount, on a form whose account selection had silently cleared, and a
multi-account selection was gone entirely.
The failed path now derives the ticks from the in-memory links rather than
`pluck`: nothing is persisted on a rejected create, so a query would come back
empty and change nothing.
Confirmed load-bearing by emptying the list again and watching the new
controller test fail. bin/rails test: 6941 runs, 27920 assertions, 0 failures.
RuboCop and erb_lint clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye
* fix(goals): hold the account while checking whether it is already claimed
The exclusivity validation is a read followed by a write, so two requests
could both find no conflict and both commit a whole-account claim — the
double-count the validation exists to prevent, recreated by timing alone.
`whole_account_conflicts_on` now takes a transaction-scoped advisory lock
per account before reading. An advisory lock rather than a row lock
because the conflicting write may be an INSERT, so there is no row to
lock; transaction-scoped so it is released whichever way the enclosing
transaction ends. Accounts are locked in id order, so two goals claiming
the same pair in opposite orders cannot deadlock against each other.
Both doors go through this method — the link validation and the restore
guard — so both are covered by the one change.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye
* fix(goals): bind the advisory lock key instead of interpolating it
Brakeman flagged the hand-built SQL, correctly. The key is a digest of an
id and could not carry a payload, but a raw interpolated string in a model
is the shape a reader has to stop and verify.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye
* fix(goals): stop the advisory lock warning on every acquisition
`pg_advisory_xact_lock` returns `void`, which the adapter cannot type, so
each call logged "unknown OID 2278". Projected through a subquery so the
result set is a plain integer.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye
* fix(goals): claim every account a goal touches in one deterministic order
Follow-up on the advisory lock: autosave validates each link separately, so
each was taking its own account lock in association order. Two goals saving
links on the same two accounts in opposite orders would then hold one lock
each and wait on the other.
The goal takes the whole set up front, sorted, before any child validates.
The per-account lock inside `whole_account_conflicts_on` stays for a link
saved on its own, and re-taking a lock the transaction already holds costs
nothing.
The ordering test fixes the account ids rather than generating them: the
assertion is entirely about order, and random UUIDs would have let it pass
half the time on association order alone.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye
* feat(goals): a reached goal lets go of the money it was holding
Marking a goal complete did nothing to the money. Verified on a 10,000
account with two goals earmarked 5,000 each:
both funded precaution 5000 (100%) | vacances 5000 (100%)
vacances completed precaution 5000 (100%) | vacances 5000 (100%)
spend 5000 precaution 2500 ( 50%) | vacances 2500 (100%)
vacances archived precaution 5000 (100%) | vacances 2500 ( 50%)
Three faults in a row. A completed goal kept reserving, so the pool still
claimed 10,000 from an account holding 5,000 and the pro-rata haircut cut
the untouched precaution goal in half. The intuitive action fixed nothing
— only Archive released anything, and its confirmation talked about list
visibility. And the history ended up false: a goal that genuinely reached
5,000 was filed away showing 2,500.
A completed goal now releases its earmark, and the amount it reached is
frozen when `complete` fires. Releasing alone was not enough: the figure
would still be recomputed from the live balance, so spending the money
would walk the finished goal back down and rewrite its own record.
THREE places filter on state, and they must agree or an account will
advertise headroom the goals deny: the shared pool, Account#goal_earmarked_total,
and the whole-balance link guard. They now read one constant,
Goal::RELEASED_STATES. The guard's two tests are inverted with it — a
completed goal no longer blocks a new link, because refusing one on
account of a finished goal whose money has already been handed back would
be inexplicable.
`paused` is deliberately not released: pausing means "I have stopped
feeding this", not "I have let it go". Nothing is backfilled either — an
already-completed goal's past value cannot be recovered, and guessing it
would freeze an already-eroded number.
The reached panel is the same one that already existed, corrected rather
than doubled. It used to say "Goal closed at ..." for a goal merely at
100%, and offer Archive — the one gesture that does not release anything.
It now offers closing, says what closing does to the money, and shows the
frozen amount and date once closed. `kind` arrives without behavior for
Lots B3 and B4, and already earns its keep: a maintained reserve at 100%
is never asked to close.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DJ1npaGEHr6t2HW1rYZdt4
* fix(goals): stop a closed goal's projection outrunning its own accounts
Addresses review feedback on #3165.
Freezing `current_balance` at completion made it independent of the linked
accounts, but `projection_payload` still divides it by their live total to
scale the historical series. Spend those accounts after closing and the ratio
runs past 1, scaling every point by the difference and drawing a chart that
never happened.
Capped at 1. The series is the whole linked-account history scaled to this
goal's share of it, and a share cannot exceed the whole — whatever the frozen
figure says.
The test stubs the series at its collaborator rather than building Balance
rows: `ChartSeriesBuilder` returns zeros for a fixture account in this
environment, and a series of zeros multiplies to zero whatever the ratio, so
the obvious version of this test passed without the fix and proved nothing.
It asserts the scaled point never exceeds the historical figure it came from —
unclamped, 5,000 rendered as 5,000 x 33.
Confirmed load-bearing by removing the cap and watching it fail.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye
* fix(goals): stamp a goal closed only when it is actually closed
AASM runs an event's `after` hook on the non-bang form too, and the non-bang
form does not save. `goal.complete` therefore left the row `active` in the
database while stamping it with a completion snapshot — a goal still being
funded, carrying a frozen amount and a completion date. Verified in a
console: `state = "active"` beside `completed_amount = 4000`.
Everything downstream that keys off `completed_amount.present?` then read
that goal as closed, so the two halves of the same fact disagreed about
whether the goal had finished.
The side effects hang off the persisted state change instead, still inside
the save transaction so a later failure takes both back. `reopen` and
`unarchive` move the same way, and for the same reason — a plain `reopen`
was thawing a goal that stayed completed.
The memos are cleared again there: they are cleared at transition time, but
anything reading the goal between then and the save refills them from the
old state, and the frozen figure has to be the closing one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye
---------
Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
706 lines
24 KiB
Ruby
706 lines
24 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_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_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&.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
|