mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-08-06 05:55:21 +02:00
Start and end balance anchors for historical account balances (#2455)
* Add kind field to valuation * Fix schema conflict * Add kind to valuation * Scaffold opening balance manager * Opening balance manager implementation * Update account import to use opening balance manager + tests * Update account to use opening balance manager * Fix test assertions, usage of current balance manager * Lint fixes * Add Opening Balance manager, add tests to forward calculator * Add credit card to "all cash" designation * Simplify valuation model * Add current balance manager with tests * Add current balance logic to reverse calculator and plaid sync * Tweaks to initial calc logic * Ledger testing helper, tweak assertions for reverse calculator * Update test assertions * Extract balance transformer, simplify calculators * Algo simplifications * Final tweaks to calculators * Cleanup * Fix error, propagate sync errors up to parent * Update migration script, valuation naming
This commit is contained in:
parent
9110ab27d2
commit
c1d98fe73b
35 changed files with 1903 additions and 355 deletions
52
app/models/account/anchorable.rb
Normal file
52
app/models/account/anchorable.rb
Normal file
|
@ -0,0 +1,52 @@
|
|||
# All accounts are "anchored" with start/end valuation records, with transactions,
|
||||
# trades, and reconciliations between them.
|
||||
module Account::Anchorable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
include Monetizable
|
||||
|
||||
monetize :opening_balance
|
||||
end
|
||||
|
||||
def set_opening_anchor_balance(**opts)
|
||||
opening_balance_manager.set_opening_balance(**opts)
|
||||
end
|
||||
|
||||
def opening_anchor_date
|
||||
opening_balance_manager.opening_date
|
||||
end
|
||||
|
||||
def opening_anchor_balance
|
||||
opening_balance_manager.opening_balance
|
||||
end
|
||||
|
||||
def has_opening_anchor?
|
||||
opening_balance_manager.has_opening_anchor?
|
||||
end
|
||||
|
||||
def set_current_anchor_balance(balance)
|
||||
current_balance_manager.set_current_balance(balance)
|
||||
end
|
||||
|
||||
def current_anchor_balance
|
||||
current_balance_manager.current_balance
|
||||
end
|
||||
|
||||
def current_anchor_date
|
||||
current_balance_manager.current_date
|
||||
end
|
||||
|
||||
def has_current_anchor?
|
||||
current_balance_manager.has_current_anchor?
|
||||
end
|
||||
|
||||
private
|
||||
def opening_balance_manager
|
||||
@opening_balance_manager ||= Account::OpeningBalanceManager.new(self)
|
||||
end
|
||||
|
||||
def current_balance_manager
|
||||
@current_balance_manager ||= Account::CurrentBalanceManager.new(self)
|
||||
end
|
||||
end
|
|
@ -18,7 +18,7 @@ class Account::BalanceUpdater
|
|||
end
|
||||
|
||||
valuation_entry = account.entries.valuations.find_or_initialize_by(date: date) do |entry|
|
||||
entry.entryable = Valuation.new
|
||||
entry.entryable = Valuation.new(kind: "reconciliation")
|
||||
end
|
||||
|
||||
valuation_entry.amount = balance
|
||||
|
|
86
app/models/account/current_balance_manager.rb
Normal file
86
app/models/account/current_balance_manager.rb
Normal file
|
@ -0,0 +1,86 @@
|
|||
class Account::CurrentBalanceManager
|
||||
InvalidOperation = Class.new(StandardError)
|
||||
|
||||
Result = Struct.new(:success?, :changes_made?, :error, keyword_init: true)
|
||||
|
||||
def initialize(account)
|
||||
@account = account
|
||||
end
|
||||
|
||||
def has_current_anchor?
|
||||
current_anchor_valuation.present?
|
||||
end
|
||||
|
||||
# Our system should always make sure there is a current anchor, and that it is up to date.
|
||||
# The fallback is provided for backwards compatibility, but should not be relied on since account.balance is a "cached/derived" value.
|
||||
def current_balance
|
||||
if current_anchor_valuation
|
||||
current_anchor_valuation.entry.amount
|
||||
else
|
||||
Rails.logger.warn "No current balance anchor found for account #{account.id}. Using cached balance instead, which may be out of date."
|
||||
account.balance
|
||||
end
|
||||
end
|
||||
|
||||
def current_date
|
||||
if current_anchor_valuation
|
||||
current_anchor_valuation.entry.date
|
||||
else
|
||||
Date.current
|
||||
end
|
||||
end
|
||||
|
||||
def set_current_balance(balance)
|
||||
# A current balance anchor implies there is an external data source that will keep it updated. Since manual accounts
|
||||
# are tracked by the user, a current balance anchor is not appropriate.
|
||||
raise InvalidOperation, "Manual accounts cannot set current balance anchor. Set opening balance or use a reconciliation instead." if account.manual?
|
||||
|
||||
if current_anchor_valuation
|
||||
changes_made = update_current_anchor(balance)
|
||||
Result.new(success?: true, changes_made?: changes_made, error: nil)
|
||||
else
|
||||
create_current_anchor(balance)
|
||||
Result.new(success?: true, changes_made?: true, error: nil)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
attr_reader :account
|
||||
|
||||
def current_anchor_valuation
|
||||
@current_anchor_valuation ||= account.valuations.current_anchor.includes(:entry).first
|
||||
end
|
||||
|
||||
def create_current_anchor(balance)
|
||||
account.entries.create!(
|
||||
date: Date.current,
|
||||
name: Valuation.build_current_anchor_name(account.accountable_type),
|
||||
amount: balance,
|
||||
currency: account.currency,
|
||||
entryable: Valuation.new(kind: "current_anchor")
|
||||
)
|
||||
end
|
||||
|
||||
def update_current_anchor(balance)
|
||||
changes_made = false
|
||||
|
||||
ActiveRecord::Base.transaction do
|
||||
# Update associated entry attributes
|
||||
entry = current_anchor_valuation.entry
|
||||
|
||||
if entry.amount != balance
|
||||
entry.amount = balance
|
||||
changes_made = true
|
||||
end
|
||||
|
||||
if entry.date != Date.current
|
||||
entry.date = Date.current
|
||||
changes_made = true
|
||||
end
|
||||
|
||||
entry.save! if entry.changed?
|
||||
end
|
||||
|
||||
changes_made
|
||||
end
|
||||
end
|
|
@ -15,4 +15,5 @@ module Account::Linkable
|
|||
def unlinked?
|
||||
!linked?
|
||||
end
|
||||
alias_method :manual?, :unlinked?
|
||||
end
|
||||
|
|
99
app/models/account/opening_balance_manager.rb
Normal file
99
app/models/account/opening_balance_manager.rb
Normal file
|
@ -0,0 +1,99 @@
|
|||
class Account::OpeningBalanceManager
|
||||
Result = Struct.new(:success?, :changes_made?, :error, keyword_init: true)
|
||||
|
||||
def initialize(account)
|
||||
@account = account
|
||||
end
|
||||
|
||||
def has_opening_anchor?
|
||||
opening_anchor_valuation.present?
|
||||
end
|
||||
|
||||
# Most accounts should have an opening anchor. If not, we derive the opening date from the oldest entry date
|
||||
def opening_date
|
||||
return opening_anchor_valuation.entry.date if opening_anchor_valuation.present?
|
||||
|
||||
[
|
||||
account.entries.valuations.order(:date).first&.date,
|
||||
account.entries.where.not(entryable_type: "Valuation").order(:date).first&.date&.prev_day
|
||||
].compact.min || Date.current
|
||||
end
|
||||
|
||||
def opening_balance
|
||||
opening_anchor_valuation&.entry&.amount || 0
|
||||
end
|
||||
|
||||
def set_opening_balance(balance:, date: nil)
|
||||
resolved_date = date || default_date
|
||||
|
||||
# Validate date is before oldest entry
|
||||
if date && oldest_entry_date && resolved_date >= oldest_entry_date
|
||||
return Result.new(success?: false, changes_made?: false, error: "Opening balance date must be before the oldest entry date")
|
||||
end
|
||||
|
||||
if opening_anchor_valuation.nil?
|
||||
create_opening_anchor(
|
||||
balance: balance,
|
||||
date: resolved_date
|
||||
)
|
||||
Result.new(success?: true, changes_made?: true, error: nil)
|
||||
else
|
||||
changes_made = update_opening_anchor(balance: balance, date: date)
|
||||
Result.new(success?: true, changes_made?: changes_made, error: nil)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
attr_reader :account
|
||||
|
||||
def opening_anchor_valuation
|
||||
@opening_anchor_valuation ||= account.valuations.opening_anchor.includes(:entry).first
|
||||
end
|
||||
|
||||
def oldest_entry_date
|
||||
@oldest_entry_date ||= account.entries.minimum(:date)
|
||||
end
|
||||
|
||||
def default_date
|
||||
if oldest_entry_date
|
||||
[ oldest_entry_date - 1.day, 2.years.ago.to_date ].min
|
||||
else
|
||||
2.years.ago.to_date
|
||||
end
|
||||
end
|
||||
|
||||
def create_opening_anchor(balance:, date:)
|
||||
account.entries.create!(
|
||||
date: date,
|
||||
name: Valuation.build_opening_anchor_name(account.accountable_type),
|
||||
amount: balance,
|
||||
currency: account.currency,
|
||||
entryable: Valuation.new(
|
||||
kind: "opening_anchor"
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
def update_opening_anchor(balance:, date: nil)
|
||||
changes_made = false
|
||||
|
||||
ActiveRecord::Base.transaction do
|
||||
# Update associated entry attributes
|
||||
entry = opening_anchor_valuation.entry
|
||||
|
||||
if entry.amount != balance
|
||||
entry.amount = balance
|
||||
changes_made = true
|
||||
end
|
||||
|
||||
if date.present? && entry.date != date
|
||||
entry.date = date
|
||||
changes_made = true
|
||||
end
|
||||
|
||||
entry.save! if entry.changed?
|
||||
end
|
||||
|
||||
changes_made
|
||||
end
|
||||
end
|
Loading…
Add table
Add a link
Reference in a new issue