1
0
Fork 0
mirror of https://github.com/maybe-finance/maybe.git synced 2025-07-30 18:49:39 +02:00

Account creation methods and tests

This commit is contained in:
Zach Gollwitzer 2025-07-09 11:38:34 -04:00
parent 2e09d1a8c0
commit a7cd046563
4 changed files with 425 additions and 2 deletions

View file

@ -1,4 +1,6 @@
class Account < ApplicationRecord class Account < ApplicationRecord
InvalidBalanceError = Class.new(StandardError)
include Syncable, Monetizable, Chartable, Linkable, Enrichable include Syncable, Monetizable, Chartable, Linkable, Enrichable
include AASM include AASM
@ -15,7 +17,7 @@ class Account < ApplicationRecord
has_many :holdings, dependent: :destroy has_many :holdings, dependent: :destroy
has_many :balances, dependent: :destroy has_many :balances, dependent: :destroy
monetize :balance, :cash_balance monetize :balance, :cash_balance, :non_cash_balance
enum :classification, { asset: "asset", liability: "liability" }, validate: { allow_nil: true } enum :classification, { asset: "asset", liability: "liability" }, validate: { allow_nil: true }
@ -126,6 +128,87 @@ class Account < ApplicationRecord
Account::BalanceUpdater.new(self, balance:, currency:, date:, notes:).update Account::BalanceUpdater.new(self, balance:, currency:, date:, notes:).update
end end
def update_current_balance(balance:, cash_balance:)
raise InvalidBalanceError, "Cash balance cannot exceed balance" if cash_balance > balance
if opening_anchor_valuation.present? && valuations.where(kind: "recon").empty?
adjust_opening_balance_with_delta(balance:, cash_balance:)
else
reconcile_balance!(balance:, cash_balance:, date: Date.current)
end
end
def reconcile_balance!(balance:, cash_balance:, date:)
raise InvalidBalanceError, "Cash balance cannot exceed balance" if cash_balance > balance
raise InvalidBalanceError, "Linked accounts cannot be reconciled" if linked?
existing_valuation = valuations.joins(:entry).where(kind: "recon", entry: { date: Date.current }).first
if existing_valuation.present?
existing_valuation.update!(
balance: balance,
cash_balance: cash_balance
)
else
entries.create!(
date: date,
name: Valuation::Name.new("recon", self.accountable_type),
amount: balance,
currency: self.currency,
entryable: Valuation.new(
kind: "recon",
balance: balance,
cash_balance: cash_balance
)
)
end
end
def adjust_opening_balance_with_delta(balance:, cash_balance:)
delta = self.balance - balance
cash_delta = self.cash_balance - cash_balance
set_or_update_opening_balance!(
balance: balance - delta,
cash_balance: cash_balance - cash_delta
)
end
def set_or_update_opening_balance!(balance:, cash_balance:, date: nil)
# A reasonable start date for most accounts to fill up adequate history for graphs
fallback_opening_date = 2.years.ago.to_date
raise InvalidBalanceError, "Cash balance cannot exceed balance" if cash_balance > balance
transaction do
if opening_anchor_valuation
opening_anchor_valuation.update!(
balance: balance,
cash_balance: cash_balance
)
opening_anchor_valuation.entry.update!(amount: balance)
opening_anchor_valuation.entry.update!(date: date) unless date.nil?
opening_anchor_valuation
else
entry = entries.create!(
date: date || fallback_opening_date,
name: Valuation::Name.new("opening_anchor", self.accountable_type),
amount: balance,
currency: self.currency,
entryable: Valuation.new(
kind: "opening_anchor",
balance: balance,
cash_balance: cash_balance,
)
)
entry.valuation
end
end
end
def start_date def start_date
first_entry_date = entries.minimum(:date) || Date.current first_entry_date = entries.minimum(:date) || Date.current
first_entry_date - 1.day first_entry_date - 1.day
@ -153,4 +236,20 @@ class Account < ApplicationRecord
def long_subtype_label def long_subtype_label
accountable_class.long_subtype_label_for(subtype) || accountable_class.display_name accountable_class.long_subtype_label_for(subtype) || accountable_class.display_name
end end
# For depository accounts, this is 0 (total balance is liquid cash)
# For all other accounts, this represents "asset value" or "debt value"
# (i.e. Investment accounts would refer to this as "holdings value")
def non_cash_balance
balance - cash_balance
end
private
def opening_anchor_valuation
valuations.opening_anchor.first
end
def current_anchor_valuation
valuations.current_anchor.first
end
end end

View file

