1
0
Fork 0
mirror of https://github.com/maybe-finance/maybe.git synced 2025-08-05 21:45:23 +02:00

Account balance anchors

This commit is contained in:
Zach Gollwitzer 2025-07-07 11:31:37 -04:00
parent 662f2c04ce
commit 15f8d827b5
8 changed files with 226 additions and 21 deletions

View file

@ -59,28 +59,22 @@ class Account < ApplicationRecord
def create_and_sync(attributes)
attributes[:accountable_attributes] ||= {} # Ensure accountable is created, even if empty
account = new(attributes.merge(cash_balance: attributes[:balance]))
initial_balance = attributes.dig(:accountable_attributes, :initial_balance)&.to_d || 0
initial_balance = attributes.dig(:accountable_attributes, :initial_balance)&.to_d || account.balance
transaction do
# Create 2 valuations for new accounts to establish a value history for users to see
account.entries.build(
name: "Current Balance",
date: Date.current,
amount: account.balance,
currency: account.currency,
entryable: Valuation.new
)
account.entries.build(
name: "Initial Balance",
date: 1.day.ago.to_date,
amount: initial_balance,
currency: account.currency,
entryable: Valuation.new
account.entries.build(
name: Valuation::Name.new("opening_anchor", account.accountable_type).to_s,
date: 2.years.ago.to_date,
amount: initial_balance,
currency: account.currency,
entryable: Valuation.new(
kind: "opening_anchor",
balance: initial_balance,
cash_balance: initial_balance,
currency: account.currency
)
)
account.save!
end
account.save!
account.sync_later
account
end

View file

@ -23,7 +23,7 @@ class Account::BalanceUpdater
valuation_entry.amount = balance
valuation_entry.currency = currency if currency.present?
valuation_entry.name = "Manual #{account.accountable.balance_display_name} update"
valuation_entry.name = valuation_name(valuation_entry.entryable, account)
valuation_entry.notes = notes if notes.present?
valuation_entry.save!
end
@ -44,4 +44,8 @@ class Account::BalanceUpdater
def requires_update?
date != Date.current || account.balance != balance || account.currency != currency
end
def valuation_name(valuation_entry, account)
Valuation::Name.new(valuation_entry.entryable.kind, account.accountable_type).to_s
end
end

View file

@ -14,6 +14,11 @@ class Entry < ApplicationRecord
validates :date, uniqueness: { scope: [ :account_id, :entryable_type ] }, if: -> { valuation? }
validates :date, comparison: { greater_than: -> { min_supported_date } }
# To ensure we can recreate balance history solely from Entries, all entries must post on or before the current anchor (i.e. "Current balance"),
# and after the opening anchor (i.e. "Opening balance"). This domain invariant should be enforced by the Account model when adding/modifying entries.
validate :date_after_opening_anchor
validate :date_on_or_before_current_anchor
scope :visible, -> {
joins(:account).where(accounts: { status: [ "draft", "active" ] })
}
@ -96,4 +101,39 @@ class Entry < ApplicationRecord
all.size
end
end
private
def date_after_opening_anchor
return unless account && date
# Skip validation for anchor valuations themselves
return if valuation? && entryable.kind.in?(%w[opening_anchor current_anchor])
opening_anchor_date = account.valuations
.joins(:entry)
.where(kind: "opening_anchor")
.pluck(Arel.sql("entries.date"))
.first
if opening_anchor_date && date <= opening_anchor_date
errors.add(:date, "must be after the opening balance date (#{opening_anchor_date})")
end
end
def date_on_or_before_current_anchor
return unless account && date
# Skip validation for anchor valuations themselves
return if valuation? && entryable.kind.in?(%w[opening_anchor current_anchor])
current_anchor_date = account.valuations
.joins(:entry)
.where(kind: "current_anchor")
.pluck(Arel.sql("entries.date"))
.first
if current_anchor_date && date > current_anchor_date
errors.add(:date, "must be on or before the current balance date (#{current_anchor_date})")
end
end
end

View file

@ -1,3 +1,29 @@
class Valuation < ApplicationRecord
include Entryable
enum :kind, {
recon: "recon", # A balance reconciliation that sets the Account balance from this point forward (often defined by user)
snapshot: "snapshot", # An "event-sourcing snapshot", which is purely for performance so less history is required to derive the balance
opening_anchor: "opening_anchor", # Each account has a single opening anchor, which defines the opening balance on the account
current_anchor: "current_anchor" # Each account has a single current anchor, which defines the current balance on the account
}, validate: true
# Each account can have at most 1 opening anchor and 1 current anchor. All valuations between these anchors should
# be either "recon" or "snapshot". This ensures we can reliably construct the account balance history solely from Entries.
validate :unique_anchor_per_account, if: -> { opening_anchor? || current_anchor? }
private
def unique_anchor_per_account
return unless entry&.account
existing_anchor = entry.account.valuations
.joins(:entry)
.where(kind: kind)
.where.not(id: id)
.exists?
if existing_anchor
errors.add(:kind, "#{kind.humanize} already exists for this account")
end
end
end

View file

@ -0,0 +1,63 @@
# While typically a view concern, we store the `name` in the DB as a denormalized value to keep our search classes simpler.
# This is a simple class to handle the logic for generating the name.
class Valuation::Name
def initialize(valuation_kind, accountable_type)
@valuation_kind = valuation_kind
@accountable_type = accountable_type
end
def to_s
case valuation_kind
when "opening_anchor"
opening_anchor_name
when "current_anchor"
current_anchor_name
else
recon_name
end
end
private
attr_reader :valuation_kind, :accountable_type
# The start value on the account
def opening_anchor_name
case accountable_type
when "Property"
"Original purchase price"
when "Loan"
"Original principal"
when "Investment"
"Opening account value"
else
"Opening balance"
end
end
# The current value on the account
def current_anchor_name
case accountable_type
when "Property"
"Current market value"
when "Loan"
"Current loan balance"
when "Investment"
"Current account value"
else
"Current balance"
end
end
# Any "reconciliation" in the middle of the timeline, typically an "override" by the user to account
# for missing entries that cause the balance to be incorrect.
def recon_name
case accountable_type
when "Property", "Investment"
"Manual value update"
when "Loan"
"Manual principal update"
else
"Manual balance update"
end
end
end