1
0
Fork 0
mirror of https://github.com/maybe-finance/maybe.git synced 2025-08-07 22:45:20 +02:00

Balance sheet cache layer with cache-busting

This commit is contained in:
Zach Gollwitzer 2025-06-10 15:26:06 -04:00
parent dab693d74f
commit f1c7660ff6
12 changed files with 245 additions and 107 deletions

View file

@ -31,11 +31,11 @@ class Assistant::Function::GetBalanceSheet < Assistant::Function
monthly_history: historical_data(period)
},
assets: {
current: family.balance_sheet.total_assets_money.format,
current: family.balance_sheet.assets.total_money.format,
monthly_history: historical_data(period, classification: "asset")
},
liabilities: {
current: family.balance_sheet.total_liabilities_money.format,
current: family.balance_sheet.liabilities.total_money.format,
monthly_history: historical_data(period, classification: "liability")
},
insights: insights_data
@ -65,8 +65,8 @@ class Assistant::Function::GetBalanceSheet < Assistant::Function
end
def insights_data
assets = family.balance_sheet.total_assets
liabilities = family.balance_sheet.total_liabilities
assets = family.balance_sheet.assets.total
liabilities = family.balance_sheet.liabilities.total
ratio = liabilities.zero? ? 0 : (liabilities / assets.to_f)
{

View file

@ -1,7 +1,7 @@
class BalanceSheet
include Monetizable
monetize :total_assets, :total_liabilities, :net_worth
monetize :net_worth
attr_reader :family
@ -9,82 +9,32 @@ class BalanceSheet
@family = family
end
def total_assets
totals_query.filter { |t| t.classification == "asset" }.sum(&:converted_balance)
def assets
@assets ||= ClassificationGroup.new(
classification: "asset",
currency: family.currency,
accounts: account_totals.asset_accounts
)
end
def total_liabilities
totals_query.filter { |t| t.classification == "liability" }.sum(&:converted_balance)
end
def net_worth
total_assets - total_liabilities
def liabilities
@liabilities ||= ClassificationGroup.new(
classification: "liability",
currency: family.currency,
accounts: account_totals.liability_accounts
)
end
def classification_groups
Rails.cache.fetch(family.build_cache_key("bs_classification_groups")) do
asset_groups = account_groups("asset")
liability_groups = account_groups("liability")
[
ClassificationGroup.new(
key: "asset",
display_name: "Assets",
icon: "plus",
total_money: total_assets_money,
account_groups: asset_groups,
syncing?: asset_groups.any?(&:syncing?)
),
ClassificationGroup.new(
key: "liability",
display_name: "Debts",
icon: "minus",
total_money: total_liabilities_money,
account_groups: liability_groups,
syncing?: liability_groups.any?(&:syncing?)
)
]
end
[ assets, liabilities ]
end
def account_groups(classification = nil)
Rails.cache.fetch(family.build_cache_key("bs_account_groups_#{classification || 'all'}")) do
classification_accounts = classification ? totals_query.filter { |t| t.classification == classification } : totals_query
classification_total = classification_accounts.sum(&:converted_balance)
def account_groups
[ assets.account_groups, liabilities.account_groups ].flatten
end
account_groups = classification_accounts.group_by(&:accountable_type)
.transform_keys { |k| Accountable.from_type(k) }
groups = account_groups.map do |accountable, accounts|
group_total = accounts.sum(&:converted_balance)
key = accountable.model_name.param_key
group = AccountGroup.new(
id: classification ? "#{classification}_#{key}_group" : "#{key}_group",
key: key,
name: accountable.display_name,
classification: accountable.classification,
total: group_total,
total_money: Money.new(group_total, currency),
weight: classification_total.zero? ? 0 : group_total / classification_total.to_d * 100,
missing_rates?: accounts.any? { |a| a.missing_rates? },
color: accountable.color,
syncing?: accounts.any?(&:is_syncing),
accounts: accounts.map do |account|
account
end.sort_by(&:converted_balance).reverse
)
group
end
groups.sort_by do |group|
manual_order = Accountable::TYPES
type_name = group.key.camelize
manual_order.index(type_name) || Float::INFINITY
end
end
def net_worth
assets.total - liabilities.total
end
def net_worth_series(period: Period.last_30_days)
@ -109,32 +59,19 @@ class BalanceSheet
end
def syncing?
classification_groups.any? { |group| group.syncing? }
sync_status_monitor.syncing?
end
private
ClassificationGroup = Struct.new(:key, :display_name, :icon, :total_money, :account_groups, :syncing?, keyword_init: true)
AccountGroup = Struct.new(:id, :key, :name, :accountable_type, :classification, :total, :total_money, :weight, :accounts, :color, :missing_rates?, :syncing?, keyword_init: true)
def sync_status_monitor
@sync_status_monitor ||= BalanceSheet::SyncStatusMonitor.new(family)
end
def account_totals
@account_totals ||= BalanceSheet::AccountTotals.new(family, sync_status_monitor: sync_status_monitor)
end
def active_accounts
family.accounts.active.with_attached_logo
end
def totals_query
@totals_query ||= active_accounts
.joins(ActiveRecord::Base.sanitize_sql_array([ "LEFT JOIN exchange_rates ON exchange_rates.date = CURRENT_DATE AND accounts.currency = exchange_rates.from_currency AND exchange_rates.to_currency = ?", currency ]))
.joins(ActiveRecord::Base.sanitize_sql_array([
"LEFT JOIN syncs ON syncs.syncable_id = accounts.id AND syncs.syncable_type = 'Account' AND syncs.status IN (?) AND syncs.created_at > ?",
%w[pending syncing],
Sync::VISIBLE_FOR.ago
]))
.select(
"accounts.*",
"SUM(accounts.balance * COALESCE(exchange_rates.rate, 1)) as converted_balance",
"COUNT(syncs.id) > 0 as is_syncing",
ActiveRecord::Base.sanitize_sql_array([ "COUNT(CASE WHEN accounts.currency <> ? AND exchange_rates.rate IS NULL THEN 1 END) as missing_rates", currency ])
)
.group(:classification, :accountable_type, :id)
.to_a
end
end

View file

@ -0,0 +1,40 @@
class BalanceSheet::AccountGroup
include Monetizable
monetize :total, as: :total_money
attr_reader :name, :color, :accountable_type, :accounts
def initialize(name:, color:, accountable_type:, accounts:, classification_group:)
@name = name
@color = color
@accountable_type = accountable_type
@accounts = accounts
@classification_group = classification_group
end
def key
accountable_type.to_s.underscore
end
def total
accounts.sum(&:converted_balance)
end
def weight
return 0 if classification_group.total.zero?
total / classification_group.total.to_d * 100
end
def syncing?
accounts.any?(&:syncing?)
end
def currency
classification_group.currency
end
private
attr_reader :classification_group
end

View file

@ -0,0 +1,64 @@
class BalanceSheet::AccountTotals
def initialize(family, sync_status_monitor:)
@family = family
@sync_status_monitor = sync_status_monitor
end
def asset_accounts
@asset_accounts ||= account_rows.filter { |t| t.classification == "asset" }
end
def liability_accounts
@liability_accounts ||= account_rows.filter { |t| t.classification == "liability" }
end
private
attr_reader :family, :sync_status_monitor
AccountRow = Data.define(:account, :converted_balance, :is_syncing) do
def syncing? = is_syncing
# Allows Rails path helpers to generate URLs from the wrapper
def to_param = account.to_param
delegate_missing_to :account
end
def active_accounts
@active_accounts ||= family.accounts.active.with_attached_logo
end
def account_rows
@account_rows ||= query.map do |account_row|
AccountRow.new(
account: account_row,
converted_balance: account_row.converted_balance,
is_syncing: sync_status_monitor.account_syncing?(account_row)
)
end
end
def cache_key
[
"balance_sheet_account_rows",
family.id,
family.latest_sync_completed_at
].join("_")
end
def query
@query ||= Rails.cache.fetch(cache_key) do
active_accounts
.joins(ActiveRecord::Base.sanitize_sql_array([
"LEFT JOIN exchange_rates ON exchange_rates.date = ? AND accounts.currency = exchange_rates.from_currency AND exchange_rates.to_currency = ?",
Date.current,
family.currency
]))
.select(
"accounts.*",
"SUM(accounts.balance * COALESCE(exchange_rates.rate, 1)) as converted_balance"
)
.group(:classification, :accountable_type, :id)
.to_a
end
end
end

View file

@ -0,0 +1,54 @@
class BalanceSheet::ClassificationGroup
include Monetizable
monetize :total, as: :total_money
attr_reader :classification, :currency
def initialize(classification:, currency:, accounts:)
@classification = set_classification!(classification)
@name = name
@currency = currency
@accounts = accounts
end
def name
classification.titleize.pluralize
end
def icon
classification == "asset" ? "plus" : "minus"
end
def total
accounts.sum(&:converted_balance)
end
def syncing?
accounts.any?(&:syncing?)
end
# For now, we group by accountable type. This can be extended in the future to support arbitrary user groupings.
def account_groups
accounts.group_by(&:accountable_type)
.transform_keys { |at| Accountable.from_type(at) }
.map do |accountable, account_rows|
BalanceSheet::AccountGroup.new(
name: accountable.display_name,
color: accountable.color,
accountable_type: accountable,
accounts: account_rows,
classification_group: self
)
end
end
private
attr_reader :accounts
def set_classification!(classification)
raise "Invalid classification: #{classification}" unless %w[asset liability].include?(classification)
classification
end
end

View file

@ -0,0 +1,35 @@
class BalanceSheet::SyncStatusMonitor
def initialize(family)
@family = family
end
def syncing?
syncing_account_ids.any?
end
def account_syncing?(account)
syncing_account_ids.include?(account.id)
end
private
attr_reader :family
def syncing_account_ids
Rails.cache.fetch(cache_key) do
Sync.visible
.where(syncable_type: "Account", syncable_id: family.accounts.active.pluck(:id))
.pluck(:syncable_id)
.to_set
end
end
# We re-fetch the set of syncing IDs any time a sync that belongs to the family is started or completed.
# This ensures we're always fetching the latest sync statuses without re-querying on every page load in idle times (no syncs happening).
def cache_key
[
"balance_sheet_sync_status",
family.id,
family.latest_sync_activity_at
].join("_")
end
end

View file

@ -41,7 +41,7 @@
) %>
<div>
<% family.balance_sheet.account_groups("asset").each do |group| %>
<% family.balance_sheet.assets.account_groups.each do |group| %>
<%= render "accounts/accountable_group", account_group: group, mobile: mobile %>
<% end %>
</div>
@ -61,7 +61,7 @@
) %>
<div>
<% family.balance_sheet.account_groups("liability").each do |group| %>
<% family.balance_sheet.liabilities.account_groups.each do |group| %>
<%= render "accounts/accountable_group", account_group: group, mobile: mobile %>
<% end %>
</div>