@ -116,4 +116,74 @@ class Family < ApplicationRecord
def self_hoster? def self_hoster?
Rails.application.config.app_mode.self_hosted? Rails.application.config.app_mode.self_hosted?
end end
def create_property_account!(name:, current_value:, purchase_price: nil, purchase_date: nil, currency: nil)
Family.transaction do
property = accounts.create!(
name: name,
balance: current_value,
cash_balance: 0,
currency: currency.presence || self.currency,
accountable: Property.new
)
property.set_or_update_opening_balance!(
balance: purchase_price || 0,
cash_balance: 0,
date: purchase_date
)
property
end
end
def create_vehicle_account(current_value:, purchase_price: nil, purchase_date: nil, currency: nil)
# TODO
end
def create_depository_account(current_balance:, opening_date: nil, currency: nil)
# TODO
end
# Investment account values are built up by adding holdings / trades, not by initializing a "balance"
def create_investment_account(currency: nil)
# TODO
end
def create_other_asset_account(current_value:, purchase_price: nil, purchase_date: nil, currency: nil)
# TODO
end
def create_other_liability_account(current_debt:, original_debt: nil, origination_date: nil, currency: nil)
# TODO
end
# For now, crypto accounts are very simple; we just track overall value
def create_crypto_account(current_value:, currency: nil)
# TODO
end
def create_credit_card_account(current_debt:, opening_date: nil, currency: nil)
# TODO
end
def create_loan_account(current_principal:, original_principal: nil, origination_date: nil, currency: nil)
# TODO
end
def link_depository_account
# TODO
end
def link_investment_account
# TODO
end
def link_credit_card_account
# TODO
end
def link_loan_account
# TODO
end
end end

View file

