fix(balance-sheet): preserve disabled-account net worth history (#1730)

* fix(balance-sheet): preserve disabled account history

* fix(balance-sheet): tighten historical account scope

* fix(balance-sheet): stop disabled account carry-forward

---------

Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
This commit is contained in:
ghost
2026-06-04 13:11:51 -07:00
committed by GitHub
parent f499a3db01
commit 683f518780
8 changed files with 146 additions and 14 deletions

View File

@@ -41,7 +41,11 @@ class Account < ApplicationRecord
enum :classification, { asset: "asset", liability: "liability" }, validate: { allow_nil: true }
scope :visible, -> { where(status: [ "draft", "active" ]) }
VISIBLE_STATUSES = %w[draft active].freeze
HISTORICAL_STATUSES = (VISIBLE_STATUSES + %w[disabled]).freeze
scope :visible, -> { where(status: VISIBLE_STATUSES) }
scope :historical, -> { where(status: HISTORICAL_STATUSES) }
scope :assets, -> { where(classification: "asset") }
scope :liabilities, -> { where(classification: "liability") }
scope :alphabetically, -> { order(:name) }

View File

@@ -1,10 +1,14 @@
class Balance::ChartSeriesBuilder
def initialize(account_ids:, currency:, period: Period.last_30_days, interval: nil, favorable_direction: "up")
def initialize(account_ids:, currency:, period: Period.last_30_days, interval: nil,
favorable_direction: "up", account_active_until_dates: {})
@account_ids = account_ids
@currency = currency
@period = period
@interval = interval
@favorable_direction = favorable_direction
@account_active_until_dates = account_active_until_dates.compact
.transform_keys(&:to_s)
.transform_values { |date| date.to_date.iso8601 }
end
def balance_series
@@ -29,7 +33,7 @@ class Balance::ChartSeriesBuilder
end
private
attr_reader :account_ids, :currency, :period, :favorable_direction
attr_reader :account_ids, :currency, :period, :favorable_direction, :account_active_until_dates
def interval
@interval || period.interval
@@ -74,7 +78,8 @@ class Balance::ChartSeriesBuilder
start_date: period.start_date,
end_date: period.end_date,
interval: interval,
sign_multiplier: sign_multiplier
sign_multiplier: sign_multiplier,
account_active_until_dates_json: account_active_until_dates.to_json
}
])
rescue => e
@@ -96,6 +101,19 @@ class Balance::ChartSeriesBuilder
SELECT generate_series(DATE :start_date, DATE :end_date, :interval::interval)::date AS date
UNION DISTINCT
SELECT :end_date::date -- Ensure end date is included
),
account_windows AS (
SELECT
account_window.account_id::uuid AS account_id,
account_window.active_until_date::date AS active_until_date
FROM jsonb_each_text(CAST(:account_active_until_dates_json AS jsonb))
AS account_window(account_id, active_until_date)
),
selected_accounts AS (
SELECT accounts.*, account_windows.active_until_date
FROM accounts
LEFT JOIN account_windows ON account_windows.account_id = accounts.id
WHERE accounts.id = ANY(array[:account_ids]::uuid[])
)
SELECT
d.date,
@@ -119,7 +137,8 @@ class Balance::ChartSeriesBuilder
END * COALESCE(er.rate, 1) * :sign_multiplier::integer
), 0) AS start_holdings_balance
FROM dates d
CROSS JOIN accounts
LEFT JOIN selected_accounts accounts
ON accounts.active_until_date IS NULL OR d.date <= accounts.active_until_date
LEFT JOIN LATERAL (
SELECT b.end_balance,
b.end_cash_balance,
@@ -153,7 +172,6 @@ class Balance::ChartSeriesBuilder
LIMIT 1)
) AS rate
) er ON TRUE
WHERE accounts.id = ANY(array[:account_ids]::uuid[])
GROUP BY d.date
ORDER BY d.date
SQL

View File

@@ -0,0 +1,18 @@
class BalanceSheet::HistoricalAccountScope
def initialize(family, user: nil)
@family = family
@user = user
end
def account_ids
relation.pluck(:id)
end
def relation
scope = family.accounts.historical
user.present? ? scope.included_in_finances_for(user) : scope
end
private
attr_reader :family, :user
end

View File