View file

@ -1,6 +1,6 @@
<%# locals: (account_group:, mobile: false, open: nil, **args) %>
<div id="<%= mobile ? "mobile_#{account_group.id}" : account_group.id %>">
<div id="<%= mobile ? "mobile_#{account_group.key}" : account_group.key %>">
<% is_open = open.nil? ? account_group.accounts.any? { |account| page_active?(account_path(account)) } : open %>
<%= render DisclosureComponent.new(title: account_group.name, align: :left, open: is_open) do |disclosure| %>
<% disclosure.with_summary_content do %>

View file

@ -5,7 +5,7 @@
<div class="bg-container shadow-border-xs rounded-xl space-y-4 p-4">
<h2 class="text-lg font-medium inline-flex items-center gap-1.5">
<span>
<%= classification_group.display_name %>
<%= classification_group.name %>
</span>
<% if classification_group.account_groups.any? %>

View file

@ -0,0 +1,6 @@
class AddSyncTimestampsToFamily < ActiveRecord::Migration[7.2]
def change
add_column :families, :latest_sync_activity_at, :datetime, default: -> { "CURRENT_TIMESTAMP" }
add_column :families, :latest_sync_completed_at, :datetime, default: -> { "CURRENT_TIMESTAMP" }
end
end

4
db/schema.rb generated
View file

