1
0
Fork 0
mirror of https://github.com/maybe-finance/maybe.git synced 2025-08-05 05:25:24 +02:00

Multi-Currency Part 2 (#543)

* Support all currencies, handle outside DB

* Remove currencies from seed

* Fix account balance namespace

* Set default currency on authentication

* Cache currency instances

* Implement multi-currency syncs with tests

* Series fallback, passing tests

* Fix conflicts

* Make value group concrete class that works with currency values

* Fix migration conflict

* Update tests to expect multi-currency results

* Update account list to use group method

* Namespace updates

* Fetch unknown exchange rates from API

* Fix date range bug

* Ensure demo data works without external API

* Enforce cascades only at DB level
This commit is contained in:
Zach Gollwitzer 2024-03-21 13:39:10 -04:00 committed by GitHub
parent de0cba9fed
commit 110855d077
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
55 changed files with 1226 additions and 714 deletions

View file

@ -0,0 +1,98 @@
require "test_helper"
require "csv"
class Account::Balance::CalculatorTest < ActiveSupport::TestCase
# See: https://docs.google.com/spreadsheets/d/18LN5N-VLq4b49Mq1fNwF7_eBiHSQB46qQduRtdAEN98/edit?usp=sharing
setup do
@expected_balances = CSV.read("test/fixtures/account/expected_balances.csv", headers: true).map do |row|
{
"date" => (Date.current + row["date_offset"].to_i.days).to_date,
"collectable" => row["collectable"],
"checking" => row["checking"],
"savings_with_valuation_overrides" => row["savings_with_valuation_overrides"],
"credit_card" => row["credit_card"],
"multi_currency" => row["multi_currency"],
# Balances should be calculated for all currencies of an account
"eur_checking_eur" => row["eur_checking_eur"],
"eur_checking_usd" => row["eur_checking_usd"]
}
end
end
test "syncs account with only valuations" do
account = accounts(:collectable)
calculator = Account::Balance::Calculator.new(account)
calculator.calculate
expected = @expected_balances.map { |row| row["collectable"].to_d }
actual = calculator.daily_balances.map { |b| b[:balance] }
assert_equal expected, actual
end
test "syncs account with only transactions" do
account = accounts(:checking)
calculator = Account::Balance::Calculator.new(account)
calculator.calculate
expected = @expected_balances.map { |row| row["checking"].to_d }
actual = calculator.daily_balances.map { |b| b[:balance] }
assert_equal expected, actual
end
test "syncs account with both valuations and transactions" do
account = accounts(:savings_with_valuation_overrides)
calculator = Account::Balance::Calculator.new(account)
calculator.calculate
expected = @expected_balances.map { |row| row["savings_with_valuation_overrides"].to_d }
actual = calculator.daily_balances.map { |b| b[:balance] }
assert_equal expected, actual
end
test "syncs liability account" do
account = accounts(:credit_card)
calculator = Account::Balance::Calculator.new(account)
calculator.calculate
expected = @expected_balances.map { |row| row["credit_card"].to_d }
actual = calculator.daily_balances.map { |b| b[:balance] }
assert_equal expected, actual
end
test "syncs foreign currency account" do
account = accounts(:eur_checking)
calculator = Account::Balance::Calculator.new(account)
calculator.calculate
# Calculator should calculate balances in both account and family currency
expected_eur_balances = @expected_balances.map { |row| row["eur_checking_eur"].to_d }
expected_usd_balances = @expected_balances.map { |row| row["eur_checking_usd"].to_d }
actual_eur_balances = calculator.daily_balances.select { |b| b[:currency] == "EUR" }.sort_by { |b| b[:date] }.map { |b| b[:balance] }
actual_usd_balances = calculator.daily_balances.select { |b| b[:currency] == "USD" }.sort_by { |b| b[:date] }.map { |b| b[:balance] }
assert_equal expected_eur_balances, actual_eur_balances
assert_equal expected_usd_balances, actual_usd_balances
end
test "syncs multi currency account" do
account = accounts(:multi_currency)
calculator = Account::Balance::Calculator.new(account)
calculator.calculate
expected_balances = @expected_balances.map { |row| row["multi_currency"].to_d }
actual_balances = calculator.daily_balances.map { |b| b[:balance] }
assert_equal expected_balances, actual_balances
end
end

View file

@ -1,61 +0,0 @@
require "test_helper"
class Account::BalanceCalculatorTest < ActiveSupport::TestCase
test "syncs account with only valuations" do
account = accounts(:collectable)
daily_balances = Account::BalanceCalculator.new(account).daily_balances
expected_balances = [
400, 400, 400, 400, 400, 400, 400, 400, 400, 400,
400, 400, 400, 400, 400, 400, 400, 400, 700, 700,
700, 700, 700, 700, 700, 700, 550, 550, 550, 550,
550
].map(&:to_d)
assert_equal expected_balances, daily_balances.map { |b| b[:balance] }
end
test "syncs account with only transactions" do
account = accounts(:checking)
daily_balances = Account::BalanceCalculator.new(account).daily_balances
expected_balances = [
4000, 3985, 3985, 3985, 3985, 3985, 3985, 3985, 5060, 5060,
5060, 5060, 5060, 5060, 5060, 5040, 5040, 5040, 5010, 5010,
5010, 5010, 5010, 5010, 5010, 5000, 5000, 5000, 5000, 5000,
5000
].map(&:to_d)
assert_equal expected_balances, daily_balances.map { |b| b[:balance] }
end
test "syncs account with both valuations and transactions" do
account = accounts(:savings_with_valuation_overrides)
daily_balances = Account::BalanceCalculator.new(account).daily_balances
expected_balances = [
21250, 21750, 21750, 21750, 21750, 21000, 21000, 21000, 21000, 21000,
21000, 21000, 19000, 19000, 19000, 19000, 19000, 19000, 19500, 19500,
19500, 19500, 19500, 19500, 19500, 19700, 19700, 20500, 20500, 20500,
20000
].map(&:to_d)
assert_equal expected_balances, daily_balances.map { |b| b[:balance] }
end
test "syncs liability account" do
account = accounts(:credit_card)
daily_balances = Account::BalanceCalculator.new(account).daily_balances
expected_balances = [
1040, 940, 940, 940, 940, 940, 940, 940, 940, 940,
940, 940, 940, 940, 940, 960, 960, 960, 990, 990,
990, 990, 990, 990, 990, 1000, 1000, 1000, 1000, 1000,
1000
].map(&:to_d)
assert_equal expected_balances, daily_balances.map { |b| b[:balance] }
end
end

View file

@ -14,6 +14,15 @@ class Account::SyncableTest < ActiveSupport::TestCase
assert_equal 31, account.balances.count
end
test "foreign currency account has balances in each currency after syncing" do
account = accounts(:eur_checking)
account.sync
assert_equal 62, account.balances.count
assert_equal 31, account.balances.where(currency: "EUR").count
assert_equal 31, account.balances.where(currency: "USD").count
end
test "stale balances are purged after syncing" do
account = accounts(:savings_with_valuation_overrides)

View file

@ -1,6 +1,6 @@
require "test_helper"
class AccountBalanceTest < ActiveSupport::TestCase
class Account::BalanceTest < ActiveSupport::TestCase
# test "the truth" do
# assert true
# end

View file

@ -1,8 +1,20 @@
require "test_helper"
require "csv"
class AccountTest < ActiveSupport::TestCase
def setup
@account = accounts(:checking)
@family = families(:dylan_family)
@snapshots = CSV.read("test/fixtures/family/expected_snapshots.csv", headers: true).map do |row|
{
"date" => (Date.current + row["date_offset"].to_i.days).to_date,
"assets" => row["assets"],
"liabilities" => row["liabilities"],
"Account::Depository" => row["depositories"],
"Account::Credit" => row["credits"],
"Account::OtherAsset" => row["other_assets"]
}
end
end
test "new account should be valid" do
@ -10,4 +22,92 @@ class AccountTest < ActiveSupport::TestCase
assert_not_nil @account.accountable_id
assert_not_nil @account.accountable
end
test "recognizes foreign currency account" do
regular_account = accounts(:checking)
foreign_account = accounts(:eur_checking)
assert_not regular_account.foreign_currency?
assert foreign_account.foreign_currency?
end
test "recognizes multi currency account" do
regular_account = accounts(:checking)
multi_currency_account = accounts(:multi_currency)
assert_not regular_account.multi_currency?
assert multi_currency_account.multi_currency?
end
test "multi currency and foreign currency are different concepts" do
multi_currency_account = accounts(:multi_currency)
assert_equal multi_currency_account.family.currency, multi_currency_account.currency
assert multi_currency_account.multi_currency?
assert_not multi_currency_account.foreign_currency?
end
test "syncs regular account" do
@account.sync
assert_equal "ok", @account.status
assert_equal 31, @account.balances.count
end
test "syncs foreign currency account" do
account = accounts(:eur_checking)
account.sync
assert_equal "ok", account.status
assert_equal 31, account.balances.where(currency: "USD").count
assert_equal 31, account.balances.where(currency: "EUR").count
end
test "groups accounts by type" do
@family.accounts.each do |account|
account.sync
end
result = @family.accounts.by_group(period: Period.all)
expected_assets = @snapshots.last["assets"].to_d
expected_liabilities = @snapshots.last["liabilities"].to_d
assets = result[:assets]
liabilities = result[:liabilities]
assert_equal @family.assets, assets.sum
assert_equal @family.liabilities, liabilities.sum
depositories = assets.children.find { |group| group.name == "Account::Depository" }
properties = assets.children.find { |group| group.name == "Account::Property" }
vehicles = assets.children.find { |group| group.name == "Account::Vehicle" }
investments = assets.children.find { |group| group.name == "Account::Investment" }
other_assets = assets.children.find { |group| group.name == "Account::OtherAsset" }
credits = liabilities.children.find { |group| group.name == "Account::Credit" }
loans = liabilities.children.find { |group| group.name == "Account::Loan" }
other_liabilities = liabilities.children.find { |group| group.name == "Account::OtherLiability" }
assert_equal 4, depositories.children.count
assert_equal 0, properties.children.count
assert_equal 0, vehicles.children.count
assert_equal 0, investments.children.count
assert_equal 1, other_assets.children.count
assert_equal 1, credits.children.count
assert_equal 0, loans.children.count
assert_equal 0, other_liabilities.children.count
end
test "generates series with last balance equal to current account balance" do
# If account hasn't been synced, series falls back to a single point with the current balance
assert_equal @account.balance_money, @account.series.last.value
@account.sync
# Synced series will always have final balance equal to the current account balance
assert_equal @account.balance_money, @account.series.last.value
end
test "generates empty series for foreign currency if no exchange rate" do
account = accounts(:eur_checking)
# We know EUR -> NZD exchange rate is not available in fixtures
assert_equal 0, account.series(currency: "NZD").values.count
end
end

View file

@ -1,7 +0,0 @@
require "test_helper"
class CurrencyTest < ActiveSupport::TestCase
# test "the truth" do
# assert true
# end
end

View file

@ -8,6 +8,17 @@ class FamilyTest < ActiveSupport::TestCase
@family.accounts.each do |account|
account.sync
end
# See this Google Sheet for calculations and expected results for dylan_family:
# https://docs.google.com/spreadsheets/d/18LN5N-VLq4b49Mq1fNwF7_eBiHSQB46qQduRtdAEN98/edit?usp=sharing
@expected_snapshots = CSV.read("test/fixtures/family/expected_snapshots.csv", headers: true).map do |row|
{
"date" => (Date.current + row["date_offset"].to_i.days).to_date,
"net_worth" => row["net_worth"],
"assets" => row["assets"],
"liabilities" => row["liabilities"]
}
end
end
test "should have many users" do
@ -38,45 +49,37 @@ class FamilyTest < ActiveSupport::TestCase
end
test "should calculate total assets" do
assert_equal Money.new(25550), @family.assets_money
expected = @expected_snapshots.last["assets"].to_d
assert_equal Money.new(expected), @family.assets
end
test "should calculate total liabilities" do
assert_equal Money.new(1000), @family.liabilities_money
expected = @expected_snapshots.last["liabilities"].to_d
assert_equal Money.new(expected), @family.liabilities
end
test "should calculate net worth" do
assert_equal Money.new(24550), @family.net_worth_money
expected = @expected_snapshots.last["net_worth"].to_d
assert_equal Money.new(expected), @family.net_worth
end
test "should calculate snapshot correctly" do
# See this Google Sheet for calculations and expected results for dylan_family:
# https://docs.google.com/spreadsheets/d/18LN5N-VLq4b49Mq1fNwF7_eBiHSQB46qQduRtdAEN98/edit?usp=sharing
expected_snapshots = CSV.read("test/fixtures/family/expected_snapshots.csv", headers: true).map do |row|
{
"date" => (Date.current + row["date_offset"].to_i.days).to_date,
"net_worth" => row["net_worth"],
"assets" => row["assets"],
"liabilities" => row["liabilities"]
}
end
asset_series = @family.snapshot[:asset_series]
liability_series = @family.snapshot[:liability_series]
net_worth_series = @family.snapshot[:net_worth_series]
assert_equal expected_snapshots.count, asset_series.values.count
assert_equal expected_snapshots.count, liability_series.values.count
assert_equal expected_snapshots.count, net_worth_series.values.count
assert_equal @expected_snapshots.count, asset_series.values.count
assert_equal @expected_snapshots.count, liability_series.values.count
assert_equal @expected_snapshots.count, net_worth_series.values.count
expected_snapshots.each_with_index do |row, index|
@expected_snapshots.each_with_index do |row, index|
expected_assets = TimeSeries::Value.new(date: row["date"], value: Money.new(row["assets"].to_d))
expected_liabilities = TimeSeries::Value.new(date: row["date"], value: Money.new(row["liabilities"].to_d))
expected_net_worth = TimeSeries::Value.new(date: row["date"], value: Money.new(row["net_worth"].to_d))
assert_equal expected_assets, asset_series.values[index]
assert_equal expected_liabilities, liability_series.values[index]
assert_equal expected_net_worth, net_worth_series.values[index]
assert_in_delta expected_assets.value.amount, Money.new(asset_series.values[index].value).amount, 0.01
assert_in_delta expected_liabilities.value.amount, Money.new(liability_series.values[index].value).amount, 0.01
assert_in_delta expected_net_worth.value.amount, Money.new(net_worth_series.values[index].value).amount, 0.01
end
end

View file

@ -7,20 +7,20 @@ class ValueGroupTest < ActiveSupport::TestCase
collectable = accounts(:collectable)
# Level 1
@assets = ValueGroup.new("Assets")
@assets = ValueGroup.new("Assets", :usd)
# Level 2
@depositories = @assets.add_child_node("Depositories")
@other_assets = @assets.add_child_node("Other Assets")
@depositories = @assets.add_child_group("Depositories", :usd)
@other_assets = @assets.add_child_group("Other Assets", :usd)
# Level 3 (leaf/value nodes)
@checking_node = @depositories.add_value_node(checking)
@savings_node = @depositories.add_value_node(savings)
@collectable_node = @other_assets.add_value_node(collectable)
@checking_node = @depositories.add_value_node(OpenStruct.new({ name: "Checking", value: Money.new(5000) }), Money.new(5000))
@savings_node = @depositories.add_value_node(OpenStruct.new({ name: "Savings", value: Money.new(20000) }), Money.new(20000))
@collectable_node = @other_assets.add_value_node(OpenStruct.new({ name: "Collectable", value: Money.new(550) }), Money.new(550))
end
test "empty group works" do
group = ValueGroup.new
group = ValueGroup.new("Root", :usd)
assert_equal "Root", group.name
assert_equal [], group.children
@ -32,7 +32,7 @@ class ValueGroupTest < ActiveSupport::TestCase
test "group without value nodes has no value" do
assets = ValueGroup.new("Assets")
depositories = assets.add_child_node("Depositories")
depositories = assets.add_child_group("Depositories")
assert_equal 0, assets.sum
assert_equal 0, depositories.sum
@ -57,24 +57,24 @@ class ValueGroupTest < ActiveSupport::TestCase
end
test "group with value nodes aggregates totals correctly" do
assert_equal 5000, @checking_node.sum
assert_equal 20000, @savings_node.sum
assert_equal 550, @collectable_node.sum
assert_equal Money.new(5000), @checking_node.sum
assert_equal Money.new(20000), @savings_node.sum
assert_equal Money.new(550), @collectable_node.sum
assert_equal 25000, @depositories.sum
assert_equal 550, @other_assets.sum
assert_equal Money.new(25000), @depositories.sum
assert_equal Money.new(550), @other_assets.sum
assert_equal 25550, @assets.sum
assert_equal Money.new(25550), @assets.sum
end
test "group averages leaf nodes" do
assert_equal 5000, @checking_node.avg
assert_equal 20000, @savings_node.avg
assert_equal 550, @collectable_node.avg
assert_equal Money.new(5000), @checking_node.avg
assert_equal Money.new(20000), @savings_node.avg
assert_equal Money.new(550), @collectable_node.avg
assert_in_delta 12500, @depositories.avg, 0.01
assert_in_delta 550, @other_assets.avg, 0.01
assert_in_delta 8516.67, @assets.avg, 0.01
assert_in_delta 12500, @depositories.avg.amount, 0.01
assert_in_delta 550, @other_assets.avg.amount, 0.01
assert_in_delta 8516.67, @assets.avg.amount, 0.01
end
# Percentage of parent group (i.e. collectable is 100% of "Other Assets" group)
@ -88,19 +88,19 @@ class ValueGroupTest < ActiveSupport::TestCase
end
test "handles unbalanced tree" do
vehicles = @assets.add_child_node("Vehicles")
vehicles = @assets.add_child_group("Vehicles")
# Since we didn't add any value nodes to vehicles, shouldn't affect rollups
assert_equal 25550, @assets.sum
assert_equal Money.new(25550), @assets.sum
end
test "can attach and aggregate time series" do
checking_series = TimeSeries.new([ { date: 1.day.ago.to_date, value: 4000 }, { date: Date.current, value: 5000 } ])
savings_series = TimeSeries.new([ { date: 1.day.ago.to_date, value: 19000 }, { date: Date.current, value: 20000 } ])
checking_series = TimeSeries.new([ { date: 1.day.ago.to_date, value: Money.new(4000) }, { date: Date.current, value: Money.new(5000) } ])
savings_series = TimeSeries.new([ { date: 1.day.ago.to_date, value: Money.new(19000) }, { date: Date.current, value: Money.new(20000) } ])
@checking_node.attach_series(checking_series)
@savings_node.attach_series(savings_series)
@checking_node.series = checking_series
@savings_node.series = savings_series
assert_not_nil @checking_node.series
assert_not_nil @savings_node.series
@ -108,8 +108,8 @@ class ValueGroupTest < ActiveSupport::TestCase
assert_equal @checking_node.sum, @checking_node.series.last.value
assert_equal @savings_node.sum, @savings_node.series.last.value
aggregated_depository_series = TimeSeries.new([ { date: 1.day.ago.to_date, value: 23000 }, { date: Date.current, value: 25000 } ])
aggregated_assets_series = TimeSeries.new([ { date: 1.day.ago.to_date, value: 23000 }, { date: Date.current, value: 25000 } ])
aggregated_depository_series = TimeSeries.new([ { date: 1.day.ago.to_date, value: Money.new(23000) }, { date: Date.current, value: Money.new(25000) } ])
aggregated_assets_series = TimeSeries.new([ { date: 1.day.ago.to_date, value: Money.new(23000) }, { date: Date.current, value: Money.new(25000) } ])
assert_equal aggregated_depository_series.values, @depositories.series.values
assert_equal aggregated_assets_series.values, @assets.series.values
@ -117,29 +117,29 @@ class ValueGroupTest < ActiveSupport::TestCase
test "attached series must be a TimeSeries" do
assert_raises(RuntimeError) do
@checking_node.attach_series([])
@checking_node.series = []
end
end
test "cannot add time series to non-leaf node" do
assert_raises(RuntimeError) do
@assets.attach_series(TimeSeries.new([]))
@assets.series = TimeSeries.new([])
end
end
test "can only add value node at leaf level of tree" do
root = ValueGroup.new("Root Level")
grandparent = root.add_child_node("Grandparent")
parent = grandparent.add_child_node("Parent")
grandparent = root.add_child_group("Grandparent")
parent = grandparent.add_child_group("Parent")
value_node = parent.add_value_node(OpenStruct.new({ name: "Value Node", value: 100 }))
value_node = parent.add_value_node(OpenStruct.new({ name: "Value Node", value: Money.new(100) }), Money.new(100))
assert_raises(RuntimeError) do
value_node.add_value_node(OpenStruct.new({ name: "Value Node", value: 100 }))
value_node.add_value_node(OpenStruct.new({ name: "Value Node", value: Money.new(100) }), Money.new(100))
end
assert_raises(RuntimeError) do
grandparent.add_value_node(OpenStruct.new({ name: "Value Node", value: 100 }))
grandparent.add_value_node(OpenStruct.new({ name: "Value Node", value: Money.new(100) }), Money.new(100))
end
end
end