Skip to content
This repository was archived by the owner on Jul 27, 2025. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/controllers/accounts_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ def sync
end

def chart
@chart_view = params[:chart_view] || "balance"
render layout: "application"
end

Expand Down
1 change: 1 addition & 0 deletions app/controllers/concerns/accountable_resource.rb
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ def new
end

def show
@chart_view = params[:chart_view] || "balance"
@q = params.fetch(:q, {}).permit(:search)
entries = @account.entries.search(@q).reverse_chronological

Expand Down
1 change: 1 addition & 0 deletions app/controllers/concerns/auto_sync.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ def sync_family

def family_needs_auto_sync?
return false unless Current.family.present?
return false unless Current.family.accounts.active.any?

(Current.family.last_synced_at&.to_date || 1.day.ago) < Date.current
end
Expand Down
31 changes: 19 additions & 12 deletions app/models/account.rb
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
class Account < ApplicationRecord
include Syncable, Monetizable, Issuable, Chartable
include Syncable, Monetizable, Issuable, Chartable, Enrichable, Linkable

validates :name, :balance, :currency, presence: true

belongs_to :family
belongs_to :import, optional: true
belongs_to :plaid_account, optional: true

has_many :import_mappings, as: :mappable, dependent: :destroy, class_name: "Import::Mapping"
has_many :entries, dependent: :destroy, class_name: "Account::Entry"
Expand Down Expand Up @@ -75,7 +74,16 @@ def destroy_later
def sync_data(start_date: nil)
update!(last_synced_at: Time.current)

Syncer.new(self, start_date: start_date).run
Rails.logger.info("Auto-matching transfers")
family.auto_match_transfers!

Rails.logger.info("Processing balances (#{linked? ? 'reverse' : 'forward'})")
sync_balances

if enrichable?
Rails.logger.info("Enriching transaction data")
enrich_data
end
end

def post_sync
Expand All @@ -93,10 +101,6 @@ def current_holdings
holdings.where(currency: currency, date: holdings.maximum(:date)).order(amount: :desc)
end

def enrich_data
DataEnricher.new(self).run
end

def update_with_sync!(attributes)
should_update_balance = attributes[:balance] && attributes[:balance].to_d != balance

Expand All @@ -123,11 +127,14 @@ def update_balance!(balance)
end
end

def sparkline_series
cache_key = family.build_cache_key("#{id}_sparkline")
def start_date
first_entry_date = entries.minimum(:date) || Date.current
first_entry_date - 1.day
end

Rails.cache.fetch(cache_key) do
balance_series
private
def sync_balances
strategy = linked? ? :reverse : :forward
Balance::Syncer.new(self, strategy: strategy).sync_balances
end
end
end
35 changes: 35 additions & 0 deletions app/models/account/balance/base_calculator.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
class Account::Balance::BaseCalculator
attr_reader :account

def initialize(account)
@account = account
end

def calculate
Rails.logger.tagged(self.class.name) do
calculate_balances
end
end

private
def sync_cache
@sync_cache ||= Account::Balance::SyncCache.new(account)
end

def build_balance(date, cash_balance, holdings_value)
Account::Balance.new(
account_id: account.id,
date: date,
balance: holdings_value + cash_balance,
cash_balance: cash_balance,
currency: account.currency
)
end

def calculate_next_balance(prior_balance, transactions, direction: :forward)
flows = transactions.sum(&:amount)
negated = direction == :forward ? account.asset? : account.liability?
flows *= -1 if negated
prior_balance + flows
end
end
28 changes: 28 additions & 0 deletions app/models/account/balance/forward_calculator.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
class Account::Balance::ForwardCalculator < Account::Balance::BaseCalculator
private
def calculate_balances
current_cash_balance = 0
next_cash_balance = nil

@balances = []

account.start_date.upto(Date.current).each do |date|
entries = sync_cache.get_entries(date)
holdings = sync_cache.get_holdings(date)
holdings_value = holdings.sum(&:amount)
valuation = sync_cache.get_valuation(date)