@ -31,4 +31,195 @@ class AccountTest < ActiveSupport::TestCase
assert_equal "Investments", account.short_subtype_label assert_equal "Investments", account.short_subtype_label
assert_equal "Investments", account.long_subtype_label assert_equal "Investments", account.long_subtype_label
end end
# Currency updates earn their own method because updating an account currency incurs
# side effects like recalculating balances, etc.
test "can update the account currency" do
@account.update_currency("EUR")
assert_equal "EUR", @account.currency
end
# If a user has an opening balance (valuation) for their manual account and has 1+ transactions, the intent of
# "updating current balance" typically means that their start balance is incorrect. We follow that user intent
# by default and find the delta required, and update the opening balance so that the timeline reflects this current balance
#
# The purpose of this is so we're not cluttering up their timeline with "balance reconciliations" that reset the balance
# on the current date. Our goal is to keep the timeline with as few "Valuations" as possible.
#
# If we ever build a UI that gives user options, this test expectation may require some updates, but for now this
# is the least surprising outcome.
test "when manual account has opening valuation and transactions, adjust opening balance directly with delta" do
account = @family.accounts.create!(
name: "Test",
balance: 900, # the balance after opening valuation + transaction have "synced" (1000 - 100 = 900)
cash_balance: 900,
currency: "USD",
accountable: Depository.new
)
account.entries.create!(
date: 1.year.ago.to_date,
name: "Test opening valuation",
amount: 1000,
currency: "USD",
entryable: Valuation.new(
kind: "opening_anchor",
balance: 1000,
cash_balance: 1000
)
)
account.entries.create!(
date: 10.days.ago.to_date,
name: "Test expense transaction",
amount: 100,
currency: "USD",
entryable: Transaction.new
)
# What we're asserting here:
# 1. User creates the account with an opening balance of 1000
# 2. User creates a transaction of 100, which then reduces the balance to 900 (the current balance value on account above)
# 3. User requests "current balance update" back to 1000, which was their intention
# 4. We adjust the opening balance by the delta (100) to 1100, which is the new opening balance, so that the transaction
# of 100 reduces it down to 1000, which is the current balance they intended.
assert_equal 1, account.valuations.count
assert_equal 1, account.transactions.count
# No new valuation is appended; we're just adjusting the opening valuation anchor
assert_no_difference "account.entries.count" do
account.update_current_balance(balance: 1000, cash_balance: 1000)
end
opening_valuation = account.valuations.first
assert_equal 1100, opening_valuation.balance
assert_equal 1100, opening_valuation.cash_balance
end
# If the user has a "recon valuation" already (i.e. they applied a "balance override"), the most accurate thing we can do is append
# a new recon valuation to the current day (i.e. "from this day forward, the balance is X"). Any other action risks altering the user's view
# of their balance timeline and makes too many assumptions.
test "when manual account has 1+ reconciling valuations, append a new recon valuation rather than adjusting opening balance" do
account = @family.accounts.create!(
name: "Test",
balance: 1000,
cash_balance: 1000,
currency: "USD",
accountable: Depository.new
)
account.entries.create!(
date: 1.year.ago.to_date,
name: "Test opening valuation",
amount: 1000,
currency: "USD",
entryable: Valuation.new(
kind: "opening_anchor",
balance: 1000,
cash_balance: 1000
)
)
# User is "overriding" the balance to 1200 here
account.entries.create!(
date: 30.days.ago.to_date,
name: "First manual recon valuation",
amount: 1200,
currency: "USD",
entryable: Valuation.new(
kind: "recon",
balance: 1200,
cash_balance: 1200
)
)
assert_equal 2, account.valuations.count
# Here, we assume user is once again "overriding" the balance to 1400
account.update_current_balance(balance: 1400, cash_balance: 1400)
most_recent_valuation = account.valuations.joins(:entry).order("entries.date DESC").first
assert_equal 3, account.valuations.count
assert_equal 1400, most_recent_valuation.balance
assert_equal 1400, most_recent_valuation.cash_balance
end
# Updating "current balance" for a linked account is a pure system operation that manages the "current anchor" valuation
test "updating current balance for linked account modifies current anchor valuation" do
# TODO
end
# A recon valuation is an override for a user to "reset" the balance from a specific date forward.
# This means, "The balance on X date is Y", which is then used as the new starting point to apply transactions against
test "manual accounts can add recon valuations at any point in the account timeline" do
assert_equal 1, @account.valuations.count
@account.reconcile_balance!(balance: 1000, cash_balance: 1000, date: 2.days.ago.to_date)
assert_equal 2, @account.valuations.count
most_recent_valuation = @account.valuations.joins(:entry).order("entries.date DESC").first
assert_equal 1000, most_recent_valuation.balance
assert_equal 1000, most_recent_valuation.cash_balance
end
# While technically valid and possible to calculate, "recon" valuations for a linked account rarely make sense
# and add complexity. If the user has linked to a data provider, we expect the provider to be responsible for
# delivering the correct set of transactions to construct the historical balance
test "recon valuations are invalid for linked accounts" do
linked_account = accounts(:connected)
assert_raises Account::InvalidBalanceError do
linked_account.reconcile_balance!(balance: 1000, cash_balance: 1000, date: 2.days.ago.to_date)
end
end
test "sets or updates opening balance" do
Entry.destroy_all
assert_equal 0, @account.entries.valuations.count
# Creates non-existent opening valuation
@account.set_or_update_opening_balance!(
balance: 2000,
cash_balance: 2000,
date: 2.days.ago.to_date
)
opening_valuation_entry = @account.entries.first
assert_equal 2000, opening_valuation_entry.amount
assert_equal 2.days.ago.to_date, opening_valuation_entry.date
assert_equal 2000, opening_valuation_entry.valuation.balance
assert_equal 2000, opening_valuation_entry.valuation.cash_balance
# Updates existing opening valuation
@account.set_or_update_opening_balance!(
balance: 3000,
cash_balance: 3000
)
opening_valuation_entry = @account.entries.first
assert_equal 3000, opening_valuation_entry.amount
assert_equal 2.days.ago.to_date, opening_valuation_entry.date
assert_equal 3000, opening_valuation_entry.valuation.balance
assert_equal 3000, opening_valuation_entry.valuation.cash_balance
end
# While we don't allow "recon" valuations for a linked account, we DO allow opening balance updates. This is because
# providers rarely give 100% of the transaction history (usually cuts off at 2 years), which can misrepresent the true
# "opening date" on the account and obscure longer net worth historical graphs. This is an *optional* way for the user
# to get their linked account histories "perfect".
test "can update the opening balance and date for a linked account" do
# TODO
end
test "can update the opening balance and date for a manual account" do
# TODO
end
end end

View file

@ -4,6 +4,69 @@ class FamilyTest < ActiveSupport::TestCase
include SyncableInterfaceTest include SyncableInterfaceTest
def setup def setup
@syncable = families(:dylan_family) @syncable = @family = families(:dylan_family)
end
test "creates manual property account" do
account = @family.create_property_account!(
name: "My House",
current_value: 450000,
purchase_price: 400000,
purchase_date: 1.year.ago.to_date
)
valuations = account.valuations
assert_equal 1, valuations.count
assert_equal "opening_anchor", valuations.first.kind
assert_equal 400000, valuations.first.balance
assert_equal 0, valuations.first.cash_balance
assert_equal "My House", account.name
assert_equal 450000, account.balance
assert_equal 0, account.cash_balance
end
test "creates manual vehicle account" do
# TODO
end
test "creates manual depository account" do
# TODO
end
test "creates manual investment account" do
# TODO
end
test "creates manual other asset or liability account" do
# TODO
end
test "creates manual crypto account" do
# TODO
end
test "creates manual credit card account" do
# TODO
end
test "creates manual loan account" do
# TODO
end
test "creates linked depository account" do
# TODO
end
test "creates linked investment account" do
# TODO
end
test "creates linked credit card account" do
# TODO
end
test "creates linked loan account" do
# TODO
end end
end end