mirror of
https://github.com/we-promise/sure.git
synced 2026-04-08 14:54:49 +00:00
* Fix linked account balance currency mismatch When linking accounts from providers (Lunchflow, SimpleFIN, Enable Banking), the initial sync was creating balances before the correct currency was known. This caused: 1. Opening anchor entry created with default currency (USD/EUR) 2. First sync created balances with wrong currency 3. Later syncs created balances with correct currency 4. Both currency balances existed, charts showed wrong (zero) values Changes: - Add `skip_initial_sync` parameter to `Account.create_and_sync` - Skip initial sync for linked accounts (provider sync handles it) - Add currency filter to ChartSeriesBuilder query to only fetch balances matching the account's current currency * Add migration script and add tests * Update schema.rb --------- Signed-off-by: soky srm <sokysrm@gmail.com> Co-authored-by: sokie <sokysrm@gmail.com>
324 lines
12 KiB
Ruby
324 lines
12 KiB
Ruby
require "test_helper"
|
|
|
|
class Balance::ChartSeriesBuilderTest < ActiveSupport::TestCase
|
|
include BalanceTestHelper
|
|
|
|
setup do
|
|
end
|
|
|
|
test "balance series with fallbacks and gapfills" do
|
|
account = accounts(:depository)
|
|
account.balances.destroy_all
|
|
|
|
# With gaps
|
|
create_balance(account: account, date: 3.days.ago.to_date, balance: 1000)
|
|
create_balance(account: account, date: 1.day.ago.to_date, balance: 1100)
|
|
create_balance(account: account, date: Date.current, balance: 1200)
|
|
|
|
builder = Balance::ChartSeriesBuilder.new(
|
|
account_ids: [ account.id ],
|
|
currency: "USD",
|
|
period: Period.last_30_days,
|
|
interval: "1 day"
|
|
)
|
|
|
|
assert_equal 31, builder.balance_series.size # Last 30 days == 31 total balances
|
|
assert_equal 0, builder.balance_series.first.value
|
|
|
|
expected = [
|
|
0, # No value, so fallback to 0
|
|
1000,
|
|
1000, # Last observation carried forward
|
|
1100,
|
|
1200
|
|
]
|
|
|
|
assert_equal expected, builder.balance_series.last(5).map { |v| v.value.amount }
|
|
end
|
|
|
|
test "exchange rates apply locf when missing" do
|
|
account = accounts(:depository)
|
|
account.balances.destroy_all
|
|
|
|
create_balance(account: account, date: 2.days.ago.to_date, balance: 1000)
|
|
create_balance(account: account, date: 1.day.ago.to_date, balance: 1100)
|
|
create_balance(account: account, date: Date.current, balance: 1200)
|
|
|
|
builder = Balance::ChartSeriesBuilder.new(
|
|
account_ids: [ account.id ],
|
|
currency: "EUR", # Will need to convert existing balances to EUR
|
|
period: Period.custom(start_date: 2.days.ago.to_date, end_date: Date.current),
|
|
interval: "1 day"
|
|
)
|
|
|
|
# Only 1 rate in DB. We'll be missing the first and last days in the series.
|
|
# This rate should be applied to 1 day ago and today, but not 2 days ago (will fall back to 1)
|
|
ExchangeRate.create!(date: 1.day.ago.to_date, from_currency: "USD", to_currency: "EUR", rate: 2)
|
|
|
|
expected = [
|
|
1000, # No rate available, so fall back to 1:1 conversion (1000 USD = 1000 EUR)
|
|
2200, # Rate available, so use 2:1 conversion (1100 USD = 2200 EUR)
|
|
2400 # Rate NOT available, but LOCF will use the last available rate, so use 2:1 conversion (1200 USD = 2400 EUR)
|
|
]
|
|
|
|
assert_equal expected, builder.balance_series.map { |v| v.value.amount }
|
|
end
|
|
|
|
test "combines asset and liability accounts properly" do
|
|
asset_account = accounts(:depository)
|
|
liability_account = accounts(:credit_card)
|
|
|
|
Balance.destroy_all
|
|
|
|
create_balance(account: asset_account, date: 3.days.ago.to_date, balance: 500)
|
|
create_balance(account: asset_account, date: 1.day.ago.to_date, balance: 1000)
|
|
create_balance(account: asset_account, date: Date.current, balance: 1000)
|
|
|
|
create_balance(account: liability_account, date: 3.days.ago.to_date, balance: 200)
|
|
create_balance(account: liability_account, date: 2.days.ago.to_date, balance: 200)
|
|
create_balance(account: liability_account, date: Date.current, balance: 100)
|
|
|
|
builder = Balance::ChartSeriesBuilder.new(
|
|
account_ids: [ asset_account.id, liability_account.id ],
|
|
currency: "USD",
|
|
period: Period.custom(start_date: 4.days.ago.to_date, end_date: Date.current),
|
|
interval: "1 day"
|
|
)
|
|
|
|
expected = [
|
|
0, # No asset or liability balances - 4 days ago
|
|
300, # 500 - 200 = 300 - 3 days ago
|
|
300, # 500 - 200 = 300 (500 is locf) - 2 days ago
|
|
800, # 1000 - 200 = 800 (200 is locf) - 1 day ago
|
|
900 # 1000 - 100 = 900 - today
|
|
]
|
|
|
|
assert_equal expected, 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
|
|
|
|
create_balance(account: account, date: 1.day.ago.to_date, balance: 1000)
|
|
create_balance(account: account, date: Date.current, balance: 500)
|
|
|
|
builder = Balance::ChartSeriesBuilder.new(
|
|
account_ids: [ account.id ],
|
|
currency: "USD",
|
|
period: Period.custom(start_date: 1.day.ago.to_date, end_date: Date.current),
|
|
favorable_direction: "up"
|
|
)
|
|
|
|
# Since favorable direction is up and balances are liabilities, the values should be negative
|
|
expected = [ -1000, -500 ]
|
|
|
|
assert_equal expected, builder.balance_series.map { |v| v.value.amount }
|
|
|
|
builder = Balance::ChartSeriesBuilder.new(
|
|
account_ids: [ account.id ],
|
|
currency: "USD",
|
|
period: Period.custom(start_date: 1.day.ago.to_date, end_date: Date.current),
|
|
favorable_direction: "down"
|
|
)
|
|
|
|
# Since favorable direction is down and balances are liabilities, the values should be positive
|
|
expected = [ 1000, 500 ]
|
|
|
|
assert_equal expected, builder.balance_series.map { |v| v.value.amount }
|
|
end
|
|
|
|
test "uses balances matching account currency for correct chart data" do
|
|
# This test verifies that chart data is built from balances with proper currency.
|
|
# Data integrity is maintained by:
|
|
# 1. Account.create_and_sync with skip_initial_sync: true for linked accounts
|
|
# 2. Migration cleanup_orphaned_currency_balances for existing data
|
|
account = accounts(:depository)
|
|
account.balances.destroy_all
|
|
|
|
# Account is in USD, create balances in USD
|
|
create_balance(account: account, date: 2.days.ago.to_date, balance: 1000)
|
|
create_balance(account: account, date: 1.day.ago.to_date, balance: 1500)
|
|
create_balance(account: account, date: Date.current, balance: 2000)
|
|
|
|
builder = Balance::ChartSeriesBuilder.new(
|
|
account_ids: [ account.id ],
|
|
currency: "USD",
|
|
period: Period.custom(start_date: 2.days.ago.to_date, end_date: Date.current),
|
|
interval: "1 day"
|
|
)
|
|
|
|
series = builder.balance_series
|
|
assert_equal 3, series.size
|
|
assert_equal [ 1000, 1500, 2000 ], series.map { |v| v.value.amount }
|
|
end
|
|
|
|
test "balances are converted to target currency using exchange rates" do
|
|
# Create account with EUR currency
|
|
family = families(:dylan_family)
|
|
account = family.accounts.create!(
|
|
name: "EUR Account",
|
|
balance: 1000,
|
|
currency: "EUR",
|
|
accountable: Depository.new
|
|
)
|
|
|
|
account.balances.destroy_all
|
|
|
|
# Create balances in EUR (matching account currency)
|
|
create_balance(account: account, date: 1.day.ago.to_date, balance: 1000)
|
|
create_balance(account: account, date: Date.current, balance: 1200)
|
|
|
|
# Add exchange rate EUR -> USD
|
|
ExchangeRate.create!(date: 1.day.ago.to_date, from_currency: "EUR", to_currency: "USD", rate: 1.1)
|
|
|
|
# Request chart in USD (different from account's EUR)
|
|
builder = Balance::ChartSeriesBuilder.new(
|
|
account_ids: [ account.id ],
|
|
currency: "USD",
|
|
period: Period.custom(start_date: 1.day.ago.to_date, end_date: Date.current),
|
|
interval: "1 day"
|
|
)
|
|
|
|
series = builder.balance_series
|
|
# EUR balances converted to USD at 1.1 rate (LOCF for today)
|
|
assert_equal [ 1100, 1320 ], series.map { |v| v.value.amount }
|
|
end
|
|
|
|
test "linked account with orphaned currency balances shows correct values after cleanup" do
|
|
# This test reproduces the original bug scenario:
|
|
# 1. Linked account created with initial sync before correct currency was known
|
|
# 2. Opening anchor and first sync created balances with wrong currency (USD)
|
|
# 3. Provider sync updated account to correct currency (EUR) and created new balances
|
|
# 4. Both USD and EUR balances existed - charts showed wrong values
|
|
#
|
|
# The fix:
|
|
# 1. skip_initial_sync prevents this going forward
|
|
# 2. Migration cleans up orphaned balances for existing linked accounts
|
|
|
|
# Use the connected (linked) account fixture
|
|
linked_account = accounts(:connected)
|
|
linked_account.balances.destroy_all
|
|
|
|
# Simulate the bug: account is now EUR but has old USD balances from initial sync
|
|
linked_account.update!(currency: "EUR")
|
|
|
|
# Create orphaned balances in wrong currency (USD) - from initial sync before currency was known
|
|
Balance.create!(
|
|
account: linked_account,
|
|
date: 3.days.ago.to_date,
|
|
balance: 1000,
|
|
cash_balance: 1000,
|
|
currency: "USD", # Wrong currency!
|
|
start_cash_balance: 1000,
|
|
start_non_cash_balance: 0,
|
|
cash_inflows: 0,
|
|
cash_outflows: 0,
|
|
non_cash_inflows: 0,
|
|
non_cash_outflows: 0,
|
|
net_market_flows: 0,
|
|
cash_adjustments: 0,
|
|
non_cash_adjustments: 0,
|
|
flows_factor: 1
|
|
)
|
|
|
|
Balance.create!(
|
|
account: linked_account,
|
|
date: 2.days.ago.to_date,
|
|
balance: 1100,
|
|
cash_balance: 1100,
|
|
currency: "USD", # Wrong currency!
|
|
start_cash_balance: 1100,
|
|
start_non_cash_balance: 0,
|
|
cash_inflows: 0,
|
|
cash_outflows: 0,
|
|
non_cash_inflows: 0,
|
|
non_cash_outflows: 0,
|
|
net_market_flows: 0,
|
|
cash_adjustments: 0,
|
|
non_cash_adjustments: 0,
|
|
flows_factor: 1
|
|
)
|
|
|
|
# Create correct balances in EUR - from provider sync after currency was known
|
|
create_balance(account: linked_account, date: 1.day.ago.to_date, balance: 5000)
|
|
create_balance(account: linked_account, date: Date.current, balance: 5500)
|
|
|
|
# Verify we have both currency balances (the bug state)
|
|
assert_equal 2, linked_account.balances.where(currency: "USD").count
|
|
assert_equal 2, linked_account.balances.where(currency: "EUR").count
|
|
|
|
# Simulate migration cleanup: delete orphaned balances with wrong currency
|
|
linked_account.balances.where.not(currency: linked_account.currency).delete_all
|
|
|
|
# Verify cleanup removed orphaned balances
|
|
assert_equal 0, linked_account.balances.where(currency: "USD").count
|
|
assert_equal 2, linked_account.balances.where(currency: "EUR").count
|
|
|
|
# Now chart should show correct EUR values
|
|
builder = Balance::ChartSeriesBuilder.new(
|
|
account_ids: [ linked_account.id ],
|
|
currency: "EUR",
|
|
period: Period.custom(start_date: 2.days.ago.to_date, end_date: Date.current),
|
|
interval: "1 day"
|
|
)
|
|
|
|
series = builder.balance_series
|
|
# After cleanup: only EUR balances exist, chart shows correct values
|
|
# Day 2 ago: 0 (no EUR balance), Day 1 ago: 5000, Today: 5500
|
|
assert_equal [ 0, 5000, 5500 ], series.map { |v| v.value.amount }
|
|
end
|
|
|
|
test "chart ignores orphaned currency balances via currency filter" do
|
|
# This test verifies the currency filter correctly ignores orphaned balances.
|
|
# The filter `b.currency = accounts.currency` ensures only valid balances are used.
|
|
#
|
|
# Bug scenario: Account currency changed from USD to EUR after initial sync,
|
|
# leaving orphaned USD balances. Without the filter, charts would show wrong values.
|
|
|
|
linked_account = accounts(:connected)
|
|
linked_account.balances.destroy_all
|
|
|
|
# Account is EUR but has orphaned USD balances (bug state)
|
|
linked_account.update!(currency: "EUR")
|
|
|
|
# Create orphaned USD balance (wrong currency)
|
|
Balance.create!(
|
|
account: linked_account,
|
|
date: 1.day.ago.to_date,
|
|
balance: 9999,
|
|
cash_balance: 9999,
|
|
currency: "USD", # Wrong currency - doesn't match account.currency (EUR)
|
|
start_cash_balance: 9999,
|
|
start_non_cash_balance: 0,
|
|
cash_inflows: 0,
|
|
cash_outflows: 0,
|
|
non_cash_inflows: 0,
|
|
non_cash_outflows: 0,
|
|
net_market_flows: 0,
|
|
cash_adjustments: 0,
|
|
non_cash_adjustments: 0,
|
|
flows_factor: 1
|
|
)
|
|
|
|
# Chart correctly ignores USD balance because account.currency is EUR
|
|
builder = Balance::ChartSeriesBuilder.new(
|
|
account_ids: [ linked_account.id ],
|
|
currency: "EUR",
|
|
period: Period.custom(start_date: 1.day.ago.to_date, end_date: Date.current),
|
|
interval: "1 day"
|
|
)
|
|
|
|
series = builder.balance_series
|
|
|
|
# Currency filter ensures orphaned USD balance (9999) is ignored
|
|
# Chart shows zeros because no EUR balances exist
|
|
assert_equal 2, series.size
|
|
assert_equal [ 0, 0 ], series.map { |v| v.value.amount }
|
|
|
|
# Verify the orphaned balance still exists in DB (migration will clean it up)
|
|
assert_equal 1, linked_account.balances.where(currency: "USD").count
|
|
assert_equal 0, linked_account.balances.where(currency: "EUR").count
|
|
end
|
|
end
|