next_cash_balance = if valuation
valuation.amount - holdings_value
else
calculate_next_balance(current_cash_balance, entries, direction: :forward)
end

@balances << build_balance(date, next_cash_balance, holdings_value)

current_cash_balance = next_cash_balance
end

@balances
end
end
32 changes: 32 additions & 0 deletions app/models/account/balance/reverse_calculator.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
class Account::Balance::ReverseCalculator < Account::Balance::BaseCalculator
private
def calculate_balances
current_cash_balance = account.cash_balance
previous_cash_balance = nil

@balances = []

Date.current.downto(account.start_date).map do |date|
entries = sync_cache.get_entries(date)
holdings = sync_cache.get_holdings(date)
holdings_value = holdings.sum(&:amount)
valuation = sync_cache.get_valuation(date)

previous_cash_balance = if valuation
valuation.amount - holdings_value
else
calculate_next_balance(current_cash_balance, entries, direction: :reverse)
end

if valuation.present?
@balances << build_balance(date, previous_cash_balance, holdings_value)
else
@balances << build_balance(date, current_cash_balance, holdings_value)
end

current_cash_balance = previous_cash_balance
end

@balances
end
end
46 changes: 46 additions & 0 deletions app/models/account/balance/sync_cache.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
class Account::Balance::SyncCache
def initialize(account)
@account = account
end

def get_valuation(date)
converted_entries.find { |e| e.date == date && e.account_valuation? }
end

def get_holdings(date)
converted_holdings.select { |h| h.date == date }
end

def get_entries(date)
converted_entries.select { |e| e.date == date && (e.account_transaction? || e.account_trade?) }
end

private
attr_reader :account

def converted_entries
@converted_entries ||= account.entries.order(:date).to_a.map do |e|
converted_entry = e.dup
converted_entry.amount = converted_entry.amount_money.exchange_to(
account.currency,
date: e.date,
fallback_rate: 1
).amount
converted_entry.currency = account.currency
converted_entry
end
end

def converted_holdings
@converted_holdings ||= account.holdings.map do |h|
converted_holding = h.dup
converted_holding.amount = converted_holding.amount_money.exchange_to(
account.currency,
date: h.date,
fallback_rate: 1
).amount
converted_holding.currency = account.currency
converted_holding
end
end
end
69 changes: 69 additions & 0 deletions app/models/account/balance/syncer.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
class Account::Balance::Syncer
attr_reader :account, :strategy

def initialize(account, strategy:)
@account = account
@strategy = strategy
end

def sync_balances
Account::Balance.transaction do
sync_holdings
calculate_balances

Rails.logger.info("Persisting #{@balances.size} balances")
persist_balances

purge_stale_balances

if strategy == :forward
update_account_info
end
end
end

private
def sync_holdings
@holdings = Account::Holding::Syncer.new(account, strategy: strategy).sync_holdings
end

def update_account_info
calculated_balance = @balances.sort_by(&:date).last&.balance || 0
calculated_holdings_value = @holdings.select { |h| h.date == Date.current }.sum(&:amount) || 0
calculated_cash_balance = calculated_balance - calculated_holdings_value

Rails.logger.info("Balance update: cash=#{calculated_cash_balance}, total=#{calculated_balance}")

account.update!(
balance: calculated_balance,
cash_balance: calculated_cash_balance
)
end

def calculate_balances
@balances = calculator.calculate
end

def persist_balances
current_time = Time.now
account.balances.upsert_all(
@balances.map { |b| b.attributes
.slice("date", "balance", "cash_balance", "currency")
.merge("updated_at" => current_time) },
unique_by: %i[account_id date currency]
)
end

def purge_stale_balances
deleted_count = account.balances.delete_by("date < ?", account.start_date)
Rails.logger.info("Purged #{deleted_count} stale balances") if deleted_count > 0
end

def calculator
if strategy == :reverse
Account::Balance::ReverseCalculator.new(account)
else
Account::Balance::ForwardCalculator.new(account)
end
end
end
Loading