@@ -7,7 +7,8 @@ class BalanceSheet::NetWorthSeriesBuilder
def net_worth_series(period: Period.last_30_days)
Rails.cache.fetch(cache_key(period)) do
builder = Balance::ChartSeriesBuilder.new(
account_ids: visible_account_ids,
account_ids: historical_account_ids,
account_active_until_dates: disabled_account_active_until_dates,
currency: family.currency,
period: period,
favorable_direction: "up"
@@ -20,18 +21,31 @@ class BalanceSheet::NetWorthSeriesBuilder
private
attr_reader :family, :user
def visible_account_ids
@visible_account_ids ||= begin
scope = family.accounts.visible
scope = scope.included_in_finances_for(user) if user
scope.pluck(:id)
def historical_accounts
@historical_accounts ||= historical_account_scope.relation.to_a
end
def historical_account_ids
@historical_account_ids ||= historical_accounts.map(&:id)
end
def disabled_account_active_until_dates
@disabled_account_active_until_dates ||= historical_accounts.each_with_object({}) do |account, dates|
next unless account.disabled?
disabled_on = (account.disabled_at || account.updated_at).to_date
dates[account.id] = disabled_on - 1.day
end
end
def historical_account_scope
@historical_account_scope ||= BalanceSheet::HistoricalAccountScope.new(family, user: user)
end
def cache_key(period)
shares_version = user ? AccountShare.where(user: user).maximum(:updated_at)&.to_i : nil
key = [
"balance_sheet_net_worth_series",
"balance_sheet_net_worth_series_historical",
user&.id,
shares_version,
period.start_date,

View File

@@ -0,0 +1,16 @@
class AddDisabledAtToAccounts < ActiveRecord::Migration[7.2]
def change
add_column :accounts, :disabled_at, :datetime
reversible do |dir|
dir.up do
execute <<~SQL.squish
UPDATE accounts
SET disabled_at = updated_at
WHERE status = 'disabled'
AND disabled_at IS NULL
SQL
end
end
end
end

3
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: 2026_05_31_153000) do
ActiveRecord::Schema[7.2].define(version: 2026_05_31_213000) do
# These are extensions that must be enabled in order to support this database
enable_extension "pgcrypto"
enable_extension "plpgsql"
@@ -117,6 +117,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_31_153000) do
t.string "institution_domain"
t.text "notes"
t.uuid "owner_id"
t.datetime "disabled_at"
t.integer "account_providers_count", default: 0, null: false
t.index ["accountable_id", "accountable_type"], name: "index_accounts_on_accountable_id_and_accountable_type"
t.index ["accountable_type"], name: "index_accounts_on_accountable_type"

View File

@@ -96,6 +96,24 @@ class Balance::ChartSeriesBuilderTest < ActiveSupport::TestCase
assert_equal expected, builder.balance_series.map { |v| v.value.amount }
end
test "account active until dates stop locf while preserving date rows" do
account = accounts(:depository)
account.balances.destroy_all
period = Period.custom(start_date: 2.days.ago.to_date, end_date: Date.current)
create_balance(account: account, date: period.start_date, balance: 1000)
builder = Balance::ChartSeriesBuilder.new(
account_ids: [ account.id ],
account_active_until_dates: { account.id => 1.day.ago.to_date },
currency: "USD",
period: period,
interval: "1 day"
)
assert_equal [ 1000, 1000, 0 ], builder.balance_series.map { |v| v.value.amount }
end
test "when favorable direction is down balance signage inverts" do
account = accounts(:credit_card)
account.balances.destroy_all

View File

@@ -1,6 +1,8 @@
require "test_helper"
class BalanceSheetTest < ActiveSupport::TestCase
include BalanceTestHelper
setup do
@family = families(:empty)
end
@@ -46,6 +48,47 @@ class BalanceSheetTest < ActiveSupport::TestCase
assert_equal 1000, BalanceSheet.new(@family).liabilities.total
end
test "net worth series preserves disabled history without carrying it into current totals" do
period = Period.custom(start_date: Date.current - 1.day, end_date: Date.current)
active_account = create_account(balance: 20_000, accountable: Depository.new)
disabled_account = create_account(balance: 0, accountable: Depository.new)
pending_deletion_account = create_account(balance: 0, accountable: Depository.new)
disabled_account.disable!
pending_deletion_account.mark_for_deletion!
assert_not_nil disabled_account.reload.disabled_at
create_balance(account: active_account, date: period.start_date, balance: 10_000)
create_balance(account: active_account, date: period.end_date, balance: 20_000)
create_balance(account: disabled_account, date: period.start_date, balance: 20_000)
create_balance(account: disabled_account, date: period.end_date, balance: 10_000)
create_balance(account: pending_deletion_account, date: period.start_date, balance: 40_000)
create_balance(account: pending_deletion_account, date: period.end_date, balance: 80_000)
series = BalanceSheet.new(@family).net_worth_series(period: period)
values_by_date = series.values.index_by(&:date)
assert_equal 30_000, values_by_date.fetch(period.start_date).value.amount
assert_equal 20_000, BalanceSheet.new(@family).net_worth
assert_equal BalanceSheet.new(@family).net_worth, values_by_date.fetch(period.end_date).value.amount
end
test "historical account scope respects shared-account finance settings" do
member = users(:new_email)
included_account = create_account(balance: 0, accountable: Depository.new)
excluded_account = create_account(balance: 0, accountable: Depository.new)
included_account.disable!
excluded_account.disable!
included_account.share_with!(member, include_in_finances: true)
excluded_account.share_with!(member, include_in_finances: false)
account_ids = BalanceSheet::HistoricalAccountScope.new(@family, user: member).account_ids
assert_includes account_ids, included_account.id
assert_not_includes account_ids, excluded_account.id
end
test "calculates asset group totals" do
create_account(balance: 1000, accountable: Depository.new)
create_account(balance: 2000, accountable: Depository.new)