@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.2].define(version: 2025_06_05_031616) do
ActiveRecord::Schema[7.2].define(version: 2025_06_10_181219) do
# These are extensions that must be enabled in order to support this database
enable_extension "pgcrypto"
enable_extension "plpgsql"
@ -228,6 +228,8 @@ ActiveRecord::Schema[7.2].define(version: 2025_06_05_031616) do
t.boolean "data_enrichment_enabled", default: false
t.boolean "early_access", default: false
t.boolean "auto_sync_on_login", default: true, null: false
t.datetime "latest_sync_activity_at", default: -> { "CURRENT_TIMESTAMP" }
t.datetime "latest_sync_completed_at", default: -> { "CURRENT_TIMESTAMP" }
end
create_table "holdings", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|

View file

@ -6,23 +6,23 @@ class BalanceSheetTest < ActiveSupport::TestCase
end
test "calculates total assets" do
assert_equal 0, BalanceSheet.new(@family).total_assets
assert_equal 0, BalanceSheet.new(@family).assets.total
create_account(balance: 1000, accountable: Depository.new)
create_account(balance: 5000, accountable: OtherAsset.new)
create_account(balance: 10000, accountable: CreditCard.new) # ignored
assert_equal 1000 + 5000, BalanceSheet.new(@family).total_assets
assert_equal 1000 + 5000, BalanceSheet.new(@family).assets.total
end
test "calculates total liabilities" do
assert_equal 0, BalanceSheet.new(@family).total_liabilities
assert_equal 0, BalanceSheet.new(@family).liabilities.total
create_account(balance: 1000, accountable: CreditCard.new)
create_account(balance: 5000, accountable: OtherLiability.new)
create_account(balance: 10000, accountable: Depository.new) # ignored
assert_equal 1000 + 5000, BalanceSheet.new(@family).total_liabilities
assert_equal 1000 + 5000, BalanceSheet.new(@family).liabilities.total
end
test "calculates net worth" do
@ -42,8 +42,8 @@ class BalanceSheetTest < ActiveSupport::TestCase
other_liability.update!(is_active: false)
assert_equal 10000 - 1000, BalanceSheet.new(@family).net_worth
assert_equal 10000, BalanceSheet.new(@family).total_assets
assert_equal 1000, BalanceSheet.new(@family).total_liabilities
assert_equal 10000, BalanceSheet.new(@family).assets.total
assert_equal 1000, BalanceSheet.new(@family).liabilities.total
end
test "calculates asset group totals" do
@ -53,7 +53,7 @@ class BalanceSheetTest < ActiveSupport::TestCase
create_account(balance: 5000, accountable: OtherAsset.new)
create_account(balance: 10000, accountable: CreditCard.new) # ignored
asset_groups = BalanceSheet.new(@family).account_groups("asset")
asset_groups = BalanceSheet.new(@family).assets.account_groups
assert_equal 3, asset_groups.size
assert_equal 1000 + 2000, asset_groups.find { |ag| ag.name == "Cash" }.total
@ -68,7 +68,7 @@ class BalanceSheetTest < ActiveSupport::TestCase
create_account(balance: 5000, accountable: OtherLiability.new)
create_account(balance: 10000, accountable: Depository.new) # ignored
liability_groups = BalanceSheet.new(@family).account_groups("liability")
liability_groups = BalanceSheet.new(@family).liabilities.account_groups
assert_equal 2, liability_groups.size
assert_equal 1000 + 2000, liability_groups.find { |ag| ag.name == "Credit Cards" }.total