mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-07-19 05:09:38 +02:00
Balance sheet cache layer, non-blocking sync UI (#2356)
* Balance sheet cache layer with cache-busting * Update family cache timestamps during Sync * Less blocking sync loaders * Consolidate family data caching key logic * Fix turbo stream broadcasts * Remove dev delay * Add back account group sorting
This commit is contained in:
parent
dab693d74f
commit
10ce2c8e23
35 changed files with 529 additions and 466 deletions
|
@ -2,10 +2,11 @@ class AccountableSparklinesController < ApplicationController
|
||||||
def show
|
def show
|
||||||
@accountable = Accountable.from_type(params[:accountable_type]&.classify)
|
@accountable = Accountable.from_type(params[:accountable_type]&.classify)
|
||||||
|
|
||||||
# Pre-load the series to catch any errors before rendering
|
etag_key = cache_key
|
||||||
@series = Rails.cache.fetch(cache_key) do
|
|
||||||
account_ids = family.accounts.active.where(accountable_type: @accountable.name).pluck(:id)
|
|
||||||
|
|
||||||
|
# Use HTTP conditional GET so the client receives 304 Not Modified when possible.
|
||||||
|
if stale?(etag: etag_key, last_modified: family.latest_sync_completed_at)
|
||||||
|
@series = Rails.cache.fetch(etag_key, expires_in: 24.hours) do
|
||||||
builder = Balance::ChartSeriesBuilder.new(
|
builder = Balance::ChartSeriesBuilder.new(
|
||||||
account_ids: account_ids,
|
account_ids: account_ids,
|
||||||
currency: family.currency,
|
currency: family.currency,
|
||||||
|
@ -18,9 +19,7 @@ class AccountableSparklinesController < ApplicationController
|
||||||
end
|
end
|
||||||
|
|
||||||
render layout: false
|
render layout: false
|
||||||
rescue => e
|
end
|
||||||
Rails.logger.error "Accountable sparkline error for #{@accountable&.name}: #{e.message}"
|
|
||||||
render partial: "accountable_sparklines/error", layout: false
|
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
@ -28,7 +27,15 @@ class AccountableSparklinesController < ApplicationController
|
||||||
Current.family
|
Current.family
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def accountable
|
||||||
|
Accountable.from_type(params[:accountable_type]&.classify)
|
||||||
|
end
|
||||||
|
|
||||||
|
def account_ids
|
||||||
|
family.accounts.active.where(accountable_type: accountable.name).pluck(:id)
|
||||||
|
end
|
||||||
|
|
||||||
def cache_key
|
def cache_key
|
||||||
family.build_cache_key("#{@accountable.name}_sparkline")
|
family.build_cache_key("#{@accountable.name}_sparkline", invalidate_on_data_updates: true)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -23,12 +23,14 @@ class AccountsController < ApplicationController
|
||||||
end
|
end
|
||||||
|
|
||||||
def sparkline
|
def sparkline
|
||||||
# Pre-load the sparkline series to catch any errors before rendering
|
etag_key = @account.family.build_cache_key("#{@account.id}_sparkline", invalidate_on_data_updates: true)
|
||||||
|
|
||||||
|
# Short-circuit with 304 Not Modified when the client already has the latest version.
|
||||||
|
# We defer the expensive series computation until we know the content is stale.
|
||||||
|
if stale?(etag: etag_key, last_modified: @account.family.latest_sync_completed_at)
|
||||||
@sparkline_series = @account.sparkline_series
|
@sparkline_series = @account.sparkline_series
|
||||||
render layout: false
|
render layout: false
|
||||||
rescue => e
|
end
|
||||||
Rails.logger.error "Sparkline error for account #{@account.id}: #{e.message}"
|
|
||||||
render partial: "accounts/sparkline_error", layout: false
|
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
|
@ -61,18 +61,6 @@ class Account < ApplicationRecord
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def syncing?
|
|
||||||
self_syncing = syncs.visible.any?
|
|
||||||
|
|
||||||
# Since Plaid Items sync as a "group", if the item is syncing, even if the account
|
|
||||||
# sync hasn't yet started (i.e. we're still fetching the Plaid data), show it as syncing in UI.
|
|
||||||
if linked?
|
|
||||||
plaid_account&.plaid_item&.syncing? || self_syncing
|
|
||||||
else
|
|
||||||
self_syncing
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def institution_domain
|
def institution_domain
|
||||||
url_string = plaid_account&.plaid_item&.institution_url
|
url_string = plaid_account&.plaid_item&.institution_url
|
||||||
return nil unless url_string.present?
|
return nil unless url_string.present?
|
||||||
|
|
|
@ -24,20 +24,10 @@ module Account::Chartable
|
||||||
end
|
end
|
||||||
|
|
||||||
def sparkline_series
|
def sparkline_series
|
||||||
cache_key = family.build_cache_key("#{id}_sparkline")
|
cache_key = family.build_cache_key("#{id}_sparkline", invalidate_on_data_updates: true)
|
||||||
|
|
||||||
Rails.cache.fetch(cache_key) do
|
Rails.cache.fetch(cache_key, expires_in: 24.hours) do
|
||||||
balance_series
|
balance_series
|
||||||
end
|
end
|
||||||
rescue => e
|
|
||||||
Rails.logger.error "Sparkline series error for account #{id}: #{e.message}"
|
|
||||||
# Return empty series as fallback
|
|
||||||
Series.new(
|
|
||||||
start_date: 30.days.ago.to_date,
|
|
||||||
end_date: Date.current,
|
|
||||||
interval: "1 day",
|
|
||||||
values: [],
|
|
||||||
favorable_direction: favorable_direction
|
|
||||||
)
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -16,13 +16,13 @@ class Account::SyncCompleteEvent
|
||||||
locals: { account: account }
|
locals: { account: account }
|
||||||
)
|
)
|
||||||
|
|
||||||
# Replace the groups this account belongs to in the sidebar
|
# Replace the groups this account belongs to in both desktop and mobile sidebars
|
||||||
account_group_ids.each do |id|
|
sidebar_targets.each do |(tab, mobile_flag)|
|
||||||
account.broadcast_replace_to(
|
account.broadcast_replace_to(
|
||||||
account.family,
|
account.family,
|
||||||
target: id,
|
target: account_group.dom_id(tab: tab, mobile: mobile_flag),
|
||||||
partial: "accounts/accountable_group",
|
partial: "accounts/accountable_group",
|
||||||
locals: { account_group: account_group, open: true }
|
locals: { account_group: account_group, open: true, all_tab: tab == :all, mobile: mobile_flag }
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -37,18 +37,18 @@ class Account::SyncCompleteEvent
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
# The sidebar will show the account in both its classification tab and the "all" tab,
|
# Returns an array of [tab, mobile?] tuples that should receive an update.
|
||||||
# so we need to broadcast to both.
|
# We broadcast to both the classification-specific tab and the "all" tab,
|
||||||
def account_group_ids
|
# for desktop (mobile: false) and mobile (mobile: true) variants.
|
||||||
unless account_group.present?
|
def sidebar_targets
|
||||||
error = Error.new("Account #{account.id} is not part of an account group")
|
return [] unless account_group.present?
|
||||||
Rails.logger.warn(error.message)
|
|
||||||
Sentry.capture_exception(error, level: :warning)
|
|
||||||
return []
|
|
||||||
end
|
|
||||||
|
|
||||||
id = account_group.id
|
[
|
||||||
[ id, "#{account_group.classification}_#{id}" ]
|
[ account_group.classification.to_sym, false ],
|
||||||
|
[ :all, false ],
|
||||||
|
[ account_group.classification.to_sym, true ],
|
||||||
|
[ :all, true ]
|
||||||
|
]
|
||||||
end
|
end
|
||||||
|
|
||||||
def account_group
|
def account_group
|
||||||
|
|
|
@ -31,11 +31,11 @@ class Assistant::Function::GetBalanceSheet < Assistant::Function
|
||||||
monthly_history: historical_data(period)
|
monthly_history: historical_data(period)
|
||||||
},
|
},
|
||||||
assets: {
|
assets: {
|
||||||
current: family.balance_sheet.total_assets_money.format,
|
current: family.balance_sheet.assets.total_money.format,
|
||||||
monthly_history: historical_data(period, classification: "asset")
|
monthly_history: historical_data(period, classification: "asset")
|
||||||
},
|
},
|
||||||
liabilities: {
|
liabilities: {
|
||||||
current: family.balance_sheet.total_liabilities_money.format,
|
current: family.balance_sheet.liabilities.total_money.format,
|
||||||
monthly_history: historical_data(period, classification: "liability")
|
monthly_history: historical_data(period, classification: "liability")
|
||||||
},
|
},
|
||||||
insights: insights_data
|
insights: insights_data
|
||||||
|
@ -65,8 +65,8 @@ class Assistant::Function::GetBalanceSheet < Assistant::Function
|
||||||
end
|
end
|
||||||
|
|
||||||
def insights_data
|
def insights_data
|
||||||
assets = family.balance_sheet.total_assets
|
assets = family.balance_sheet.assets.total
|
||||||
liabilities = family.balance_sheet.total_liabilities
|
liabilities = family.balance_sheet.liabilities.total
|
||||||
ratio = liabilities.zero? ? 0 : (liabilities / assets.to_f)
|
ratio = liabilities.zero? ? 0 : (liabilities / assets.to_f)
|
||||||
|
|
||||||
{
|
{
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
class BalanceSheet
|
class BalanceSheet
|
||||||
include Monetizable
|
include Monetizable
|
||||||
|
|
||||||
monetize :total_assets, :total_liabilities, :net_worth
|
monetize :net_worth
|
||||||
|
|
||||||
attr_reader :family
|
attr_reader :family
|
||||||
|
|
||||||
|
@ -9,99 +9,36 @@ class BalanceSheet
|
||||||
@family = family
|
@family = family
|
||||||
end
|
end
|
||||||
|
|
||||||
def total_assets
|
def assets
|
||||||
totals_query.filter { |t| t.classification == "asset" }.sum(&:converted_balance)
|
@assets ||= ClassificationGroup.new(
|
||||||
|
classification: "asset",
|
||||||
|
currency: family.currency,
|
||||||
|
accounts: account_totals.asset_accounts
|
||||||
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
def total_liabilities
|
def liabilities
|
||||||
totals_query.filter { |t| t.classification == "liability" }.sum(&:converted_balance)
|
@liabilities ||= ClassificationGroup.new(
|
||||||
end
|
classification: "liability",
|
||||||
|
currency: family.currency,
|
||||||
def net_worth
|
accounts: account_totals.liability_accounts
|
||||||
total_assets - total_liabilities
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
def classification_groups
|
def classification_groups
|
||||||
Rails.cache.fetch(family.build_cache_key("bs_classification_groups")) do
|
[ assets, liabilities ]
|
||||||
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
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def account_groups(classification = nil)
|
def account_groups
|
||||||
Rails.cache.fetch(family.build_cache_key("bs_account_groups_#{classification || 'all'}")) do
|
[ assets.account_groups, liabilities.account_groups ].flatten
|
||||||
classification_accounts = classification ? totals_query.filter { |t| t.classification == classification } : totals_query
|
|
||||||
classification_total = classification_accounts.sum(&:converted_balance)
|
|
||||||
|
|
||||||
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
|
end
|
||||||
|
|
||||||
groups.sort_by do |group|
|
def net_worth
|
||||||
manual_order = Accountable::TYPES
|
assets.total - liabilities.total
|
||||||
type_name = group.key.camelize
|
|
||||||
manual_order.index(type_name) || Float::INFINITY
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def net_worth_series(period: Period.last_30_days)
|
def net_worth_series(period: Period.last_30_days)
|
||||||
memo_key = [ period.start_date, period.end_date ].compact.join("_")
|
net_worth_series_builder.net_worth_series(period: period)
|
||||||
|
|
||||||
@net_worth_series ||= {}
|
|
||||||
|
|
||||||
account_ids = active_accounts.pluck(:id)
|
|
||||||
|
|
||||||
builder = (@net_worth_series[memo_key] ||= Balance::ChartSeriesBuilder.new(
|
|
||||||
account_ids: account_ids,
|
|
||||||
currency: currency,
|
|
||||||
period: period,
|
|
||||||
favorable_direction: "up"
|
|
||||||
))
|
|
||||||
|
|
||||||
builder.balance_series
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def currency
|
def currency
|
||||||
|
@ -109,32 +46,19 @@ class BalanceSheet
|
||||||
end
|
end
|
||||||
|
|
||||||
def syncing?
|
def syncing?
|
||||||
classification_groups.any? { |group| group.syncing? }
|
sync_status_monitor.syncing?
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
ClassificationGroup = Struct.new(:key, :display_name, :icon, :total_money, :account_groups, :syncing?, keyword_init: true)
|
def sync_status_monitor
|
||||||
AccountGroup = Struct.new(:id, :key, :name, :accountable_type, :classification, :total, :total_money, :weight, :accounts, :color, :missing_rates?, :syncing?, keyword_init: true)
|
@sync_status_monitor ||= SyncStatusMonitor.new(family)
|
||||||
|
|
||||||
def active_accounts
|
|
||||||
family.accounts.active.with_attached_logo
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def totals_query
|
def account_totals
|
||||||
@totals_query ||= active_accounts
|
@account_totals ||= AccountTotals.new(family, sync_status_monitor: sync_status_monitor)
|
||||||
.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 ]))
|
end
|
||||||
.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 > ?",
|
def net_worth_series_builder
|
||||||
%w[pending syncing],
|
@net_worth_series_builder ||= NetWorthSeriesBuilder.new(family)
|
||||||
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
|
||||||
end
|
end
|
||||||
|
|
61
app/models/balance_sheet/account_group.rb
Normal file
61
app/models/balance_sheet/account_group.rb
Normal file
|
@ -0,0 +1,61 @@
|
||||||
|
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
|
||||||
|
|
||||||
|
# A stable DOM id for this group.
|
||||||
|
# Example outputs:
|
||||||
|
# dom_id(tab: :asset) # => "asset_depository"
|
||||||
|
# dom_id(tab: :all, mobile: true) # => "mobile_all_depository"
|
||||||
|
#
|
||||||
|
# Keeping all of the logic here means the view layer and broadcaster only
|
||||||
|
# need to ask the object for its DOM id instead of rebuilding string
|
||||||
|
# fragments in multiple places.
|
||||||
|
def dom_id(tab: nil, mobile: false)
|
||||||
|
parts = []
|
||||||
|
parts << "mobile" if mobile
|
||||||
|
parts << (tab ? tab.to_s : classification.to_s)
|
||||||
|
parts << key
|
||||||
|
parts.compact.join("_")
|
||||||
|
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
|
||||||
|
|
||||||
|
# "asset" or "liability"
|
||||||
|
def classification
|
||||||
|
classification_group.classification
|
||||||
|
end
|
||||||
|
|
||||||
|
def currency
|
||||||
|
classification_group.currency
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
attr_reader :classification_group
|
||||||
|
end
|
63
app/models/balance_sheet/account_totals.rb
Normal file
63
app/models/balance_sheet/account_totals.rb
Normal file
|
@ -0,0 +1,63 @@
|
||||||
|
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
|
||||||
|
family.build_cache_key(
|
||||||
|
"balance_sheet_account_rows",
|
||||||
|
invalidate_on_data_updates: true
|
||||||
|
)
|
||||||
|
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
|
61
app/models/balance_sheet/classification_group.rb
Normal file
61
app/models/balance_sheet/classification_group.rb
Normal file
|
@ -0,0 +1,61 @@
|
||||||
|
class BalanceSheet::ClassificationGroup
|
||||||
|
include Monetizable
|
||||||
|
|
||||||
|
monetize :total, as: :total_money
|
||||||
|
|
||||||
|
attr_reader :classification, :currency
|
||||||
|
|
||||||
|
def initialize(classification:, currency:, accounts:)
|
||||||
|
@classification = normalize_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
|
||||||
|
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
|
||||||
|
|
||||||
|
# Sort the groups using the manual order defined by Accountable::TYPES so that
|
||||||
|
# the UI displays account groups in a predictable, domain-specific sequence.
|
||||||
|
groups.sort_by do |group|
|
||||||
|
manual_order = Accountable::TYPES
|
||||||
|
type_name = group.key.camelize
|
||||||
|
manual_order.index(type_name) || Float::INFINITY
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
attr_reader :accounts
|
||||||
|
|
||||||
|
def normalize_classification!(classification)
|
||||||
|
raise ArgumentError, "Invalid classification: #{classification}" unless %w[asset liability].include?(classification)
|
||||||
|
classification
|
||||||
|
end
|
||||||
|
end
|
38
app/models/balance_sheet/net_worth_series_builder.rb
Normal file
38
app/models/balance_sheet/net_worth_series_builder.rb
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
class BalanceSheet::NetWorthSeriesBuilder
|
||||||
|
def initialize(family)
|
||||||
|
@family = family
|
||||||
|
end
|
||||||
|
|
||||||
|
def net_worth_series(period: Period.last_30_days)
|
||||||
|
Rails.cache.fetch(cache_key(period)) do
|
||||||
|
builder = Balance::ChartSeriesBuilder.new(
|
||||||
|
account_ids: active_account_ids,
|
||||||
|
currency: family.currency,
|
||||||
|
period: period,
|
||||||
|
favorable_direction: "up"
|
||||||
|
)
|
||||||
|
|
||||||
|
builder.balance_series
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
attr_reader :family
|
||||||
|
|
||||||
|
def active_account_ids
|
||||||
|
@active_account_ids ||= family.accounts.active.with_attached_logo.pluck(:id)
|
||||||
|
end
|
||||||
|
|
||||||
|
def cache_key(period)
|
||||||
|
key = [
|
||||||
|
"balance_sheet_net_worth_series",
|
||||||
|
period.start_date,
|
||||||
|
period.end_date
|
||||||
|
].compact.join("_")
|
||||||
|
|
||||||
|
family.build_cache_key(
|
||||||
|
key,
|
||||||
|
invalidate_on_data_updates: true
|
||||||
|
)
|
||||||
|
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
|
|
@ -6,7 +6,7 @@ module Syncable
|
||||||
end
|
end
|
||||||
|
|
||||||
def syncing?
|
def syncing?
|
||||||
raise NotImplementedError, "Subclasses must implement the syncing? method"
|
syncs.visible.any?
|
||||||
end
|
end
|
||||||
|
|
||||||
# Schedules a sync for syncable. If there is an existing sync pending/syncing for this syncable,
|
# Schedules a sync for syncable. If there is an existing sync pending/syncing for this syncable,
|
||||||
|
|
|
@ -35,25 +35,6 @@ class Family < ApplicationRecord
|
||||||
validates :locale, inclusion: { in: I18n.available_locales.map(&:to_s) }
|
validates :locale, inclusion: { in: I18n.available_locales.map(&:to_s) }
|
||||||
validates :date_format, inclusion: { in: DATE_FORMATS.map(&:last) }
|
validates :date_format, inclusion: { in: DATE_FORMATS.map(&:last) }
|
||||||
|
|
||||||
# If any accounts or plaid items are syncing, the family is also syncing, even if a formal "Family Sync" is not running.
|
|
||||||
def syncing?
|
|
||||||
# Check for any in-progress syncs that belong directly to the family, to one of the
|
|
||||||
# family's accounts, or to one of the family's Plaid items. By moving the `visible`
|
|
||||||
# scope to the beginning we narrow down the candidate rows **before** performing the
|
|
||||||
# joins and by explicitly constraining the `syncable_type` for the direct Family
|
|
||||||
# match we allow Postgres to use the composite index on `(syncable_type, syncable_id)`.
|
|
||||||
Sync.visible
|
|
||||||
.joins("LEFT JOIN accounts ON accounts.id = syncs.syncable_id AND syncs.syncable_type = 'Account'")
|
|
||||||
.joins("LEFT JOIN plaid_items ON plaid_items.id = syncs.syncable_id AND syncs.syncable_type = 'PlaidItem'")
|
|
||||||
.where(
|
|
||||||
"(syncs.syncable_type = 'Family' AND syncs.syncable_id = :family_id) OR " \
|
|
||||||
"accounts.family_id = :family_id OR " \
|
|
||||||
"plaid_items.family_id = :family_id",
|
|
||||||
family_id: id
|
|
||||||
)
|
|
||||||
.exists?
|
|
||||||
end
|
|
||||||
|
|
||||||
def assigned_merchants
|
def assigned_merchants
|
||||||
merchant_ids = transactions.where.not(merchant_id: nil).pluck(:merchant_id).uniq
|
merchant_ids = transactions.where.not(merchant_id: nil).pluck(:merchant_id).uniq
|
||||||
Merchant.where(id: merchant_ids)
|
Merchant.where(id: merchant_ids)
|
||||||
|
@ -110,13 +91,15 @@ class Family < ApplicationRecord
|
||||||
entries.order(:date).first&.date || Date.current
|
entries.order(:date).first&.date || Date.current
|
||||||
end
|
end
|
||||||
|
|
||||||
# Cache key that is invalidated when any of the family's entries are updated (which affect rollups and other calculations)
|
def build_cache_key(key, invalidate_on_data_updates: false)
|
||||||
def build_cache_key(key)
|
# Our data sync process updates this timestamp whenever any family account successfully completes a data update.
|
||||||
|
# By including it in the cache key, we can expire caches every time family account data changes.
|
||||||
|
data_invalidation_key = invalidate_on_data_updates ? latest_sync_completed_at : nil
|
||||||
|
|
||||||
[
|
[
|
||||||
"family",
|
|
||||||
id,
|
id,
|
||||||
key,
|
key,
|
||||||
entries.maximum(:updated_at)
|
data_invalidation_key
|
||||||
].compact.join("_")
|
].compact.join("_")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -46,14 +46,6 @@ class PlaidItem < ApplicationRecord
|
||||||
DestroyJob.perform_later(self)
|
DestroyJob.perform_later(self)
|
||||||
end
|
end
|
||||||
|
|
||||||
def syncing?
|
|
||||||
Sync.joins("LEFT JOIN accounts a ON a.id = syncs.syncable_id AND syncs.syncable_type = 'Account'")
|
|
||||||
.joins("LEFT JOIN plaid_accounts pa ON pa.id = a.plaid_account_id")
|
|
||||||
.where("syncs.syncable_id = ? OR pa.plaid_item_id = ?", id, id)
|
|
||||||
.visible
|
|
||||||
.exists?
|
|
||||||
end
|
|
||||||
|
|
||||||
def import_latest_plaid_data
|
def import_latest_plaid_data
|
||||||
PlaidItem::Importer.new(self, plaid_provider: plaid_provider).import
|
PlaidItem::Importer.new(self, plaid_provider: plaid_provider).import
|
||||||
end
|
end
|
||||||
|
|
|
@ -29,13 +29,13 @@ class Sync < ApplicationRecord
|
||||||
state :failed
|
state :failed
|
||||||
state :stale
|
state :stale
|
||||||
|
|
||||||
after_all_transitions :log_status_change
|
after_all_transitions :handle_transition
|
||||||
|
|
||||||
event :start, after_commit: :report_warnings do
|
event :start, after_commit: :handle_start_transition do
|
||||||
transitions from: :pending, to: :syncing
|
transitions from: :pending, to: :syncing
|
||||||
end
|
end
|
||||||
|
|
||||||
event :complete do
|
event :complete, after_commit: :handle_completion_transition do
|
||||||
transitions from: :syncing, to: :completed
|
transitions from: :syncing, to: :completed
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -163,9 +163,30 @@ class Sync < ApplicationRecord
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def handle_start_transition
|
||||||
|
report_warnings
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_transition
|
||||||
|
log_status_change
|
||||||
|
family.touch(:latest_sync_activity_at)
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_completion_transition
|
||||||
|
family.touch(:latest_sync_completed_at)
|
||||||
|
end
|
||||||
|
|
||||||
def window_valid
|
def window_valid
|
||||||
if window_start_date && window_end_date && window_start_date > window_end_date
|
if window_start_date && window_end_date && window_start_date > window_end_date
|
||||||
errors.add(:window_end_date, "must be greater than window_start_date")
|
errors.add(:window_end_date, "must be greater than window_start_date")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def family
|
||||||
|
if syncable.is_a?(Family)
|
||||||
|
syncable
|
||||||
|
else
|
||||||
|
syncable.family
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -1,8 +0,0 @@
|
||||||
<%= turbo_frame_tag "#{params[:accountable_type]}_sparkline" do %>
|
|
||||||
<div class="flex items-center justify-end gap-1">
|
|
||||||
<div class="w-8 h-3 flex items-center justify-center">
|
|
||||||
<%= icon("alert-triangle", size: "sm", class: "text-warning") %>
|
|
||||||
</div>
|
|
||||||
<p class="font-mono text-right text-xs text-warning">Error</p>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
|
@ -1,5 +1,4 @@
|
||||||
<% cache Current.family.build_cache_key("#{@accountable.name}_sparkline_html") do %>
|
<%= turbo_frame_tag "#{@accountable.model_name.param_key}_sparkline" do %>
|
||||||
<%= turbo_frame_tag "#{@accountable.model_name.param_key}_sparkline" do %>
|
|
||||||
<div class="flex items-center justify-end gap-1">
|
<div class="flex items-center justify-end gap-1">
|
||||||
<div class="w-8 h-3">
|
<div class="w-8 h-3">
|
||||||
<%= render "shared/sparkline", id: dom_id(@accountable, :sparkline_chart), series: @series %>
|
<%= render "shared/sparkline", id: dom_id(@accountable, :sparkline_chart), series: @series %>
|
||||||
|
@ -9,5 +8,4 @@
|
||||||
style: "color: #{@series.trend.color}",
|
style: "color: #{@series.trend.color}",
|
||||||
class: "font-mono text-right text-xs font-medium text-primary" %>
|
class: "font-mono text-right text-xs font-medium text-primary" %>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
|
@ -41,7 +41,7 @@
|
||||||
) %>
|
) %>
|
||||||
|
|
||||||
<div>
|
<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 %>
|
<%= render "accounts/accountable_group", account_group: group, mobile: mobile %>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
@ -61,7 +61,7 @@
|
||||||
) %>
|
) %>
|
||||||
|
|
||||||
<div>
|
<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 %>
|
<%= render "accounts/accountable_group", account_group: group, mobile: mobile %>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
@ -82,7 +82,7 @@
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<% family.balance_sheet.account_groups.each do |group| %>
|
<% family.balance_sheet.account_groups.each do |group| %>
|
||||||
<%= render "accounts/accountable_group", account_group: group, mobile: mobile %>
|
<%= render "accounts/accountable_group", account_group: group, mobile: mobile, all_tab: true %>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,25 +1,22 @@
|
||||||
<%# locals: (account_group:, mobile: false, open: nil, **args) %>
|
<%# locals: (account_group:, mobile: false, all_tab: false, open: nil, **args) %>
|
||||||
|
|
||||||
<div id="<%= mobile ? "mobile_#{account_group.id}" : account_group.id %>">
|
<div id="<%= account_group.dom_id(tab: all_tab ? :all : nil, mobile: mobile) %>">
|
||||||
<% is_open = open.nil? ? account_group.accounts.any? { |account| page_active?(account_path(account)) } : open %>
|
<% 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| %>
|
<%= render DisclosureComponent.new(title: account_group.name, align: :left, open: is_open) do |disclosure| %>
|
||||||
<% disclosure.with_summary_content do %>
|
<% disclosure.with_summary_content do %>
|
||||||
<div class="ml-auto text-right grow">
|
|
||||||
<% if account_group.syncing? %>
|
<% if account_group.syncing? %>
|
||||||
<div class="space-y-1">
|
<div class="ml-2 group-open:hidden">
|
||||||
<div class="h-5 w-24 rounded ml-auto bg-loader"></div>
|
<%= render partial: "shared/sync_indicator", locals: { size: "xs" } %>
|
||||||
<div class="flex items-center w-8 h-4 ml-auto">
|
|
||||||
<div class="w-6 h-px bg-loader"></div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<% end %>
|
||||||
<% else %>
|
|
||||||
|
<div class="ml-auto text-right grow">
|
||||||
<%= tag.p format_money(account_group.total_money), class: "text-sm font-medium text-primary" %>
|
<%= tag.p format_money(account_group.total_money), class: "text-sm font-medium text-primary" %>
|
||||||
<%= turbo_frame_tag "#{account_group.key}_sparkline", src: accountable_sparkline_path(account_group.key), loading: "lazy", data: { controller: "turbo-frame-timeout", turbo_frame_timeout_timeout_value: 10000 } do %>
|
<%= turbo_frame_tag "#{account_group.key}_sparkline", src: accountable_sparkline_path(account_group.key), loading: "lazy", data: { controller: "turbo-frame-timeout", turbo_frame_timeout_timeout_value: 10000 } do %>
|
||||||
<div class="flex items-center w-8 h-4 ml-auto">
|
<div class="flex items-center w-8 h-4 ml-auto">
|
||||||
<div class="w-6 h-px bg-loader"></div>
|
<div class="w-6 h-px bg-loader"></div>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
<% end %>
|
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
|
@ -34,20 +31,15 @@
|
||||||
<%= render "accounts/logo", account: account, size: "sm", color: account_group.color %>
|
<%= render "accounts/logo", account: account, size: "sm", color: account_group.color %>
|
||||||
|
|
||||||
<div class="min-w-0 grow">
|
<div class="min-w-0 grow">
|
||||||
<%= tag.p account.name, class: "text-sm text-primary font-medium mb-0.5 truncate" %>
|
<div class="flex items-center gap-2 mb-0.5">
|
||||||
|
<%= tag.p account.name, class: "text-sm text-primary font-medium truncate" %>
|
||||||
|
<% if account.syncing? %>
|
||||||
|
<%= render partial: "shared/sync_indicator", locals: { size: "xs" } %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
<%= tag.p account.short_subtype_label, class: "text-sm text-secondary truncate" %>
|
<%= tag.p account.short_subtype_label, class: "text-sm text-secondary truncate" %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<% if account.syncing? %>
|
|
||||||
<div class="ml-auto text-right grow h-10">
|
|
||||||
<div class="space-y-1">
|
|
||||||
<div class="h-5 w-24 bg-loader rounded ml-auto"></div>
|
|
||||||
<div class="flex items-center w-8 h-4 ml-auto">
|
|
||||||
<div class="w-6 h-px bg-loader"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<% else %>
|
|
||||||
<div class="ml-auto text-right grow h-10">
|
<div class="ml-auto text-right grow h-10">
|
||||||
<%= tag.p format_money(account.balance_money), class: "text-sm font-medium text-primary whitespace-nowrap" %>
|
<%= tag.p format_money(account.balance_money), class: "text-sm font-medium text-primary whitespace-nowrap" %>
|
||||||
<%= turbo_frame_tag dom_id(account, :sparkline), src: sparkline_account_path(account), loading: "lazy", data: { controller: "turbo-frame-timeout", turbo_frame_timeout_timeout_value: 10000 } do %>
|
<%= turbo_frame_tag dom_id(account, :sparkline), src: sparkline_account_path(account), loading: "lazy", data: { controller: "turbo-frame-timeout", turbo_frame_timeout_timeout_value: 10000 } do %>
|
||||||
|
@ -58,7 +50,6 @@
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
<% end %>
|
<% end %>
|
||||||
<% end %>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="my-2">
|
<div class="my-2">
|
||||||
|
|
|
@ -1,8 +0,0 @@
|
||||||
<%= turbo_frame_tag dom_id(@account, :sparkline) do %>
|
|
||||||
<div class="flex items-center justify-end gap-1">
|
|
||||||
<div class="w-8 h-5 flex items-center justify-center">
|
|
||||||
<%= icon("alert-triangle", size: "sm", class: "text-warning") %>
|
|
||||||
</div>
|
|
||||||
<p class="font-mono text-right text-xs text-warning">Error</p>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
|
@ -2,9 +2,6 @@
|
||||||
<% trend = series.trend %>
|
<% trend = series.trend %>
|
||||||
|
|
||||||
<%= turbo_frame_tag dom_id(@account, :chart_details) do %>
|
<%= turbo_frame_tag dom_id(@account, :chart_details) do %>
|
||||||
<% if @account.syncing? %>
|
|
||||||
<%= render "accounts/chart_loader" %>
|
|
||||||
<% else %>
|
|
||||||
<div class="px-4">
|
<div class="px-4">
|
||||||
<%= render partial: "shared/trend_change", locals: { trend: trend, comparison_label: @period.comparison_label } %>
|
<%= render partial: "shared/trend_change", locals: { trend: trend, comparison_label: @period.comparison_label } %>
|
||||||
</div>
|
</div>
|
||||||
|
@ -22,5 +19,4 @@
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
|
@ -9,19 +9,15 @@
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
<%= tag.p account.investment? ? "Total value" : default_value_title, class: "text-sm font-medium text-secondary" %>
|
<%= tag.p account.investment? ? "Total value" : default_value_title, class: "text-sm font-medium text-secondary" %>
|
||||||
|
|
||||||
<% if !account.syncing? && account.investment? %>
|
<% if account.investment? %>
|
||||||
<%= render "investments/value_tooltip", balance: account.balance_money, holdings: account.balance_money - account.cash_balance_money, cash: account.cash_balance_money %>
|
<%= render "investments/value_tooltip", balance: account.balance_money, holdings: account.balance_money - account.cash_balance_money, cash: account.cash_balance_money %>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-row gap-2 items-baseline">
|
<div class="flex flex-row gap-2 items-baseline">
|
||||||
<% if account.syncing? %>
|
|
||||||
<div class="bg-loader rounded-md h-7 w-20"></div>
|
|
||||||
<% else %>
|
|
||||||
<%= tag.p format_money(account.balance_money), class: "text-primary text-3xl font-medium truncate" %>
|
<%= tag.p format_money(account.balance_money), class: "text-primary text-3xl font-medium truncate" %>
|
||||||
<% if account.currency != Current.family.currency %>
|
<% if account.currency != Current.family.currency %>
|
||||||
<%= tag.p format_money(account.balance_money.exchange_to(Current.family.currency, fallback_rate: 1)), class: "text-sm font-medium text-secondary" %>
|
<%= tag.p format_money(account.balance_money.exchange_to(Current.family.currency, fallback_rate: 1)), class: "text-sm font-medium text-secondary" %>
|
||||||
<% end %>
|
<% end %>
|
||||||
<% end %>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
@ -10,12 +10,18 @@
|
||||||
<div class="flex items-center gap-3 overflow-hidden">
|
<div class="flex items-center gap-3 overflow-hidden">
|
||||||
<%= render "accounts/logo", account: account %>
|
<%= render "accounts/logo", account: account %>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
<div class="truncate">
|
<div class="truncate">
|
||||||
<h2 class="font-medium text-xl truncate"><%= title || account.name %></h2>
|
<h2 class="font-medium text-xl truncate"><%= title || account.name %></h2>
|
||||||
<% if subtitle.present? %>
|
<% if subtitle.present? %>
|
||||||
<p class="text-sm text-secondary"><%= subtitle %></p>
|
<p class="text-sm text-secondary"><%= subtitle %></p>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<% if account.syncing? %>
|
||||||
|
<%= render partial: "shared/sync_indicator", locals: { size: "sm" } %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
<% cache Current.family.build_cache_key("account_#{@account.id}_sparkline_html") do %>
|
<%= turbo_frame_tag dom_id(@account, :sparkline) do %>
|
||||||
<%= turbo_frame_tag dom_id(@account, :sparkline) do %>
|
|
||||||
<div class="flex items-center justify-end gap-1">
|
<div class="flex items-center justify-end gap-1">
|
||||||
<div class="w-8 h-5">
|
<div class="w-8 h-5">
|
||||||
<%= render "shared/sparkline", id: dom_id(@account, :sparkline_chart), series: @sparkline_series %>
|
<%= render "shared/sparkline", id: dom_id(@account, :sparkline_chart), series: @sparkline_series %>
|
||||||
|
@ -9,5 +8,4 @@
|
||||||
style: "color: #{@sparkline_series.trend.color}",
|
style: "color: #{@sparkline_series.trend.color}",
|
||||||
class: "font-mono text-right text-xs font-medium text-primary" %>
|
class: "font-mono text-right text-xs font-medium text-primary" %>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
|
@ -20,12 +20,8 @@
|
||||||
<div class="col-span-2 flex justify-end items-center gap-2">
|
<div class="col-span-2 flex justify-end items-center gap-2">
|
||||||
<% cash_weight = account.balance.zero? ? 0 : account.cash_balance / account.balance * 100 %>
|
<% cash_weight = account.balance.zero? ? 0 : account.cash_balance / account.balance * 100 %>
|
||||||
|
|
||||||
<% if account.syncing? %>
|
|
||||||
<div class="w-16 h-6 bg-loader rounded-full"></div>
|
|
||||||
<% else %>
|
|
||||||
<%= render "shared/progress_circle", progress: cash_weight %>
|
<%= render "shared/progress_circle", progress: cash_weight %>
|
||||||
<%= tag.p number_to_percentage(cash_weight, precision: 1) %>
|
<%= tag.p number_to_percentage(cash_weight, precision: 1) %>
|
||||||
<% end %>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-span-2 text-right">
|
<div class="col-span-2 text-right">
|
||||||
|
@ -33,13 +29,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-span-2 text-right">
|
<div class="col-span-2 text-right">
|
||||||
<% if account.syncing? %>
|
|
||||||
<div class="flex justify-end">
|
|
||||||
<div class="w-16 h-6 bg-loader rounded-full"></div>
|
|
||||||
</div>
|
|
||||||
<% else %>
|
|
||||||
<%= tag.p format_money account.cash_balance_money %>
|
<%= tag.p format_money account.cash_balance_money %>
|
||||||
<% end %>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-span-2 text-right">
|
<div class="col-span-2 text-right">
|
||||||
|
|
|
@ -17,9 +17,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-span-2 flex justify-end items-center gap-2">
|
<div class="col-span-2 flex justify-end items-center gap-2">
|
||||||
<% if holding.account.syncing? %>
|
<% if holding.weight %>
|
||||||
<div class="w-16 h-6 bg-loader rounded-full"></div>
|
|
||||||
<% elsif holding.weight %>
|
|
||||||
<%= render "shared/progress_circle", progress: holding.weight %>
|
<%= render "shared/progress_circle", progress: holding.weight %>
|
||||||
<%= tag.p number_to_percentage(holding.weight, precision: 1) %>
|
<%= tag.p number_to_percentage(holding.weight, precision: 1) %>
|
||||||
<% else %>
|
<% else %>
|
||||||
|
@ -28,39 +26,21 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-span-2 text-right">
|
<div class="col-span-2 text-right">
|
||||||
<% if holding.account.syncing? %>
|
|
||||||
<div class="flex justify-end">
|
|
||||||
<div class="w-16 h-6 bg-loader rounded-full"></div>
|
|
||||||
</div>
|
|
||||||
<% else %>
|
|
||||||
<%= tag.p format_money holding.avg_cost %>
|
<%= tag.p format_money holding.avg_cost %>
|
||||||
<%= tag.p t(".per_share"), class: "font-normal text-secondary" %>
|
<%= tag.p t(".per_share"), class: "font-normal text-secondary" %>
|
||||||
<% end %>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-span-2 text-right">
|
<div class="col-span-2 text-right">
|
||||||
<% if holding.account.syncing? %>
|
|
||||||
<div class="flex flex-col gap-2 items-end">
|
|
||||||
<div class="w-16 h-4 bg-loader rounded-full"></div>
|
|
||||||
<div class="w-16 h-2 bg-loader rounded-full"></div>
|
|
||||||
</div>
|
|
||||||
<% else %>
|
|
||||||
<% if holding.amount_money %>
|
<% if holding.amount_money %>
|
||||||
<%= tag.p format_money holding.amount_money %>
|
<%= tag.p format_money holding.amount_money %>
|
||||||
<% else %>
|
<% else %>
|
||||||
<%= tag.p "--", class: "text-secondary" %>
|
<%= tag.p "--", class: "text-secondary" %>
|
||||||
<% end %>
|
<% end %>
|
||||||
<%= tag.p t(".shares", qty: number_with_precision(holding.qty, precision: 1)), class: "font-normal text-secondary" %>
|
<%= tag.p t(".shares", qty: number_with_precision(holding.qty, precision: 1)), class: "font-normal text-secondary" %>
|
||||||
<% end %>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-span-2 text-right">
|
<div class="col-span-2 text-right">
|
||||||
<% if holding.account.syncing? %>
|
<% if holding.trend %>
|
||||||
<div class="flex flex-col gap-2 items-end">
|
|
||||||
<div class="w-16 h-4 bg-loader rounded-full"></div>
|
|
||||||
<div class="w-16 h-2 bg-loader rounded-full"></div>
|
|
||||||
</div>
|
|
||||||
<% elsif holding.trend %>
|
|
||||||
<%= tag.p format_money(holding.trend.value), style: "color: #{holding.trend.color};" %>
|
<%= tag.p format_money(holding.trend.value), style: "color: #{holding.trend.color};" %>
|
||||||
<%= tag.p "(#{number_to_percentage(holding.trend.percent, precision: 1)})", style: "color: #{holding.trend.color};" %>
|
<%= tag.p "(#{number_to_percentage(holding.trend.percent, precision: 1)})", style: "color: #{holding.trend.color};" %>
|
||||||
<% else %>
|
<% else %>
|
||||||
|
|
|
@ -3,26 +3,24 @@
|
||||||
<div class="space-y-4" id="balance-sheet">
|
<div class="space-y-4" id="balance-sheet">
|
||||||
<% balance_sheet.classification_groups.each do |classification_group| %>
|
<% balance_sheet.classification_groups.each do |classification_group| %>
|
||||||
<div class="bg-container shadow-border-xs rounded-xl space-y-4 p-4">
|
<div class="bg-container shadow-border-xs rounded-xl space-y-4 p-4">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
<h2 class="text-lg font-medium inline-flex items-center gap-1.5">
|
<h2 class="text-lg font-medium inline-flex items-center gap-1.5">
|
||||||
<span>
|
<span>
|
||||||
<%= classification_group.display_name %>
|
<%= classification_group.name %>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<% if classification_group.account_groups.any? %>
|
<% if classification_group.account_groups.any? %>
|
||||||
<span class="text-secondary">·</span>
|
<span class="text-secondary">·</span>
|
||||||
|
|
||||||
<% if classification_group.syncing? %>
|
|
||||||
<div class="flex items-center w-8 h-4 ml-auto">
|
|
||||||
<div class="bg-loader w-full h-full rounded-md"></div>
|
|
||||||
</div>
|
|
||||||
<% else %>
|
|
||||||
<span class="text-secondary font-medium text-lg"><%= classification_group.total_money.format(precision: 0) %></span>
|
<span class="text-secondary font-medium text-lg"><%= classification_group.total_money.format(precision: 0) %></span>
|
||||||
<% end %>
|
<% end %>
|
||||||
<% end %>
|
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<% if classification_group.account_groups.any? %>
|
<% if classification_group.syncing? %>
|
||||||
|
<%= render partial: "shared/sync_indicator", locals: { size: "sm" } %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% if classification_group.account_groups.any? %>
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div class="flex gap-1">
|
<div class="flex gap-1">
|
||||||
<% classification_group.account_groups.each do |account_group| %>
|
<% classification_group.account_groups.each do |account_group| %>
|
||||||
|
@ -30,9 +28,6 @@
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<% if classification_group.syncing? %>
|
|
||||||
<p class="text-xs text-subdued animate-pulse">Calculating latest balance data...</p>
|
|
||||||
<% else %>
|
|
||||||
<div class="flex flex-wrap gap-4">
|
<div class="flex flex-wrap gap-4">
|
||||||
<% classification_group.account_groups.each do |account_group| %>
|
<% classification_group.account_groups.each do |account_group| %>
|
||||||
<div class="flex items-center gap-2 text-sm">
|
<div class="flex items-center gap-2 text-sm">
|
||||||
|
@ -42,7 +37,6 @@
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="bg-surface rounded-xl p-1 space-y-1 overflow-x-auto">
|
<div class="bg-surface rounded-xl p-1 space-y-1 overflow-x-auto">
|
||||||
|
@ -71,17 +65,6 @@
|
||||||
<p><%= account_group.name %></p>
|
<p><%= account_group.name %></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<% if account_group.syncing? %>
|
|
||||||
<div class="flex items-center justify-between text-right gap-6">
|
|
||||||
<div class="w-28 shrink-0 flex items-center justify-end gap-2">
|
|
||||||
<div class="bg-loader rounded-md h-4 w-12"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="w-40 shrink-0 flex justify-end">
|
|
||||||
<div class="bg-loader rounded-md h-4 w-12"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<% else %>
|
|
||||||
<div class="flex items-center justify-between text-right gap-6">
|
<div class="flex items-center justify-between text-right gap-6">
|
||||||
<div class="w-28 shrink-0 flex items-center justify-end gap-2">
|
<div class="w-28 shrink-0 flex items-center justify-end gap-2">
|
||||||
<%= render "pages/dashboard/group_weight", weight: account_group.weight, color: account_group.color %>
|
<%= render "pages/dashboard/group_weight", weight: account_group.weight, color: account_group.color %>
|
||||||
|
@ -91,7 +74,6 @@
|
||||||
<p><%= format_money(account_group.total_money) %></p>
|
<p><%= format_money(account_group.total_money) %></p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
|
||||||
</summary>
|
</summary>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
@ -103,17 +85,6 @@
|
||||||
<%= link_to account.name, account_path(account) %>
|
<%= link_to account.name, account_path(account) %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<% if account.syncing? %>
|
|
||||||
<div class="ml-auto flex items-center text-right gap-6">
|
|
||||||
<div class="w-28 shrink-0 flex items-center justify-end gap-2">
|
|
||||||
<div class="bg-loader rounded-md h-4 w-12"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="w-40 shrink-0 flex justify-end">
|
|
||||||
<div class="bg-loader rounded-md h-4 w-12"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<% else %>
|
|
||||||
<div class="ml-auto flex items-center text-right gap-6">
|
<div class="ml-auto flex items-center text-right gap-6">
|
||||||
<div class="w-28 shrink-0 flex items-center justify-end gap-2">
|
<div class="w-28 shrink-0 flex items-center justify-end gap-2">
|
||||||
<%
|
<%
|
||||||
|
@ -128,7 +99,6 @@
|
||||||
<p><%= format_money(account.balance_money) %></p>
|
<p><%= format_money(account.balance_money) %></p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<% if idx < account_group.accounts.size - 1 %>
|
<% if idx < account_group.accounts.size - 1 %>
|
||||||
|
|
|
@ -5,14 +5,14 @@
|
||||||
<div class="flex justify-between gap-4 px-4">
|
<div class="flex justify-between gap-4 px-4">
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
<p class="text-sm text-secondary font-medium"><%= t(".title") %></p>
|
<p class="text-sm text-secondary font-medium"><%= t(".title") %></p>
|
||||||
|
|
||||||
<% if balance_sheet.syncing? %>
|
<% if balance_sheet.syncing? %>
|
||||||
<div class="flex flex-col gap-2">
|
<%= render partial: "shared/sync_indicator", locals: { size: "sm" } %>
|
||||||
<div class="bg-loader rounded-md h-7 w-20"></div>
|
<% end %>
|
||||||
<div class="bg-loader rounded-md h-5 w-32"></div>
|
|
||||||
</div>
|
</div>
|
||||||
<% else %>
|
|
||||||
<p class="text-primary -space-x-0.5 text-3xl font-medium">
|
<p class="text-primary -space-x-0.5 text-3xl font-medium">
|
||||||
<%= series.trend.current.format %>
|
<%= series.trend.current.format %>
|
||||||
</p>
|
</p>
|
||||||
|
@ -22,7 +22,6 @@
|
||||||
<% else %>
|
<% else %>
|
||||||
<%= render partial: "shared/trend_change", locals: { trend: series.trend, comparison_label: period.comparison_label } %>
|
<%= render partial: "shared/trend_change", locals: { trend: series.trend, comparison_label: period.comparison_label } %>
|
||||||
<% end %>
|
<% end %>
|
||||||
<% end %>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -35,11 +34,6 @@
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<% if balance_sheet.syncing? %>
|
|
||||||
<div class="w-full flex items-center justify-center p-4 h-52">
|
|
||||||
<div class="bg-loader rounded-md h-full w-full"></div>
|
|
||||||
</div>
|
|
||||||
<% else %>
|
|
||||||
<% if series.any? %>
|
<% if series.any? %>
|
||||||
<div
|
<div
|
||||||
id="netWorthChart"
|
id="netWorthChart"
|
||||||
|
@ -51,5 +45,5 @@
|
||||||
<p class="text-secondary text-sm"><%= t(".data_not_available") %></p>
|
<p class="text-secondary text-sm"><%= t(".data_not_available") %></p>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
<% end %>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
5
app/views/shared/_sync_indicator.html.erb
Normal file
5
app/views/shared/_sync_indicator.html.erb
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
<%# locals: (size: "md") %>
|
||||||
|
|
||||||
|
<div class="animate-spin text-gray-500">
|
||||||
|
<%= icon "loader-circle", color: "current", size: size %>
|
||||||
|
</div>
|
|
@ -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.
|
# 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
|
# These are extensions that must be enabled in order to support this database
|
||||||
enable_extension "pgcrypto"
|
enable_extension "pgcrypto"
|
||||||
enable_extension "plpgsql"
|
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 "data_enrichment_enabled", default: false
|
||||||
t.boolean "early_access", default: false
|
t.boolean "early_access", default: false
|
||||||
t.boolean "auto_sync_on_login", default: true, null: 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
|
end
|
||||||
|
|
||||||
create_table "holdings", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
create_table "holdings", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||||
|
|
|
@ -9,13 +9,4 @@ class AccountableSparklinesControllerTest < ActionDispatch::IntegrationTest
|
||||||
get accountable_sparkline_url("depository")
|
get accountable_sparkline_url("depository")
|
||||||
assert_response :success
|
assert_response :success
|
||||||
end
|
end
|
||||||
|
|
||||||
test "should handle sparkline errors gracefully" do
|
|
||||||
# Mock an error in the balance_series method
|
|
||||||
Balance::ChartSeriesBuilder.any_instance.stubs(:balance_series).raises(StandardError.new("Test error"))
|
|
||||||
|
|
||||||
get accountable_sparkline_url("depository")
|
|
||||||
assert_response :success
|
|
||||||
assert_match /Error/, response.body
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
|
@ -25,13 +25,4 @@ class AccountsControllerTest < ActionDispatch::IntegrationTest
|
||||||
get sparkline_account_url(@account)
|
get sparkline_account_url(@account)
|
||||||
assert_response :success
|
assert_response :success
|
||||||
end
|
end
|
||||||
|
|
||||||
test "should handle sparkline errors gracefully" do
|
|
||||||
# Mock an error in the balance_series method to bypass the rescue in sparkline_series
|
|
||||||
Balance::ChartSeriesBuilder.any_instance.stubs(:balance_series).raises(StandardError.new("Test error"))
|
|
||||||
|
|
||||||
get sparkline_account_url(@account)
|
|
||||||
assert_response :success
|
|
||||||
assert_match /Error/, response.body
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
|
@ -6,23 +6,23 @@ class BalanceSheetTest < ActiveSupport::TestCase
|
||||||
end
|
end
|
||||||
|
|
||||||
test "calculates total assets" do
|
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: 1000, accountable: Depository.new)
|
||||||
create_account(balance: 5000, accountable: OtherAsset.new)
|
create_account(balance: 5000, accountable: OtherAsset.new)
|
||||||
create_account(balance: 10000, accountable: CreditCard.new) # ignored
|
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
|
end
|
||||||
|
|
||||||
test "calculates total liabilities" do
|
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: 1000, accountable: CreditCard.new)
|
||||||
create_account(balance: 5000, accountable: OtherLiability.new)
|
create_account(balance: 5000, accountable: OtherLiability.new)
|
||||||
create_account(balance: 10000, accountable: Depository.new) # ignored
|
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
|
end
|
||||||
|
|
||||||
test "calculates net worth" do
|
test "calculates net worth" do
|
||||||
|
@ -42,8 +42,8 @@ class BalanceSheetTest < ActiveSupport::TestCase
|
||||||
other_liability.update!(is_active: false)
|
other_liability.update!(is_active: false)
|
||||||
|
|
||||||
assert_equal 10000 - 1000, BalanceSheet.new(@family).net_worth
|
assert_equal 10000 - 1000, BalanceSheet.new(@family).net_worth
|
||||||
assert_equal 10000, BalanceSheet.new(@family).total_assets
|
assert_equal 10000, BalanceSheet.new(@family).assets.total
|
||||||
assert_equal 1000, BalanceSheet.new(@family).total_liabilities
|
assert_equal 1000, BalanceSheet.new(@family).liabilities.total
|
||||||
end
|
end
|
||||||
|
|
||||||
test "calculates asset group totals" do
|
test "calculates asset group totals" do
|
||||||
|
@ -53,7 +53,7 @@ class BalanceSheetTest < ActiveSupport::TestCase
|
||||||
create_account(balance: 5000, accountable: OtherAsset.new)
|
create_account(balance: 5000, accountable: OtherAsset.new)
|
||||||
create_account(balance: 10000, accountable: CreditCard.new) # ignored
|
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 3, asset_groups.size
|
||||||
assert_equal 1000 + 2000, asset_groups.find { |ag| ag.name == "Cash" }.total
|
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: 5000, accountable: OtherLiability.new)
|
||||||
create_account(balance: 10000, accountable: Depository.new) # ignored
|
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 2, liability_groups.size
|
||||||
assert_equal 1000 + 2000, liability_groups.find { |ag| ag.name == "Credit Cards" }.total
|
assert_equal 1000 + 2000, liability_groups.find { |ag| ag.name == "Credit Cards" }.total
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue