Account::Sync model and test fixture simplifications (#968)

* Add sync model

* Fresh fixtures for sync tests

* Sync tests overhaul

* Fix entry tests

* Complete remaining model test updates

* Update system tests

* Update demo data task

* Add system tests back to PR checks

* More simplifications, add empty family to fixtures for easier testing
This commit is contained in:
Zach Gollwitzer
2024-07-10 11:22:59 -04:00
committed by GitHub
parent de5a2e55b3
commit c6bdf49f10
60 changed files with 929 additions and 1353 deletions

View File

@@ -1,101 +0,0 @@
require "test_helper"
require "csv"
class Account::Balance::CalculatorTest < ActiveSupport::TestCase
include FamilySnapshotTestHelper
test "syncs other asset balances" do
expected_balances = get_expected_balances_for(:collectable)
assert_account_balances calculated_balances_for(:collectable), expected_balances
end
test "syncs other liability balances" do
expected_balances = get_expected_balances_for(:iou)
assert_account_balances calculated_balances_for(:iou), expected_balances
end
test "syncs credit balances" do
expected_balances = get_expected_balances_for :credit_card
assert_account_balances calculated_balances_for(:credit_card), expected_balances
end
test "syncs checking account balances" do
expected_balances = get_expected_balances_for(:checking)
assert_account_balances calculated_balances_for(:checking), expected_balances
end
test "syncs foreign checking account balances" do
required_exchange_rates_for_sync = [
1.0834, 1.0845, 1.0819, 1.0872, 1.0788, 1.0743, 1.0755, 1.0774,
1.0778, 1.0783, 1.0773, 1.0709, 1.0729, 1.0773, 1.0778, 1.078,
1.0809, 1.0818, 1.0824, 1.0822, 1.0854, 1.0845, 1.0839, 1.0807,
1.084, 1.0856, 1.0858, 1.0898, 1.095, 1.094, 1.0926, 1.0986
]
required_exchange_rates_for_sync.each_with_index do |exchange_rate, idx|
ExchangeRate.create! date: idx.days.ago.to_date, from_currency: "EUR", to_currency: "USD", rate: exchange_rate
end
# Foreign accounts will generate balances for all currencies
expected_usd_balances = get_expected_balances_for(:eur_checking_usd)
expected_eur_balances = get_expected_balances_for(:eur_checking_eur)
calculated_balances = calculated_balances_for(:eur_checking)
calculated_usd_balances = calculated_balances.select { |b| b[:currency] == "USD" }
calculated_eur_balances = calculated_balances.select { |b| b[:currency] == "EUR" }
assert_account_balances calculated_usd_balances, expected_usd_balances
assert_account_balances calculated_eur_balances, expected_eur_balances
end
test "syncs multi-currency checking account balances" do
required_exchange_rates_for_sync = [
{ from_currency: "EUR", to_currency: "USD", date: 4.days.ago.to_date, rate: 1.0788 },
{ from_currency: "EUR", to_currency: "USD", date: 19.days.ago.to_date, rate: 1.0822 }
]
ExchangeRate.insert_all(required_exchange_rates_for_sync)
expected_balances = get_expected_balances_for(:multi_currency)
assert_account_balances calculated_balances_for(:multi_currency), expected_balances
end
test "syncs savings accounts balances" do
expected_balances = get_expected_balances_for(:savings)
assert_account_balances calculated_balances_for(:savings), expected_balances
end
test "syncs investment account balances" do
expected_balances = get_expected_balances_for(:brokerage)
assert_account_balances calculated_balances_for(:brokerage), expected_balances
end
test "syncs loan account balances" do
expected_balances = get_expected_balances_for(:mortgage_loan)
assert_account_balances calculated_balances_for(:mortgage_loan), expected_balances
end
test "syncs property account balances" do
expected_balances = get_expected_balances_for(:house)
assert_account_balances calculated_balances_for(:house), expected_balances
end
test "syncs vehicle account balances" do
expected_balances = get_expected_balances_for(:car)
assert_account_balances calculated_balances_for(:car), expected_balances
end
private
def assert_account_balances(actual_balances, expected_balances)
assert_equal expected_balances.count, actual_balances.count
actual_balances.each do |ab|
expected_balance = expected_balances.find { |eb| eb[:date] == ab[:date] }
assert_in_delta expected_balance[:balance], ab[:balance], 0.01, "Balance incorrect on date: #{ab[:date]}"
end
end
def calculated_balances_for(account_key)
Account::Balance::Calculator.new(accounts(account_key)).daily_balances
end
end

View File

@@ -0,0 +1,133 @@
require "test_helper"
class Account::Balance::SyncerTest < ActiveSupport::TestCase
include Account::EntriesTestHelper
setup do
@account = families(:empty).accounts.create!(name: "Test", balance: 20000, currency: "USD", accountable: Depository.new)
end
test "syncs account with no entries" do
assert_equal 0, @account.balances.count
syncer = Account::Balance::Syncer.new(@account)
syncer.run
assert_equal [ @account.balance ], @account.balances.chronological.map(&:balance)
end
test "syncs account with valuations only" do
create_valuation(account: @account, date: 2.days.ago.to_date, amount: 22000)
syncer = Account::Balance::Syncer.new(@account)
syncer.run
assert_equal [ 22000, 22000, @account.balance ], @account.balances.chronological.map(&:balance)
end
test "syncs account with transactions only" do
create_transaction(account: @account, date: 4.days.ago.to_date, amount: 100)
create_transaction(account: @account, date: 2.days.ago.to_date, amount: -500)
syncer = Account::Balance::Syncer.new(@account)
syncer.run
assert_equal [ 19600, 19500, 19500, 20000, 20000, @account.balance ], @account.balances.chronological.map(&:balance)
end
test "syncs account with valuations and transactions" do
create_valuation(account: @account, date: 5.days.ago.to_date, amount: 20000)
create_transaction(account: @account, date: 3.days.ago.to_date, amount: -500)
create_transaction(account: @account, date: 2.days.ago.to_date, amount: 100)
create_valuation(account: @account, date: 1.day.ago.to_date, amount: 25000)
syncer = Account::Balance::Syncer.new(@account)
syncer.run
assert_equal [ 20000, 20000, 20500, 20400, 25000, @account.balance ], @account.balances.chronological.map(&:balance)
end
test "syncs account with transactions in multiple currencies" do
ExchangeRate.create! date: 1.day.ago.to_date, from_currency: "EUR", to_currency: "USD", rate: 1.2
create_transaction(account: @account, date: 3.days.ago.to_date, amount: 100, currency: "USD")
create_transaction(account: @account, date: 2.days.ago.to_date, amount: 300, currency: "USD")
create_transaction(account: @account, date: 1.day.ago.to_date, amount: 500, currency: "EUR") # €500 * 1.2 = $600
syncer = Account::Balance::Syncer.new(@account)
syncer.run
assert_equal [ 21000, 20900, 20600, 20000, @account.balance ], @account.balances.chronological.map(&:balance)
end
test "converts foreign account balances to family currency" do
@account.update! currency: "EUR"
create_transaction(date: 1.day.ago.to_date, amount: 1000, account: @account, currency: "EUR")
create_exchange_rate(2.days.ago.to_date, from: "EUR", to: "USD", rate: 2)
create_exchange_rate(1.day.ago.to_date, from: "EUR", to: "USD", rate: 2)
create_exchange_rate(Date.current, from: "EUR", to: "USD", rate: 2)
syncer = Account::Balance::Syncer.new(@account)
syncer.run
usd_balances = @account.balances.where(currency: "USD").chronological.map(&:balance)
eur_balances = @account.balances.where(currency: "EUR").chronological.map(&:balance)
assert_equal [ 21000, 20000, @account.balance ], eur_balances # native account balances
assert_equal [ 42000, 40000, @account.balance * 2 ], usd_balances # converted balances at rate of 2:1
end
test "fails with error if exchange rate not available for any entry" do
create_transaction(account: @account, currency: "EUR")
syncer = Account::Balance::Syncer.new(@account)
assert_raises Money::ConversionError do
syncer.run
end
end
# Account is able to calculate balances in its own currency (i.e. can still show a historical graph), but
# doesn't have exchange rates available to convert those calculated balances to the family currency
test "completes with warning if exchange rates not available to convert to family currency" do
@account.update! currency: "EUR"
syncer = Account::Balance::Syncer.new(@account)
syncer.run
assert_equal 1, syncer.warnings.count
end
test "overwrites existing balances and purges stale balances" do
assert_equal 0, @account.balances.size
@account.balances.create! date: Date.current, currency: "USD", balance: 30000 # incorrect balance, will be updated
@account.balances.create! date: 10.years.ago.to_date, currency: "USD", balance: 35000 # Out of range balance, will be deleted
assert_equal 2, @account.balances.size
syncer = Account::Balance::Syncer.new(@account)
syncer.run
assert_equal [ @account.balance ], @account.balances.chronological.map(&:balance)
end
test "partial sync does not affect balances prior to sync start date" do
existing_balance = @account.balances.create! date: 2.days.ago.to_date, currency: "USD", balance: 30000
transaction = create_transaction(account: @account, date: 1.day.ago.to_date, amount: 100, currency: "USD")
syncer = Account::Balance::Syncer.new(@account, start_date: 1.day.ago.to_date)
syncer.run
assert_equal [ existing_balance.balance, existing_balance.balance - transaction.amount, @account.balance ], @account.balances.chronological.map(&:balance)
end
private
def create_exchange_rate(date, from:, to:, rate:)
ExchangeRate.create! date: date, from_currency: from, to_currency: to, rate: rate
end
end

View File

@@ -1,26 +1,29 @@
require "test_helper"
class Account::EntryTest < ActiveSupport::TestCase
include Account::EntriesTestHelper
setup do
@entry = account_entries :checking_one
@family = families :dylan_family
@entry = account_entries :transaction
end
test "valuations cannot have more than one entry per day" do
new_entry = Account::Entry.new \
entryable: Account::Valuation.new,
date: @entry.date, # invalid
currency: @entry.currency,
amount: @entry.amount
existing_valuation = account_entries :valuation
assert new_entry.invalid?
new_valuation = Account::Entry.new \
entryable: Account::Valuation.new,
date: existing_valuation.date, # invalid
currency: existing_valuation.currency,
amount: existing_valuation.amount
assert new_valuation.invalid?
end
test "triggers sync with correct start date when transaction is set to prior date" do
prior_date = @entry.date - 1
@entry.update! date: prior_date
@entry.account.expects(:sync_later).with(prior_date)
@entry.account.expects(:sync_later).with(start_date: prior_date)
@entry.sync_account_later
end
@@ -28,48 +31,62 @@ class Account::EntryTest < ActiveSupport::TestCase
prior_date = @entry.date
@entry.update! date: @entry.date + 1
@entry.account.expects(:sync_later).with(prior_date)
@entry.account.expects(:sync_later).with(start_date: prior_date)
@entry.sync_account_later
end
test "triggers sync with correct start date when transaction deleted" do
prior_entry = account_entries(:checking_two) # 12 days ago
current_entry = account_entries(:checking_one) # 5 days ago
current_entry = create_transaction(date: 1.day.ago.to_date)
prior_entry = create_transaction(date: current_entry.date - 1.day)
current_entry.destroy!
current_entry.account.expects(:sync_later).with(prior_entry.date)
current_entry.account.expects(:sync_later).with(start_date: prior_entry.date)
current_entry.sync_account_later
end
test "can search entries" do
family = families(:empty)
account = family.accounts.create! name: "Test", balance: 0, accountable: Depository.new
category = family.categories.first
merchant = family.merchants.first
create_transaction(account: account, name: "a transaction")
create_transaction(account: account, name: "ignored")
create_transaction(account: account, name: "third transaction", category: category, merchant: merchant)
params = { search: "a" }
assert_equal 12, Account::Entry.search(params).size
assert_equal 2, family.entries.search(params).size
params = params.merge(categories: [ "Food & Drink" ]) # transaction specific search param
params = params.merge(categories: [ category.name ], merchants: [ merchant.name ]) # transaction specific search param
assert_equal 2, Account::Entry.search(params).size
assert_equal 1, family.entries.search(params).size
end
test "can calculate total spending for a group of transactions" do
assert_equal Money.new(2135), @family.entries.expense_total("USD")
assert_equal Money.new(1010.85, "EUR"), @family.entries.expense_total("EUR")
family = families(:empty)
account = family.accounts.create! name: "Test", balance: 0, accountable: Depository.new
create_transaction(account: account, amount: 100)
create_transaction(account: account, amount: 100)
create_transaction(account: account, amount: -500) # income, will be ignored
assert_equal Money.new(200), family.entries.expense_total("USD")
end
test "can calculate total income for a group of transactions" do
assert_equal -Money.new(2075), @family.entries.income_total("USD")
assert_equal -Money.new(250, "EUR"), @family.entries.income_total("EUR")
family = families(:empty)
account = family.accounts.create! name: "Test", balance: 0, accountable: Depository.new
create_transaction(account: account, amount: -100)
create_transaction(account: account, amount: -100)
create_transaction(account: account, amount: 500) # income, will be ignored
assert_equal Money.new(-200), family.entries.income_total("USD")
end
# See: https://github.com/maybe-finance/maybe/wiki/vision#signage-of-money
test "transactions with negative amounts are inflows, positive amounts are outflows to an account" do
inflow_transaction = account_entries(:checking_four)
outflow_transaction = account_entries(:checking_five)
assert inflow_transaction.amount < 0
assert inflow_transaction.inflow?
assert outflow_transaction.amount >= 0
assert outflow_transaction.outflow?
assert create_transaction(amount: -10).inflow?
assert create_transaction(amount: 10).outflow?
end
end

View File

@@ -0,0 +1,36 @@
require "test_helper"
class Account::SyncTest < ActiveSupport::TestCase
setup do
@account = accounts(:depository)
@sync = Account::Sync.for(@account)
@balance_syncer = mock("Account::Balance::Syncer")
Account::Balance::Syncer.expects(:new).with(@account, start_date: nil).returns(@balance_syncer).once
end
test "runs sync" do
@balance_syncer.expects(:run).once
@balance_syncer.expects(:warnings).returns([ "test sync warning" ]).once
assert_equal "pending", @sync.status
assert_equal [], @sync.warnings
assert_nil @sync.last_ran_at
@sync.run
assert_equal "completed", @sync.status
assert_equal [ "test sync warning" ], @sync.warnings
assert @sync.last_ran_at
end
test "handles sync errors" do
@balance_syncer.expects(:run).raises(StandardError.new("test sync error"))
@sync.run
assert @sync.last_ran_at
assert_equal "failed", @sync.status
assert_equal "test sync error", @sync.error
end
end

View File

@@ -1,124 +0,0 @@
require "test_helper"
class Account::SyncableTest < ActiveSupport::TestCase
include ActiveJob::TestHelper
setup do
@account = accounts(:savings)
end
test "calculates effective start date of an account" do
assert_equal 31.days.ago.to_date, accounts(:collectable).effective_start_date
assert_equal 31.days.ago.to_date, @account.effective_start_date
end
test "syncs regular account" do
@account.sync
assert_equal "ok", @account.status
assert_equal 32, @account.balances.count
end
test "syncs multi currency account" do
required_exchange_rates_for_sync = [
{ from_currency: "EUR", to_currency: "USD", date: 4.days.ago.to_date, rate: 1.0788 },
{ from_currency: "EUR", to_currency: "USD", date: 19.days.ago.to_date, rate: 1.0822 }
]
ExchangeRate.insert_all(required_exchange_rates_for_sync)
account = accounts(:multi_currency)
account.sync
assert_equal "ok", account.status
assert_equal 32, account.balances.where(currency: "USD").count
end
test "triggers sync job" do
assert_enqueued_with(job: AccountSyncJob, args: [ @account, Date.current ]) do
@account.sync_later(Date.current)
end
end
test "account has no balances until synced" do
account = accounts(:savings)
assert_equal 0, account.balances.count
end
test "account has balances after syncing" do
account = accounts(:savings)
account.sync
assert_equal 32, account.balances.count
end
test "partial sync with missing historical balances performs a full sync" do
account = accounts(:savings)
account.sync 10.days.ago.to_date
assert_equal 32, account.balances.count
end
test "balances are updated after syncing" do
account = accounts(:savings)
balance_date = 10.days.ago
account.balances.create!(date: balance_date, balance: 1000)
account.sync
assert_equal 19500, account.balances.find_by(date: balance_date)[:balance]
end
test "can perform a partial sync with a given sync start date" do
# Perform a full sync to populate all balances
@account.sync
# Perform partial sync
sync_start_date = 5.days.ago.to_date
balances_before_sync = @account.balances.to_a
@account.sync sync_start_date
balances_after_sync = @account.reload.balances.to_a
# Balances on or after should be updated
balances_after_sync.each do |balance_after_sync|
balance_before_sync = balances_before_sync.find { |b| b.date == balance_after_sync.date }
if balance_after_sync.date >= sync_start_date
assert balance_before_sync.updated_at < balance_after_sync.updated_at
else
assert_equal balance_before_sync.updated_at, balance_after_sync.updated_at
end
end
end
test "foreign currency account has balances in each currency after syncing" do
required_exchange_rates_for_sync = [
1.0834, 1.0845, 1.0819, 1.0872, 1.0788, 1.0743, 1.0755, 1.0774,
1.0778, 1.0783, 1.0773, 1.0709, 1.0729, 1.0773, 1.0778, 1.078,
1.0809, 1.0818, 1.0824, 1.0822, 1.0854, 1.0845, 1.0839, 1.0807,
1.084, 1.0856, 1.0858, 1.0898, 1.095, 1.094, 1.0926, 1.0986
]
required_exchange_rates_for_sync.each_with_index do |exchange_rate, idx|
ExchangeRate.create! date: idx.days.ago.to_date, from_currency: "EUR", to_currency: "USD", rate: exchange_rate
end
account = accounts(:eur_checking)
account.sync
assert_equal 64, account.balances.count
assert_equal 32, account.balances.where(currency: "EUR").count
assert_equal 32, account.balances.where(currency: "USD").count
end
test "stale balances are purged after syncing" do
account = accounts(:savings)
# Create old, stale balances that should be purged (since they are before account start date)
account.balances.create!(date: 1.year.ago, balance: 1000)
account.balances.create!(date: 2.years.ago, balance: 2000)
account.balances.create!(date: 3.years.ago, balance: 3000)
account.sync
assert_equal 32, account.balances.count
end
end

View File

@@ -2,22 +2,8 @@ require "test_helper"
class Account::TransferTest < ActiveSupport::TestCase
setup do
# Transfers can be posted on different dates
@outflow = accounts(:checking).entries.create! \
date: 1.day.ago.to_date,
name: "Transfer to Savings",
amount: 100,
currency: "USD",
marked_as_transfer: true,
entryable: Account::Transaction.new
@inflow = accounts(:savings).entries.create! \
date: Date.current,
name: "Transfer from Savings",
amount: -100,
currency: "USD",
marked_as_transfer: true,
entryable: Account::Transaction.new
@outflow = account_entries(:transfer_out)
@inflow = account_entries(:transfer_in)
end
test "transfer valid if it has inflow and outflow from different accounts for the same amount" do
@@ -28,14 +14,15 @@ class Account::TransferTest < ActiveSupport::TestCase
test "transfer must have 2 transactions" do
invalid_transfer_1 = Account::Transfer.new entries: [ @outflow ]
invalid_transfer_2 = Account::Transfer.new entries: [ @inflow, @outflow, account_entries(:savings_four) ]
invalid_transfer_2 = Account::Transfer.new entries: [ @inflow, @outflow, account_entries(:transaction) ]
assert invalid_transfer_1.invalid?
assert invalid_transfer_2.invalid?
end
test "transfer cannot have 2 transactions from the same account" do
account = accounts(:checking)
account = accounts(:depository)
inflow = account.entries.create! \
date: Date.current,
name: "Inflow",