Add exchange rate feature with multi-currency transactions and transfers support (#1099)

Co-authored-by: Pedro J. Aramburu <pedro@joakin.dev>
This commit is contained in:
Pedro J. Aramburu
2026-04-08 16:05:58 -03:00
committed by GitHub
parent 8e81e967fc
commit f699660479
48 changed files with 1886 additions and 73 deletions

View File

@@ -693,8 +693,8 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase
end
test "multi-currency account falls back to full recalc so late exchange rate imports are picked up" do
# Step 1: Create account with a EUR entry but NO exchange rate yet.
# SyncCache will use fallback_rate: 1, so the €500 entry is treated as $500.
# Step 1: Create account with a EUR entry and a stale exchange rate (1:1 EUR→USD).
# This simulates an initial sync where an imprecise rate is available.
account = create_account_with_ledger(
account: { type: Depository, currency: "USD" },
entries: [
@@ -703,14 +703,16 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase
{ type: "transaction", date: 2.days.ago.to_date, amount: -500, currency: "EUR" }
]
)
ExchangeRate.create!(date: 2.days.ago.to_date, from_currency: "EUR", to_currency: "USD", rate: 1.0)
# First full sync — balances computed with fallback rate (1:1 EUR→USD).
# First full sync — balances computed with stale rate (1:1 EUR→USD).
# opening 100 + $100 txn + €500*1.0 = $700
Balance::Materializer.new(account, strategy: :forward).materialize_balances
stale_balance = account.balances.find_by(date: 2.days.ago.to_date)
assert stale_balance, "Balance should exist after full sync"
# Step 2: Exchange rate arrives later (e.g. daily cron imports it).
ExchangeRate.create!(date: 2.days.ago.to_date, from_currency: "EUR", to_currency: "USD", rate: 1.2)
# Step 2: Corrected exchange rate arrives later (e.g. daily cron imports it).
ExchangeRate.find_by!(date: 2.days.ago.to_date, from_currency: "EUR", to_currency: "USD").update!(rate: 1.2)
# Step 3: Next sync requests incremental from today — but the guard should
# force a full recalc because the account has multi-currency entries.
@@ -725,7 +727,7 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase
# The EUR entry on 2.days.ago is now converted at 1.2, so the balance
# picks up the corrected rate: opening 100 + $100 txn + €500*1.2 = $800
# (without the guard, incremental mode would have seeded from the stale
# $700 balance computed with fallback_rate 1, and never corrected it).
# $700 balance computed with rate 1.0, and never corrected it).
corrected = result.find { |b| b.date == 2.days.ago.to_date }
assert corrected
assert_equal 800, corrected.balance,

View File

@@ -0,0 +1,156 @@
require "test_helper"
class Balance::SyncCacheTest < ActiveSupport::TestCase
setup do
@family = families(:dylan_family)
@account = @family.accounts.create!(
name: "Test Account",
accountable: Depository.new,
currency: "USD",
balance: 1000
)
end
test "uses custom exchange rate from transaction extra field when present" do
# Create a transaction with EUR currency and custom exchange rate
_entry = @account.entries.create!(
date: Date.current,
name: "Test Transaction",
amount: 100, # €100
currency: "EUR",
entryable: Transaction.new(
category: @family.categories.first,
extra: { "exchange_rate" => "1.5" } # Custom rate: 1.5 (vs actual rate might be different)
)
)
sync_cache = Balance::SyncCache.new(@account)
converted_entries = sync_cache.send(:converted_entries)
converted_entry = converted_entries.first
assert_equal "USD", converted_entry.currency
assert_equal 150.0, converted_entry.amount # 100 * 1.5 = 150
end
test "uses standard exchange rate lookup when custom rate not present" do
# Create an exchange rate in the database
ExchangeRate.create!(
from_currency: "EUR",
to_currency: "USD",
date: Date.current,
rate: 1.2
)
_entry = @account.entries.create!(
date: Date.current,
name: "Test Transaction",
amount: 100, # €100
currency: "EUR",
entryable: Transaction.new(
category: @family.categories.first,
extra: {} # No custom exchange rate
)
)
sync_cache = Balance::SyncCache.new(@account)
converted_entries = sync_cache.send(:converted_entries)
converted_entry = converted_entries.first
assert_equal "USD", converted_entry.currency
assert_equal 120.0, converted_entry.amount # 100 * 1.2 = 120
end
test "converts multiple entries with correct rates" do
# Create exchange rates
ExchangeRate.create!(
from_currency: "EUR",
to_currency: "USD",
date: Date.current,
rate: 1.2
)
ExchangeRate.create!(
from_currency: "GBP",
to_currency: "USD",
date: Date.current,
rate: 1.27
)
# Create multiple entries in different currencies
_eur_entry = @account.entries.create!(
date: Date.current,
name: "EUR Transaction",
amount: 100,
currency: "EUR",
entryable: Transaction.new(
category: @family.categories.first,
extra: {}
)
)
_gbp_entry = @account.entries.create!(
date: Date.current,
name: "GBP Transaction",
amount: 50,
currency: "GBP",
entryable: Transaction.new(
category: @family.categories.first,
extra: {}
)
)
_usd_entry = @account.entries.create!(
date: Date.current,
name: "USD Transaction",
amount: 75,
currency: "USD",
entryable: Transaction.new(
category: @family.categories.first,
extra: {}
)
)
sync_cache = Balance::SyncCache.new(@account)
converted_entries = sync_cache.send(:converted_entries)
assert_equal 3, converted_entries.length
# All should be in USD
converted_entries.each { |e| assert_equal "USD", e.currency }
# Check converted amounts
# Sort amounts to check regardless of order
amounts = converted_entries.map(&:amount).sort
assert_in_delta 63.5, amounts[0], 0.01 # 50 GBP * 1.27
assert_in_delta 75.0, amounts[1], 0.01 # 75 USD * 1.0
assert_in_delta 120.0, amounts[2], 0.01 # 100 EUR * 1.2
end
test "prioritizes custom rate over fetched rate" do
# Create fetched rate
ExchangeRate.create!(
from_currency: "EUR",
to_currency: "USD",
date: Date.current,
rate: 1.2
)
# Create entry with custom rate that differs from fetched
_entry = @account.entries.create!(
date: Date.current,
name: "EUR Transaction with custom rate",
amount: 100,
currency: "EUR",
entryable: Transaction.new(
category: @family.categories.first,
extra: { "exchange_rate" => "1.5" }
)
)
sync_cache = Balance::SyncCache.new(@account)
converted_entries = sync_cache.send(:converted_entries)
converted_entry = converted_entries.first
# Should use custom rate (1.5), not fetched rate (1.2)
assert_equal 150.0, converted_entry.amount # 100 * 1.5, not 100 * 1.2
end
end

View File

@@ -58,8 +58,9 @@ class HoldingTest < ActiveSupport::TestCase
nvda_qty = BigDecimal("5") + BigDecimal("30")
expected_nvda_usd = nvda_total_usd / nvda_qty
assert_equal Money.new(expected_amzn_usd, "CAD").exchange_to("USD", fallback_rate: 1), @amzn.avg_cost
assert_equal Money.new(expected_nvda_usd, "CAD").exchange_to("USD", fallback_rate: 1), @nvda.avg_cost
ExchangeRate.stubs(:find_or_fetch_rate).returns(OpenStruct.new(rate: 1))
assert_equal Money.new(expected_amzn_usd, "CAD").exchange_to("USD"), @amzn.avg_cost
assert_equal Money.new(expected_nvda_usd, "CAD").exchange_to("USD"), @nvda.avg_cost
end
test "calculates total return trend" do

View File

@@ -67,4 +67,64 @@ class TransactionTest < ActiveSupport::TestCase
assert_includes Transaction::ACTIVITY_LABELS, "Exchange"
assert_includes Transaction::ACTIVITY_LABELS, "Other"
end
test "exchange_rate getter returns nil when extra is nil" do
transaction = Transaction.new
assert_nil transaction.exchange_rate
end
test "exchange_rate setter stores normalized numeric value" do
transaction = Transaction.new
transaction.exchange_rate = "1.5"
assert_equal 1.5, transaction.exchange_rate
end
test "exchange_rate setter marks invalid input" do
transaction = Transaction.new
transaction.exchange_rate = "not a number"
assert_equal "not a number", transaction.extra["exchange_rate"]
assert transaction.extra["exchange_rate_invalid"]
end
test "exchange_rate validation rejects non-numeric input" do
transaction = Transaction.new(
category: categories(:income),
extra: { "exchange_rate" => "invalid" }
)
transaction.exchange_rate = "not a number"
assert_not transaction.valid?
assert_includes transaction.errors[:exchange_rate], "must be a number"
end
test "exchange_rate validation rejects zero values" do
transaction = Transaction.new(
category: categories(:income)
)
transaction.exchange_rate = 0
assert_not transaction.valid?
assert_includes transaction.errors[:exchange_rate], "must be greater than 0"
end
test "exchange_rate validation rejects negative values" do
transaction = Transaction.new(
category: categories(:income)
)
transaction.exchange_rate = -1.5
assert_not transaction.valid?
assert_includes transaction.errors[:exchange_rate], "must be greater than 0"
end
test "exchange_rate validation allows positive values" do
transaction = Transaction.new(
category: categories(:income)
)
transaction.exchange_rate = 1.5
assert transaction.valid?
end
end

View File

@@ -218,4 +218,104 @@ class Transfer::CreatorTest < ActiveSupport::TestCase
)
end
end
test "creates transfer with custom exchange rate when provided" do
# Create accounts with different currencies
usd_account = @family.accounts.create!(name: "USD Account", balance: 1000, currency: "USD", accountable: Depository.new)
eur_account = @family.accounts.create!(name: "EUR Account", balance: 1000, currency: "EUR", accountable: Depository.new)
custom_rate = 0.92 # 1 USD = 0.92 EUR
amount = 100
creator = Transfer::Creator.new(
family: @family,
source_account_id: usd_account.id,
destination_account_id: eur_account.id,
date: @date,
amount: amount,
exchange_rate: custom_rate
)
transfer = creator.create
assert transfer.persisted?
# Verify outflow transaction is in source currency
outflow = transfer.outflow_transaction
assert_equal amount, outflow.entry.amount
assert_equal "USD", outflow.entry.currency
# Verify inflow transaction uses custom exchange rate
inflow = transfer.inflow_transaction
expected_eur_amount = (amount * custom_rate * -1).round(2)
assert_in_delta expected_eur_amount, inflow.entry.amount, 0.01
assert_equal "EUR", inflow.entry.currency
end
test "falls back to fetched exchange rate when custom rate not provided" do
# Create accounts with different currencies
usd_account = @family.accounts.create!(name: "USD Account", balance: 1000, currency: "USD", accountable: Depository.new)
gbp_account = @family.accounts.create!(name: "GBP Account", balance: 1000, currency: "GBP", accountable: Depository.new)
# Mock the exchange rate lookup
ExchangeRate.expects(:find_or_fetch_rate)
.with(from: "USD", to: "GBP", date: @date)
.returns(OpenStruct.new(rate: 0.79))
creator = Transfer::Creator.new(
family: @family,
source_account_id: usd_account.id,
destination_account_id: gbp_account.id,
date: @date,
amount: 100
)
transfer = creator.create
assert transfer.persisted?
assert_equal "USD", transfer.outflow_transaction.entry.currency
assert_equal "GBP", transfer.inflow_transaction.entry.currency
end
test "raises when no exchange rate available and none provided" do
# Create accounts with different currencies
usd_account = @family.accounts.create!(name: "USD Account", balance: 1000, currency: "USD", accountable: Depository.new)
jpy_account = @family.accounts.create!(name: "JPY Account", balance: 100000, currency: "JPY", accountable: Depository.new)
# Mock no exchange rate found
ExchangeRate.expects(:find_or_fetch_rate)
.with(from: "USD", to: "JPY", date: @date)
.returns(nil)
creator = Transfer::Creator.new(
family: @family,
source_account_id: usd_account.id,
destination_account_id: jpy_account.id,
date: @date,
amount: 100
)
assert_raises(Money::ConversionError) do
creator.create
end
end
test "custom exchange rate with very small value is valid" do
usd_account = @family.accounts.create!(name: "USD Account", balance: 1000, currency: "USD", accountable: Depository.new)
eur_account = @family.accounts.create!(name: "EUR Account", balance: 1000, currency: "EUR", accountable: Depository.new)
creator = Transfer::Creator.new(
family: @family,
source_account_id: usd_account.id,
destination_account_id: eur_account.id,
date: @date,
amount: 100,
exchange_rate: 0.000001
)
transfer = creator.create
assert transfer.persisted?
assert_in_delta(-0.0001, transfer.inflow_transaction.entry.amount, 0.0001)
end
end