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:
parent
dab693d74f
commit
f1c7660ff6
12 changed files with 245 additions and 107 deletions
|
@ -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)
|
||||
|
||||
{
|
||||
|
|
|
@ -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
|
||||
|
|
40
app/models/balance_sheet/account_group.rb
Normal file
40
app/models/balance_sheet/account_group.rb
Normal 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
|
64
app/models/balance_sheet/account_totals.rb
Normal file
64
app/models/balance_sheet/account_totals.rb
Normal 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
|
54
app/models/balance_sheet/classification_group.rb
Normal file
54
app/models/balance_sheet/classification_group.rb
Normal 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
|
35
app/models/balance_sheet/sync_status_monitor.rb
Normal file
35
app/models/balance_sheet/sync_status_monitor.rb
Normal 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
|
|
@ -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>
|
||||
|
|
|
@ -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 %>
|
||||
|
|
|
@ -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? %>
|
||||
|
|
|
@ -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
4
db/schema.rb
generated
|
@ -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|
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue