mirror of
https://github.com/we-promise/sure.git
synced 2026-04-19 03:54:08 +00:00
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:
committed by
GitHub
parent
8e81e967fc
commit
f699660479
@@ -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,
|
||||
|
||||
156
test/models/balance/sync_cache_test.rb
Normal file
156
test/models/balance/sync_cache_test.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user