1
0
Fork 0
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)
Some checks are pending
Publish Docker image / ci (push) Waiting to run
Publish Docker image / Build docker image (push) Blocked by required conditions

* 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:
Zach Gollwitzer 2025-07-15 11:42:41 -04:00 committed by GitHub
parent 9110ab27d2
commit c1d98fe73b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
35 changed files with 1903 additions and 355 deletions

View 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

View file

@ -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

View 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

View file

@ -15,4 +15,5 @@ module Account::Linkable
def unlinked?
!linked?
end
alias_method :manual?, :unlinked?
end

View 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