mirror of
https://github.com/we-promise/sure.git
synced 2026-05-30 07:49:01 +00:00
Merge branch 'main' into feat/savings-goals
Signed-off-by: Guillem Arias Fauste <accounts@gariasf.com>
This commit is contained in:
@@ -192,6 +192,75 @@ class Account::OpeningBalanceManagerTest < ActiveSupport::TestCase
|
||||
assert_equal original_date, opening_anchor.entry.date # Should remain unchanged
|
||||
end
|
||||
|
||||
test "when existing anchor date is before later activity, update can preserve anchor date" do
|
||||
manager = Account::OpeningBalanceManager.new(@depository_account)
|
||||
original_date = 4.months.ago.to_date
|
||||
|
||||
result = manager.set_opening_balance(
|
||||
balance: 1000,
|
||||
date: original_date
|
||||
)
|
||||
assert result.success?
|
||||
|
||||
@depository_account.entries.create!(
|
||||
date: 2.months.ago.to_date,
|
||||
name: "Later transaction",
|
||||
amount: 100,
|
||||
currency: "USD",
|
||||
entryable: Transaction.new
|
||||
)
|
||||
|
||||
result = manager.set_opening_balance(
|
||||
balance: 0,
|
||||
date: original_date
|
||||
)
|
||||
|
||||
assert result.success?
|
||||
assert result.changes_made?
|
||||
|
||||
opening_anchor = @depository_account.valuations.opening_anchor.first
|
||||
opening_anchor.reload
|
||||
assert_equal 0, opening_anchor.entry.amount
|
||||
assert_equal original_date, opening_anchor.entry.date
|
||||
end
|
||||
|
||||
test "recomputes oldest entry date when older activity is added after manager initialization" do
|
||||
oldest_date = 60.days.ago.to_date
|
||||
@depository_account.entries.create!(
|
||||
date: oldest_date,
|
||||
name: "Existing transaction",
|
||||
amount: 100,
|
||||
currency: "USD",
|
||||
entryable: Transaction.new
|
||||
)
|
||||
|
||||
manager = Account::OpeningBalanceManager.new(@depository_account)
|
||||
|
||||
result = manager.set_opening_balance(
|
||||
balance: 1000,
|
||||
date: oldest_date - 1.day
|
||||
)
|
||||
assert result.success?
|
||||
|
||||
newly_oldest_date = oldest_date - 2.days
|
||||
@depository_account.entries.create!(
|
||||
date: newly_oldest_date,
|
||||
name: "New older transaction",
|
||||
amount: 50,
|
||||
currency: "USD",
|
||||
entryable: Transaction.new
|
||||
)
|
||||
|
||||
result = manager.set_opening_balance(
|
||||
balance: 1200,
|
||||
date: newly_oldest_date
|
||||
)
|
||||
|
||||
assert_not result.success?
|
||||
assert_not result.changes_made?
|
||||
assert_equal "Opening balance date must be before the oldest entry date", result.error
|
||||
end
|
||||
|
||||
test "when date is equal to or greater than account's oldest entry, returns error result" do
|
||||
# Create an entry with a specific date
|
||||
oldest_date = 60.days.ago.to_date
|
||||
|
||||
@@ -280,6 +280,25 @@ class Account::ProviderImportAdapterTest < ActiveSupport::TestCase
|
||||
end
|
||||
end
|
||||
|
||||
test "imports trade with provider exchange rate" do
|
||||
investment_account = accounts(:investment)
|
||||
adapter = Account::ProviderImportAdapter.new(investment_account)
|
||||
security = securities(:aapl)
|
||||
|
||||
entry = adapter.import_trade(
|
||||
security: security,
|
||||
quantity: 5,
|
||||
price: 150.00,
|
||||
amount: 750.00,
|
||||
currency: "USD",
|
||||
date: Date.today,
|
||||
source: "plaid",
|
||||
exchange_rate: 0.91
|
||||
)
|
||||
|
||||
assert_equal 0.91, entry.entryable.exchange_rate
|
||||
end
|
||||
|
||||
test "raises error when security is missing for trade import" do
|
||||
exception = assert_raises(ArgumentError) do
|
||||
@adapter.import_trade(
|
||||
@@ -489,7 +508,8 @@ class Account::ProviderImportAdapterTest < ActiveSupport::TestCase
|
||||
amount: 2000.00,
|
||||
currency: "USD",
|
||||
date: Date.today,
|
||||
source: "plaid"
|
||||
source: "plaid",
|
||||
exchange_rate: 0.95
|
||||
)
|
||||
|
||||
assert_equal entry.id, updated_entry.id
|
||||
@@ -498,11 +518,46 @@ class Account::ProviderImportAdapterTest < ActiveSupport::TestCase
|
||||
assert_equal 10, updated_entry.entryable.qty
|
||||
assert_equal 200.00, updated_entry.entryable.price
|
||||
assert_equal "USD", updated_entry.entryable.currency
|
||||
assert_equal 0.95, updated_entry.entryable.exchange_rate
|
||||
# Entry attributes should also be updated
|
||||
assert_equal 2000.00, updated_entry.amount
|
||||
end
|
||||
end
|
||||
|
||||
test "preserves existing exchange rate when reimport omits it" do
|
||||
investment_account = accounts(:investment)
|
||||
adapter = Account::ProviderImportAdapter.new(investment_account)
|
||||
aapl = securities(:aapl)
|
||||
|
||||
entry = adapter.import_trade(
|
||||
external_id: "plaid_trade_exchange_rate_preserved",
|
||||
security: aapl,
|
||||
quantity: 5,
|
||||
price: 150.00,
|
||||
amount: 750.00,
|
||||
currency: "USD",
|
||||
date: Date.today,
|
||||
source: "plaid",
|
||||
exchange_rate: 0.95
|
||||
)
|
||||
|
||||
assert_no_difference "investment_account.entries.count" do
|
||||
updated_entry = adapter.import_trade(
|
||||
external_id: "plaid_trade_exchange_rate_preserved",
|
||||
security: aapl,
|
||||
quantity: 10,
|
||||
price: 200.00,
|
||||
amount: 2000.00,
|
||||
currency: "USD",
|
||||
date: Date.today,
|
||||
source: "plaid"
|
||||
)
|
||||
|
||||
assert_equal entry.id, updated_entry.id
|
||||
assert_equal 0.95, updated_entry.entryable.exchange_rate
|
||||
end
|
||||
end
|
||||
|
||||
test "raises error when external_id collision occurs across different entryable types for transaction" do
|
||||
investment_account = accounts(:investment)
|
||||
adapter = Account::ProviderImportAdapter.new(investment_account)
|
||||
@@ -826,6 +881,51 @@ class Account::ProviderImportAdapterTest < ActiveSupport::TestCase
|
||||
end
|
||||
end
|
||||
|
||||
test "clears pending flag, preserves pending date, and records old external_id when claiming pending entry with nil extra" do
|
||||
# Enable Banking booked transactions often have nil extra (no FX, no MCC).
|
||||
# The deep_merge path is skipped, so we must clear the pending flag explicitly.
|
||||
pending_date = Date.today - 2.days
|
||||
pending_entry = @adapter.import_transaction(
|
||||
external_id: "eb_pending_nil_extra",
|
||||
amount: 42.00,
|
||||
currency: "EUR",
|
||||
date: pending_date,
|
||||
name: "Supermarket",
|
||||
source: "enable_banking",
|
||||
extra: { "enable_banking" => { "pending" => true } }
|
||||
)
|
||||
|
||||
assert pending_entry.transaction.pending?, "should be pending before claim"
|
||||
|
||||
assert_no_difference "@account.entries.count" do
|
||||
posted_entry = @adapter.import_transaction(
|
||||
external_id: "eb_booked_nil_extra",
|
||||
amount: 42.00,
|
||||
currency: "EUR",
|
||||
date: Date.today, # booked date is later than pending date
|
||||
name: "Supermarket Posted",
|
||||
source: "enable_banking",
|
||||
extra: nil # typical for simple Enable Banking booked transactions
|
||||
)
|
||||
|
||||
assert_equal pending_entry.id, posted_entry.id, "should claim the pending entry"
|
||||
assert_equal "eb_booked_nil_extra", posted_entry.external_id
|
||||
|
||||
posted_entry.reload
|
||||
|
||||
# Pending flag must be cleared so the entry no longer shows a pending badge
|
||||
assert_not posted_entry.transaction.pending?, "pending flag should be cleared after claim"
|
||||
|
||||
# Date must be the original pending date, not the later booked date
|
||||
assert_equal pending_date, posted_entry.date, "pending date should be preserved, not overwritten with booked date"
|
||||
|
||||
# Old pending external_id must be stored so the sync engine can skip re-importing it
|
||||
claimed_ids = posted_entry.transaction.extra&.dig("auto_claimed_pending_ids") || []
|
||||
assert_includes claimed_ids, "eb_pending_nil_extra",
|
||||
"auto_claimed_pending_ids should record the old pending external_id"
|
||||
end
|
||||
end
|
||||
|
||||
test "does not reconcile when posted transaction has same external_id as pending" do
|
||||
# When external_id matches, normal dedup should handle it
|
||||
pending_entry = @adapter.import_transaction(
|
||||
|
||||
31
test/models/account/syncer_test.rb
Normal file
31
test/models/account/syncer_test.rb
Normal file
@@ -0,0 +1,31 @@
|
||||
require "test_helper"
|
||||
require "ostruct"
|
||||
|
||||
class Account::SyncerTest < ActiveSupport::TestCase
|
||||
test "applies IBKR historical balance overrides after materialization" do
|
||||
family = families(:empty)
|
||||
account = family.accounts.create!(
|
||||
name: "IBKR Brokerage",
|
||||
balance: 0,
|
||||
cash_balance: 0,
|
||||
currency: "CHF",
|
||||
accountable: Investment.new(subtype: "brokerage")
|
||||
)
|
||||
ibkr_account = family.ibkr_items.create!(
|
||||
name: "IBKR",
|
||||
query_id: "QUERY123",
|
||||
token: "TOKEN123"
|
||||
).ibkr_accounts.create!(
|
||||
name: "Main",
|
||||
ibkr_account_id: "U1234567",
|
||||
currency: "CHF"
|
||||
)
|
||||
ibkr_account.ensure_account_provider!(account)
|
||||
|
||||
Account::MarketDataImporter.any_instance.expects(:import_all).once
|
||||
Balance::Materializer.any_instance.expects(:materialize_balances).once
|
||||
IbkrAccount::HistoricalBalancesSync.any_instance.expects(:sync!).once
|
||||
|
||||
Account::Syncer.new(account).perform_sync(OpenStruct.new(window_start_date: nil))
|
||||
end
|
||||
end
|
||||
30
test/models/account_ibkr_creation_test.rb
Normal file
30
test/models/account_ibkr_creation_test.rb
Normal file
@@ -0,0 +1,30 @@
|
||||
require "test_helper"
|
||||
|
||||
class AccountIbkrCreationTest < ActiveSupport::TestCase
|
||||
fixtures :families, :ibkr_items, :ibkr_accounts
|
||||
|
||||
test "uses interactive brokers account id as part of the default name" do
|
||||
ibkr_account = ibkr_accounts(:main_account)
|
||||
|
||||
account = Account.create_from_ibkr_account(ibkr_account)
|
||||
|
||||
assert_equal "Interactive Brokers (U1234567)", account.name
|
||||
assert_equal "Investment", account.accountable_type
|
||||
assert_equal "CHF", account.currency
|
||||
end
|
||||
|
||||
test "falls back to provider name when ibkr account id is missing" do
|
||||
family = families(:empty)
|
||||
ibkr_item = ibkr_items(:empty_item)
|
||||
ibkr_account = ibkr_item.ibkr_accounts.create!(
|
||||
name: "Imported IBKR Account",
|
||||
ibkr_account_id: nil,
|
||||
currency: "USD"
|
||||
)
|
||||
|
||||
account = Account.create_from_ibkr_account(ibkr_account)
|
||||
|
||||
assert_equal "Interactive Brokers", account.name
|
||||
assert_equal family, account.family
|
||||
end
|
||||
end
|
||||
@@ -288,4 +288,55 @@ class AccountTest < ActiveSupport::TestCase
|
||||
assert_equal "read_write", share.permission
|
||||
assert share.include_in_finances?
|
||||
end
|
||||
|
||||
test "current_holdings prefers latest provider snapshot holdings across currencies" do
|
||||
account = @family.accounts.create!(
|
||||
owner: @admin,
|
||||
name: "Linked Brokerage",
|
||||
balance: 1000,
|
||||
currency: "USD",
|
||||
accountable: Investment.new
|
||||
)
|
||||
|
||||
coinstats_item = @family.coinstats_items.create!(name: "CoinStats", api_key: "test-key")
|
||||
coinstats_account = coinstats_item.coinstats_accounts.create!(name: "Brokerage", currency: "USD")
|
||||
account_provider = AccountProvider.create!(account: account, provider: coinstats_account)
|
||||
|
||||
eur_security = Security.create!(ticker: "ASML", name: "ASML")
|
||||
chf_security = Security.create!(ticker: "NOVN", name: "Novartis")
|
||||
|
||||
provider_holding = account.holdings.create!(
|
||||
security: eur_security,
|
||||
date: Date.current,
|
||||
qty: 2,
|
||||
price: 500,
|
||||
amount: 1000,
|
||||
currency: "EUR",
|
||||
account_provider: account_provider,
|
||||
cost_basis: 450
|
||||
)
|
||||
|
||||
account.holdings.create!(
|
||||
security: eur_security,
|
||||
date: Date.current,
|
||||
qty: 2,
|
||||
price: 540,
|
||||
amount: 1080,
|
||||
currency: "USD"
|
||||
)
|
||||
|
||||
second_provider_holding = account.holdings.create!(
|
||||
security: chf_security,
|
||||
date: Date.current,
|
||||
qty: 3,
|
||||
price: 90,
|
||||
amount: 270,
|
||||
currency: "CHF",
|
||||
account_provider: account_provider,
|
||||
cost_basis: 80
|
||||
)
|
||||
|
||||
assert_equal [ provider_holding.id, second_provider_holding.id ].sort, account.current_holdings.pluck(:id).sort
|
||||
assert_equal %w[CHF EUR], account.current_holdings.pluck(:currency).sort
|
||||
end
|
||||
end
|
||||
|
||||
@@ -60,6 +60,31 @@ class Balance::SyncCacheTest < ActiveSupport::TestCase
|
||||
assert_equal 120.0, converted_entry.amount # 100 * 1.2 = 120
|
||||
end
|
||||
|
||||
test "uses custom exchange rate from trade when present" do
|
||||
security = Security.create!(ticker: "TST", name: "Test")
|
||||
|
||||
_entry = @account.entries.create!(
|
||||
date: Date.current,
|
||||
name: "Test Trade",
|
||||
amount: 100,
|
||||
currency: "EUR",
|
||||
entryable: Trade.new(
|
||||
security: security,
|
||||
qty: 1,
|
||||
price: 100,
|
||||
currency: "EUR",
|
||||
exchange_rate: 1.5
|
||||
)
|
||||
)
|
||||
|
||||
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
|
||||
end
|
||||
|
||||
test "converts multiple entries with correct rates" do
|
||||
# Create exchange rates
|
||||
ExchangeRate.create!(
|
||||
@@ -197,4 +222,35 @@ class Balance::SyncCacheTest < ActiveSupport::TestCase
|
||||
# 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
|
||||
|
||||
test "prioritizes trade custom rate over fetched rate" do
|
||||
ExchangeRate.create!(
|
||||
from_currency: "EUR",
|
||||
to_currency: "USD",
|
||||
date: Date.current,
|
||||
rate: 1.2
|
||||
)
|
||||
|
||||
security = Security.create!(ticker: "TST2", name: "Test 2")
|
||||
|
||||
_entry = @account.entries.create!(
|
||||
date: Date.current,
|
||||
name: "EUR Trade with custom rate",
|
||||
amount: 100,
|
||||
currency: "EUR",
|
||||
entryable: Trade.new(
|
||||
security: security,
|
||||
qty: 1,
|
||||
price: 100,
|
||||
currency: "EUR",
|
||||
exchange_rate: 1.5
|
||||
)
|
||||
)
|
||||
|
||||
sync_cache = Balance::SyncCache.new(@account)
|
||||
converted_entries = sync_cache.send(:converted_entries)
|
||||
|
||||
converted_entry = converted_entries.first
|
||||
assert_equal 150.0, converted_entry.amount
|
||||
end
|
||||
end
|
||||
|
||||
92
test/models/brex_account/transactions/processor_test.rb
Normal file
92
test/models/brex_account/transactions/processor_test.rb
Normal file
@@ -0,0 +1,92 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "test_helper"
|
||||
|
||||
class BrexAccount::Transactions::ProcessorTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@brex_item = brex_items(:one)
|
||||
@brex_account = @brex_item.brex_accounts.create!(
|
||||
account_id: "cash_unlinked",
|
||||
account_kind: "cash",
|
||||
name: "Unlinked Cash",
|
||||
currency: "USD",
|
||||
raw_transactions_payload: [
|
||||
{
|
||||
id: "tx_skipped",
|
||||
amount: { amount: 1_00, currency: "USD" },
|
||||
description: "Skipped transaction",
|
||||
posted_at_date: "2026-01-02"
|
||||
}
|
||||
]
|
||||
)
|
||||
end
|
||||
|
||||
test "counts intentionally skipped transactions separately from failures" do
|
||||
result = BrexAccount::Transactions::Processor.new(@brex_account).process
|
||||
|
||||
assert result[:success]
|
||||
assert_equal 1, result[:total]
|
||||
assert_equal 0, result[:imported]
|
||||
assert_equal 1, result[:skipped]
|
||||
assert_equal 0, result[:failed]
|
||||
assert_equal "No linked account", result[:skipped_transactions].first[:reason]
|
||||
assert_empty result[:errors]
|
||||
end
|
||||
|
||||
test "imports linked transactions successfully" do
|
||||
link_brex_account!
|
||||
|
||||
result = BrexAccount::Transactions::Processor.new(@brex_account).process
|
||||
|
||||
assert result[:success]
|
||||
assert_equal 1, result[:total]
|
||||
assert_equal 1, result[:imported]
|
||||
assert_equal 0, result[:skipped]
|
||||
assert_equal 0, result[:failed]
|
||||
assert_empty result[:skipped_transactions]
|
||||
assert_empty result[:errors]
|
||||
end
|
||||
|
||||
test "aggregates partial transaction failures" do
|
||||
link_brex_account!
|
||||
@brex_account.update!(
|
||||
raw_transactions_payload: [
|
||||
{
|
||||
id: "tx_success",
|
||||
amount: { amount: 1_00, currency: "USD" },
|
||||
description: "Successful transaction",
|
||||
posted_at_date: "2026-01-02"
|
||||
},
|
||||
{
|
||||
id: "tx_failure",
|
||||
amount: { amount: 2_00, currency: "USD" },
|
||||
description: "Failed transaction",
|
||||
posted_at_date: "not-a-date"
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
result = BrexAccount::Transactions::Processor.new(@brex_account).process
|
||||
|
||||
assert_not result[:success]
|
||||
assert_equal 2, result[:total]
|
||||
assert_equal 1, result[:imported]
|
||||
assert_equal 0, result[:skipped]
|
||||
assert_equal 1, result[:failed]
|
||||
assert_empty result[:skipped_transactions]
|
||||
assert_equal "tx_failure", result[:errors].first[:transaction_id]
|
||||
assert_match(/Unable to parse transaction date/, result[:errors].first[:error])
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def link_brex_account!
|
||||
account = @brex_item.family.accounts.create!(
|
||||
name: "Linked Cash",
|
||||
balance: 0,
|
||||
currency: "USD",
|
||||
accountable: Depository.new
|
||||
)
|
||||
AccountProvider.create!(account: account, provider: @brex_account)
|
||||
end
|
||||
end
|
||||
210
test/models/brex_account_test.rb
Normal file
210
test/models/brex_account_test.rb
Normal file
@@ -0,0 +1,210 @@
|
||||
require "test_helper"
|
||||
|
||||
class BrexAccountTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@family_a = families(:dylan_family)
|
||||
@family_b = families(:empty)
|
||||
|
||||
@item_a = BrexItem.create!(
|
||||
family: @family_a,
|
||||
name: "Family A Brex",
|
||||
token: "token_a",
|
||||
base_url: "https://api-staging.brex.com",
|
||||
status: "good"
|
||||
)
|
||||
|
||||
@item_b = BrexItem.create!(
|
||||
family: @family_b,
|
||||
name: "Family B Brex",
|
||||
token: "token_b",
|
||||
base_url: "https://api-staging.brex.com",
|
||||
status: "good"
|
||||
)
|
||||
end
|
||||
|
||||
test "same account_id can be linked under different brex_items" do
|
||||
BrexAccount.create!(
|
||||
brex_item: @item_a,
|
||||
account_id: "shared_brex_acc_1",
|
||||
name: "Checking",
|
||||
currency: "USD",
|
||||
current_balance: 5000
|
||||
)
|
||||
|
||||
# A second family connecting the same Brex account must succeed and produce
|
||||
# an independent ledger (separate BrexAccount row, separate Account).
|
||||
assert_difference "BrexAccount.count", 1 do
|
||||
BrexAccount.create!(
|
||||
brex_item: @item_b,
|
||||
account_id: "shared_brex_acc_1",
|
||||
name: "Checking",
|
||||
currency: "USD",
|
||||
current_balance: 5000
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
test "declares raw Brex payloads as encrypted" do
|
||||
skip "Encryption not configured" unless BrexAccount.encryption_ready?
|
||||
|
||||
encrypted_attributes = BrexAccount.encrypted_attributes.map(&:to_s)
|
||||
|
||||
assert_includes encrypted_attributes, "raw_payload"
|
||||
assert_includes encrypted_attributes, "raw_transactions_payload"
|
||||
end
|
||||
|
||||
test "same account_id can be linked under different brex_items in the same family" do
|
||||
item_a_2 = BrexItem.create!(
|
||||
family: @family_a,
|
||||
name: "Family A Second Brex",
|
||||
token: "token_a_2",
|
||||
base_url: "https://api-staging.brex.com",
|
||||
status: "good"
|
||||
)
|
||||
|
||||
BrexAccount.create!(
|
||||
brex_item: @item_a,
|
||||
account_id: "shared_brex_acc_1",
|
||||
name: "Checking",
|
||||
currency: "USD",
|
||||
current_balance: 5000
|
||||
)
|
||||
|
||||
assert_difference "BrexAccount.count", 1 do
|
||||
BrexAccount.create!(
|
||||
brex_item: item_a_2,
|
||||
account_id: "shared_brex_acc_1",
|
||||
name: "Checking",
|
||||
currency: "USD",
|
||||
current_balance: 5000
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
test "same account_id cannot appear twice under the same brex_item" do
|
||||
BrexAccount.create!(
|
||||
brex_item: @item_a,
|
||||
account_id: "duplicate_acc",
|
||||
name: "Checking",
|
||||
currency: "USD",
|
||||
current_balance: 1000
|
||||
)
|
||||
|
||||
duplicate = BrexAccount.new(
|
||||
brex_item: @item_a,
|
||||
account_id: "duplicate_acc",
|
||||
name: "Checking",
|
||||
currency: "USD",
|
||||
current_balance: 1000
|
||||
)
|
||||
refute duplicate.valid?
|
||||
assert_includes duplicate.errors[:account_id], "has already been taken"
|
||||
|
||||
assert_raises(ActiveRecord::RecordInvalid) do
|
||||
BrexAccount.create!(
|
||||
brex_item: @item_a,
|
||||
account_id: "duplicate_acc",
|
||||
name: "Checking",
|
||||
currency: "USD",
|
||||
current_balance: 1000
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
test "minor-unit money converts to decimal account balances" do
|
||||
brex_account = @item_a.brex_accounts.create!(
|
||||
account_id: "cash_1",
|
||||
name: "Operating",
|
||||
currency: "USD",
|
||||
account_kind: "cash"
|
||||
)
|
||||
|
||||
brex_account.upsert_brex_snapshot!(
|
||||
{
|
||||
id: "cash_1",
|
||||
name: "Operating",
|
||||
account_kind: "cash",
|
||||
current_balance: { amount: 123_456, currency: "USD" },
|
||||
available_balance: { amount: 120_000, currency: "USD" }
|
||||
}
|
||||
)
|
||||
|
||||
assert_equal BigDecimal("1234.56"), brex_account.current_balance
|
||||
assert_equal BigDecimal("1200.0"), brex_account.available_balance
|
||||
end
|
||||
|
||||
test "invalid Brex money amount falls back to zero" do
|
||||
assert_equal BigDecimal("0"), BrexAccount.money_to_decimal(amount: "not-a-number", currency: "USD")
|
||||
end
|
||||
|
||||
test "snapshot sanitizes full account and routing numbers" do
|
||||
brex_account = @item_a.brex_accounts.create!(
|
||||
account_id: "cash_2",
|
||||
name: "Operating",
|
||||
currency: "USD",
|
||||
account_kind: "cash"
|
||||
)
|
||||
|
||||
brex_account.upsert_brex_snapshot!(
|
||||
{
|
||||
id: "cash_2",
|
||||
name: "Operating",
|
||||
account_kind: "cash",
|
||||
current_balance: { amount: 100, currency: "USD" },
|
||||
account_number: "account-last4-9012",
|
||||
routing_number: "routing-last4-0021",
|
||||
token: "test-token-placeholder"
|
||||
}
|
||||
)
|
||||
|
||||
payload = brex_account.raw_payload
|
||||
refute_includes payload.values.compact.map(&:to_s).join(" "), "account-last4-9012"
|
||||
refute_includes payload.values.compact.map(&:to_s).join(" "), "routing-last4-0021"
|
||||
assert_equal "9012", payload["account_number_last4"]
|
||||
assert_equal "0021", payload["routing_number_last4"]
|
||||
assert_equal "[FILTERED]", payload["token"]
|
||||
end
|
||||
|
||||
test "transaction payload sanitizer drops arbitrary card metadata" do
|
||||
sanitized = BrexAccount.sanitize_payload(
|
||||
{
|
||||
id: "tx_1",
|
||||
card_metadata: {
|
||||
card_id: "card_1",
|
||||
pan: "test-pan-placeholder",
|
||||
private_note: "private",
|
||||
last_four: "card ending 1111"
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
assert_equal({ "card_id" => "card_1", "last_four" => "1111" }, sanitized["card_metadata"])
|
||||
refute_includes sanitized.to_s, "test-pan-placeholder"
|
||||
refute_includes sanitized.to_s, "private"
|
||||
end
|
||||
|
||||
test "transaction payload sanitizer limits card metadata last four to digits" do
|
||||
sanitized = BrexAccount.sanitize_payload(card_metadata: { card_last_four: "card id abc9876" })
|
||||
|
||||
assert_equal "9876", sanitized["card_metadata"]["last_four"]
|
||||
refute_includes sanitized.to_s, "abc9876"
|
||||
end
|
||||
|
||||
test "linked_account uses the cached account association" do
|
||||
brex_account = @item_a.brex_accounts.create!(
|
||||
account_id: "cash_linked_alias",
|
||||
name: "Linked Alias",
|
||||
currency: "USD",
|
||||
account_kind: "cash"
|
||||
)
|
||||
account = @family_a.accounts.create!(
|
||||
name: "Linked Alias",
|
||||
balance: 0,
|
||||
currency: "USD",
|
||||
accountable: Depository.new
|
||||
)
|
||||
AccountProvider.create!(account: account, provider: brex_account)
|
||||
|
||||
assert_equal brex_account.account, brex_account.linked_account
|
||||
end
|
||||
end
|
||||
131
test/models/brex_entry/processor_test.rb
Normal file
131
test/models/brex_entry/processor_test.rb
Normal file
@@ -0,0 +1,131 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "test_helper"
|
||||
|
||||
class BrexEntry::ProcessorTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@family = families(:dylan_family)
|
||||
@brex_item = brex_items(:one)
|
||||
@account = @family.accounts.create!(
|
||||
name: "Brex Card",
|
||||
balance: 0,
|
||||
currency: "USD",
|
||||
accountable: CreditCard.new
|
||||
)
|
||||
@brex_account = @brex_item.brex_accounts.create!(
|
||||
account_id: BrexAccount.card_account_id,
|
||||
account_kind: "card",
|
||||
name: "Brex Card",
|
||||
currency: "USD",
|
||||
current_balance: 0
|
||||
)
|
||||
AccountProvider.create!(account: @account, provider: @brex_account)
|
||||
end
|
||||
|
||||
test "imports card purchase with Brex signed amount preserved" do
|
||||
entry = BrexEntry::Processor.new(card_transaction(amount: 12_34), brex_account: @brex_account).process
|
||||
|
||||
assert_equal BigDecimal("12.34"), entry.amount
|
||||
assert_equal "USD", entry.currency
|
||||
assert_equal "brex", entry.source
|
||||
assert_equal Date.new(2026, 1, 2), entry.date
|
||||
assert_equal "STAPLES", entry.transaction.merchant.name
|
||||
assert_equal "card_1", entry.transaction.extra.dig("brex", "card_id")
|
||||
assert_equal "STAPLES", entry.transaction.extra.dig("brex", "merchant", "raw_descriptor")
|
||||
refute_includes entry.transaction.extra.dig("brex", "merchant").to_s, "test-pan-placeholder"
|
||||
refute_includes entry.transaction.extra.dig("brex", "merchant").to_s, "pan"
|
||||
end
|
||||
|
||||
test "imports card payment as negative amount" do
|
||||
entry = BrexEntry::Processor.new(card_transaction(id: "tx_payment", amount: -50_00, type: "COLLECTION"), brex_account: @brex_account).process
|
||||
|
||||
assert_equal BigDecimal("-50.0"), entry.amount
|
||||
assert_equal "cc_payment", entry.transaction.kind
|
||||
end
|
||||
|
||||
test "is idempotent by external id and source" do
|
||||
transaction = card_transaction(id: "tx_duplicate", amount: 12_34)
|
||||
|
||||
assert_difference -> { @account.entries.where(source: "brex", external_id: "brex_tx_duplicate").count }, 1 do
|
||||
BrexEntry::Processor.new(transaction, brex_account: @brex_account).process
|
||||
BrexEntry::Processor.new(transaction, brex_account: @brex_account).process
|
||||
end
|
||||
end
|
||||
|
||||
test "tolerates nullable Brex fields and unknown types" do
|
||||
transaction = {
|
||||
id: "tx_nullable",
|
||||
amount: nil,
|
||||
description: "Cash movement",
|
||||
posted_at_date: "2026-01-03",
|
||||
initiated_at_date: "2026-01-02",
|
||||
type: "NEW_BREX_TYPE"
|
||||
}
|
||||
|
||||
entry = BrexEntry::Processor.new(transaction, brex_account: @brex_account).process
|
||||
|
||||
assert_equal BigDecimal("0"), entry.amount
|
||||
assert_equal "Cash movement", entry.name
|
||||
assert_equal "NEW_BREX_TYPE", entry.transaction.extra.dig("brex", "type")
|
||||
end
|
||||
|
||||
test "uses localized default transaction name" do
|
||||
transaction = card_transaction(id: "tx_default_name", amount: 12_34)
|
||||
transaction.delete(:description)
|
||||
transaction.delete(:merchant)
|
||||
|
||||
entry = BrexEntry::Processor.new(transaction, brex_account: @brex_account).process
|
||||
|
||||
assert_equal I18n.t("brex_items.entries.default_name"), entry.name
|
||||
end
|
||||
|
||||
test "logs validation failure without re-reading missing external id" do
|
||||
Rails.logger.expects(:error).with(regexp_matches(/Validation error for transaction brex_unknown/)).once
|
||||
|
||||
assert_raises(ArgumentError) do
|
||||
BrexEntry::Processor.new(card_transaction(id: nil, amount: 12_34), brex_account: @brex_account).process
|
||||
end
|
||||
end
|
||||
|
||||
test "logs save failure with cached external id" do
|
||||
Account::ProviderImportAdapter.any_instance
|
||||
.expects(:import_transaction)
|
||||
.raises(ActiveRecord::RecordInvalid.new(Entry.new))
|
||||
Rails.logger.expects(:error).with(regexp_matches(/Failed to save transaction brex_tx_save_failure/)).once
|
||||
|
||||
assert_raises(StandardError) do
|
||||
BrexEntry::Processor.new(card_transaction(id: "tx_save_failure", amount: 12_34), brex_account: @brex_account).process
|
||||
end
|
||||
end
|
||||
|
||||
test "logs missing transaction currency before using account fallback" do
|
||||
Rails.logger.expects(:warn).with(regexp_matches(/Invalid Brex currency nil for transaction tx_missing_currency/)).once
|
||||
|
||||
entry = BrexEntry::Processor.new(
|
||||
card_transaction(id: "tx_missing_currency", amount: 12_34).tap { |transaction| transaction[:amount].delete(:currency) },
|
||||
brex_account: @brex_account
|
||||
).process
|
||||
|
||||
assert_equal "USD", entry.currency
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def card_transaction(id: "tx_1", amount:, type: "CARD_EXPENSE")
|
||||
{
|
||||
id: id,
|
||||
amount: { amount: amount, currency: "USD" },
|
||||
description: "Office supplies",
|
||||
posted_at_date: "2026-01-02",
|
||||
initiated_at_date: "2026-01-01",
|
||||
type: type,
|
||||
card_id: "card_1",
|
||||
merchant: {
|
||||
raw_descriptor: "STAPLES",
|
||||
card_metadata: {
|
||||
pan: "test-pan-placeholder"
|
||||
}
|
||||
}
|
||||
}
|
||||
end
|
||||
end
|
||||
394
test/models/brex_item/account_flow_test.rb
Normal file
394
test/models/brex_item/account_flow_test.rb
Normal file
@@ -0,0 +1,394 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "test_helper"
|
||||
|
||||
class BrexItem::AccountFlowTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
SyncJob.stubs(:perform_later)
|
||||
@family = families(:dylan_family)
|
||||
@brex_item = brex_items(:one)
|
||||
end
|
||||
|
||||
test "requires explicit item when multiple credentialed connections exist" do
|
||||
BrexItem.create!(
|
||||
family: @family,
|
||||
name: "Second Brex",
|
||||
token: "second_brex_token",
|
||||
base_url: "https://api.brex.com"
|
||||
)
|
||||
|
||||
flow = BrexItem::AccountFlow.new(family: @family)
|
||||
|
||||
assert_not flow.selected?
|
||||
assert flow.selection_required?
|
||||
end
|
||||
|
||||
test "preload payload returns explicit selection error when multiple connections exist" do
|
||||
BrexItem.create!(
|
||||
family: @family,
|
||||
name: "Second Brex",
|
||||
token: "second_brex_token",
|
||||
base_url: "https://api.brex.com"
|
||||
)
|
||||
|
||||
payload = BrexItem::AccountFlow.new(family: @family).preload_payload
|
||||
|
||||
assert_equal false, payload[:success]
|
||||
assert_equal "select_connection", payload[:error]
|
||||
assert_nil payload[:has_accounts]
|
||||
end
|
||||
|
||||
test "preload payload treats cached empty accounts as a cache hit" do
|
||||
cache_key = BrexItem::AccountFlow.cache_key(@family, @brex_item)
|
||||
Rails.cache.expects(:read).with(cache_key).returns([])
|
||||
Rails.cache.expects(:write).never
|
||||
@brex_item.expects(:brex_provider).never
|
||||
|
||||
payload = BrexItem::AccountFlow.new(family: @family, brex_item: @brex_item).preload_payload
|
||||
|
||||
assert payload[:success]
|
||||
assert_equal false, payload[:has_accounts]
|
||||
assert_equal true, payload[:cached]
|
||||
end
|
||||
|
||||
test "account cache keys isolate multiple credentialed connections with shared upstream ids" do
|
||||
second_item = BrexItem.create!(
|
||||
family: @family,
|
||||
name: "Second Brex",
|
||||
token: "second_brex_token",
|
||||
base_url: "https://api.brex.com"
|
||||
)
|
||||
first_cache_key = BrexItem::AccountFlow.cache_key(@family, @brex_item)
|
||||
second_cache_key = BrexItem::AccountFlow.cache_key(@family, second_item)
|
||||
|
||||
refute_equal first_cache_key, second_cache_key
|
||||
|
||||
Rails.cache.expects(:read).with(first_cache_key).never
|
||||
Rails.cache.expects(:read).with(second_cache_key).returns(
|
||||
[ { id: BrexAccount.card_account_id, name: "Second Brex Card", account_kind: "card" } ]
|
||||
)
|
||||
Rails.cache.expects(:write).never
|
||||
|
||||
result = BrexItem::AccountFlow.new(family: @family, brex_item: second_item).select_accounts_result(accountable_type: "CreditCard")
|
||||
|
||||
assert result.success?
|
||||
assert_equal [ "Second Brex Card" ], result.available_accounts.map { |account| account.with_indifferent_access[:name] }
|
||||
end
|
||||
|
||||
test "preload payload reports invalid explicit connection as selection error" do
|
||||
payload = BrexItem::AccountFlow.new(
|
||||
family: @family,
|
||||
brex_item_id: " #{SecureRandom.uuid} "
|
||||
).preload_payload
|
||||
|
||||
assert_equal false, payload[:success]
|
||||
assert_equal "select_connection", payload[:error]
|
||||
assert_nil payload[:has_accounts]
|
||||
end
|
||||
|
||||
test "import accounts reports missing selected item as no api token" do
|
||||
flow = BrexItem::AccountFlow.new(family: @family, brex_item_id: SecureRandom.uuid)
|
||||
|
||||
assert_raises BrexItem::AccountFlow::NoApiTokenError do
|
||||
flow.import_accounts_from_api_if_needed
|
||||
end
|
||||
end
|
||||
|
||||
test "link result returns navigation instead of raising expected selection errors" do
|
||||
BrexItem.create!(
|
||||
family: @family,
|
||||
name: "Second Brex",
|
||||
token: "second_brex_token",
|
||||
base_url: "https://api.brex.com"
|
||||
)
|
||||
|
||||
result = BrexItem::AccountFlow.new(family: @family).link_new_accounts_result(
|
||||
account_ids: [ "cash_import_1" ],
|
||||
accountable_type: "Depository"
|
||||
)
|
||||
|
||||
assert_equal :settings_providers, result.target
|
||||
assert_equal :alert, result.flash_type
|
||||
assert_equal I18n.t("brex_items.link_accounts.select_connection"), result.message
|
||||
end
|
||||
|
||||
test "link new accounts rejects unsupported account type before creating accounts" do
|
||||
flow = BrexItem::AccountFlow.new(family: @family, brex_item: @brex_item)
|
||||
@brex_item.expects(:brex_provider).never
|
||||
|
||||
assert_no_difference [ "Account.count", "BrexAccount.count", "AccountProvider.count" ] do
|
||||
result = flow.link_new_accounts_result(
|
||||
account_ids: [ "cash_import_1" ],
|
||||
accountable_type: "Investment"
|
||||
)
|
||||
|
||||
assert_equal :new_account, result.target
|
||||
assert_equal :alert, result.flash_type
|
||||
assert_equal I18n.t("brex_items.link_accounts.invalid_account_type"), result.message
|
||||
end
|
||||
end
|
||||
|
||||
test "link new accounts converts unexpected errors into navigation alerts" do
|
||||
flow = BrexItem::AccountFlow.new(family: @family, brex_item: @brex_item)
|
||||
flow.expects(:link_new_accounts!).raises(StandardError, "link failure")
|
||||
|
||||
result = flow.link_new_accounts_result(
|
||||
account_ids: [ "cash_import_1" ],
|
||||
accountable_type: "Depository"
|
||||
)
|
||||
|
||||
assert_equal :new_account, result.target
|
||||
assert_equal :alert, result.flash_type
|
||||
assert_equal I18n.t("brex_items.errors.unexpected_error"), result.message
|
||||
end
|
||||
|
||||
test "link existing account converts unexpected errors into navigation alerts" do
|
||||
account = @family.accounts.create!(
|
||||
name: "Manual Checking",
|
||||
balance: 0,
|
||||
currency: "USD",
|
||||
accountable: Depository.new
|
||||
)
|
||||
flow = BrexItem::AccountFlow.new(family: @family, brex_item: @brex_item)
|
||||
flow.expects(:link_existing_account!).raises(StandardError, "link existing failure")
|
||||
|
||||
result = flow.link_existing_account_result(account: account, brex_account_id: "cash_import_1")
|
||||
|
||||
assert_equal :accounts, result.target
|
||||
assert_equal :alert, result.flash_type
|
||||
assert_equal I18n.t("brex_items.errors.unexpected_error"), result.message
|
||||
end
|
||||
|
||||
test "imports provider accounts into the selected item" do
|
||||
brex_item = BrexItem.create!(
|
||||
family: @family,
|
||||
name: "Import Brex",
|
||||
token: "import_brex_token",
|
||||
base_url: "https://api.brex.com"
|
||||
)
|
||||
|
||||
provider = mock("brex_provider")
|
||||
provider.expects(:get_accounts).returns(
|
||||
accounts: [
|
||||
{
|
||||
id: "cash_import_1",
|
||||
name: "Imported Cash",
|
||||
account_kind: "cash",
|
||||
current_balance: { amount: 12_345, currency: "USD" },
|
||||
account_number: "account-last4-3456"
|
||||
}
|
||||
]
|
||||
)
|
||||
brex_item.expects(:brex_provider).returns(provider)
|
||||
|
||||
flow = BrexItem::AccountFlow.new(family: @family, brex_item: brex_item)
|
||||
|
||||
assert_difference -> { brex_item.brex_accounts.count }, 1 do
|
||||
assert_nil flow.import_accounts_from_api_if_needed
|
||||
end
|
||||
|
||||
brex_account = brex_item.brex_accounts.find_by!(account_id: "cash_import_1")
|
||||
assert_equal "Imported Cash", brex_account.name
|
||||
assert_equal "3456", brex_account.raw_payload["account_number_last4"]
|
||||
refute_includes brex_account.raw_payload.to_s, "account-last4-3456"
|
||||
end
|
||||
|
||||
test "refreshes existing provider accounts during setup discovery" do
|
||||
brex_item = BrexItem.create!(
|
||||
family: @family,
|
||||
name: "Refresh Brex",
|
||||
token: "refresh_brex_token",
|
||||
base_url: "https://api.brex.com"
|
||||
)
|
||||
brex_item.brex_accounts.create!(
|
||||
account_id: "cash_import_1",
|
||||
name: "Old Cash",
|
||||
currency: "USD",
|
||||
account_kind: "cash",
|
||||
current_balance: 1
|
||||
)
|
||||
|
||||
provider = mock("brex_provider")
|
||||
provider.expects(:get_accounts).returns(
|
||||
accounts: [
|
||||
{
|
||||
id: "cash_import_1",
|
||||
name: "Updated Cash",
|
||||
account_kind: "cash",
|
||||
current_balance: { amount: 12_345, currency: "USD" }
|
||||
}
|
||||
]
|
||||
)
|
||||
brex_item.expects(:brex_provider).returns(provider)
|
||||
|
||||
flow = BrexItem::AccountFlow.new(family: @family, brex_item: brex_item)
|
||||
|
||||
assert_no_difference -> { brex_item.brex_accounts.count } do
|
||||
assert_nil flow.import_accounts_from_api_if_needed
|
||||
end
|
||||
|
||||
brex_account = brex_item.brex_accounts.find_by!(account_id: "cash_import_1")
|
||||
assert_equal "Updated Cash", brex_account.name
|
||||
assert_equal BigDecimal("123.45"), brex_account.current_balance
|
||||
end
|
||||
|
||||
test "complete setup result is unsuccessful when any account creation fails" do
|
||||
first_brex_account = @brex_item.brex_accounts.create!(
|
||||
account_id: "setup_result_partial_1",
|
||||
account_kind: "cash",
|
||||
name: "Setup Result Partial One",
|
||||
currency: "USD",
|
||||
current_balance: 100
|
||||
)
|
||||
second_brex_account = @brex_item.brex_accounts.create!(
|
||||
account_id: "setup_result_partial_2",
|
||||
account_kind: "cash",
|
||||
name: "Setup Result Partial Two",
|
||||
currency: "USD",
|
||||
current_balance: 100
|
||||
)
|
||||
second_brex_account.update_column(:name, nil)
|
||||
|
||||
result = BrexItem::AccountFlow.new(family: @family, brex_item: @brex_item).complete_setup_result(
|
||||
account_types: {
|
||||
first_brex_account.id => "Depository",
|
||||
second_brex_account.id => "Depository"
|
||||
},
|
||||
account_subtypes: {}
|
||||
)
|
||||
|
||||
refute result.success?
|
||||
assert_match(/failed/i, result.message)
|
||||
assert first_brex_account.reload.account_provider.present?
|
||||
assert_nil second_brex_account.reload.account_provider
|
||||
end
|
||||
|
||||
test "complete setup creates account links with default subtype" do
|
||||
brex_account = @brex_item.brex_accounts.create!(
|
||||
account_id: "setup_cash_1",
|
||||
account_kind: "cash",
|
||||
name: "Setup Cash",
|
||||
currency: "USD",
|
||||
current_balance: 100
|
||||
)
|
||||
|
||||
flow = BrexItem::AccountFlow.new(family: @family, brex_item: @brex_item)
|
||||
|
||||
assert_difference "AccountProvider.count", 1 do
|
||||
result = flow.complete_setup!(
|
||||
account_types: { brex_account.id => "Depository" },
|
||||
account_subtypes: {}
|
||||
)
|
||||
|
||||
assert_equal 1, result.created_count
|
||||
assert_equal 0, result.skipped_count
|
||||
end
|
||||
|
||||
account = brex_account.reload.account
|
||||
assert_equal "Setup Cash", account.name
|
||||
assert_equal Depository::DEFAULT_SUBTYPE, account.accountable.subtype
|
||||
end
|
||||
|
||||
test "complete setup keeps prior accounts when one account creation fails" do
|
||||
first_brex_account = @brex_item.brex_accounts.create!(
|
||||
account_id: "setup_partial_1",
|
||||
account_kind: "cash",
|
||||
name: "Setup Partial One",
|
||||
currency: "USD",
|
||||
current_balance: 100
|
||||
)
|
||||
second_brex_account = @brex_item.brex_accounts.create!(
|
||||
account_id: "setup_partial_2",
|
||||
account_kind: "cash",
|
||||
name: "Setup Partial Two",
|
||||
currency: "USD",
|
||||
current_balance: 100
|
||||
)
|
||||
second_brex_account.update_column(:name, nil)
|
||||
|
||||
result = BrexItem::AccountFlow.new(family: @family, brex_item: @brex_item).complete_setup!(
|
||||
account_types: {
|
||||
first_brex_account.id => "Depository",
|
||||
second_brex_account.id => "Depository"
|
||||
},
|
||||
account_subtypes: {}
|
||||
)
|
||||
|
||||
assert_equal 1, result.created_count
|
||||
assert_equal 1, result.failed_count
|
||||
assert first_brex_account.reload.account_provider.present?
|
||||
assert_nil second_brex_account.reload.account_provider
|
||||
end
|
||||
|
||||
test "link new accounts rolls back account creation when provider link fails" do
|
||||
provider = mock("brex_provider")
|
||||
provider.expects(:get_accounts).returns(
|
||||
accounts: [
|
||||
{
|
||||
id: "rollback_cash_1",
|
||||
name: "Rollback Cash",
|
||||
account_kind: "cash",
|
||||
current_balance: { amount: 12_345, currency: "USD" }
|
||||
}
|
||||
]
|
||||
)
|
||||
@brex_item.expects(:brex_provider).returns(provider)
|
||||
AccountProvider.expects(:create!).raises(ActiveRecord::RecordInvalid.new(AccountProvider.new))
|
||||
|
||||
flow = BrexItem::AccountFlow.new(family: @family, brex_item: @brex_item)
|
||||
|
||||
assert_no_difference [ "Account.count", "BrexAccount.count", "AccountProvider.count" ] do
|
||||
assert_raises(ActiveRecord::RecordInvalid) do
|
||||
flow.link_new_accounts!(account_ids: [ "rollback_cash_1" ], accountable_type: "Depository")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
test "link existing account rolls back provider account when link creation fails" do
|
||||
account = @family.accounts.create!(
|
||||
name: "Existing Cash",
|
||||
balance: 0,
|
||||
currency: "USD",
|
||||
accountable: Depository.new
|
||||
)
|
||||
provider = mock("brex_provider")
|
||||
provider.expects(:get_accounts).returns(
|
||||
accounts: [
|
||||
{
|
||||
id: "rollback_existing_cash_1",
|
||||
name: "Rollback Existing Cash",
|
||||
account_kind: "cash",
|
||||
current_balance: { amount: 12_345, currency: "USD" }
|
||||
}
|
||||
]
|
||||
)
|
||||
@brex_item.expects(:brex_provider).returns(provider)
|
||||
AccountProvider.expects(:create!).raises(ActiveRecord::RecordInvalid.new(AccountProvider.new))
|
||||
|
||||
flow = BrexItem::AccountFlow.new(family: @family, brex_item: @brex_item)
|
||||
|
||||
assert_no_difference [ "BrexAccount.count", "AccountProvider.count" ] do
|
||||
assert_raises(ActiveRecord::RecordInvalid) do
|
||||
flow.link_existing_account!(account: account, brex_account_id: "rollback_existing_cash_1")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
test "complete setup result returns localized notice" do
|
||||
brex_account = @brex_item.brex_accounts.create!(
|
||||
account_id: "setup_result_cash_1",
|
||||
account_kind: "cash",
|
||||
name: "Setup Result Cash",
|
||||
currency: "USD",
|
||||
current_balance: 100
|
||||
)
|
||||
|
||||
result = BrexItem::AccountFlow.new(family: @family, brex_item: @brex_item).complete_setup_result(
|
||||
account_types: { brex_account.id => "Depository" },
|
||||
account_subtypes: {}
|
||||
)
|
||||
|
||||
assert result.success?
|
||||
assert_equal I18n.t("brex_items.complete_account_setup.success", count: 1), result.message
|
||||
end
|
||||
end
|
||||
331
test/models/brex_item/importer_test.rb
Normal file
331
test/models/brex_item/importer_test.rb
Normal file
@@ -0,0 +1,331 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "test_helper"
|
||||
|
||||
class BrexItem::ImporterTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@family = families(:dylan_family)
|
||||
@brex_item = brex_items(:one)
|
||||
@account = @family.accounts.create!(
|
||||
name: "Operating Cash",
|
||||
balance: 0,
|
||||
currency: "USD",
|
||||
accountable: Depository.new(subtype: "checking")
|
||||
)
|
||||
@brex_account = @brex_item.brex_accounts.create!(
|
||||
account_id: "cash_1",
|
||||
account_kind: "cash",
|
||||
name: "Operating Cash",
|
||||
currency: "USD",
|
||||
current_balance: 0
|
||||
)
|
||||
AccountProvider.create!(account: @account, provider: @brex_account)
|
||||
end
|
||||
|
||||
test "imports account discovery and fetches transactions only for linked accounts" do
|
||||
provider = mock("brex_provider")
|
||||
provider.expects(:get_accounts).returns(accounts: [ cash_account_payload, card_account_payload ])
|
||||
provider.expects(:get_cash_transactions).with("cash_1", start_date: Date.new(2026, 1, 1)).returns(
|
||||
transactions: [
|
||||
{
|
||||
id: "cash_tx_1",
|
||||
amount: { amount: 12_34, currency: "USD" },
|
||||
description: "Wire fee",
|
||||
posted_at_date: "2026-01-02"
|
||||
}
|
||||
]
|
||||
)
|
||||
provider.expects(:get_primary_card_transactions).never
|
||||
|
||||
result = BrexItem::Importer.new(@brex_item, brex_provider: provider, sync_start_date: Date.new(2026, 1, 1)).import
|
||||
|
||||
assert result[:success]
|
||||
assert_equal 1, result[:accounts_updated]
|
||||
assert_equal 1, result[:accounts_created]
|
||||
assert_equal [ "cash_tx_1" ], @brex_account.reload.raw_transactions_payload.map { |tx| tx["id"] }
|
||||
assert_equal "card", @brex_item.brex_accounts.find_by!(account_id: BrexAccount.card_account_id).account_kind
|
||||
end
|
||||
|
||||
test "counts only newly stored transactions as imported" do
|
||||
@brex_account.update!(
|
||||
raw_transactions_payload: [
|
||||
{
|
||||
id: "cash_tx_1",
|
||||
amount: { amount: 12_34, currency: "USD" },
|
||||
description: "Existing wire fee",
|
||||
posted_at_date: "2026-01-02"
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
provider = mock("brex_provider")
|
||||
provider.expects(:get_accounts).returns(accounts: [ cash_account_payload ])
|
||||
provider.expects(:get_cash_transactions).with("cash_1", start_date: anything).returns(
|
||||
transactions: [
|
||||
{
|
||||
id: "cash_tx_1",
|
||||
amount: { amount: 12_34, currency: "USD" },
|
||||
description: "Existing wire fee",
|
||||
posted_at_date: "2026-01-02"
|
||||
},
|
||||
{
|
||||
id: "cash_tx_2",
|
||||
amount: { amount: 56_78, currency: "USD" },
|
||||
description: "New wire fee",
|
||||
posted_at_date: "2026-01-03"
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
result = BrexItem::Importer.new(@brex_item, brex_provider: provider, sync_start_date: Date.new(2026, 1, 1)).import
|
||||
|
||||
assert result[:success]
|
||||
assert_equal 1, result[:transactions_imported]
|
||||
assert_equal [ "cash_tx_1", "cash_tx_2" ], @brex_account.reload.raw_transactions_payload.map { |tx| tx["id"] }
|
||||
end
|
||||
|
||||
test "keeps raw transaction snapshots bounded to the sync window" do
|
||||
@brex_account.update!(
|
||||
raw_transactions_payload: [
|
||||
{
|
||||
id: "old_cash_tx",
|
||||
amount: { amount: 12_34, currency: "USD" },
|
||||
description: "Old wire fee",
|
||||
posted_at_date: "2025-12-01"
|
||||
},
|
||||
{
|
||||
id: "recent_cash_tx",
|
||||
amount: { amount: 56_78, currency: "USD" },
|
||||
description: "Recent wire fee",
|
||||
posted_at_date: "2026-01-02"
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
sync_start_date = Date.new(2026, 1, 1)
|
||||
provider = mock("brex_provider")
|
||||
provider.expects(:get_accounts).returns(accounts: [ cash_account_payload ])
|
||||
provider.expects(:get_cash_transactions).with("cash_1", start_date: sync_start_date).returns(
|
||||
transactions: [
|
||||
{
|
||||
id: "ignored_before_window",
|
||||
amount: { amount: 1_00, currency: "USD" },
|
||||
description: "Ignored old transaction",
|
||||
posted_at_date: "2025-12-31"
|
||||
},
|
||||
{
|
||||
id: "new_cash_tx",
|
||||
amount: { amount: 2_00, currency: "USD" },
|
||||
description: "New transaction",
|
||||
posted_at_date: "2026-01-03"
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
result = BrexItem::Importer.new(@brex_item, brex_provider: provider, sync_start_date: sync_start_date).import
|
||||
|
||||
assert result[:success]
|
||||
assert_equal 1, result[:transactions_imported]
|
||||
assert_equal [ "recent_cash_tx", "new_cash_tx" ], @brex_account.reload.raw_transactions_payload.map { |tx| tx["id"] }
|
||||
end
|
||||
|
||||
test "uses explicit sync start date for cash and card transaction fetches" do
|
||||
card_account = @family.accounts.create!(
|
||||
name: "Brex Card",
|
||||
balance: 0,
|
||||
currency: "USD",
|
||||
accountable: CreditCard.new
|
||||
)
|
||||
brex_card_account = @brex_item.brex_accounts.create!(
|
||||
account_id: BrexAccount.card_account_id,
|
||||
account_kind: "card",
|
||||
name: "Brex Card",
|
||||
currency: "USD",
|
||||
current_balance: 0
|
||||
)
|
||||
AccountProvider.create!(account: card_account, provider: brex_card_account)
|
||||
|
||||
sync_start_date = Date.new(2026, 2, 1)
|
||||
provider = mock("brex_provider")
|
||||
provider.expects(:get_accounts).returns(accounts: [ cash_account_payload, card_account_payload ])
|
||||
provider.expects(:get_cash_transactions).with("cash_1", start_date: sync_start_date).returns(transactions: [])
|
||||
provider.expects(:get_primary_card_transactions).with(start_date: sync_start_date).returns(transactions: [])
|
||||
|
||||
result = BrexItem::Importer.new(
|
||||
@brex_item,
|
||||
brex_provider: provider,
|
||||
sync_start_date: sync_start_date
|
||||
).import
|
||||
|
||||
assert result[:success]
|
||||
end
|
||||
|
||||
test "imports aggregate card transactions only into the selected connection" do
|
||||
first_card_account = @family.accounts.create!(
|
||||
name: "First Brex Card",
|
||||
balance: 0,
|
||||
currency: "USD",
|
||||
accountable: CreditCard.new
|
||||
)
|
||||
first_brex_card_account = @brex_item.brex_accounts.create!(
|
||||
account_id: BrexAccount.card_account_id,
|
||||
account_kind: "card",
|
||||
name: "First Brex Card",
|
||||
currency: "USD",
|
||||
current_balance: 0
|
||||
)
|
||||
AccountProvider.create!(account: first_card_account, provider: first_brex_card_account)
|
||||
|
||||
second_item = BrexItem.create!(
|
||||
family: @family,
|
||||
name: "Second Brex",
|
||||
token: "second_brex_token",
|
||||
base_url: "https://api.brex.com"
|
||||
)
|
||||
second_card_account = @family.accounts.create!(
|
||||
name: "Second Brex Card",
|
||||
balance: 0,
|
||||
currency: "USD",
|
||||
accountable: CreditCard.new
|
||||
)
|
||||
second_brex_card_account = second_item.brex_accounts.create!(
|
||||
account_id: BrexAccount.card_account_id,
|
||||
account_kind: "card",
|
||||
name: "Second Brex Card",
|
||||
currency: "USD",
|
||||
current_balance: 0,
|
||||
raw_transactions_payload: [
|
||||
{
|
||||
id: "second_connection_card_tx",
|
||||
amount: { amount: 42_00, currency: "USD" },
|
||||
description: "Existing second connection card transaction",
|
||||
posted_at_date: "2026-02-01"
|
||||
}
|
||||
]
|
||||
)
|
||||
AccountProvider.create!(account: second_card_account, provider: second_brex_card_account)
|
||||
|
||||
provider = mock("brex_provider")
|
||||
provider.expects(:get_accounts).returns(accounts: [ cash_account_payload, card_account_payload ])
|
||||
provider.expects(:get_cash_transactions).with("cash_1", start_date: anything).returns(transactions: [])
|
||||
provider.expects(:get_primary_card_transactions).with(start_date: anything).returns(
|
||||
transactions: [
|
||||
{
|
||||
id: "first_connection_card_tx",
|
||||
amount: { amount: 21_00, currency: "USD" },
|
||||
description: "First connection card transaction",
|
||||
posted_at_date: "2026-02-02",
|
||||
card_id: "card_account_1"
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
result = BrexItem::Importer.new(@brex_item, brex_provider: provider, sync_start_date: Date.new(2026, 2, 1)).import
|
||||
|
||||
assert result[:success]
|
||||
assert_equal [ "first_connection_card_tx" ], first_brex_card_account.reload.raw_transactions_payload.map { |tx| tx["id"] }
|
||||
assert_equal [ "second_connection_card_tx" ], second_brex_card_account.reload.raw_transactions_payload.map { |tx| tx["id"] }
|
||||
end
|
||||
|
||||
test "raises and reports snapshot persistence failures" do
|
||||
provider = mock("brex_provider")
|
||||
provider.expects(:get_accounts).returns(accounts: [ cash_account_payload ])
|
||||
@brex_item.expects(:upsert_brex_snapshot!).raises(StandardError.new("snapshot failed"))
|
||||
|
||||
error = assert_raises StandardError do
|
||||
BrexItem::Importer.new(@brex_item, brex_provider: provider).import
|
||||
end
|
||||
|
||||
assert_equal "snapshot failed", error.message
|
||||
end
|
||||
|
||||
test "marks item as requiring update on authorization errors" do
|
||||
provider = mock("brex_provider")
|
||||
provider.expects(:get_accounts).raises(
|
||||
Provider::Brex::BrexError.new("Access forbidden", :access_forbidden, http_status: 403, trace_id: "trace_123")
|
||||
)
|
||||
|
||||
result = BrexItem::Importer.new(@brex_item, brex_provider: provider).import
|
||||
|
||||
refute result[:success]
|
||||
assert @brex_item.reload.requires_update?
|
||||
end
|
||||
|
||||
test "clears requires update after a clean import" do
|
||||
@brex_item.update!(status: :requires_update)
|
||||
|
||||
provider = mock("brex_provider")
|
||||
provider.expects(:get_accounts).returns(accounts: [ cash_account_payload ])
|
||||
provider.expects(:get_cash_transactions).with("cash_1", start_date: anything).returns(transactions: [])
|
||||
|
||||
result = BrexItem::Importer.new(@brex_item, brex_provider: provider).import
|
||||
|
||||
assert result[:success]
|
||||
assert @brex_item.reload.good?
|
||||
end
|
||||
|
||||
test "refreshes already discovered unlinked accounts during import" do
|
||||
unlinked_account = @brex_item.brex_accounts.create!(
|
||||
account_id: "cash_unlinked_1",
|
||||
account_kind: "cash",
|
||||
name: "Old Unlinked Cash",
|
||||
currency: "USD",
|
||||
current_balance: 1
|
||||
)
|
||||
|
||||
provider = mock("brex_provider")
|
||||
provider.expects(:get_accounts).returns(
|
||||
accounts: [
|
||||
cash_account_payload,
|
||||
cash_account_payload.merge(
|
||||
id: "cash_unlinked_1",
|
||||
name: "Updated Unlinked Cash",
|
||||
current_balance: { amount: 987_65, currency: "USD" }
|
||||
)
|
||||
]
|
||||
)
|
||||
provider.expects(:get_cash_transactions).with("cash_1", start_date: anything).returns(transactions: [])
|
||||
|
||||
result = BrexItem::Importer.new(@brex_item, brex_provider: provider).import
|
||||
|
||||
assert result[:success]
|
||||
assert_equal 2, result[:accounts_updated]
|
||||
assert_equal "Updated Unlinked Cash", unlinked_account.reload.name
|
||||
assert_equal BigDecimal("987.65"), unlinked_account.current_balance
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def cash_account_payload
|
||||
{
|
||||
id: "cash_1",
|
||||
name: "Operating Cash",
|
||||
account_kind: "cash",
|
||||
status: "ACTIVE",
|
||||
current_balance: { amount: 120_000, currency: "USD" },
|
||||
available_balance: { amount: 110_000, currency: "USD" },
|
||||
account_number: "account-last4-9012",
|
||||
routing_number: "routing-last4-0021"
|
||||
}
|
||||
end
|
||||
|
||||
def card_account_payload
|
||||
{
|
||||
id: BrexAccount.card_account_id,
|
||||
name: "Brex Card",
|
||||
account_kind: "card",
|
||||
status: "ACTIVE",
|
||||
current_balance: { amount: 1_234, currency: "USD" },
|
||||
available_balance: { amount: 100_000, currency: "USD" },
|
||||
account_limit: { amount: 150_000, currency: "USD" },
|
||||
raw_card_accounts: [
|
||||
{
|
||||
id: "card_account_1",
|
||||
card_metadata: {
|
||||
pan: "test-pan-placeholder"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
end
|
||||
end
|
||||
136
test/models/brex_item/syncer_test.rb
Normal file
136
test/models/brex_item/syncer_test.rb
Normal file
@@ -0,0 +1,136 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "test_helper"
|
||||
|
||||
class BrexItem::SyncerTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@brex_item = brex_items(:one)
|
||||
@syncer = BrexItem::Syncer.new(@brex_item)
|
||||
end
|
||||
|
||||
test "passes sync window start date to importer" do
|
||||
window_start_date = Date.new(2026, 2, 1)
|
||||
sync = mock_sync(window_start_date: window_start_date)
|
||||
|
||||
@brex_item.expects(:import_latest_brex_data).with(sync_start_date: window_start_date).once
|
||||
|
||||
@syncer.perform_sync(sync)
|
||||
end
|
||||
|
||||
test "records localized setup status text and counts" do
|
||||
window_start_date = Date.new(2026, 2, 1)
|
||||
sync = recording_sync(window_start_date: window_start_date)
|
||||
|
||||
@brex_item.expects(:import_latest_brex_data).with(sync_start_date: window_start_date).once
|
||||
|
||||
@syncer.perform_sync(sync)
|
||||
|
||||
assert_equal [
|
||||
I18n.t("brex_items.syncer.importing_accounts"),
|
||||
I18n.t("brex_items.syncer.checking_account_configuration"),
|
||||
I18n.t("brex_items.syncer.accounts_need_setup", count: 1)
|
||||
], sync.updates.filter_map { |attrs| attrs[:status_text] }
|
||||
|
||||
assert_equal 1, sync.sync_stats["total_accounts"]
|
||||
assert_equal 0, sync.sync_stats["linked_accounts"]
|
||||
assert_equal 1, sync.sync_stats["unlinked_accounts"]
|
||||
end
|
||||
|
||||
test "records importer failure counts in health stats" do
|
||||
sync = recording_sync(window_start_date: Date.new(2026, 2, 1))
|
||||
@brex_item.expects(:import_latest_brex_data).returns(
|
||||
success: false,
|
||||
accounts_failed: 2,
|
||||
transactions_failed: 1
|
||||
)
|
||||
|
||||
@syncer.perform_sync(sync)
|
||||
|
||||
assert_equal 2, sync.sync_stats["total_errors"]
|
||||
assert_equal [
|
||||
I18n.t("brex_items.syncer.accounts_failed", count: 2),
|
||||
I18n.t("brex_items.syncer.transactions_failed", count: 1)
|
||||
], sync.sync_stats["errors"].map { |error| error["message"] }
|
||||
end
|
||||
|
||||
test "records account processing and scheduling failures in health stats" do
|
||||
account = @brex_item.family.accounts.create!(
|
||||
name: "Linked Brex Checking",
|
||||
balance: 0,
|
||||
currency: "USD",
|
||||
accountable: Depository.new
|
||||
)
|
||||
brex_account = @brex_item.brex_accounts.first
|
||||
AccountProvider.create!(account: account, provider: brex_account)
|
||||
|
||||
sync = recording_sync(window_start_date: Date.new(2026, 2, 1))
|
||||
@brex_item.expects(:import_latest_brex_data).returns(
|
||||
success: true,
|
||||
accounts_failed: 0,
|
||||
transactions_failed: 0
|
||||
)
|
||||
@brex_item.expects(:process_accounts).returns([
|
||||
{ brex_account_id: brex_account.id, success: false, error: "processing failure" }
|
||||
])
|
||||
@brex_item.expects(:schedule_account_syncs).returns([
|
||||
{ account_id: account.id, success: false, error: "scheduling failure" }
|
||||
])
|
||||
|
||||
@syncer.perform_sync(sync)
|
||||
|
||||
assert_equal 2, sync.sync_stats["total_errors"]
|
||||
assert_equal [
|
||||
I18n.t("brex_items.syncer.account_processing_failed", count: 1),
|
||||
I18n.t("brex_items.syncer.account_sync_failed", count: 1)
|
||||
], sync.sync_stats["errors"].map { |error| error["message"] }
|
||||
end
|
||||
|
||||
test "raises user safe credential error for Brex auth failures" do
|
||||
sync = mock_sync(window_start_date: Date.new(2026, 2, 1))
|
||||
@brex_item.expects(:import_latest_brex_data)
|
||||
.raises(Provider::Brex::BrexError.new("raw upstream auth body", :unauthorized, http_status: 401))
|
||||
Sentry.expects(:capture_exception)
|
||||
|
||||
error = assert_raises(BrexItem::Syncer::SafeSyncError) do
|
||||
@syncer.perform_sync(sync)
|
||||
end
|
||||
|
||||
assert_equal I18n.t("brex_items.syncer.credentials_invalid"), error.message
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def mock_sync(window_start_date:)
|
||||
sync = mock("sync")
|
||||
sync.stubs(:respond_to?).with(:status_text).returns(true)
|
||||
sync.stubs(:respond_to?).with(:sync_stats).returns(true)
|
||||
sync.stubs(:sync_stats).returns({})
|
||||
sync.stubs(:window_start_date).returns(window_start_date)
|
||||
sync.stubs(:window_end_date).returns(nil)
|
||||
sync.stubs(:update!)
|
||||
sync
|
||||
end
|
||||
|
||||
def recording_sync(window_start_date:)
|
||||
Class.new do
|
||||
attr_accessor :sync_stats, :status_text
|
||||
attr_reader :updates
|
||||
|
||||
define_method(:initialize) do |start_date|
|
||||
@window_start_date = start_date
|
||||
@window_end_date = nil
|
||||
@created_at = Time.current
|
||||
@sync_stats = {}
|
||||
@updates = []
|
||||
end
|
||||
|
||||
attr_reader :window_start_date, :window_end_date, :created_at
|
||||
|
||||
def update!(attributes)
|
||||
@updates << attributes
|
||||
self.sync_stats = attributes[:sync_stats] if attributes.key?(:sync_stats)
|
||||
self.status_text = attributes[:status_text] if attributes.key?(:status_text)
|
||||
end
|
||||
end.new(window_start_date)
|
||||
end
|
||||
end
|
||||
198
test/models/brex_item_test.rb
Normal file
198
test/models/brex_item_test.rb
Normal file
@@ -0,0 +1,198 @@
|
||||
require "test_helper"
|
||||
|
||||
class BrexItemTest < ActiveSupport::TestCase
|
||||
def setup
|
||||
@brex_item = brex_items(:one)
|
||||
end
|
||||
|
||||
test "fixture is valid" do
|
||||
assert @brex_item.valid?
|
||||
end
|
||||
|
||||
test "belongs to family" do
|
||||
assert_equal families(:dylan_family), @brex_item.family
|
||||
end
|
||||
|
||||
test "credentials_configured returns true when token present" do
|
||||
assert @brex_item.credentials_configured?
|
||||
end
|
||||
|
||||
test "credentials_configured returns false when token blank" do
|
||||
@brex_item.token = nil
|
||||
assert_not @brex_item.credentials_configured?
|
||||
end
|
||||
|
||||
test "credentials_configured returns false when token is whitespace" do
|
||||
@brex_item.token = " "
|
||||
assert_not @brex_item.credentials_configured?
|
||||
end
|
||||
|
||||
test "effective_base_url returns custom url when set" do
|
||||
assert_equal "https://api-staging.brex.com", @brex_item.effective_base_url
|
||||
end
|
||||
|
||||
test "effective_base_url returns default when base_url blank" do
|
||||
@brex_item.base_url = nil
|
||||
assert_equal "https://api.brex.com", @brex_item.effective_base_url
|
||||
end
|
||||
|
||||
test "base_url accepts official Brex API roots" do
|
||||
assert BrexItem.new(family: families(:empty), name: "Production", token: "token", base_url: "https://api.brex.com").valid?
|
||||
assert BrexItem.new(family: families(:empty), name: "Staging", token: "token", base_url: "https://api-staging.brex.com").valid?
|
||||
end
|
||||
|
||||
test "base_url normalizes official URL case and trailing slash" do
|
||||
item = BrexItem.create!(
|
||||
family: families(:empty),
|
||||
name: "Normalized Brex",
|
||||
token: "token",
|
||||
base_url: " HTTPS://API.BREX.COM/ "
|
||||
)
|
||||
|
||||
assert_equal "https://api.brex.com", item.base_url
|
||||
end
|
||||
|
||||
test "token is stripped before validation and save" do
|
||||
item = BrexItem.create!(
|
||||
family: families(:empty),
|
||||
name: "Token Normalized Brex",
|
||||
token: " normalized_token ",
|
||||
base_url: "https://api.brex.com"
|
||||
)
|
||||
|
||||
assert_equal "normalized_token", item.token
|
||||
end
|
||||
|
||||
test "token cannot be blanked on update" do
|
||||
original_token = @brex_item.token
|
||||
|
||||
assert_raises(ActiveRecord::RecordInvalid) do
|
||||
@brex_item.update!(token: " ")
|
||||
end
|
||||
|
||||
assert_equal original_token, @brex_item.reload.token
|
||||
assert_includes @brex_item.errors[:token], "can't be blank"
|
||||
end
|
||||
|
||||
test "base_url rejects non-Brex hosts and endpoint paths" do
|
||||
[
|
||||
"http://api.brex.com",
|
||||
"https://evil.example.test",
|
||||
"https://localhost",
|
||||
"https://127.0.0.1",
|
||||
"https://10.0.0.1",
|
||||
"https://api.brex.com.evil.example",
|
||||
"https://api.brex.com@127.0.0.1",
|
||||
"https://api.brex.com:444",
|
||||
"https://api.brex.com/v2",
|
||||
"https://api.brex.com?debug=true",
|
||||
"//api.brex.com"
|
||||
].each do |base_url|
|
||||
item = BrexItem.new(family: families(:empty), name: "Invalid Brex", token: "token", base_url: base_url)
|
||||
|
||||
refute item.valid?, "Expected #{base_url.inspect} to be invalid"
|
||||
assert_includes item.errors[:base_url], I18n.t("activerecord.errors.models.brex_item.attributes.base_url.official_hosts_only")
|
||||
end
|
||||
end
|
||||
|
||||
test "brex_provider returns Provider::Brex instance" do
|
||||
provider = @brex_item.brex_provider
|
||||
assert_instance_of Provider::Brex, provider
|
||||
assert_equal @brex_item.token, provider.token
|
||||
end
|
||||
|
||||
test "declares Brex token and raw payload as encrypted" do
|
||||
skip "Encryption not configured" unless BrexItem.encryption_ready?
|
||||
|
||||
assert_includes BrexItem.encrypted_attributes.map(&:to_s), "token"
|
||||
assert_includes BrexItem.encrypted_attributes.map(&:to_s), "raw_payload"
|
||||
end
|
||||
|
||||
test "resolve for returns explicit credentialed item scoped to family" do
|
||||
resolved = BrexItem.resolve_for(family: @brex_item.family, brex_item_id: " #{@brex_item.id} ")
|
||||
|
||||
assert_equal @brex_item, resolved
|
||||
end
|
||||
|
||||
test "resolve for refuses explicit items without usable credentials" do
|
||||
item = BrexItem.create!(
|
||||
family: @brex_item.family,
|
||||
name: "Blank Resolve Brex",
|
||||
token: "temporary_token",
|
||||
base_url: "https://api.brex.com"
|
||||
)
|
||||
item.update_column(:token, " ")
|
||||
|
||||
assert_nil BrexItem.resolve_for(family: @brex_item.family, brex_item_id: item.id)
|
||||
end
|
||||
|
||||
test "resolve for does not select one item when multiple credentialed items exist" do
|
||||
BrexItem.create!(
|
||||
family: @brex_item.family,
|
||||
name: "Second Resolve Brex",
|
||||
token: "second_resolve_token",
|
||||
base_url: "https://api.brex.com"
|
||||
)
|
||||
|
||||
assert_nil BrexItem.resolve_for(family: @brex_item.family)
|
||||
end
|
||||
|
||||
test "schema requires name and token" do
|
||||
columns = BrexItem.columns.index_by(&:name)
|
||||
|
||||
assert_equal false, columns["name"].null
|
||||
assert_equal false, columns["token"].null
|
||||
end
|
||||
|
||||
test "brex_provider returns nil when credentials not configured" do
|
||||
@brex_item.token = nil
|
||||
assert_nil @brex_item.brex_provider
|
||||
end
|
||||
|
||||
test "brex_provider returns nil when persisted base_url is not allowed" do
|
||||
@brex_item.update_column(:base_url, "https://evil.example.test")
|
||||
|
||||
assert_nil @brex_item.reload.brex_provider
|
||||
end
|
||||
|
||||
test "family credential check ignores blank and scheduled for deletion items" do
|
||||
family = families(:empty)
|
||||
blank_item = BrexItem.create!(
|
||||
family: family,
|
||||
name: "Blank Brex",
|
||||
token: "temporary_token",
|
||||
base_url: "https://api-staging.brex.com"
|
||||
)
|
||||
blank_item.update_column(:token, "")
|
||||
|
||||
whitespace_item = BrexItem.create!(
|
||||
family: family,
|
||||
name: "Whitespace Brex",
|
||||
token: "temporary_token",
|
||||
base_url: "https://api-staging.brex.com"
|
||||
)
|
||||
whitespace_item.update_column(:token, " ")
|
||||
|
||||
deleted_item = BrexItem.create!(
|
||||
family: family,
|
||||
name: "Deleted Brex",
|
||||
token: "deleted_token",
|
||||
base_url: "https://api-staging.brex.com",
|
||||
scheduled_for_deletion: true
|
||||
)
|
||||
|
||||
refute family.has_brex_credentials?
|
||||
|
||||
whitespace_item.update_column(:token, "configured_token")
|
||||
assert family.has_brex_credentials?
|
||||
|
||||
whitespace_item.update_column(:token, " ")
|
||||
deleted_item.update!(scheduled_for_deletion: false)
|
||||
assert family.has_brex_credentials?
|
||||
end
|
||||
|
||||
test "syncer returns BrexItem::Syncer instance" do
|
||||
syncer = @brex_item.send(:syncer)
|
||||
assert_instance_of BrexItem::Syncer, syncer
|
||||
end
|
||||
end
|
||||
@@ -157,6 +157,121 @@ class EnableBankingAccount::Transactions::ProcessorTest < ActiveSupport::TestCas
|
||||
assert_equal 1, result[:imported]
|
||||
end
|
||||
|
||||
test "imports id-less transaction using content fingerprint" do
|
||||
tx = {
|
||||
"booking_date" => Date.current.to_s,
|
||||
"transaction_amount" => { "amount" => "19.99", "currency" => "EUR" },
|
||||
"credit_debit_indicator" => "DBIT",
|
||||
"creditor" => { "name" => "Spotify" }
|
||||
}
|
||||
@enable_banking_account.update!(raw_transactions_payload: [ tx ])
|
||||
|
||||
assert_difference "@account.entries.count", 1 do
|
||||
EnableBankingAccount::Transactions::Processor.new(@enable_banking_account).process
|
||||
end
|
||||
|
||||
expected_id = EnableBankingEntry::Processor.compute_external_id(tx)
|
||||
assert @account.entries.exists?(external_id: expected_id, source: "enable_banking")
|
||||
end
|
||||
|
||||
test "id-less transaction does not appear in failed count" do
|
||||
tx = {
|
||||
"booking_date" => Date.current.to_s,
|
||||
"transaction_amount" => { "amount" => "5.00", "currency" => "EUR" },
|
||||
"credit_debit_indicator" => "CRDT",
|
||||
"debtor" => { "name" => "Employer" }
|
||||
}
|
||||
@enable_banking_account.update!(raw_transactions_payload: [ tx ])
|
||||
|
||||
result = EnableBankingAccount::Transactions::Processor.new(@enable_banking_account).process
|
||||
|
||||
assert_equal 0, result[:failed]
|
||||
end
|
||||
|
||||
test "does not re-import a pending transaction whose external_id was auto-claimed" do
|
||||
# When a pending entry is automatically matched to a booked transaction by the
|
||||
# amount/date heuristic (find_pending_transaction), the old pending external_id
|
||||
# is stored in auto_claimed_pending_ids so subsequent syncs don't recreate it.
|
||||
pending_ext_id = "enable_banking_PDNG_AUTO_CLAIMED"
|
||||
|
||||
booked_entry = create_transaction(
|
||||
account: @account,
|
||||
name: "Grocery Store",
|
||||
date: 1.day.ago.to_date,
|
||||
amount: 55,
|
||||
currency: "EUR",
|
||||
external_id: "enable_banking_BOOK_SETTLED",
|
||||
source: "enable_banking"
|
||||
)
|
||||
booked_entry.transaction.update!(
|
||||
extra: {
|
||||
"auto_claimed_pending_ids" => [ pending_ext_id ]
|
||||
}
|
||||
)
|
||||
|
||||
@enable_banking_account.update!(
|
||||
raw_transactions_payload: [
|
||||
raw_pending_transaction(transaction_id: "PDNG_AUTO_CLAIMED")
|
||||
]
|
||||
)
|
||||
|
||||
assert_no_difference "@account.entries.count" do
|
||||
EnableBankingAccount::Transactions::Processor.new(@enable_banking_account).process
|
||||
end
|
||||
end
|
||||
|
||||
test "does not re-import when both manual_merge and auto_claimed_pending_ids exclusions are present" do
|
||||
manually_merged_ext_id = "enable_banking_PDNG_MANUAL"
|
||||
auto_claimed_ext_id = "enable_banking_PDNG_AUTO"
|
||||
|
||||
manual_entry = create_transaction(
|
||||
account: @account,
|
||||
name: "Manual Merge Entry",
|
||||
date: 2.days.ago.to_date,
|
||||
amount: 20,
|
||||
currency: "EUR",
|
||||
external_id: "enable_banking_BOOK_MANUAL",
|
||||
source: "enable_banking"
|
||||
)
|
||||
manual_entry.transaction.update!(
|
||||
extra: {
|
||||
"manual_merge" => {
|
||||
"merged_from_external_id" => manually_merged_ext_id,
|
||||
"merged_at" => Time.current.iso8601,
|
||||
"source" => "enable_banking"
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
auto_entry = create_transaction(
|
||||
account: @account,
|
||||
name: "Auto Claimed Entry",
|
||||
date: 1.day.ago.to_date,
|
||||
amount: 30,
|
||||
currency: "EUR",
|
||||
external_id: "enable_banking_BOOK_AUTO",
|
||||
source: "enable_banking"
|
||||
)
|
||||
auto_entry.transaction.update!(
|
||||
extra: { "auto_claimed_pending_ids" => [ auto_claimed_ext_id ] }
|
||||
)
|
||||
|
||||
@enable_banking_account.update!(
|
||||
raw_transactions_payload: [
|
||||
raw_pending_transaction(transaction_id: "PDNG_MANUAL"), # excluded via manual_merge
|
||||
raw_pending_transaction(transaction_id: "PDNG_AUTO"), # excluded via auto_claimed_pending_ids
|
||||
raw_pending_transaction(transaction_id: "PDNG_BRAND_NEW_XXXX") # new — should import
|
||||
]
|
||||
)
|
||||
|
||||
result = nil
|
||||
assert_difference "@account.entries.count", 1 do
|
||||
result = EnableBankingAccount::Transactions::Processor.new(@enable_banking_account).process
|
||||
end
|
||||
assert_equal 2, result[:skipped]
|
||||
assert_equal 1, result[:imported]
|
||||
end
|
||||
|
||||
test "handles empty raw_transactions_payload gracefully" do
|
||||
@enable_banking_account.update!(raw_transactions_payload: nil)
|
||||
|
||||
|
||||
@@ -83,17 +83,89 @@ class EnableBankingEntry::ProcessorTest < ActiveSupport::TestCase
|
||||
end
|
||||
end
|
||||
|
||||
test "raises ArgumentError when both transaction_id and entry_reference are nil" do
|
||||
# --- compute_external_id unit tests ---
|
||||
|
||||
test "compute_external_id returns transaction_id-based id when present" do
|
||||
assert_equal "enable_banking_txn_abc",
|
||||
EnableBankingEntry::Processor.compute_external_id(transaction_id: "txn_abc", entry_reference: "ref_xyz")
|
||||
end
|
||||
|
||||
test "compute_external_id falls back to entry_reference when transaction_id is blank" do
|
||||
assert_equal "enable_banking_ref_xyz",
|
||||
EnableBankingEntry::Processor.compute_external_id(transaction_id: nil, entry_reference: "ref_xyz")
|
||||
end
|
||||
|
||||
test "compute_external_id returns content fingerprint when both id fields are absent" do
|
||||
tx = {
|
||||
booking_date: "2026-03-15",
|
||||
transaction_amount: { amount: "42.00", currency: "EUR" },
|
||||
credit_debit_indicator: "DBIT",
|
||||
creditor: { name: "Spar" }
|
||||
}
|
||||
result = EnableBankingEntry::Processor.compute_external_id(tx)
|
||||
assert result.start_with?("enable_banking_content_"), "Expected content fingerprint, got: #{result}"
|
||||
end
|
||||
|
||||
test "compute_external_id fingerprint is stable across calls" do
|
||||
tx = {
|
||||
booking_date: "2026-03-15",
|
||||
transaction_amount: { amount: "42.00", currency: "EUR" },
|
||||
credit_debit_indicator: "DBIT",
|
||||
creditor: { name: "Spar" }
|
||||
}
|
||||
assert_equal EnableBankingEntry::Processor.compute_external_id(tx),
|
||||
EnableBankingEntry::Processor.compute_external_id(tx)
|
||||
end
|
||||
|
||||
test "compute_external_id returns nil for transaction with no identifiable content" do
|
||||
assert_nil EnableBankingEntry::Processor.compute_external_id({})
|
||||
assert_nil EnableBankingEntry::Processor.compute_external_id(transaction_id: nil, entry_reference: nil)
|
||||
end
|
||||
|
||||
# --- ID-less transaction processing ---
|
||||
|
||||
test "imports transaction using content fingerprint when transaction_id and entry_reference are absent" do
|
||||
tx = {
|
||||
transaction_id: nil,
|
||||
entry_reference: nil,
|
||||
booking_date: Date.current.to_s,
|
||||
transaction_amount: { amount: "10.00", currency: "EUR" },
|
||||
creditor: { name: "Test" },
|
||||
creditor: { name: "Lidl" },
|
||||
credit_debit_indicator: "DBIT",
|
||||
status: "BOOK"
|
||||
}
|
||||
|
||||
assert_difference "@account.entries.count", 1 do
|
||||
EnableBankingEntry::Processor.new(tx, enable_banking_account: @enable_banking_account).process
|
||||
end
|
||||
|
||||
expected_id = EnableBankingEntry::Processor.compute_external_id(tx)
|
||||
assert @account.entries.exists?(external_id: expected_id, source: "enable_banking")
|
||||
end
|
||||
|
||||
test "does not create duplicate when same id-less transaction is processed twice" do
|
||||
tx = {
|
||||
transaction_id: nil,
|
||||
entry_reference: nil,
|
||||
booking_date: Date.current.to_s,
|
||||
transaction_amount: { amount: "10.00", currency: "EUR" },
|
||||
creditor: { name: "Lidl" },
|
||||
credit_debit_indicator: "DBIT",
|
||||
status: "BOOK"
|
||||
}
|
||||
|
||||
assert_difference "@account.entries.count", 1 do
|
||||
EnableBankingEntry::Processor.new(tx, enable_banking_account: @enable_banking_account).process
|
||||
end
|
||||
|
||||
assert_no_difference "@account.entries.count" do
|
||||
EnableBankingEntry::Processor.new(tx, enable_banking_account: @enable_banking_account).process
|
||||
end
|
||||
end
|
||||
|
||||
test "raises ArgumentError for transaction with no identifiable content at all" do
|
||||
tx = { transaction_id: nil, entry_reference: nil }
|
||||
|
||||
assert_raises(ArgumentError) do
|
||||
EnableBankingEntry::Processor.new(tx, enable_banking_account: @enable_banking_account).process
|
||||
end
|
||||
|
||||
@@ -63,6 +63,42 @@ class EnableBankingItem::ImporterErrorHandlingTest < ActiveSupport::TestCase
|
||||
assert @enable_banking_item.reload.requires_update?
|
||||
end
|
||||
|
||||
test "fetch_and_store_transactions succeeds and skips pending when ASPSP rejects PDNG transaction_status" do
|
||||
enable_banking_account = EnableBankingAccount.new(uid: "test_uid")
|
||||
@importer.stubs(:determine_sync_start_date).returns(Date.today)
|
||||
@importer.stubs(:include_pending?).returns(true)
|
||||
|
||||
pdng_error = Provider::EnableBanking::EnableBankingError.new(
|
||||
"Validation error from Enable Banking API: {\"message\":\"Wrong transactionStatus provided in getAccountTransactions call: PDNG\"}",
|
||||
:validation_error
|
||||
)
|
||||
|
||||
@importer.stubs(:fetch_paginated_transactions).with(enable_banking_account, has_entries(transaction_status: "BOOK")).returns([])
|
||||
@importer.stubs(:fetch_paginated_transactions).with(enable_banking_account, has_entries(transaction_status: "PDNG")).raises(pdng_error)
|
||||
|
||||
result = @importer.send(:fetch_and_store_transactions, enable_banking_account)
|
||||
|
||||
assert result[:success]
|
||||
end
|
||||
|
||||
test "fetch_and_store_transactions fails when validation error is unrelated to transactionStatus" do
|
||||
enable_banking_account = EnableBankingAccount.new(uid: "test_uid")
|
||||
@importer.stubs(:determine_sync_start_date).returns(Date.today)
|
||||
@importer.stubs(:include_pending?).returns(true)
|
||||
|
||||
date_error = Provider::EnableBanking::EnableBankingError.new(
|
||||
"Validation error from Enable Banking API: {\"message\":\"Invalid date_from format\"}",
|
||||
:validation_error
|
||||
)
|
||||
|
||||
@importer.stubs(:fetch_paginated_transactions).with(enable_banking_account, has_entries(transaction_status: "BOOK")).returns([])
|
||||
@importer.stubs(:fetch_paginated_transactions).with(enable_banking_account, has_entries(transaction_status: "PDNG")).raises(date_error)
|
||||
|
||||
result = @importer.send(:fetch_and_store_transactions, enable_banking_account)
|
||||
|
||||
assert_not result[:success]
|
||||
end
|
||||
|
||||
test "fetch_and_update_balance updates status to requires_update on unauthorized error" do
|
||||
enable_banking_account = EnableBankingAccount.new(uid: "test_uid")
|
||||
def @mock_provider.get_account_balances(**args)
|
||||
|
||||
145
test/models/enable_banking_item/importer_id_less_test.rb
Normal file
145
test/models/enable_banking_item/importer_id_less_test.rb
Normal file
@@ -0,0 +1,145 @@
|
||||
require "test_helper"
|
||||
|
||||
class EnableBankingItem::ImporterIdLessTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@family = families(:dylan_family)
|
||||
@account = accounts(:depository)
|
||||
|
||||
@enable_banking_item = EnableBankingItem.create!(
|
||||
family: @family,
|
||||
name: "Test EB",
|
||||
country_code: "RO",
|
||||
application_id: "test_app_id",
|
||||
client_certificate: "test_cert",
|
||||
session_id: "test_session",
|
||||
session_expires_at: 1.day.from_now,
|
||||
sync_start_date: 1.month.ago.to_date
|
||||
)
|
||||
@enable_banking_account = EnableBankingAccount.create!(
|
||||
enable_banking_item: @enable_banking_item,
|
||||
name: "Current Account",
|
||||
uid: "hash_idless_test",
|
||||
account_id: "uuid-idless-1234-abcd",
|
||||
currency: "RON"
|
||||
)
|
||||
AccountProvider.create!(account: @account, provider: @enable_banking_account)
|
||||
|
||||
@mock_provider = mock()
|
||||
@importer = EnableBankingItem::Importer.new(@enable_banking_item, enable_banking_provider: @mock_provider)
|
||||
end
|
||||
|
||||
def id_less_tx(amount: "50.00", creditor: "Kaufland", date: Date.current.to_s)
|
||||
{
|
||||
booking_date: date,
|
||||
transaction_amount: { amount: amount, currency: "RON" },
|
||||
credit_debit_indicator: "DBIT",
|
||||
creditor: { name: creditor }
|
||||
}
|
||||
end
|
||||
|
||||
test "stores id-less transactions in raw_transactions_payload on first sync" do
|
||||
tx = id_less_tx
|
||||
|
||||
@importer.stubs(:fetch_paginated_transactions).with(@enable_banking_account, has_entry(transaction_status: "BOOK")).returns([ tx ])
|
||||
@importer.stubs(:fetch_paginated_transactions).with(@enable_banking_account, has_entry(transaction_status: "PDNG")).returns([])
|
||||
@importer.stubs(:include_pending?).returns(false)
|
||||
@importer.stubs(:determine_sync_start_date).returns(1.month.ago.to_date)
|
||||
|
||||
@importer.send(:fetch_and_store_transactions, @enable_banking_account)
|
||||
|
||||
@enable_banking_account.reload
|
||||
assert_equal 1, @enable_banking_account.raw_transactions_payload.count
|
||||
end
|
||||
|
||||
test "does not re-store id-less transaction on second sync" do
|
||||
tx = id_less_tx
|
||||
|
||||
# First sync
|
||||
@importer.stubs(:fetch_paginated_transactions).with(@enable_banking_account, has_entry(transaction_status: "BOOK")).returns([ tx ])
|
||||
@importer.stubs(:fetch_paginated_transactions).with(@enable_banking_account, has_entry(transaction_status: "PDNG")).returns([])
|
||||
@importer.stubs(:include_pending?).returns(false)
|
||||
@importer.stubs(:determine_sync_start_date).returns(1.month.ago.to_date)
|
||||
|
||||
@importer.send(:fetch_and_store_transactions, @enable_banking_account)
|
||||
@enable_banking_account.reload
|
||||
assert_equal 1, @enable_banking_account.raw_transactions_payload.count
|
||||
|
||||
# Second sync with the same transaction
|
||||
@importer.send(:fetch_and_store_transactions, @enable_banking_account)
|
||||
@enable_banking_account.reload
|
||||
assert_equal 1, @enable_banking_account.raw_transactions_payload.count
|
||||
end
|
||||
|
||||
test "stores multiple distinct id-less transactions separately" do
|
||||
tx1 = id_less_tx(amount: "50.00", creditor: "Kaufland")
|
||||
tx2 = id_less_tx(amount: "12.50", creditor: "Starbucks")
|
||||
|
||||
@importer.stubs(:fetch_paginated_transactions).with(@enable_banking_account, has_entry(transaction_status: "BOOK")).returns([ tx1, tx2 ])
|
||||
@importer.stubs(:fetch_paginated_transactions).with(@enable_banking_account, has_entry(transaction_status: "PDNG")).returns([])
|
||||
@importer.stubs(:include_pending?).returns(false)
|
||||
@importer.stubs(:determine_sync_start_date).returns(1.month.ago.to_date)
|
||||
|
||||
@importer.send(:fetch_and_store_transactions, @enable_banking_account)
|
||||
|
||||
@enable_banking_account.reload
|
||||
assert_equal 2, @enable_banking_account.raw_transactions_payload.count
|
||||
end
|
||||
|
||||
test "removes stored id-less pending entry when its booked counterpart arrives" do
|
||||
tx = id_less_tx(amount: "30.00", creditor: "Netflix")
|
||||
pending_tx = tx.merge(_pending: true)
|
||||
|
||||
@enable_banking_account.update!(raw_transactions_payload: [ pending_tx ])
|
||||
|
||||
@importer.stubs(:fetch_paginated_transactions).with(@enable_banking_account, has_entry(transaction_status: "BOOK")).returns([ tx ])
|
||||
@importer.stubs(:fetch_paginated_transactions).with(@enable_banking_account, has_entry(transaction_status: "PDNG")).returns([])
|
||||
@importer.stubs(:include_pending?).returns(true)
|
||||
@importer.stubs(:determine_sync_start_date).returns(1.month.ago.to_date)
|
||||
|
||||
@importer.send(:fetch_and_store_transactions, @enable_banking_account)
|
||||
|
||||
@enable_banking_account.reload
|
||||
stored = @enable_banking_account.raw_transactions_payload
|
||||
assert_equal 1, stored.count
|
||||
assert_nil stored.first["_pending"]
|
||||
end
|
||||
|
||||
# Regression: pending row has entry_reference only; booked counterpart gains
|
||||
# transaction_id on settlement. Fingerprints diverge but entry_reference is
|
||||
# stable — the pending entry must still be removed from stored payload.
|
||||
test "removes stored pending entry when settled book row gains a transaction_id" do
|
||||
entry_ref = "REF-SETTLE-123"
|
||||
|
||||
pending_tx = {
|
||||
"entry_reference" => entry_ref,
|
||||
"booking_date" => Date.current.to_s,
|
||||
"transaction_amount" => { "amount" => "15.00", "currency" => "RON" },
|
||||
"credit_debit_indicator" => "DBIT",
|
||||
"creditor" => { "name" => "Bolt" },
|
||||
"_pending" => true
|
||||
}
|
||||
|
||||
booked_tx = {
|
||||
transaction_id: "TXN-NEW-456",
|
||||
entry_reference: entry_ref,
|
||||
booking_date: Date.current.to_s,
|
||||
transaction_amount: { amount: "15.00", currency: "RON" },
|
||||
credit_debit_indicator: "DBIT",
|
||||
creditor: { name: "Bolt" }
|
||||
}
|
||||
|
||||
@enable_banking_account.update!(raw_transactions_payload: [ pending_tx ])
|
||||
|
||||
@importer.stubs(:fetch_paginated_transactions).with(@enable_banking_account, has_entry(transaction_status: "BOOK")).returns([ booked_tx ])
|
||||
@importer.stubs(:fetch_paginated_transactions).with(@enable_banking_account, has_entry(transaction_status: "PDNG")).returns([])
|
||||
@importer.stubs(:include_pending?).returns(true)
|
||||
@importer.stubs(:determine_sync_start_date).returns(1.month.ago.to_date)
|
||||
|
||||
@importer.send(:fetch_and_store_transactions, @enable_banking_account)
|
||||
|
||||
@enable_banking_account.reload
|
||||
stored = @enable_banking_account.raw_transactions_payload
|
||||
assert_equal 1, stored.count, "Stale pending entry should have been removed"
|
||||
assert_nil stored.first["_pending"], "Remaining entry should be the booked row"
|
||||
end
|
||||
end
|
||||
@@ -47,7 +47,7 @@ class Family::DataExporterTest < ActiveSupport::TestCase
|
||||
assert zip_data.is_a?(StringIO)
|
||||
|
||||
# Check that the zip contains all expected files
|
||||
expected_files = [ "accounts.csv", "transactions.csv", "trades.csv", "categories.csv", "rules.csv", "all.ndjson" ]
|
||||
expected_files = [ "accounts.csv", "transactions.csv", "trades.csv", "categories.csv", "rules.csv", "attachments.json", "all.ndjson" ]
|
||||
|
||||
Zip::File.open_buffer(zip_data) do |zip|
|
||||
actual_files = zip.entries.map(&:name)
|
||||
@@ -55,6 +55,109 @@ class Family::DataExporterTest < ActiveSupport::TestCase
|
||||
end
|
||||
end
|
||||
|
||||
test "exports attachment manifest metadata without binary payloads" do
|
||||
entry = @account.entries.create!(
|
||||
name: "Receipt Transaction",
|
||||
amount: 12.34,
|
||||
currency: "USD",
|
||||
date: Date.current,
|
||||
entryable: Transaction.new
|
||||
)
|
||||
transaction = entry.transaction
|
||||
transaction.attachments.attach(
|
||||
io: StringIO.new("receipt bytes"),
|
||||
filename: "receipt.pdf",
|
||||
content_type: "application/pdf"
|
||||
)
|
||||
|
||||
family_document = @family.family_documents.create!(
|
||||
filename: "statement.pdf",
|
||||
status: "ready"
|
||||
)
|
||||
family_document.file.attach(
|
||||
io: StringIO.new("statement bytes"),
|
||||
filename: "statement.pdf",
|
||||
content_type: "application/pdf"
|
||||
)
|
||||
|
||||
other_account = @other_family.accounts.create!(
|
||||
name: "Other Attachment Account",
|
||||
accountable: Depository.new,
|
||||
balance: 0,
|
||||
currency: "USD"
|
||||
)
|
||||
other_entry = other_account.entries.create!(
|
||||
name: "Other Receipt",
|
||||
amount: 1,
|
||||
currency: "USD",
|
||||
date: Date.current,
|
||||
entryable: Transaction.new
|
||||
)
|
||||
other_entry.transaction.attachments.attach(
|
||||
io: StringIO.new("other bytes"),
|
||||
filename: "other-receipt.pdf",
|
||||
content_type: "application/pdf"
|
||||
)
|
||||
|
||||
zip_data = @exporter.generate_export
|
||||
|
||||
Zip::File.open_buffer(zip_data) do |zip|
|
||||
manifest = JSON.parse(zip.read("attachments.json"))
|
||||
attachments = manifest["attachments"]
|
||||
filenames = attachments.map { |attachment| attachment["filename"] }
|
||||
|
||||
assert_equal 1, manifest["version"]
|
||||
assert_equal false, manifest["binary_included"]
|
||||
assert_includes filenames, "receipt.pdf"
|
||||
assert_includes filenames, "statement.pdf"
|
||||
refute_includes filenames, "other-receipt.pdf"
|
||||
|
||||
transaction_item = attachments.find { |attachment| attachment["record_type"] == "Transaction" }
|
||||
assert_equal transaction.id, transaction_item["record_id"]
|
||||
assert_equal entry.id, transaction_item["entry_id"]
|
||||
assert_equal @account.id, transaction_item["account_id"]
|
||||
assert_equal "attachments", transaction_item["name"]
|
||||
assert_equal "application/pdf", transaction_item["content_type"]
|
||||
assert_equal false, transaction_item["binary_included"]
|
||||
|
||||
document_item = attachments.find { |attachment| attachment["record_type"] == "FamilyDocument" }
|
||||
assert_equal family_document.id, document_item["record_id"]
|
||||
assert_equal "ready", document_item["status"]
|
||||
assert_equal "file", document_item["name"]
|
||||
assert_equal false, document_item["binary_included"]
|
||||
end
|
||||
end
|
||||
|
||||
test "exports split parent receipts in attachment manifest" do
|
||||
split_parent = create_transaction_entry(
|
||||
@account,
|
||||
amount: 60,
|
||||
date: Date.parse("2024-01-25"),
|
||||
name: "Split parent receipt"
|
||||
)
|
||||
split_parent.entryable.attachments.attach(
|
||||
io: StringIO.new("split parent receipt bytes"),
|
||||
filename: "split-parent-receipt.pdf",
|
||||
content_type: "application/pdf"
|
||||
)
|
||||
split_parent.split!([
|
||||
{ name: "Split child", amount: 60, category_id: @category.id }
|
||||
])
|
||||
|
||||
zip_data = @exporter.generate_export
|
||||
|
||||
Zip::File.open_buffer(zip_data) do |zip|
|
||||
manifest = JSON.parse(zip.read("attachments.json"))
|
||||
attachment = manifest["attachments"].find { |item| item["filename"] == "split-parent-receipt.pdf" }
|
||||
|
||||
assert attachment
|
||||
assert_equal "Transaction", attachment["record_type"]
|
||||
assert_equal split_parent.entryable.id, attachment["record_id"]
|
||||
assert_equal split_parent.id, attachment["entry_id"]
|
||||
assert_equal @account.id, attachment["account_id"]
|
||||
end
|
||||
end
|
||||
|
||||
test "generates valid CSV files" do
|
||||
zip_data = @exporter.generate_export
|
||||
|
||||
@@ -215,9 +318,56 @@ class Family::DataExporterTest < ActiveSupport::TestCase
|
||||
assert_equal "set_transaction_category", actions[0]["action_type"]
|
||||
# Should export category name instead of UUID
|
||||
assert_equal "Test Category", actions[0]["value"]
|
||||
assert_equal({ "type" => "Category", "id" => @category.id, "name" => "Test Category" }, actions[0]["value_ref"])
|
||||
end
|
||||
end
|
||||
|
||||
test "exports rule condition value refs for mapped operands" do
|
||||
category_rule = @family.rules.build(
|
||||
name: "Category Condition Rule",
|
||||
resource_type: "transaction",
|
||||
active: true
|
||||
)
|
||||
category_rule.conditions.build(
|
||||
condition_type: "transaction_category",
|
||||
operator: "=",
|
||||
value: @category.id
|
||||
)
|
||||
category_rule.actions.build(action_type: "auto_categorize")
|
||||
category_rule.save!
|
||||
|
||||
zip_data = @exporter.generate_export
|
||||
|
||||
Zip::File.open_buffer(zip_data) do |zip|
|
||||
rule_data = zip.read("all.ndjson").split("\n").filter_map do |line|
|
||||
parsed = JSON.parse(line)
|
||||
parsed if parsed["type"] == "Rule" && parsed["data"]["name"] == "Category Condition Rule"
|
||||
end.first
|
||||
|
||||
condition = rule_data["data"]["conditions"].first
|
||||
assert_equal "Test Category", condition["value"]
|
||||
assert_equal({ "type" => "Category", "id" => @category.id, "name" => "Test Category" }, condition["value_ref"])
|
||||
end
|
||||
end
|
||||
|
||||
test "rule operand lookup skips name fallback for stale UUID values" do
|
||||
stale_uuid = SecureRandom.uuid
|
||||
relation = mock
|
||||
relation.expects(:find_by).with(id: stale_uuid).once.returns(nil)
|
||||
relation.expects(:find_by).with(name: stale_uuid).never
|
||||
|
||||
operand = @exporter.send(
|
||||
:rule_operand,
|
||||
stale_uuid,
|
||||
type: "Category",
|
||||
relation: relation,
|
||||
fallback_to_name: true
|
||||
)
|
||||
|
||||
assert_equal stale_uuid, operand[:value]
|
||||
assert_nil operand[:value_ref]
|
||||
end
|
||||
|
||||
test "exports rule actions and maps tag UUIDs to names" do
|
||||
# Create a rule with a tag action
|
||||
tag_rule = @family.rules.build(
|
||||
@@ -256,6 +406,7 @@ class Family::DataExporterTest < ActiveSupport::TestCase
|
||||
assert_equal "set_transaction_tags", actions[0]["action_type"]
|
||||
# Should export tag name instead of UUID
|
||||
assert_equal "Test Tag", actions[0]["value"]
|
||||
assert_equal({ "type" => "Tag", "id" => @tag.id, "name" => "Test Tag" }, actions[0]["value_ref"])
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -77,6 +77,176 @@ class Family::DataImporterTest < ActiveSupport::TestCase
|
||||
assert_equal "active", account.status
|
||||
end
|
||||
|
||||
test "imports raw balance history records" do
|
||||
ndjson = build_ndjson([
|
||||
{
|
||||
type: "Account",
|
||||
data: {
|
||||
id: "acct-1",
|
||||
name: "Balance History Checking",
|
||||
balance: "1200.00",
|
||||
currency: "USD",
|
||||
accountable_type: "Depository"
|
||||
}
|
||||
},
|
||||
{
|
||||
type: "Balance",
|
||||
data: {
|
||||
id: "balance-1",
|
||||
account_id: "acct-1",
|
||||
date: "2024-01-31",
|
||||
balance: "1200.00",
|
||||
currency: "USD",
|
||||
cash_balance: "1100.00",
|
||||
start_cash_balance: "1000.00",
|
||||
start_non_cash_balance: "0.00",
|
||||
cash_inflows: "300.00",
|
||||
cash_outflows: "200.00",
|
||||
non_cash_inflows: "0.00",
|
||||
non_cash_outflows: "0.00",
|
||||
net_market_flows: "0.00",
|
||||
cash_adjustments: "0.00",
|
||||
non_cash_adjustments: "0.00",
|
||||
flows_factor: 1
|
||||
}
|
||||
}
|
||||
])
|
||||
|
||||
Family::DataImporter.new(@family, ndjson).import!
|
||||
|
||||
account = @family.accounts.find_by!(name: "Balance History Checking")
|
||||
balance = account.balances.find_by!(date: Date.parse("2024-01-31"), currency: "USD")
|
||||
|
||||
assert_equal 1200.0, balance.balance.to_f
|
||||
assert_equal 1100.0, balance.cash_balance.to_f
|
||||
assert_equal 1000.0, balance.start_cash_balance.to_f
|
||||
assert_equal 300.0, balance.cash_inflows.to_f
|
||||
assert_equal 200.0, balance.cash_outflows.to_f
|
||||
assert_equal 1, balance.flows_factor
|
||||
end
|
||||
|
||||
test "imports duplicate raw balance records idempotently by account date and currency" do
|
||||
balance_record = {
|
||||
type: "Balance",
|
||||
data: {
|
||||
id: "balance-1",
|
||||
account_id: "acct-1",
|
||||
date: "2024-01-31",
|
||||
balance: "1200.00",
|
||||
currency: "USD",
|
||||
cash_balance: "1100.00",
|
||||
flows_factor: 1
|
||||
}
|
||||
}
|
||||
|
||||
ndjson = build_ndjson([
|
||||
{
|
||||
type: "Account",
|
||||
data: {
|
||||
id: "acct-1",
|
||||
name: "Idempotent Balance Checking",
|
||||
balance: "1200.00",
|
||||
currency: "USD",
|
||||
accountable_type: "Depository"
|
||||
}
|
||||
},
|
||||
balance_record,
|
||||
balance_record.deep_merge(data: { id: "balance-1-duplicate", balance: "1300.00", cash_balance: "1250.00" })
|
||||
])
|
||||
|
||||
Family::DataImporter.new(@family, ndjson).import!
|
||||
|
||||
account = @family.accounts.find_by!(name: "Idempotent Balance Checking")
|
||||
assert_equal 1, account.balances.where(date: Date.parse("2024-01-31"), currency: "USD").count
|
||||
|
||||
balance = account.balances.find_by!(date: Date.parse("2024-01-31"), currency: "USD")
|
||||
assert_equal 1300.0, balance.balance.to_f
|
||||
assert_equal 1250.0, balance.cash_balance.to_f
|
||||
end
|
||||
|
||||
test "preserves omitted raw balance components on duplicate records" do
|
||||
ndjson = build_ndjson([
|
||||
{
|
||||
type: "Account",
|
||||
data: {
|
||||
id: "acct-1",
|
||||
name: "Partial Balance Checking",
|
||||
balance: "1200.00",
|
||||
currency: "USD",
|
||||
accountable_type: "Depository"
|
||||
}
|
||||
},
|
||||
{
|
||||
type: "Balance",
|
||||
data: {
|
||||
id: "balance-1",
|
||||
account_id: "acct-1",
|
||||
date: "2024-01-31",
|
||||
balance: "1200.00",
|
||||
currency: "USD",
|
||||
cash_balance: "1100.00",
|
||||
cash_inflows: "300.00",
|
||||
cash_outflows: "200.00",
|
||||
flows_factor: -1
|
||||
}
|
||||
},
|
||||
{
|
||||
type: "Balance",
|
||||
data: {
|
||||
id: "balance-1-partial",
|
||||
account_id: "acct-1",
|
||||
date: "2024-01-31",
|
||||
balance: "1300.00",
|
||||
currency: "USD"
|
||||
}
|
||||
}
|
||||
])
|
||||
|
||||
Family::DataImporter.new(@family, ndjson).import!
|
||||
|
||||
account = @family.accounts.find_by!(name: "Partial Balance Checking")
|
||||
balance = account.balances.find_by!(date: Date.parse("2024-01-31"), currency: "USD")
|
||||
|
||||
assert_equal 1300.0, balance.balance.to_f
|
||||
assert_equal 1100.0, balance.cash_balance.to_f
|
||||
assert_equal 300.0, balance.cash_inflows.to_f
|
||||
assert_equal 200.0, balance.cash_outflows.to_f
|
||||
assert_equal(-1, balance.flows_factor)
|
||||
end
|
||||
|
||||
test "dates synthesized account opening balance before imported balance history" do
|
||||
ndjson = build_ndjson([
|
||||
{
|
||||
type: "Account",
|
||||
data: {
|
||||
id: "acct-1",
|
||||
name: "Balance Anchored Checking",
|
||||
balance: "500.00",
|
||||
currency: "USD",
|
||||
accountable_type: "Depository"
|
||||
}
|
||||
},
|
||||
{
|
||||
type: "Balance",
|
||||
data: {
|
||||
id: "balance-1",
|
||||
account_id: "acct-1",
|
||||
date: "2024-02-01",
|
||||
balance: "500.00",
|
||||
currency: "USD"
|
||||
}
|
||||
}
|
||||
])
|
||||
|
||||
Family::DataImporter.new(@family, ndjson).import!
|
||||
|
||||
account = @family.accounts.find_by!(name: "Balance Anchored Checking")
|
||||
opening_anchor = account.valuations.opening_anchor.first
|
||||
|
||||
assert_not_nil opening_anchor
|
||||
assert_equal Date.parse("2024-01-31"), opening_anchor.entry.date
|
||||
end
|
||||
|
||||
test "dates synthesized account opening balance before oldest imported activity" do
|
||||
ndjson = build_ndjson([
|
||||
{
|
||||
@@ -313,6 +483,85 @@ class Family::DataImporterTest < ActiveSupport::TestCase
|
||||
assert_equal(-89.99, recurring_transaction.expected_amount_avg.to_f)
|
||||
end
|
||||
|
||||
test "round trips recurring transaction export semantics" do
|
||||
source_family = Family.create!(name: "Recurring Source", currency: "USD")
|
||||
source_account = source_family.accounts.create!(
|
||||
name: "Source Checking",
|
||||
accountable: Depository.new,
|
||||
balance: 1000,
|
||||
currency: "USD"
|
||||
)
|
||||
source_merchant = source_family.merchants.create!(name: "Internet Provider")
|
||||
|
||||
source_family.recurring_transactions.create!(
|
||||
account: source_account,
|
||||
merchant: source_merchant,
|
||||
amount: -89.99,
|
||||
currency: "USD",
|
||||
expected_day_of_month: 14,
|
||||
last_occurrence_date: Date.parse("2024-01-14"),
|
||||
next_expected_date: Date.parse("2024-02-14"),
|
||||
status: "active",
|
||||
occurrence_count: 6,
|
||||
manual: true,
|
||||
expected_amount_min: -95,
|
||||
expected_amount_max: -85,
|
||||
expected_amount_avg: -89.99
|
||||
)
|
||||
|
||||
source_family.recurring_transactions.create!(
|
||||
name: "Quarterly Insurance",
|
||||
amount: 240,
|
||||
currency: "USD",
|
||||
expected_day_of_month: 28,
|
||||
last_occurrence_date: Date.parse("2024-01-28"),
|
||||
next_expected_date: Date.parse("2024-04-28"),
|
||||
status: "inactive",
|
||||
occurrence_count: 2,
|
||||
manual: false
|
||||
)
|
||||
|
||||
ndjson = nil
|
||||
Zip::File.open_buffer(Family::DataExporter.new(source_family).generate_export) do |zip|
|
||||
ndjson = zip.read("all.ndjson")
|
||||
end
|
||||
|
||||
assert_not_nil ndjson
|
||||
assert ndjson.include?('"type":"RecurringTransaction"')
|
||||
|
||||
Family::DataImporter.new(@family, ndjson).import!
|
||||
|
||||
assert_equal 2, @family.recurring_transactions.count
|
||||
|
||||
restored_account = @family.accounts.find_by!(name: "Source Checking")
|
||||
restored_merchant = @family.merchants.find_by!(name: "Internet Provider")
|
||||
restored_provider = @family.recurring_transactions.find_by!(merchant: restored_merchant)
|
||||
|
||||
assert_equal restored_account, restored_provider.account
|
||||
assert_equal(-89.99, restored_provider.amount.to_f)
|
||||
assert_equal "USD", restored_provider.currency
|
||||
assert_equal 14, restored_provider.expected_day_of_month
|
||||
assert_equal Date.parse("2024-01-14"), restored_provider.last_occurrence_date
|
||||
assert_equal Date.parse("2024-02-14"), restored_provider.next_expected_date
|
||||
assert_equal "active", restored_provider.status
|
||||
assert_equal 6, restored_provider.occurrence_count
|
||||
assert_equal true, restored_provider.manual
|
||||
assert_equal(-95.0, restored_provider.expected_amount_min.to_f)
|
||||
assert_equal(-85.0, restored_provider.expected_amount_max.to_f)
|
||||
assert_equal(-89.99, restored_provider.expected_amount_avg.to_f)
|
||||
|
||||
restored_named = @family.recurring_transactions.find_by!(name: "Quarterly Insurance")
|
||||
assert_nil restored_named.account
|
||||
assert_nil restored_named.merchant
|
||||
assert_equal 240.0, restored_named.amount.to_f
|
||||
assert_equal 28, restored_named.expected_day_of_month
|
||||
assert_equal Date.parse("2024-01-28"), restored_named.last_occurrence_date
|
||||
assert_equal Date.parse("2024-04-28"), restored_named.next_expected_date
|
||||
assert_equal "inactive", restored_named.status
|
||||
assert_equal 2, restored_named.occurrence_count
|
||||
assert_equal false, restored_named.manual
|
||||
end
|
||||
|
||||
test "imports recurring transactions with unknown status fallback" do
|
||||
ndjson = build_ndjson([
|
||||
{
|
||||
@@ -719,6 +968,54 @@ class Family::DataImporterTest < ActiveSupport::TestCase
|
||||
assert imported_holding.security_locked
|
||||
end
|
||||
|
||||
test "round trips raw balance history through full export" do
|
||||
source_family = Family.create!(
|
||||
name: "Source Balance Family",
|
||||
currency: "USD",
|
||||
locale: "en",
|
||||
date_format: "%Y-%m-%d"
|
||||
)
|
||||
source_account = source_family.accounts.create!(
|
||||
name: "Round Trip Balance Checking",
|
||||
accountable: Depository.new,
|
||||
balance: 1_500,
|
||||
currency: "USD"
|
||||
)
|
||||
source_account.balances.create!(
|
||||
date: Date.parse("2024-01-31"),
|
||||
balance: 1_500,
|
||||
cash_balance: 1_450,
|
||||
currency: "USD",
|
||||
start_cash_balance: 1_000,
|
||||
start_non_cash_balance: 0,
|
||||
cash_inflows: 700,
|
||||
cash_outflows: 250,
|
||||
non_cash_inflows: 0,
|
||||
non_cash_outflows: 0,
|
||||
net_market_flows: 0,
|
||||
cash_adjustments: 0,
|
||||
non_cash_adjustments: 0,
|
||||
flows_factor: 1
|
||||
)
|
||||
|
||||
zip_data = Family::DataExporter.new(source_family).generate_export
|
||||
ndjson = nil
|
||||
Zip::File.open_buffer(zip_data) do |zip|
|
||||
ndjson = zip.read("all.ndjson")
|
||||
end
|
||||
|
||||
Family::DataImporter.new(@family, ndjson).import!
|
||||
|
||||
imported_account = @family.accounts.find_by!(name: "Round Trip Balance Checking")
|
||||
imported_balance = imported_account.balances.find_by!(date: Date.parse("2024-01-31"), currency: "USD")
|
||||
|
||||
assert_equal 1500.0, imported_balance.balance.to_f
|
||||
assert_equal 1450.0, imported_balance.cash_balance.to_f
|
||||
assert_equal 1000.0, imported_balance.start_cash_balance.to_f
|
||||
assert_equal 700.0, imported_balance.cash_inflows.to_f
|
||||
assert_equal 250.0, imported_balance.cash_outflows.to_f
|
||||
end
|
||||
|
||||
test "imports holding snapshots with ticker fallback when exchange mic is missing" do
|
||||
existing_security = Security.create!(
|
||||
ticker: "VTI",
|
||||
@@ -1201,6 +1498,119 @@ class Family::DataImporterTest < ActiveSupport::TestCase
|
||||
assert_equal category.id, action.value
|
||||
end
|
||||
|
||||
test "imports rules from normalized operand value refs" do
|
||||
ndjson = build_ndjson([
|
||||
{
|
||||
type: "Rule",
|
||||
version: 1,
|
||||
data: {
|
||||
name: "Map Merchant To Dining",
|
||||
resource_type: "transaction",
|
||||
active: true,
|
||||
conditions: [
|
||||
{
|
||||
condition_type: "transaction_merchant",
|
||||
operator: "=",
|
||||
value_ref: {
|
||||
type: "Merchant",
|
||||
id: "source-merchant-id",
|
||||
name: "Coffee Bar"
|
||||
}
|
||||
}
|
||||
],
|
||||
actions: [
|
||||
{
|
||||
action_type: "set_transaction_category",
|
||||
value_ref: {
|
||||
type: "Category",
|
||||
id: "source-category-id",
|
||||
name: "Dining"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
])
|
||||
|
||||
importer = Family::DataImporter.new(@family, ndjson)
|
||||
importer.import!
|
||||
|
||||
rule = @family.rules.find_by!(name: "Map Merchant To Dining")
|
||||
merchant = @family.merchants.find_by!(name: "Coffee Bar")
|
||||
category = @family.categories.find_by!(name: "Dining")
|
||||
|
||||
assert_equal merchant.id, rule.conditions.first.value
|
||||
assert_equal category.id, rule.actions.first.value
|
||||
end
|
||||
|
||||
test "imports rule value refs when legacy operand values are stale UUIDs" do
|
||||
stale_merchant_id = SecureRandom.uuid
|
||||
stale_category_id = SecureRandom.uuid
|
||||
ndjson = build_ndjson([
|
||||
{
|
||||
type: "Rule",
|
||||
version: 1,
|
||||
data: {
|
||||
name: "Map Stale UUID Operands",
|
||||
resource_type: "transaction",
|
||||
active: true,
|
||||
conditions: [
|
||||
{
|
||||
condition_type: "transaction_merchant",
|
||||
operator: "=",
|
||||
value: stale_merchant_id,
|
||||
value_ref: {
|
||||
type: "Merchant",
|
||||
id: stale_merchant_id,
|
||||
name: "Coffee Bar"
|
||||
}
|
||||
}
|
||||
],
|
||||
actions: [
|
||||
{
|
||||
action_type: "set_transaction_category",
|
||||
value: stale_category_id,
|
||||
value_ref: {
|
||||
type: "Category",
|
||||
id: stale_category_id,
|
||||
name: "Dining"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
])
|
||||
|
||||
importer = Family::DataImporter.new(@family, ndjson)
|
||||
importer.import!
|
||||
|
||||
rule = @family.rules.find_by!(name: "Map Stale UUID Operands")
|
||||
merchant = @family.merchants.find_by!(name: "Coffee Bar")
|
||||
category = @family.categories.find_by!(name: "Dining")
|
||||
|
||||
assert_equal merchant.id, rule.conditions.first.value
|
||||
assert_equal category.id, rule.actions.first.value
|
||||
assert_not @family.merchants.exists?(name: stale_merchant_id)
|
||||
assert_not @family.categories.exists?(name: stale_category_id)
|
||||
end
|
||||
|
||||
test "preserves explicit false rule operand values" do
|
||||
importer = Family::DataImporter.new(@family, "")
|
||||
|
||||
value = importer.send(
|
||||
:rule_operand_value,
|
||||
{
|
||||
"value" => false,
|
||||
"value_ref" => {
|
||||
"type" => "Category",
|
||||
"name" => "Fallback"
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
assert_equal false, value
|
||||
end
|
||||
|
||||
test "imports rules with compound conditions" do
|
||||
ndjson = build_ndjson([
|
||||
{
|
||||
|
||||
@@ -10,6 +10,7 @@ class Family::SyncerTest < ActiveSupport::TestCase
|
||||
|
||||
manual_accounts_count = @family.accounts.manual.count
|
||||
plaid_items_count = @family.plaid_items.syncable.count
|
||||
brex_items_count = @family.brex_items.syncable.count
|
||||
binance_items_count = @family.binance_items.syncable.count
|
||||
|
||||
syncer = Family::Syncer.new(@family)
|
||||
@@ -24,6 +25,11 @@ class Family::SyncerTest < ActiveSupport::TestCase
|
||||
.with(parent_sync: family_sync, window_start_date: nil, window_end_date: nil)
|
||||
.times(plaid_items_count)
|
||||
|
||||
BrexItem.any_instance
|
||||
.expects(:sync_later)
|
||||
.with(parent_sync: family_sync, window_start_date: nil, window_end_date: nil)
|
||||
.times(brex_items_count)
|
||||
|
||||
BinanceItem.any_instance
|
||||
.expects(:sync_later)
|
||||
.with(parent_sync: family_sync, window_start_date: nil, window_end_date: nil)
|
||||
@@ -67,6 +73,7 @@ class Family::SyncerTest < ActiveSupport::TestCase
|
||||
LunchflowItem.any_instance.stubs(:sync_later)
|
||||
EnableBankingItem.any_instance.stubs(:sync_later)
|
||||
SophtronItem.any_instance.stubs(:sync_later)
|
||||
BrexItem.any_instance.stubs(:sync_later)
|
||||
BinanceItem.any_instance.stubs(:sync_later)
|
||||
|
||||
syncer.perform_sync(family_sync)
|
||||
|
||||
@@ -7,6 +7,7 @@ class Holding::MaterializerTest < ActiveSupport::TestCase
|
||||
@family = families(:empty)
|
||||
@account = @family.accounts.create!(name: "Test", balance: 20000, cash_balance: 20000, currency: "USD", accountable: Investment.new)
|
||||
@aapl = securities(:aapl)
|
||||
@msft = securities(:msft)
|
||||
end
|
||||
|
||||
test "syncs holdings" do
|
||||
@@ -123,4 +124,83 @@ class Holding::MaterializerTest < ActiveSupport::TestCase
|
||||
assert_equal BigDecimal("10"), yesterday_holding.qty
|
||||
assert_equal yesterday_holding.qty * yesterday_holding.price, yesterday_holding.amount
|
||||
end
|
||||
|
||||
test "cleans up calculated current-day holdings when a provider snapshot exists in another currency" do
|
||||
ExchangeRate.create!(from_currency: "EUR", to_currency: "USD", date: Date.current, rate: 1.2)
|
||||
|
||||
coinstats_item = @family.coinstats_items.create!(name: "CoinStats", api_key: "test-key")
|
||||
coinstats_account = coinstats_item.coinstats_accounts.create!(
|
||||
name: "Brokerage",
|
||||
currency: "USD"
|
||||
)
|
||||
account_provider = AccountProvider.create!(account: @account, provider: coinstats_account)
|
||||
|
||||
Holding.create!(
|
||||
account: @account,
|
||||
security: @aapl,
|
||||
qty: 10,
|
||||
price: 200,
|
||||
amount: 2000,
|
||||
currency: "EUR",
|
||||
date: Date.current,
|
||||
account_provider: account_provider,
|
||||
cost_basis: 150
|
||||
)
|
||||
|
||||
Holding::Materializer.new(@account, strategy: :reverse).materialize_holdings
|
||||
|
||||
today_holdings = @account.holdings.where(security: @aapl, date: Date.current).order(:currency)
|
||||
|
||||
assert_equal [ "EUR" ], today_holdings.pluck(:currency)
|
||||
assert_equal [ account_provider.id ], today_holdings.pluck(:account_provider_id)
|
||||
end
|
||||
|
||||
test "preserves same-day non-provider holdings for securities absent from the provider snapshot" do
|
||||
ExchangeRate.create!(from_currency: "EUR", to_currency: "USD", date: Date.current, rate: 1.2)
|
||||
|
||||
coinstats_item = @family.coinstats_items.create!(name: "CoinStats", api_key: "test-key")
|
||||
coinstats_account = coinstats_item.coinstats_accounts.create!(
|
||||
name: "Brokerage",
|
||||
currency: "USD"
|
||||
)
|
||||
account_provider = AccountProvider.create!(account: @account, provider: coinstats_account)
|
||||
|
||||
Holding.create!(
|
||||
account: @account,
|
||||
security: @aapl,
|
||||
qty: 10,
|
||||
price: 200,
|
||||
amount: 2000,
|
||||
currency: "EUR",
|
||||
date: Date.current,
|
||||
account_provider: account_provider,
|
||||
cost_basis: 150
|
||||
)
|
||||
|
||||
manual_holding = Holding.create!(
|
||||
account: @account,
|
||||
security: @msft,
|
||||
qty: 3,
|
||||
price: 250,
|
||||
amount: 750,
|
||||
currency: "USD",
|
||||
date: Date.current,
|
||||
cost_basis: 225,
|
||||
cost_basis_source: "manual",
|
||||
cost_basis_locked: true
|
||||
)
|
||||
|
||||
Holding::Materializer.new(@account, strategy: :reverse).materialize_holdings
|
||||
|
||||
assert_equal manual_holding.id, manual_holding.reload.id
|
||||
assert_equal @msft.id, manual_holding.security_id
|
||||
assert_nil manual_holding.account_provider_id
|
||||
|
||||
today_holdings = @account.holdings.where(date: Date.current)
|
||||
|
||||
assert_equal(
|
||||
[ [ @aapl.id, "EUR" ], [ @msft.id, "USD" ] ].sort,
|
||||
today_holdings.pluck(:security_id, :currency).sort
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -56,4 +56,40 @@ class Holding::PortfolioCacheTest < ActiveSupport::TestCase
|
||||
cache = Holding::PortfolioCache.new(@account, use_holdings: true)
|
||||
assert_equal holding.price, cache.get_price(@security.id, holding.date).price
|
||||
end
|
||||
|
||||
test "converts historical prices using the requested date exchange rate" do
|
||||
account = families(:empty).accounts.create!(
|
||||
name: "CHF Brokerage",
|
||||
balance: 10000,
|
||||
currency: "CHF",
|
||||
accountable: Investment.new
|
||||
)
|
||||
holding_date = 2.days.ago.to_date
|
||||
|
||||
ExchangeRate.create!(from_currency: "USD", to_currency: "CHF", date: holding_date, rate: 0.80)
|
||||
ExchangeRate.create!(from_currency: "USD", to_currency: "CHF", date: Date.current, rate: 0.95)
|
||||
|
||||
Holding.create!(
|
||||
security: @security,
|
||||
account: account,
|
||||
date: holding_date,
|
||||
qty: 1,
|
||||
price: 100,
|
||||
amount: 100,
|
||||
currency: "USD"
|
||||
)
|
||||
|
||||
Security::Price.create!(
|
||||
security: @security,
|
||||
date: holding_date,
|
||||
price: 100,
|
||||
currency: "USD"
|
||||
)
|
||||
|
||||
cache = Holding::PortfolioCache.new(account, use_holdings: true)
|
||||
converted_price = cache.get_price(@security.id, holding_date)
|
||||
|
||||
assert_equal BigDecimal("80.0"), converted_price.price
|
||||
assert_equal "CHF", converted_price.currency
|
||||
end
|
||||
end
|
||||
|
||||
@@ -20,6 +20,22 @@ class HoldingTest < ActiveSupport::TestCase
|
||||
assert_in_delta expected_nvda_weight, @nvda.weight, 0.001
|
||||
end
|
||||
|
||||
test "calculates portfolio weight after converting foreign-currency holdings" do
|
||||
ExchangeRate.create!(from_currency: "EUR", to_currency: "USD", date: Date.current, rate: 1.5)
|
||||
|
||||
foreign_security = Security.create!(ticker: "ASML", name: "ASML")
|
||||
foreign_holding = @account.holdings.create!(
|
||||
security: foreign_security,
|
||||
date: Date.current,
|
||||
qty: 1,
|
||||
price: 100,
|
||||
amount: 100,
|
||||
currency: "EUR"
|
||||
)
|
||||
|
||||
assert_in_delta 0.75, foreign_holding.weight, 0.001
|
||||
end
|
||||
|
||||
test "calculates average cost basis" do
|
||||
create_trade(@amzn.security, account: @account, qty: 10, price: 212.00, date: 1.day.ago.to_date)
|
||||
create_trade(@amzn.security, account: @account, qty: 15, price: 216.00, date: Date.current)
|
||||
|
||||
116
test/models/ibkr_account/historical_balances_sync_test.rb
Normal file
116
test/models/ibkr_account/historical_balances_sync_test.rb
Normal file
@@ -0,0 +1,116 @@
|
||||
require "test_helper"
|
||||
|
||||
class IbkrAccount::HistoricalBalancesSyncTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@family = families(:empty)
|
||||
@account = @family.accounts.create!(
|
||||
name: "IBKR Brokerage",
|
||||
balance: 0,
|
||||
cash_balance: 0,
|
||||
currency: "CHF",
|
||||
accountable: Investment.new(subtype: "brokerage")
|
||||
)
|
||||
@ibkr_account = @family.ibkr_items.create!(
|
||||
name: "IBKR",
|
||||
query_id: "QUERY123",
|
||||
token: "TOKEN123"
|
||||
).ibkr_accounts.create!(
|
||||
name: "Main",
|
||||
ibkr_account_id: "U1234567",
|
||||
currency: "CHF",
|
||||
current_balance: 3351,
|
||||
cash_balance: 1000.5,
|
||||
raw_equity_summary_payload: [
|
||||
{
|
||||
currency: "CHF",
|
||||
report_date: "2026-05-07",
|
||||
cash: "900.50",
|
||||
stock: "2300.50",
|
||||
total: "3201.00"
|
||||
},
|
||||
{
|
||||
currency: "CHF",
|
||||
report_date: "2026-05-08",
|
||||
cash: "1000.50",
|
||||
stock: "2350.50",
|
||||
total: "3351.00"
|
||||
}
|
||||
]
|
||||
)
|
||||
@ibkr_account.ensure_account_provider!(@account)
|
||||
end
|
||||
|
||||
test "upserts historical balances without creating activity entries" do
|
||||
@account.balances.create!(
|
||||
date: Date.new(2026, 5, 7),
|
||||
balance: 0,
|
||||
cash_balance: 0,
|
||||
currency: "CHF",
|
||||
start_cash_balance: 0,
|
||||
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
|
||||
)
|
||||
|
||||
assert_no_difference "@account.entries.count" do
|
||||
IbkrAccount::HistoricalBalancesSync.new(@ibkr_account).sync!
|
||||
end
|
||||
|
||||
first_balance = @account.balances.find_by!(date: Date.new(2026, 5, 7), currency: "CHF")
|
||||
second_balance = @account.balances.find_by!(date: Date.new(2026, 5, 8), currency: "CHF")
|
||||
|
||||
assert_equal BigDecimal("3201.0"), first_balance.end_balance
|
||||
assert_equal BigDecimal("900.5"), first_balance.end_cash_balance
|
||||
assert_equal BigDecimal("2300.5"), first_balance.end_non_cash_balance
|
||||
|
||||
assert_equal BigDecimal("3351.0"), second_balance.end_balance
|
||||
assert_equal BigDecimal("1000.5"), second_balance.end_cash_balance
|
||||
assert_equal BigDecimal("2350.5"), second_balance.end_non_cash_balance
|
||||
assert_equal BigDecimal("900.5"), second_balance.start_cash_balance
|
||||
assert_equal BigDecimal("2300.5"), second_balance.start_non_cash_balance
|
||||
end
|
||||
|
||||
test "accepts equity summary rows when stored account currency casing differs" do
|
||||
@ibkr_account.update!(currency: "chf")
|
||||
|
||||
IbkrAccount::HistoricalBalancesSync.new(@ibkr_account).sync!
|
||||
|
||||
first_balance = @account.balances.find_by!(date: Date.new(2026, 5, 7), currency: "CHF")
|
||||
second_balance = @account.balances.find_by!(date: Date.new(2026, 5, 8), currency: "CHF")
|
||||
|
||||
assert_equal BigDecimal("3201.0"), first_balance.end_balance
|
||||
assert_equal BigDecimal("3351.0"), second_balance.end_balance
|
||||
end
|
||||
|
||||
test "skips malformed equity summary rows and still imports valid rows" do
|
||||
@ibkr_account.update!(
|
||||
raw_equity_summary_payload: [
|
||||
nil,
|
||||
"bad-row",
|
||||
[],
|
||||
{
|
||||
currency: "CHF",
|
||||
report_date: "2026-05-09",
|
||||
cash: "1100.50",
|
||||
total: "3400.00"
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
assert_nothing_raised do
|
||||
IbkrAccount::HistoricalBalancesSync.new(@ibkr_account).sync!
|
||||
end
|
||||
|
||||
balance = @account.balances.find_by!(date: Date.new(2026, 5, 9), currency: "CHF")
|
||||
|
||||
assert_equal BigDecimal("3400.0"), balance.end_balance
|
||||
assert_equal BigDecimal("1100.5"), balance.end_cash_balance
|
||||
assert_equal BigDecimal("2299.5"), balance.end_non_cash_balance
|
||||
end
|
||||
end
|
||||
281
test/models/ibkr_account_processor_test.rb
Normal file
281
test/models/ibkr_account_processor_test.rb
Normal file
@@ -0,0 +1,281 @@
|
||||
require "test_helper"
|
||||
|
||||
class IbkrAccountProcessorTest < ActiveSupport::TestCase
|
||||
fixtures :families, :ibkr_items, :ibkr_accounts, :accounts, :securities
|
||||
|
||||
setup do
|
||||
@family = families(:dylan_family)
|
||||
@ibkr_account = ibkr_accounts(:main_account)
|
||||
|
||||
@account = @family.accounts.create!(
|
||||
name: "IBKR Investment",
|
||||
balance: 0,
|
||||
cash_balance: 0,
|
||||
currency: "CHF",
|
||||
accountable: Investment.new(subtype: "brokerage")
|
||||
)
|
||||
@ibkr_account.ensure_account_provider!(@account)
|
||||
@ibkr_account.update!(
|
||||
raw_holdings_payload: [
|
||||
{
|
||||
"asset_category" => "STK",
|
||||
"conid" => "265598",
|
||||
"security_id" => "US0378331005",
|
||||
"security_id_type" => "ISIN",
|
||||
"symbol" => securities(:aapl).ticker,
|
||||
"position" => "10",
|
||||
"mark_price" => "150.00",
|
||||
"currency" => "USD",
|
||||
"fx_rate_to_base" => "0.90",
|
||||
"cost_basis_price" => "125.50",
|
||||
"report_date" => Date.current.to_s,
|
||||
"side" => "Long"
|
||||
}
|
||||
],
|
||||
raw_activities_payload: {
|
||||
trades: [
|
||||
{
|
||||
"asset_category" => "STK",
|
||||
"trade_id" => "1001",
|
||||
"transaction_id" => "1001a",
|
||||
"conid" => "265598",
|
||||
"symbol" => securities(:aapl).ticker,
|
||||
"quantity" => "2",
|
||||
"trade_price" => "140.00",
|
||||
"currency" => "USD",
|
||||
"fx_rate_to_base" => "0.90",
|
||||
"buy_sell" => "BUY",
|
||||
"trade_date" => Date.current.to_s,
|
||||
"ib_commission" => "-1.25",
|
||||
"ib_commission_currency" => "USD"
|
||||
},
|
||||
{
|
||||
"asset_category" => "STK",
|
||||
"trade_id" => "1002",
|
||||
"transaction_id" => "1002a",
|
||||
"conid" => "265598",
|
||||
"symbol" => securities(:aapl).ticker,
|
||||
"quantity" => "-1",
|
||||
"trade_price" => "155.00",
|
||||
"currency" => "USD",
|
||||
"fx_rate_to_base" => "0.92",
|
||||
"buy_sell" => "SELL",
|
||||
"trade_date" => Date.current.to_s,
|
||||
"ib_commission" => "-1.10",
|
||||
"ib_commission_currency" => "USD"
|
||||
}
|
||||
],
|
||||
cash_transactions: [
|
||||
{
|
||||
"transaction_id" => "4001",
|
||||
"type" => "Deposits/Withdrawals",
|
||||
"amount" => "500.00",
|
||||
"currency" => "CHF",
|
||||
"fx_rate_to_base" => "1",
|
||||
"report_date" => Date.current.to_s
|
||||
},
|
||||
{
|
||||
"transaction_id" => "4002",
|
||||
"type" => "Dividends",
|
||||
"amount" => "2.50",
|
||||
"currency" => "USD",
|
||||
"fx_rate_to_base" => "0.91",
|
||||
"report_date" => Date.current.to_s,
|
||||
"conid" => "265598"
|
||||
}
|
||||
]
|
||||
},
|
||||
report_date: Date.current,
|
||||
current_balance: BigDecimal("3351.00"),
|
||||
cash_balance: BigDecimal("1000.50"),
|
||||
currency: "CHF"
|
||||
)
|
||||
end
|
||||
|
||||
test "processor imports holdings, trades, cash transactions, and commissions" do
|
||||
IbkrAccount::Processor.new(@ibkr_account).process
|
||||
|
||||
@account.reload
|
||||
assert_equal BigDecimal("3351.00"), @account.balance
|
||||
assert_equal BigDecimal("1000.50"), @account.cash_balance
|
||||
assert_equal "CHF", @account.currency
|
||||
|
||||
holding = @account.holdings.find_by(security: securities(:aapl), date: Date.current)
|
||||
assert_not_nil holding
|
||||
assert_equal BigDecimal("10"), holding.qty
|
||||
assert_equal BigDecimal("150.00"), holding.price
|
||||
assert_equal BigDecimal("125.50"), holding.cost_basis
|
||||
assert_equal "USD", holding.currency
|
||||
|
||||
buy_trade = @account.entries.find_by(external_id: "ibkr_trade_1001")
|
||||
sell_trade = @account.entries.find_by(external_id: "ibkr_trade_1002")
|
||||
assert_not_nil buy_trade
|
||||
assert_not_nil sell_trade
|
||||
assert_equal "Buy", buy_trade.entryable.investment_activity_label
|
||||
assert_equal "Sell", sell_trade.entryable.investment_activity_label
|
||||
assert_equal BigDecimal("2"), buy_trade.entryable.qty
|
||||
assert_equal BigDecimal("-1"), sell_trade.entryable.qty
|
||||
assert_equal BigDecimal("280.0"), buy_trade.amount
|
||||
assert_equal BigDecimal("-155.0"), sell_trade.amount
|
||||
assert_equal "USD", buy_trade.currency
|
||||
assert_equal "USD", sell_trade.currency
|
||||
assert_equal 0.9, buy_trade.entryable.exchange_rate
|
||||
assert_equal 0.92, sell_trade.entryable.exchange_rate
|
||||
|
||||
dividend = @account.entries.find_by(external_id: "ibkr_cash_4002")
|
||||
assert_not_nil dividend
|
||||
assert_equal "Dividend", dividend.entryable.investment_activity_label
|
||||
assert_equal BigDecimal("-2.5"), dividend.amount
|
||||
assert_equal securities(:aapl).id, dividend.entryable.extra["security_id"]
|
||||
|
||||
commission_one = @account.entries.find_by(external_id: "ibkr_trade_fee_1001")
|
||||
commission_two = @account.entries.find_by(external_id: "ibkr_trade_fee_1002")
|
||||
assert_not_nil commission_one
|
||||
assert_not_nil commission_two
|
||||
assert_equal BigDecimal("1.25"), commission_one.amount
|
||||
assert_equal BigDecimal("1.1"), commission_two.amount
|
||||
assert_equal "USD", commission_one.currency
|
||||
assert_equal "USD", commission_two.currency
|
||||
assert_equal securities(:aapl).id, commission_one.entryable.extra["security_id"]
|
||||
assert_equal securities(:aapl).id, commission_two.entryable.extra["security_id"]
|
||||
|
||||
deposit = @account.entries.find_by(external_id: "ibkr_cash_4001")
|
||||
|
||||
assert_not_nil deposit
|
||||
assert_equal "Contribution", deposit.entryable.investment_activity_label
|
||||
assert_equal BigDecimal("-500"), deposit.amount
|
||||
assert_equal "CHF", deposit.currency
|
||||
|
||||
assert_equal "USD", dividend.currency
|
||||
end
|
||||
|
||||
test "processor computes weighted provider cost basis for grouped lots" do
|
||||
@ibkr_account.update!(
|
||||
raw_holdings_payload: [
|
||||
{
|
||||
"asset_category" => "STK",
|
||||
"conid" => "265598",
|
||||
"security_id" => "US0378331005",
|
||||
"security_id_type" => "ISIN",
|
||||
"symbol" => securities(:aapl).ticker,
|
||||
"position" => "10",
|
||||
"mark_price" => "150.00",
|
||||
"currency" => "USD",
|
||||
"fx_rate_to_base" => "0.90",
|
||||
"cost_basis_price" => "125.50",
|
||||
"report_date" => Date.current.to_s,
|
||||
"side" => "Long"
|
||||
},
|
||||
{
|
||||
"asset_category" => "STK",
|
||||
"conid" => "265598",
|
||||
"security_id" => "US0378331005",
|
||||
"security_id_type" => "ISIN",
|
||||
"symbol" => securities(:aapl).ticker,
|
||||
"position" => "20",
|
||||
"mark_price" => "150.00",
|
||||
"currency" => "USD",
|
||||
"fx_rate_to_base" => "0.90",
|
||||
"cost_basis_price" => "122.00",
|
||||
"report_date" => Date.current.to_s,
|
||||
"side" => "Long"
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
IbkrAccount::Processor.new(@ibkr_account).process
|
||||
|
||||
holding = @account.holdings.find_by(security: securities(:aapl), date: Date.current)
|
||||
|
||||
assert_not_nil holding
|
||||
assert_equal BigDecimal("30"), holding.qty
|
||||
assert_equal BigDecimal("123.1667"), holding.cost_basis
|
||||
end
|
||||
|
||||
test "processor repairs default opening anchor after importing activity entries" do
|
||||
result = Account::OpeningBalanceManager.new(@account).set_opening_balance(
|
||||
balance: @ibkr_account.current_balance,
|
||||
date: 2.years.ago.to_date
|
||||
)
|
||||
|
||||
assert result.success?
|
||||
|
||||
opening_anchor = @account.valuations.opening_anchor.includes(:entry).first
|
||||
assert_not_nil opening_anchor
|
||||
assert_equal @ibkr_account.current_balance.to_d, opening_anchor.entry.amount.to_d
|
||||
|
||||
IbkrAccount::Processor.new(@ibkr_account).process
|
||||
|
||||
opening_anchor.reload
|
||||
assert_equal BigDecimal("0"), opening_anchor.entry.amount.to_d
|
||||
end
|
||||
|
||||
test "processor imports commission-free trades without creating fee entries" do
|
||||
@ibkr_account.update!(
|
||||
raw_activities_payload: {
|
||||
trades: [
|
||||
{
|
||||
"asset_category" => "STK",
|
||||
"trade_id" => "1003",
|
||||
"transaction_id" => "1003a",
|
||||
"conid" => "265598",
|
||||
"symbol" => securities(:aapl).ticker,
|
||||
"quantity" => "3",
|
||||
"trade_price" => "145.00",
|
||||
"currency" => "USD",
|
||||
"fx_rate_to_base" => "0.91",
|
||||
"buy_sell" => "BUY",
|
||||
"trade_date" => Date.current.to_s
|
||||
}
|
||||
],
|
||||
cash_transactions: []
|
||||
}
|
||||
)
|
||||
|
||||
IbkrAccount::Processor.new(@ibkr_account).process
|
||||
|
||||
trade = @account.entries.find_by(external_id: "ibkr_trade_1003")
|
||||
fee = @account.entries.find_by(external_id: "ibkr_trade_fee_1003")
|
||||
|
||||
assert_not_nil trade
|
||||
assert_equal BigDecimal("3"), trade.entryable.qty
|
||||
assert_equal BigDecimal("435.0"), trade.amount
|
||||
assert_equal "USD", trade.currency
|
||||
assert_nil fee
|
||||
end
|
||||
|
||||
test "processor logs and falls back to current date for invalid trade_date" do
|
||||
@ibkr_account.update!(
|
||||
raw_activities_payload: {
|
||||
trades: [
|
||||
{
|
||||
"asset_category" => "STK",
|
||||
"trade_id" => "1004",
|
||||
"transaction_id" => "1004a",
|
||||
"conid" => "265598",
|
||||
"symbol" => securities(:aapl).ticker,
|
||||
"quantity" => "1",
|
||||
"trade_price" => "146.00",
|
||||
"currency" => "USD",
|
||||
"fx_rate_to_base" => "0.91",
|
||||
"buy_sell" => "BUY",
|
||||
"trade_date" => "not-a-date"
|
||||
}
|
||||
],
|
||||
cash_transactions: []
|
||||
}
|
||||
)
|
||||
|
||||
Rails.logger.expects(:warn).with do |message|
|
||||
message.include?("IbkrAccount::DataHelpers - Missing or invalid trade_date") &&
|
||||
message.include?("1004")
|
||||
end
|
||||
|
||||
IbkrAccount::Processor.new(@ibkr_account).process
|
||||
|
||||
trade = @account.entries.find_by(external_id: "ibkr_trade_1004")
|
||||
|
||||
assert_not_nil trade
|
||||
assert_equal Date.current, trade.date
|
||||
end
|
||||
end
|
||||
23
test/models/ibkr_item/sync_complete_event_test.rb
Normal file
23
test/models/ibkr_item/sync_complete_event_test.rb
Normal file
@@ -0,0 +1,23 @@
|
||||
require "test_helper"
|
||||
|
||||
class IbkrItem::SyncCompleteEventTest < ActiveSupport::TestCase
|
||||
fixtures :families, :ibkr_items
|
||||
|
||||
test "broadcast refreshes linked accounts, provider item, and family stream" do
|
||||
ibkr_item = ibkr_items(:configured_item)
|
||||
family = ibkr_item.family
|
||||
account = mock("account")
|
||||
|
||||
ibkr_item.stubs(:accounts).returns([ account ])
|
||||
account.expects(:broadcast_sync_complete).once
|
||||
ibkr_item.expects(:broadcast_replace_to).with(
|
||||
family,
|
||||
target: "ibkr_item_#{ibkr_item.id}",
|
||||
partial: "ibkr_items/ibkr_item",
|
||||
locals: { ibkr_item: ibkr_item }
|
||||
).once
|
||||
family.expects(:broadcast_sync_complete).once
|
||||
|
||||
IbkrItem::SyncCompleteEvent.new(ibkr_item).broadcast
|
||||
end
|
||||
end
|
||||
26
test/models/ibkr_item/syncer_test.rb
Normal file
26
test/models/ibkr_item/syncer_test.rb
Normal file
@@ -0,0 +1,26 @@
|
||||
require "test_helper"
|
||||
|
||||
class IbkrItem::SyncerTest < ActiveSupport::TestCase
|
||||
fixtures :families, :ibkr_items
|
||||
|
||||
setup do
|
||||
@ibkr_item = ibkr_items(:configured_item)
|
||||
end
|
||||
|
||||
test "perform_sync records a single auth error when credentials are missing" do
|
||||
@ibkr_item.update!(token: nil)
|
||||
syncer = IbkrItem::Syncer.new(@ibkr_item)
|
||||
sync = @ibkr_item.syncs.create!
|
||||
|
||||
error = assert_raises(Provider::IbkrFlex::ConfigurationError) do
|
||||
syncer.perform_sync(sync)
|
||||
end
|
||||
|
||||
assert_equal "IBKR credentials are missing.", error.message
|
||||
assert_equal "requires_update", @ibkr_item.reload.status
|
||||
|
||||
stats = sync.reload.sync_stats
|
||||
assert_equal 1, stats["total_errors"]
|
||||
assert_equal [ { "message" => "IBKR credentials are missing.", "category" => "auth_error" } ], stats["errors"]
|
||||
end
|
||||
end
|
||||
42
test/models/ibkr_item_importer_test.rb
Normal file
42
test/models/ibkr_item_importer_test.rb
Normal file
@@ -0,0 +1,42 @@
|
||||
require "test_helper"
|
||||
|
||||
class IbkrItemImporterTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@family = families(:empty)
|
||||
@ibkr_item = @family.ibkr_items.create!(
|
||||
name: "Interactive Brokers",
|
||||
query_id: "QUERY123",
|
||||
token: "TOKEN123"
|
||||
)
|
||||
end
|
||||
|
||||
test "imports accounts from parsed flex statement" do
|
||||
provider = mock("ibkr_provider")
|
||||
provider.expects(:download_statement).returns(file_fixture("ibkr/flex_statement.xml").read)
|
||||
|
||||
assert_difference "IbkrAccount.count", 2 do
|
||||
result = IbkrItem::Importer.new(@ibkr_item, ibkr_provider: provider).import
|
||||
assert_equal true, result[:success]
|
||||
assert_equal 2, result[:accounts_imported]
|
||||
end
|
||||
|
||||
primary_account = @ibkr_item.ibkr_accounts.find_by!(ibkr_account_id: "U1234567")
|
||||
assert_equal "CHF", primary_account.currency
|
||||
assert_equal BigDecimal("3351.0"), primary_account.current_balance
|
||||
assert_equal 2, primary_account.raw_equity_summary_payload.size
|
||||
assert_equal 1, primary_account.raw_holdings_payload.size
|
||||
assert_equal 2, primary_account.raw_activities_payload["trades"].size
|
||||
assert_equal 2, primary_account.raw_activities_payload["cash_transactions"].size
|
||||
end
|
||||
|
||||
test "raises parse error for malformed flex statement xml" do
|
||||
provider = mock("ibkr_provider")
|
||||
provider.expects(:download_statement).returns("<FlexQueryResponse><FlexStatement>")
|
||||
|
||||
error = assert_raises(IbkrItem::ReportParser::ParseError) do
|
||||
IbkrItem::Importer.new(@ibkr_item, ibkr_provider: provider).import
|
||||
end
|
||||
|
||||
assert_match "Invalid IBKR Flex XML", error.message
|
||||
end
|
||||
end
|
||||
58
test/models/ibkr_item_report_parser_test.rb
Normal file
58
test/models/ibkr_item_report_parser_test.rb
Normal file
@@ -0,0 +1,58 @@
|
||||
require "test_helper"
|
||||
|
||||
class IbkrItemReportParserTest < ActiveSupport::TestCase
|
||||
test "parses accounts, balances, and positions from flex xml" do
|
||||
parsed = IbkrItem::ReportParser.new(file_fixture("ibkr/flex_statement.xml").read).parse
|
||||
|
||||
assert_equal "Sure Test", parsed[:metadata]["query_name"]
|
||||
assert_equal 2, parsed[:accounts].size
|
||||
|
||||
first_account = parsed[:accounts].first
|
||||
assert_equal "U1234567", first_account[:ibkr_account_id]
|
||||
assert_equal "CHF", first_account[:currency]
|
||||
assert_equal BigDecimal("1000.50"), first_account[:cash_balance]
|
||||
assert_equal BigDecimal("3351.00"), first_account[:current_balance]
|
||||
assert_equal 2, first_account[:equity_summary_in_base].size
|
||||
assert_equal 1, first_account[:open_positions].size
|
||||
assert_equal 2, first_account[:trades].size
|
||||
assert_equal 2, first_account[:cash_transactions].size
|
||||
|
||||
second_account = parsed[:accounts].second
|
||||
assert_equal "U7654321", second_account[:ibkr_account_id]
|
||||
assert_equal BigDecimal("250"), second_account[:cash_balance]
|
||||
assert_equal BigDecimal("250"), second_account[:current_balance]
|
||||
assert_equal 1, second_account[:equity_summary_in_base].size
|
||||
end
|
||||
|
||||
test "raises parse error for malformed xml" do
|
||||
error = assert_raises(IbkrItem::ReportParser::ParseError) do
|
||||
IbkrItem::ReportParser.new("<FlexQueryResponse><FlexStatement>").parse
|
||||
end
|
||||
|
||||
assert_match "Invalid IBKR Flex XML", error.message
|
||||
end
|
||||
|
||||
test "raises parse error when flex statements are missing" do
|
||||
error = assert_raises(IbkrItem::ReportParser::ParseError) do
|
||||
IbkrItem::ReportParser.new('<FlexQueryResponse queryName="Sure Test" />').parse
|
||||
end
|
||||
|
||||
assert_equal "Invalid IBKR Flex XML: no FlexStatement nodes found.", error.message
|
||||
end
|
||||
|
||||
test "raises parse error when flex statement account id is missing" do
|
||||
xml = <<~XML
|
||||
<FlexQueryResponse queryName="Sure Test">
|
||||
<FlexStatement>
|
||||
<AccountInformation currency="CHF" />
|
||||
</FlexStatement>
|
||||
</FlexQueryResponse>
|
||||
XML
|
||||
|
||||
error = assert_raises(IbkrItem::ReportParser::ParseError) do
|
||||
IbkrItem::ReportParser.new(xml).parse
|
||||
end
|
||||
|
||||
assert_equal "Invalid IBKR Flex XML: missing account identifier in FlexStatement.", error.message
|
||||
end
|
||||
end
|
||||
20
test/models/ibkr_item_test.rb
Normal file
20
test/models/ibkr_item_test.rb
Normal file
@@ -0,0 +1,20 @@
|
||||
require "test_helper"
|
||||
|
||||
class IbkrItemTest < ActiveSupport::TestCase
|
||||
fixtures :families, :ibkr_items
|
||||
|
||||
test "syncable excludes items without token" do
|
||||
item = IbkrItem.create!(
|
||||
family: families(:empty),
|
||||
name: "Interactive Brokers",
|
||||
query_id: "QUERYNEW",
|
||||
token: "TOKENNEW"
|
||||
)
|
||||
|
||||
item.token = nil
|
||||
item.save!(validate: false)
|
||||
|
||||
assert_includes IbkrItem.syncable, ibkr_items(:configured_item)
|
||||
refute_includes IbkrItem.syncable, item
|
||||
end
|
||||
end
|
||||
28
test/models/kraken_account/asset_normalizer_test.rb
Normal file
28
test/models/kraken_account/asset_normalizer_test.rb
Normal file
@@ -0,0 +1,28 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "test_helper"
|
||||
|
||||
class KrakenAccount::AssetNormalizerTest < ActiveSupport::TestCase
|
||||
test "normalizes kraken symbols through metadata and fallbacks" do
|
||||
normalizer = KrakenAccount::AssetNormalizer.new(
|
||||
"XXBT" => { "altname" => "XBT" },
|
||||
"XETH" => { "altname" => "ETH" },
|
||||
"ZUSD" => { "altname" => "USD" }
|
||||
)
|
||||
|
||||
assert_equal "BTC", normalizer.normalize("XXBT")[:symbol]
|
||||
assert_equal "ETH", normalizer.normalize("XETH")[:symbol]
|
||||
assert_equal "USD", normalizer.normalize("ZUSD")[:symbol]
|
||||
end
|
||||
|
||||
test "preserves kraken suffix variants while pricing base asset" do
|
||||
normalizer = KrakenAccount::AssetNormalizer.new("XETH" => { "altname" => "ETH" })
|
||||
|
||||
parsed = normalizer.normalize("XETH.F")
|
||||
|
||||
assert_equal "ETH.F", parsed[:symbol]
|
||||
assert_equal "ETH", parsed[:price_symbol]
|
||||
assert_equal ".F", parsed[:suffix]
|
||||
assert_equal "XETH", parsed[:raw_base]
|
||||
end
|
||||
end
|
||||
100
test/models/kraken_account/holdings_processor_test.rb
Normal file
100
test/models/kraken_account/holdings_processor_test.rb
Normal file
@@ -0,0 +1,100 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "test_helper"
|
||||
|
||||
class KrakenAccount::HoldingsProcessorTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@family = families(:dylan_family)
|
||||
@family.update!(currency: "USD")
|
||||
@item = KrakenItem.create!(
|
||||
family: @family,
|
||||
name: "Kraken",
|
||||
api_key: "k",
|
||||
api_secret: "s"
|
||||
)
|
||||
@kraken_account = @item.kraken_accounts.create!(
|
||||
name: "Kraken",
|
||||
account_id: "combined",
|
||||
account_type: "combined",
|
||||
currency: "USD",
|
||||
current_balance: 30_000,
|
||||
raw_payload: {
|
||||
"assets" => [
|
||||
{ "symbol" => "BTC", "price_symbol" => "BTC", "balance" => "0.5", "price_usd" => "60000.0", "source" => "spot" }
|
||||
]
|
||||
}
|
||||
)
|
||||
@account = Account.create!(
|
||||
family: @family,
|
||||
name: "Kraken",
|
||||
balance: 0,
|
||||
currency: "USD",
|
||||
accountable: Crypto.create!(subtype: "exchange")
|
||||
)
|
||||
@account_provider = AccountProvider.create!(account: @account, provider: @kraken_account)
|
||||
@security = Security.create!(ticker: "CRYPTO:BTC", name: "BTC", exchange_operating_mic: "XKRA", offline: true)
|
||||
KrakenAccount::SecurityResolver.stubs(:resolve).returns(@security)
|
||||
end
|
||||
|
||||
test "imports holdings with account_provider_id" do
|
||||
import_adapter = mock
|
||||
import_adapter.expects(:import_holding).with(
|
||||
has_entries(
|
||||
security: @security,
|
||||
quantity: 0.5.to_d,
|
||||
amount: 30_000.to_d,
|
||||
currency: "USD",
|
||||
price: 60_000.to_d,
|
||||
external_id: "kraken_BTC_spot_#{Date.current}",
|
||||
account_provider_id: @account_provider.id,
|
||||
source: "kraken"
|
||||
)
|
||||
)
|
||||
Account::ProviderImportAdapter.stubs(:new).returns(import_adapter)
|
||||
|
||||
KrakenAccount::HoldingsProcessor.new(@kraken_account).process
|
||||
end
|
||||
|
||||
test "does not overwrite a different provider holding with the same security/date/currency" do
|
||||
binance_item = BinanceItem.create!(family: @family, name: "Binance", api_key: "b", api_secret: "s")
|
||||
binance_account = binance_item.binance_accounts.create!(name: "Binance", account_type: "combined", currency: "USD")
|
||||
binance_provider = AccountProvider.create!(account: @account, provider: binance_account)
|
||||
existing = @account.holdings.create!(
|
||||
security: @security,
|
||||
qty: 0.25,
|
||||
amount: 15_000,
|
||||
currency: "USD",
|
||||
date: Date.current,
|
||||
price: 60_000,
|
||||
account_provider_id: binance_provider.id
|
||||
)
|
||||
|
||||
assert_no_difference -> { @account.holdings.count } do
|
||||
KrakenAccount::HoldingsProcessor.new(@kraken_account).process
|
||||
end
|
||||
|
||||
assert_equal binance_provider.id, existing.reload.account_provider_id
|
||||
assert_nil existing.external_id
|
||||
assert_nil @account.holdings.find_by(external_id: "kraken_BTC_spot_#{Date.current}")
|
||||
end
|
||||
|
||||
test "does not log raw asset payloads when holding import fails" do
|
||||
raw_asset = {
|
||||
"symbol" => "BTC",
|
||||
"price_symbol" => "BTC",
|
||||
"balance" => "0.5",
|
||||
"price_usd" => "60000.0",
|
||||
"source" => "spot",
|
||||
"account_balance_detail" => "sensitive payload"
|
||||
}
|
||||
@kraken_account.update!(raw_payload: { "assets" => [ raw_asset ] })
|
||||
failing_adapter = mock
|
||||
failing_adapter.stubs(:import_holding).raises(StandardError, "boom")
|
||||
Account::ProviderImportAdapter.stubs(:new).returns(failing_adapter)
|
||||
|
||||
Rails.logger.expects(:error)
|
||||
.with("KrakenAccount::HoldingsProcessor - failed asset symbol=BTC: boom")
|
||||
|
||||
KrakenAccount::HoldingsProcessor.new(@kraken_account).process
|
||||
end
|
||||
end
|
||||
100
test/models/kraken_account/processor_test.rb
Normal file
100
test/models/kraken_account/processor_test.rb
Normal file
@@ -0,0 +1,100 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "test_helper"
|
||||
|
||||
class KrakenAccount::ProcessorTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@family = families(:dylan_family)
|
||||
@family.update!(currency: "USD")
|
||||
@item = KrakenItem.create!(family: @family, name: "Kraken", api_key: "k", api_secret: "s")
|
||||
@kraken_account = @item.kraken_accounts.create!(
|
||||
name: "Kraken",
|
||||
account_id: "combined",
|
||||
account_type: "combined",
|
||||
currency: "USD",
|
||||
current_balance: 1000,
|
||||
raw_payload: {
|
||||
"asset_metadata" => {
|
||||
"XXBT" => { "altname" => "XBT" },
|
||||
"ZUSD" => { "altname" => "USD" }
|
||||
},
|
||||
"pair_metadata" => {
|
||||
"XXBTZUSD" => { "altname" => "XBTUSD", "base" => "XXBT", "quote" => "ZUSD" }
|
||||
}
|
||||
},
|
||||
raw_transactions_payload: {
|
||||
"trades" => {
|
||||
"buy_tx" => trade_payload("buy", "0.001", "50.00", "0.10"),
|
||||
"sell_tx" => trade_payload("sell", "0.002", "120.00", "0.20")
|
||||
}
|
||||
}
|
||||
)
|
||||
@account = Account.create!(
|
||||
family: @family,
|
||||
name: "Kraken",
|
||||
balance: 0,
|
||||
currency: "USD",
|
||||
accountable: Crypto.create!(subtype: "exchange")
|
||||
)
|
||||
AccountProvider.create!(account: @account, provider: @kraken_account)
|
||||
@security = Security.create!(ticker: "CRYPTO:BTC", name: "BTC", exchange_operating_mic: "XKRA", offline: true)
|
||||
KrakenAccount::SecurityResolver.stubs(:resolve).returns(@security)
|
||||
KrakenAccount::HoldingsProcessor.any_instance.stubs(:process).returns(nil)
|
||||
end
|
||||
|
||||
test "imports buy and sell spot fills as trade entries" do
|
||||
assert_difference -> { @account.entries.where(source: "kraken").count }, 2 do
|
||||
KrakenAccount::Processor.new(@kraken_account).process
|
||||
end
|
||||
|
||||
buy = @account.entries.find_by!(external_id: "kraken_trade_buy_tx", source: "kraken")
|
||||
assert_equal(-50.to_d, buy.amount)
|
||||
assert_equal "USD", buy.currency
|
||||
assert_equal 0.001.to_d, buy.trade.qty
|
||||
assert_equal 50_000.to_d, buy.trade.price
|
||||
assert_equal 0.10.to_d, buy.trade.fee
|
||||
assert_equal "Buy", buy.trade.investment_activity_label
|
||||
|
||||
sell = @account.entries.find_by!(external_id: "kraken_trade_sell_tx", source: "kraken")
|
||||
assert_equal 120.to_d, sell.amount
|
||||
assert_equal(-0.002.to_d, sell.trade.qty)
|
||||
assert_equal 0.20.to_d, sell.trade.fee
|
||||
assert_equal "Sell", sell.trade.investment_activity_label
|
||||
end
|
||||
|
||||
test "trade import is idempotent by txid" do
|
||||
assert_difference -> { @account.entries.where(source: "kraken").count }, 2 do
|
||||
KrakenAccount::Processor.new(@kraken_account).process
|
||||
end
|
||||
|
||||
assert_no_difference -> { @account.entries.where(source: "kraken").count } do
|
||||
KrakenAccount::Processor.new(@kraken_account).process
|
||||
end
|
||||
end
|
||||
|
||||
test "updates linked crypto account balance without cash balance" do
|
||||
KrakenAccount::Processor.new(@kraken_account).process
|
||||
|
||||
@account.reload
|
||||
assert_equal 1000.to_d, @account.balance
|
||||
assert_equal 0.to_d, @account.cash_balance
|
||||
assert_equal "USD", @account.currency
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def trade_payload(type, volume, cost, fee)
|
||||
price = volume.to_d.zero? ? 0.to_d : cost.to_d / volume.to_d
|
||||
|
||||
{
|
||||
"ordertxid" => "order_#{type}",
|
||||
"pair" => "XBTUSD",
|
||||
"time" => Time.current.to_f,
|
||||
"type" => type,
|
||||
"price" => price.to_s("F"),
|
||||
"cost" => cost,
|
||||
"fee" => fee,
|
||||
"vol" => volume
|
||||
}
|
||||
end
|
||||
end
|
||||
116
test/models/kraken_item/importer_test.rb
Normal file
116
test/models/kraken_item/importer_test.rb
Normal file
@@ -0,0 +1,116 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "test_helper"
|
||||
|
||||
class KrakenItem::ImporterTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@family = families(:dylan_family)
|
||||
@item = KrakenItem.create!(
|
||||
family: @family,
|
||||
name: "Kraken",
|
||||
api_key: "k",
|
||||
api_secret: "s"
|
||||
)
|
||||
@provider = mock
|
||||
@provider.stubs(:get_api_key_info).returns({ "name" => "Sure read-only" })
|
||||
@provider.stubs(:get_asset_pairs).returns(pair_metadata)
|
||||
@provider.stubs(:get_trades_history).returns({ "count" => 0, "trades" => {} })
|
||||
@provider.stubs(:get_ticker).returns(nil)
|
||||
end
|
||||
|
||||
test "creates a combined kraken account from BalanceEx" do
|
||||
@provider.stubs(:get_asset_info).returns(asset_metadata)
|
||||
@provider.stubs(:get_extended_balance).returns(
|
||||
"XXBT" => { "balance" => "1.0", "credit" => "0", "credit_used" => "0", "hold_trade" => "0.25" },
|
||||
"ZUSD" => { "balance" => "50.0", "credit" => "0", "credit_used" => "0", "hold_trade" => "0" }
|
||||
)
|
||||
@provider.stubs(:get_ticker).with("XBTUSD").returns("XXBTZUSD" => { "c" => [ "50000.00" ] })
|
||||
|
||||
assert_difference "@item.kraken_accounts.count", 1 do
|
||||
KrakenItem::Importer.new(@item, kraken_provider: @provider).import
|
||||
end
|
||||
|
||||
account = @item.kraken_accounts.first
|
||||
assert_equal "combined", account.account_id
|
||||
assert_equal "combined", account.account_type
|
||||
assert_equal "USD", account.currency
|
||||
assert_in_delta 50_050, account.current_balance, 0.01
|
||||
|
||||
btc = account.raw_payload["assets"].find { |asset| asset["symbol"] == "BTC" }
|
||||
assert_equal "0.75", btc["available"]
|
||||
assert_equal "0.25", btc["hold_trade"]
|
||||
end
|
||||
|
||||
test "preserves suffix assets in metadata and marks missing prices" do
|
||||
@provider.stubs(:get_asset_info).returns(asset_metadata)
|
||||
@provider.stubs(:get_extended_balance).returns(
|
||||
"XETH.F" => { "balance" => "2.0", "credit" => "0", "credit_used" => "0", "hold_trade" => "0" }
|
||||
)
|
||||
|
||||
KrakenItem::Importer.new(@item, kraken_provider: @provider).import
|
||||
|
||||
account = @item.kraken_accounts.first
|
||||
eth = account.raw_payload["assets"].first
|
||||
assert_equal "ETH.F", eth["symbol"]
|
||||
assert_equal "ETH", eth["price_symbol"]
|
||||
assert_equal ".F", eth["suffix"]
|
||||
assert_equal "missing", eth["price_status"]
|
||||
assert_includes account.extra.dig("kraken", "missing_prices"), "ETH.F"
|
||||
end
|
||||
|
||||
test "paginates TradesHistory in 50 fill pages" do
|
||||
@provider.stubs(:get_asset_info).returns({})
|
||||
@provider.stubs(:get_extended_balance).returns({})
|
||||
first_page = 50.times.to_h { |i| [ "tx#{i}", trade_payload("tx#{i}") ] }
|
||||
second_page = { "tx50" => trade_payload("tx50") }
|
||||
|
||||
@provider.expects(:get_trades_history).with(start: nil, offset: 0).returns({ "count" => 51, "trades" => first_page })
|
||||
@provider.expects(:get_trades_history).with(start: nil, offset: 50).returns({ "count" => 51, "trades" => second_page })
|
||||
|
||||
result = KrakenItem::Importer.new(@item, kraken_provider: @provider).import
|
||||
|
||||
assert_equal 51, result[:trades_imported]
|
||||
assert_equal 51, @item.kraken_accounts.first.raw_transactions_payload["trades"].size
|
||||
end
|
||||
|
||||
test "marks item requires_update when required endpoint reports permission error" do
|
||||
@provider.stubs(:get_asset_info).returns({})
|
||||
@provider.stubs(:get_asset_pairs).returns({})
|
||||
@provider.stubs(:get_extended_balance).raises(Provider::Kraken::PermissionError, "EGeneral:Permission denied")
|
||||
|
||||
assert_raises(Provider::Kraken::PermissionError) do
|
||||
KrakenItem::Importer.new(@item, kraken_provider: @provider).import
|
||||
end
|
||||
|
||||
assert @item.reload.requires_update?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def asset_metadata
|
||||
{
|
||||
"XXBT" => { "altname" => "XBT" },
|
||||
"XETH" => { "altname" => "ETH" },
|
||||
"ZUSD" => { "altname" => "USD" }
|
||||
}
|
||||
end
|
||||
|
||||
def pair_metadata
|
||||
{
|
||||
"XXBTZUSD" => { "altname" => "XBTUSD", "base" => "XXBT", "quote" => "ZUSD" }
|
||||
}
|
||||
end
|
||||
|
||||
def trade_payload(txid)
|
||||
{
|
||||
"ordertxid" => "order_#{txid}",
|
||||
"pair" => "XBTUSD",
|
||||
"time" => Time.current.to_f,
|
||||
"type" => "buy",
|
||||
"price" => "50000.0",
|
||||
"cost" => "50.0",
|
||||
"fee" => "0.1",
|
||||
"vol" => "0.001"
|
||||
}
|
||||
end
|
||||
end
|
||||
100
test/models/kraken_item_test.rb
Normal file
100
test/models/kraken_item_test.rb
Normal file
@@ -0,0 +1,100 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "test_helper"
|
||||
|
||||
class KrakenItemTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@family = families(:dylan_family)
|
||||
@item = KrakenItem.create!(
|
||||
family: @family,
|
||||
name: "My Kraken",
|
||||
api_key: "test_key",
|
||||
api_secret: "test_secret"
|
||||
)
|
||||
end
|
||||
|
||||
test "belongs to family" do
|
||||
assert_equal @family, @item.family
|
||||
end
|
||||
|
||||
test "has good status by default" do
|
||||
assert_equal "good", @item.status
|
||||
end
|
||||
|
||||
test "strips credential whitespace before validation" do
|
||||
item = KrakenItem.create!(
|
||||
family: @family,
|
||||
name: "Whitespace Kraken",
|
||||
api_key: " key \n",
|
||||
api_secret: " secret \n"
|
||||
)
|
||||
|
||||
assert_equal "key", item.api_key
|
||||
assert_equal "secret", item.api_secret
|
||||
end
|
||||
|
||||
test "rejects whitespace-only credentials" do
|
||||
item = KrakenItem.new(family: @family, name: "Blank Kraken", api_key: " ", api_secret: "\n")
|
||||
|
||||
assert_not item.valid?
|
||||
assert_includes item.errors[:api_key], "can't be blank"
|
||||
assert_includes item.errors[:api_secret], "can't be blank"
|
||||
end
|
||||
|
||||
test "credentials_configured rejects whitespace-only values" do
|
||||
@item.update_columns(api_key: " ", api_secret: "secret")
|
||||
|
||||
assert_not @item.reload.credentials_configured?
|
||||
end
|
||||
|
||||
test "next_nonce is monotonic even when stored nonce is ahead of clock" do
|
||||
@item.update!(last_nonce: 9_000_000_000_000_000_000)
|
||||
|
||||
first = @item.next_nonce!.to_i
|
||||
second = @item.next_nonce!.to_i
|
||||
|
||||
assert_equal 9_000_000_000_000_000_001, first
|
||||
assert_equal 9_000_000_000_000_000_002, second
|
||||
assert_equal second, @item.reload.last_nonce
|
||||
end
|
||||
|
||||
test "kraken provider uses item nonce generator" do
|
||||
@item.update!(last_nonce: 9_000_000_000_000_000_000)
|
||||
provider = @item.kraken_provider
|
||||
|
||||
nonce = provider.send(:nonce_generator).call
|
||||
|
||||
assert_equal "9000000000000000001", nonce
|
||||
assert_equal 9_000_000_000_000_000_001, @item.reload.last_nonce
|
||||
end
|
||||
|
||||
test "duplicate combined account ids are scoped by kraken item" do
|
||||
other_item = KrakenItem.create!(
|
||||
family: @family,
|
||||
name: "Other Kraken",
|
||||
api_key: "other_key",
|
||||
api_secret: "other_secret"
|
||||
)
|
||||
|
||||
@item.kraken_accounts.create!(name: "Main", account_id: "combined", account_type: "combined", currency: "USD")
|
||||
other_account = other_item.kraken_accounts.create!(name: "Other", account_id: "combined", account_type: "combined", currency: "USD")
|
||||
|
||||
assert other_account.persisted?
|
||||
end
|
||||
|
||||
test "encrypts credentials when active record encryption is configured" do
|
||||
skip "Encryption not configured" unless KrakenItem.encryption_ready?
|
||||
|
||||
item = KrakenItem.create!(
|
||||
family: @family,
|
||||
name: "Encrypted Kraken",
|
||||
api_key: "encrypted_key",
|
||||
api_secret: "encrypted_secret"
|
||||
)
|
||||
|
||||
quoted_id = KrakenItem.connection.quote(item.id)
|
||||
raw = KrakenItem.connection.select_one("SELECT api_key, api_secret FROM kraken_items WHERE id = #{quoted_id}")
|
||||
assert_not_equal "encrypted_key", raw["api_key"]
|
||||
assert_not_equal "encrypted_secret", raw["api_secret"]
|
||||
end
|
||||
end
|
||||
31
test/models/lunchflow_item_test.rb
Normal file
31
test/models/lunchflow_item_test.rb
Normal file
@@ -0,0 +1,31 @@
|
||||
require "test_helper"
|
||||
|
||||
class LunchflowItemTest < ActiveSupport::TestCase
|
||||
def setup
|
||||
@lunchflow_item = lunchflow_items(:one)
|
||||
end
|
||||
|
||||
test "effective_base_url returns default when base_url blank" do
|
||||
@lunchflow_item.base_url = nil
|
||||
|
||||
assert_equal LunchflowItem::DEFAULT_BASE_URL, @lunchflow_item.effective_base_url
|
||||
end
|
||||
|
||||
test "effective_base_url returns default for non-lunchflow host" do
|
||||
@lunchflow_item.base_url = "https://169.254.169.254/latest/meta-data"
|
||||
|
||||
assert_equal LunchflowItem::DEFAULT_BASE_URL, @lunchflow_item.effective_base_url
|
||||
end
|
||||
|
||||
test "effective_base_url returns default for non-https scheme" do
|
||||
@lunchflow_item.base_url = "http://lunchflow.app/api/v1"
|
||||
|
||||
assert_equal LunchflowItem::DEFAULT_BASE_URL, @lunchflow_item.effective_base_url
|
||||
end
|
||||
|
||||
test "effective_base_url returns canonical default for valid lunchflow url" do
|
||||
@lunchflow_item.base_url = "https://lunchflow.app/api/v1/"
|
||||
|
||||
assert_equal LunchflowItem::DEFAULT_BASE_URL, @lunchflow_item.effective_base_url
|
||||
end
|
||||
end
|
||||
@@ -5,6 +5,14 @@ class MintImportTest < ActiveSupport::TestCase
|
||||
@family = families(:dylan_family)
|
||||
end
|
||||
|
||||
test "default column mappings are applied after create" do
|
||||
import = @family.imports.create!(type: "MintImport")
|
||||
|
||||
MintImport.default_column_mappings.each do |attribute, value|
|
||||
assert_equal value, import.public_send(attribute)
|
||||
end
|
||||
end
|
||||
|
||||
test "generated rows preserve stable source row numbers" do
|
||||
import = @family.imports.create!(
|
||||
type: "MintImport",
|
||||
|
||||
@@ -57,6 +57,41 @@ class OidcIdentityTest < ActiveSupport::TestCase
|
||||
end
|
||||
end
|
||||
|
||||
test "sync_user_attributes! preserves user-edited first and last name" do
|
||||
# Regression for #1103: every SSO login used to overwrite user.first_name
|
||||
# with the IdP value, clobbering edits the user made inside Sure.
|
||||
@user.update!(first_name: "Adam", last_name: "Smith")
|
||||
auth = OmniAuth::AuthHash.new(
|
||||
provider: @oidc_identity.provider,
|
||||
uid: @oidc_identity.uid,
|
||||
info: { email: @user.email, first_name: "Ben", last_name: "Jones" }
|
||||
)
|
||||
|
||||
@oidc_identity.sync_user_attributes!(auth)
|
||||
|
||||
@user.reload
|
||||
assert_equal "Adam", @user.first_name
|
||||
assert_equal "Smith", @user.last_name
|
||||
# Identity record itself still mirrors the IdP for audit / debugging.
|
||||
assert_equal "Ben", @oidc_identity.info["first_name"]
|
||||
assert_equal "Jones", @oidc_identity.info["last_name"]
|
||||
end
|
||||
|
||||
test "sync_user_attributes! fills blank user names from the IdP" do
|
||||
@user.update!(first_name: nil, last_name: nil)
|
||||
auth = OmniAuth::AuthHash.new(
|
||||
provider: @oidc_identity.provider,
|
||||
uid: @oidc_identity.uid,
|
||||
info: { email: @user.email, first_name: "Ben", last_name: "Jones" }
|
||||
)
|
||||
|
||||
@oidc_identity.sync_user_attributes!(auth)
|
||||
|
||||
@user.reload
|
||||
assert_equal "Ben", @user.first_name
|
||||
assert_equal "Jones", @user.last_name
|
||||
end
|
||||
|
||||
test "creates from omniauth hash" do
|
||||
auth = OmniAuth::AuthHash.new({
|
||||
provider: "google_oauth2",
|
||||
|
||||
@@ -68,6 +68,38 @@ class Provider::BinancePublicTest < ActiveSupport::TestCase
|
||||
assert_equal [ "BTCUSD" ], response.data.map(&:symbol)
|
||||
end
|
||||
|
||||
test "search_securities strips CRYPTO: prefix from holdings-processor symbols" do
|
||||
@provider.stubs(:exchange_info_symbols).returns(sample_exchange_info)
|
||||
|
||||
response = @provider.search_securities("CRYPTO:BTC")
|
||||
|
||||
assert response.success?
|
||||
assert_includes response.data.map(&:symbol), "BTCUSD"
|
||||
end
|
||||
|
||||
test "search_securities returns a synthetic stablecoin result without hitting exchangeInfo" do
|
||||
@provider.expects(:exchange_info_symbols).never
|
||||
|
||||
response = @provider.search_securities("CRYPTO:USDT")
|
||||
|
||||
assert response.success?
|
||||
assert_equal 1, response.data.size
|
||||
row = response.data.first
|
||||
assert_equal "USDTUSD", row.symbol
|
||||
assert_equal "USDT", row.name
|
||||
assert_equal "USD", row.currency
|
||||
assert_equal "BNCX", row.exchange_operating_mic
|
||||
assert_nil row.country_code
|
||||
end
|
||||
|
||||
test "parse_ticker treats stablecoin/USD search-result form as stablecoin" do
|
||||
parsed = @provider.send(:parse_ticker, "USDTUSD")
|
||||
assert parsed[:stablecoin]
|
||||
assert_nil parsed[:binance_pair]
|
||||
assert_equal "USDT", parsed[:base]
|
||||
assert_equal "USD", parsed[:display_currency]
|
||||
end
|
||||
|
||||
test "search_securities returns empty array when query does not match" do
|
||||
@provider.stubs(:exchange_info_symbols).returns(sample_exchange_info)
|
||||
|
||||
@@ -162,6 +194,95 @@ class Provider::BinancePublicTest < ActiveSupport::TestCase
|
||||
assert_nil @provider.send(:parse_ticker, "GIBBERISH")
|
||||
end
|
||||
|
||||
test "parse_ticker strips CRYPTO: prefix from holdings processors" do
|
||||
parsed = @provider.send(:parse_ticker, "CRYPTO:BTCUSD")
|
||||
assert_equal "BTCUSDT", parsed[:binance_pair]
|
||||
assert_equal "BTC", parsed[:base]
|
||||
assert_equal "USD", parsed[:display_currency]
|
||||
end
|
||||
|
||||
test "parse_ticker flags USD stablecoins for synthetic pricing" do
|
||||
%w[USDT USDC BUSD DAI FDUSD TUSD USDP PYUSD].each do |stable|
|
||||
parsed = @provider.send(:parse_ticker, "CRYPTO:#{stable}")
|
||||
assert parsed[:stablecoin], "expected #{stable} to be flagged as stablecoin"
|
||||
assert_nil parsed[:binance_pair]
|
||||
assert_equal stable, parsed[:base]
|
||||
assert_equal "USD", parsed[:display_currency]
|
||||
end
|
||||
end
|
||||
|
||||
test "parse_ticker defaults prefixed bare base assets to the USDT pair" do
|
||||
parsed = @provider.send(:parse_ticker, "CRYPTO:SOL")
|
||||
assert_equal "SOLUSDT", parsed[:binance_pair]
|
||||
assert_equal "SOL", parsed[:base]
|
||||
assert_equal "USD", parsed[:display_currency]
|
||||
end
|
||||
|
||||
test "parse_ticker still rejects unprefixed malformed tickers" do
|
||||
# No CRYPTO: prefix → behaves like a Binance-search ticker (must end in a
|
||||
# supported fiat). Protects against false defaults like "BTCBNB" → "BTCBNBUSDT".
|
||||
assert_nil @provider.send(:parse_ticker, "SOL")
|
||||
assert_nil @provider.send(:parse_ticker, "BTCBNB")
|
||||
end
|
||||
|
||||
test "fetch_security_prices returns synthetic 1.0 USD prices for stablecoins" do
|
||||
# No HTTP call expected — short-circuited entirely.
|
||||
@provider.expects(:client).never
|
||||
|
||||
response = @provider.fetch_security_prices(
|
||||
symbol: "CRYPTO:USDT",
|
||||
exchange_operating_mic: "BNCX",
|
||||
start_date: Date.parse("2026-01-01"),
|
||||
end_date: Date.parse("2026-01-03")
|
||||
)
|
||||
|
||||
assert response.success?
|
||||
assert_equal 3, response.data.size
|
||||
assert response.data.all? { |p| p.price == 1.0 && p.currency == "USD" }
|
||||
assert_equal Date.parse("2026-01-01"), response.data.first.date
|
||||
assert_equal Date.parse("2026-01-03"), response.data.last.date
|
||||
end
|
||||
|
||||
test "fetch_security_price returns 1.0 USD for a stablecoin single day" do
|
||||
@provider.expects(:client).never
|
||||
|
||||
response = @provider.fetch_security_price(
|
||||
symbol: "CRYPTO:USDT",
|
||||
exchange_operating_mic: "BNCX",
|
||||
date: Date.parse("2026-01-15")
|
||||
)
|
||||
|
||||
assert response.success?
|
||||
assert_equal 1.0, response.data.price
|
||||
assert_equal "USD", response.data.currency
|
||||
end
|
||||
|
||||
test "fetch_security_info handles stablecoin (no Binance pair link)" do
|
||||
response = @provider.fetch_security_info(symbol: "CRYPTO:USDT", exchange_operating_mic: "BNCX")
|
||||
|
||||
assert response.success?
|
||||
assert_equal "USDT", response.data.name
|
||||
assert_equal "crypto", response.data.kind
|
||||
assert_nil response.data.links
|
||||
end
|
||||
|
||||
test "fetch_security_prices resolves a bare CRYPTO: ticker against the USDT pair" do
|
||||
rows = [ kline_row("2026-01-15", "150.25") ]
|
||||
mock_client_returning_klines(rows)
|
||||
|
||||
response = @provider.fetch_security_prices(
|
||||
symbol: "CRYPTO:SOL",
|
||||
exchange_operating_mic: "BNCX",
|
||||
start_date: Date.parse("2026-01-15"),
|
||||
end_date: Date.parse("2026-01-15")
|
||||
)
|
||||
|
||||
assert response.success?
|
||||
assert_equal 1, response.data.size
|
||||
assert_equal "USD", response.data.first.currency
|
||||
assert_in_delta 150.25, response.data.first.price
|
||||
end
|
||||
|
||||
# ================================
|
||||
# Single price
|
||||
# ================================
|
||||
|
||||
224
test/models/provider/brex_adapter_test.rb
Normal file
224
test/models/provider/brex_adapter_test.rb
Normal file
@@ -0,0 +1,224 @@
|
||||
require "uri"
|
||||
|
||||
require "test_helper"
|
||||
|
||||
class Provider::BrexAdapterTest < ActiveSupport::TestCase
|
||||
test "supports Depository accounts" do
|
||||
assert_includes Provider::BrexAdapter.supported_account_types, "Depository"
|
||||
end
|
||||
|
||||
test "supports CreditCard accounts" do
|
||||
assert_includes Provider::BrexAdapter.supported_account_types, "CreditCard"
|
||||
end
|
||||
|
||||
test "does not support Investment accounts" do
|
||||
assert_not_includes Provider::BrexAdapter.supported_account_types, "Investment"
|
||||
end
|
||||
|
||||
test "returns fallback connection config when no credentials exist yet" do
|
||||
# Brex is a per-family provider - any family can connect
|
||||
family = families(:empty)
|
||||
configs = Provider::BrexAdapter.connection_configs(family: family)
|
||||
|
||||
assert_equal 1, configs.length
|
||||
assert_equal "brex", configs.first[:key]
|
||||
assert_equal I18n.t("brex_items.provider_connection.default_name"), configs.first[:name]
|
||||
assert configs.first[:can_connect]
|
||||
end
|
||||
|
||||
test "returns one connection config per credentialed brex item" do
|
||||
family = families(:dylan_family)
|
||||
first_item = brex_items(:one)
|
||||
second_item = BrexItem.create!(
|
||||
family: family,
|
||||
name: "Business Brex",
|
||||
token: "second_brex_token",
|
||||
base_url: "https://api.brex.com"
|
||||
)
|
||||
|
||||
configs = Provider::BrexAdapter.connection_configs(family: family)
|
||||
|
||||
assert_equal 2, configs.length
|
||||
assert_equal [ "brex_#{second_item.id}", "brex_#{first_item.id}" ], configs.map { |config| config[:key] }
|
||||
assert_equal [
|
||||
I18n.t("brex_items.provider_connection.name", name: second_item.name),
|
||||
I18n.t("brex_items.provider_connection.name", name: first_item.name)
|
||||
], configs.map { |config| config[:name] }
|
||||
|
||||
new_account_uri = URI.parse(configs.first[:new_account_path].call("Depository", "/accounts"))
|
||||
assert_equal "/brex_items/select_accounts", new_account_uri.path
|
||||
assert_includes new_account_uri.query, "brex_item_id=#{second_item.id}"
|
||||
|
||||
existing_account_uri = URI.parse(configs.first[:existing_account_path].call(accounts(:depository).id))
|
||||
assert_equal "/brex_items/select_existing_account", existing_account_uri.path
|
||||
assert_includes existing_account_uri.query, "brex_item_id=#{second_item.id}"
|
||||
end
|
||||
|
||||
test "connection configs ignore items with whitespace-only tokens" do
|
||||
family = families(:dylan_family)
|
||||
BrexItem.create!(
|
||||
family: family,
|
||||
name: "Blank Brex",
|
||||
token: "temporary_token",
|
||||
base_url: "https://api.brex.com"
|
||||
).update_column(:token, " ")
|
||||
|
||||
configs = Provider::BrexAdapter.connection_configs(family: family)
|
||||
|
||||
assert_equal [ "brex_#{brex_items(:one).id}" ], configs.map { |config| config[:key] }
|
||||
end
|
||||
|
||||
test "build_provider returns nil when family is nil" do
|
||||
assert_nil Provider::BrexAdapter.build_provider(family: nil)
|
||||
end
|
||||
|
||||
test "build_provider returns nil when family has no brex items" do
|
||||
family = families(:empty)
|
||||
assert_nil Provider::BrexAdapter.build_provider(family: family)
|
||||
end
|
||||
|
||||
test "build_provider returns Brex provider when credentials configured" do
|
||||
family = families(:dylan_family)
|
||||
provider = Provider::BrexAdapter.build_provider(family: family)
|
||||
|
||||
assert_instance_of Provider::Brex, provider
|
||||
end
|
||||
|
||||
test "build_provider uses explicit brex item credentials" do
|
||||
family = families(:dylan_family)
|
||||
second_item = BrexItem.create!(
|
||||
family: family,
|
||||
name: "Business Brex",
|
||||
token: "second_brex_token",
|
||||
base_url: "https://api.brex.com"
|
||||
)
|
||||
|
||||
provider = Provider::BrexAdapter.build_provider(family: family, brex_item_id: second_item.id)
|
||||
|
||||
assert_instance_of Provider::Brex, provider
|
||||
assert_equal "second_brex_token", provider.token
|
||||
assert_equal "https://api.brex.com", provider.base_url
|
||||
end
|
||||
|
||||
test "build_provider does not pick the first connection when multiple credentials exist" do
|
||||
family = families(:dylan_family)
|
||||
BrexItem.create!(
|
||||
family: family,
|
||||
name: "Business Brex",
|
||||
token: "second_brex_token",
|
||||
base_url: "https://api.brex.com"
|
||||
)
|
||||
|
||||
assert_nil Provider::BrexAdapter.build_provider(family: family)
|
||||
end
|
||||
|
||||
test "build_provider strips surrounding token whitespace" do
|
||||
family = families(:dylan_family)
|
||||
second_item = BrexItem.create!(
|
||||
family: family,
|
||||
name: "Business Brex",
|
||||
token: " second_brex_token \n",
|
||||
base_url: "https://api.brex.com"
|
||||
)
|
||||
|
||||
provider = Provider::BrexAdapter.build_provider(family: family, brex_item_id: second_item.id)
|
||||
|
||||
assert_equal "second_brex_token", provider.token
|
||||
end
|
||||
|
||||
test "build_provider refuses brex items outside the family" do
|
||||
family = families(:dylan_family)
|
||||
other_item = BrexItem.create!(
|
||||
family: families(:empty),
|
||||
name: "Other Brex",
|
||||
token: "other_brex_token",
|
||||
base_url: "https://api.brex.com"
|
||||
)
|
||||
|
||||
assert_nil Provider::BrexAdapter.build_provider(family: family, brex_item_id: other_item.id)
|
||||
end
|
||||
|
||||
test "build_provider refuses explicit brex item without usable credentials" do
|
||||
family = families(:dylan_family)
|
||||
blank_item = BrexItem.create!(
|
||||
family: family,
|
||||
name: "Blank Brex",
|
||||
token: "temporary_token",
|
||||
base_url: "https://api.brex.com"
|
||||
)
|
||||
blank_item.update_column(:token, " ")
|
||||
|
||||
assert_nil Provider::BrexAdapter.build_provider(family: family, brex_item_id: blank_item.id)
|
||||
end
|
||||
|
||||
test "build_provider refuses explicit brex item with invalid persisted base_url" do
|
||||
family = families(:dylan_family)
|
||||
item = BrexItem.create!(
|
||||
family: family,
|
||||
name: "Invalid URL Brex",
|
||||
token: "token",
|
||||
base_url: "https://api.brex.com"
|
||||
)
|
||||
item.update_column(:base_url, "https://evil.example.test")
|
||||
|
||||
assert_nil Provider::BrexAdapter.build_provider(family: family, brex_item_id: item.id)
|
||||
end
|
||||
|
||||
test "reads institution metadata from brex account column" do
|
||||
brex_account = brex_items(:one).brex_accounts.create!(
|
||||
account_id: "metadata_cash",
|
||||
account_kind: "cash",
|
||||
name: "Metadata Cash",
|
||||
currency: "USD",
|
||||
institution_metadata: {
|
||||
"name" => "Brex",
|
||||
"domain" => "brex.com",
|
||||
"url" => "https://brex.com"
|
||||
}
|
||||
)
|
||||
|
||||
adapter = Provider::BrexAdapter.new(brex_account)
|
||||
|
||||
assert_equal "brex.com", brex_account.institution_metadata["domain"]
|
||||
assert_equal "brex.com", adapter.institution_domain
|
||||
assert_equal "Brex", adapter.institution_name
|
||||
assert_equal "https://brex.com", adapter.institution_url
|
||||
end
|
||||
|
||||
test "falls back to brex item institution metadata" do
|
||||
brex_item = brex_items(:one)
|
||||
brex_item.update!(
|
||||
institution_name: "Brex Item Name",
|
||||
institution_url: "https://brex.com/item",
|
||||
institution_color: "#123456"
|
||||
)
|
||||
brex_account = brex_item.brex_accounts.create!(
|
||||
account_id: "metadata_fallback_cash",
|
||||
account_kind: "cash",
|
||||
name: "Metadata Fallback Cash",
|
||||
currency: "USD"
|
||||
)
|
||||
|
||||
adapter = Provider::BrexAdapter.new(brex_account)
|
||||
|
||||
assert_equal "Brex Item Name", adapter.institution_name
|
||||
assert_equal "https://brex.com/item", adapter.institution_url
|
||||
assert_equal "#123456", adapter.institution_color
|
||||
end
|
||||
|
||||
test "logs institution urls without hosts" do
|
||||
brex_account = brex_items(:one).brex_accounts.create!(
|
||||
account_id: "metadata_bad_url_cash",
|
||||
account_kind: "cash",
|
||||
name: "Metadata Bad URL Cash",
|
||||
currency: "USD",
|
||||
institution_metadata: {
|
||||
"url" => "not-a-url"
|
||||
}
|
||||
)
|
||||
|
||||
Rails.logger.expects(:warn).with(regexp_matches(/institution URL has no host/))
|
||||
|
||||
assert_nil Provider::BrexAdapter.new(brex_account).institution_domain
|
||||
end
|
||||
end
|
||||
289
test/models/provider/brex_test.rb
Normal file
289
test/models/provider/brex_test.rb
Normal file
@@ -0,0 +1,289 @@
|
||||
require "test_helper"
|
||||
|
||||
class Provider::BrexTest < ActiveSupport::TestCase
|
||||
def setup
|
||||
@provider = Provider::Brex.new("test_token", base_url: "https://api-staging.brex.com")
|
||||
end
|
||||
|
||||
test "initializes with token and default base_url" do
|
||||
provider = Provider::Brex.new("my_token")
|
||||
assert_equal "my_token", provider.token
|
||||
assert_equal "https://api.brex.com", provider.base_url
|
||||
end
|
||||
|
||||
test "initializes with custom base_url" do
|
||||
assert_equal "test_token", @provider.token
|
||||
assert_equal "https://api-staging.brex.com", @provider.base_url
|
||||
end
|
||||
|
||||
test "initializes with stripped token and removes trailing base url slash" do
|
||||
provider = Provider::Brex.new(" test_token \n", base_url: "https://api.brex.com/")
|
||||
|
||||
assert_equal "test_token", provider.token
|
||||
assert_equal "https://api.brex.com", provider.base_url
|
||||
end
|
||||
|
||||
test "initializes with official staging base url" do
|
||||
provider = Provider::Brex.new("test_token", base_url: "https://api-staging.brex.com/")
|
||||
|
||||
assert_equal "https://api-staging.brex.com", provider.base_url
|
||||
end
|
||||
|
||||
test "rejects arbitrary base urls" do
|
||||
[
|
||||
"http://api.brex.com",
|
||||
"https://evil.example.test",
|
||||
"https://localhost",
|
||||
"https://127.0.0.1",
|
||||
"https://10.0.0.1",
|
||||
"https://api.brex.com.evil.example",
|
||||
"https://api.brex.com@127.0.0.1",
|
||||
"https://api.brex.com:444",
|
||||
"https://api.brex.com/v1",
|
||||
"https://api.brex.com?host=evil.example.test",
|
||||
"//api.brex.com"
|
||||
].each do |base_url|
|
||||
assert_raises ArgumentError do
|
||||
Provider::Brex.new("test_token", base_url: base_url)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
test "BrexError includes error_type" do
|
||||
error = Provider::Brex::BrexError.new("Test error", :unauthorized)
|
||||
assert_equal "Test error", error.message
|
||||
assert_equal :unauthorized, error.error_type
|
||||
end
|
||||
|
||||
test "BrexError defaults error_type to unknown" do
|
||||
error = Provider::Brex::BrexError.new("Test error")
|
||||
assert_equal :unknown, error.error_type
|
||||
end
|
||||
|
||||
test "fetches cash accounts from the v2 endpoint with bearer auth" do
|
||||
response = OpenStruct.new(
|
||||
code: 200,
|
||||
body: { items: [ { id: "cash_1", name: "Operating" } ] }.to_json,
|
||||
headers: {}
|
||||
)
|
||||
|
||||
Provider::Brex.expects(:get)
|
||||
.with(
|
||||
"https://api.brex.com/v2/accounts/cash?limit=1000",
|
||||
headers: {
|
||||
"Authorization" => "Bearer test_token",
|
||||
"Content-Type" => "application/json",
|
||||
"Accept" => "application/json"
|
||||
}
|
||||
)
|
||||
.returns(response)
|
||||
|
||||
accounts = Provider::Brex.new(" test_token ").get_cash_accounts
|
||||
|
||||
assert_equal 1, accounts.length
|
||||
assert_equal "cash_1", accounts.first[:id]
|
||||
assert_equal "cash", accounts.first[:account_kind]
|
||||
end
|
||||
|
||||
test "fetches card accounts from the paginated v2 endpoint" do
|
||||
response = OpenStruct.new(
|
||||
code: 200,
|
||||
body: [ { id: "card_account_1", status: "ACTIVE" } ].to_json,
|
||||
headers: {}
|
||||
)
|
||||
|
||||
Provider::Brex.expects(:get)
|
||||
.with(
|
||||
"https://api.brex.com/v2/accounts/card?limit=1000",
|
||||
headers: {
|
||||
"Authorization" => "Bearer test_token",
|
||||
"Content-Type" => "application/json",
|
||||
"Accept" => "application/json"
|
||||
}
|
||||
)
|
||||
.returns(response)
|
||||
|
||||
accounts = Provider::Brex.new("test_token").get_card_accounts
|
||||
|
||||
assert_equal 1, accounts.length
|
||||
assert_equal "card_account_1", accounts.first[:id]
|
||||
assert_equal "card", accounts.first[:account_kind]
|
||||
end
|
||||
|
||||
test "aggregates card accounts into one provider account" do
|
||||
cash_response = OpenStruct.new(
|
||||
code: 200,
|
||||
body: { items: [] }.to_json,
|
||||
headers: {}
|
||||
)
|
||||
card_response = OpenStruct.new(
|
||||
code: 200,
|
||||
body: {
|
||||
items: [
|
||||
{
|
||||
id: "card_account_1",
|
||||
status: "ACTIVE",
|
||||
current_balance: { amount: 12_345, currency: "USD" },
|
||||
available_balance: { amount: 100_000, currency: "USD" },
|
||||
account_limit: { amount: 250_000, currency: "USD" }
|
||||
}
|
||||
]
|
||||
}.to_json,
|
||||
headers: {}
|
||||
)
|
||||
|
||||
Provider::Brex.stubs(:get).returns(cash_response, card_response)
|
||||
|
||||
accounts_data = Provider::Brex.new("test_token").get_accounts
|
||||
|
||||
assert_equal [ "card_primary" ], accounts_data[:accounts].map { |account| account[:id] }
|
||||
assert_equal "card", accounts_data[:accounts].first[:account_kind]
|
||||
assert_equal 1, accounts_data[:accounts].first[:card_accounts_count]
|
||||
end
|
||||
|
||||
test "does not aggregate mixed currency card balances" do
|
||||
cash_response = OpenStruct.new(
|
||||
code: 200,
|
||||
body: { items: [] }.to_json,
|
||||
headers: {}
|
||||
)
|
||||
card_response = OpenStruct.new(
|
||||
code: 200,
|
||||
body: [
|
||||
{
|
||||
id: "card_account_1",
|
||||
current_balance: { amount: 12_345, currency: "USD" }
|
||||
},
|
||||
{
|
||||
id: "card_account_2",
|
||||
current_balance: { amount: 6_789, currency: "EUR" }
|
||||
}
|
||||
].to_json,
|
||||
headers: {}
|
||||
)
|
||||
|
||||
Provider::Brex.stubs(:get).returns(cash_response, card_response)
|
||||
|
||||
accounts_data = Provider::Brex.new("test_token").get_accounts
|
||||
|
||||
assert_nil accounts_data[:accounts].first[:current_balance]
|
||||
end
|
||||
|
||||
test "guards repeated pagination cursors" do
|
||||
first_response = OpenStruct.new(
|
||||
code: 200,
|
||||
body: { items: [ { id: "tx_1" } ], next_cursor: "cursor_1" }.to_json,
|
||||
headers: {}
|
||||
)
|
||||
second_response = OpenStruct.new(
|
||||
code: 200,
|
||||
body: { items: [ { id: "tx_2" } ], next_cursor: "cursor_1" }.to_json,
|
||||
headers: {}
|
||||
)
|
||||
|
||||
Provider::Brex.stubs(:get).returns(first_response, second_response)
|
||||
|
||||
error = assert_raises Provider::Brex::BrexError do
|
||||
Provider::Brex.new("test_token").get_primary_card_transactions
|
||||
end
|
||||
|
||||
assert_equal :pagination_error, error.error_type
|
||||
end
|
||||
|
||||
test "guards pagination page cap" do
|
||||
responses = (1..26).map do |page|
|
||||
OpenStruct.new(
|
||||
code: 200,
|
||||
body: { items: [ { id: "tx_#{page}" } ], next_cursor: "cursor_#{page}" }.to_json,
|
||||
headers: {}
|
||||
)
|
||||
end
|
||||
|
||||
Provider::Brex.stubs(:get).returns(*responses)
|
||||
|
||||
error = assert_raises Provider::Brex::BrexError do
|
||||
Provider::Brex.new("test_token").get_primary_card_transactions
|
||||
end
|
||||
|
||||
assert_equal :pagination_error, error.error_type
|
||||
assert_includes error.message, "exceeded 25 pages"
|
||||
end
|
||||
|
||||
test "sends posted_at_start as RFC3339 date time" do
|
||||
response = OpenStruct.new(
|
||||
code: 200,
|
||||
body: { items: [] }.to_json,
|
||||
headers: {}
|
||||
)
|
||||
|
||||
Provider::Brex.expects(:get)
|
||||
.with(
|
||||
"https://api.brex.com/v2/transactions/card/primary?posted_at_start=2026-01-02T00%3A00%3A00Z&limit=1000",
|
||||
headers: {
|
||||
"Authorization" => "Bearer test_token",
|
||||
"Content-Type" => "application/json",
|
||||
"Accept" => "application/json"
|
||||
}
|
||||
)
|
||||
.returns(response)
|
||||
|
||||
Provider::Brex.new("test_token").get_primary_card_transactions(start_date: Date.new(2026, 1, 2))
|
||||
end
|
||||
|
||||
test "raises clear error for invalid start date" do
|
||||
error = assert_raises ArgumentError do
|
||||
Provider::Brex.new("test_token").get_primary_card_transactions(start_date: "not-a-date")
|
||||
end
|
||||
|
||||
assert_includes error.message, "Invalid start_date"
|
||||
end
|
||||
|
||||
test "maps rate limits and exposes trace id without leaking body" do
|
||||
response = OpenStruct.new(
|
||||
code: 429,
|
||||
body: { message: "secret raw provider body" }.to_json,
|
||||
headers: { "x-brex-trace-id" => "trace_123" }
|
||||
)
|
||||
|
||||
Provider::Brex.stubs(:get).returns(response)
|
||||
|
||||
error = assert_raises Provider::Brex::BrexError do
|
||||
Provider::Brex.new("test_token").get_cash_accounts
|
||||
end
|
||||
|
||||
assert_equal :rate_limited, error.error_type
|
||||
assert_equal 429, error.http_status
|
||||
assert_equal "trace_123", error.trace_id
|
||||
refute_includes error.message, "secret raw provider body"
|
||||
end
|
||||
|
||||
test "maps non-success responses without exposing provider body" do
|
||||
expectations = {
|
||||
400 => [ :bad_request, "Bad request to Brex API" ],
|
||||
401 => [ :unauthorized, "Invalid Brex API token or account permissions" ],
|
||||
403 => [ :access_forbidden, "Access forbidden - check Brex API token scopes" ],
|
||||
404 => [ :not_found, "Brex resource not found" ],
|
||||
500 => [ :fetch_failed, "Failed to fetch data from Brex API: HTTP 500" ]
|
||||
}
|
||||
|
||||
expectations.each do |status, (error_type, message)|
|
||||
response = OpenStruct.new(
|
||||
code: status,
|
||||
body: { message: "secret provider body #{status}" }.to_json,
|
||||
headers: { "X-Brex-Trace-Id" => "trace_#{status}" }
|
||||
)
|
||||
|
||||
Provider::Brex.stubs(:get).returns(response)
|
||||
|
||||
error = assert_raises Provider::Brex::BrexError do
|
||||
Provider::Brex.new("test_token").get_cash_accounts
|
||||
end
|
||||
|
||||
assert_equal error_type, error.error_type
|
||||
assert_equal status, error.http_status
|
||||
assert_equal "trace_#{status}", error.trace_id
|
||||
assert_equal message, error.message
|
||||
refute_includes error.message, "secret provider body"
|
||||
end
|
||||
end
|
||||
end
|
||||
134
test/models/provider/kraken_adapter_test.rb
Normal file
134
test/models/provider/kraken_adapter_test.rb
Normal file
@@ -0,0 +1,134 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "test_helper"
|
||||
require "uri"
|
||||
|
||||
class Provider::KrakenAdapterTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
kraken_items(:requires_update).update!(scheduled_for_deletion: true)
|
||||
end
|
||||
|
||||
test "supports Crypto accounts only" do
|
||||
assert_includes Provider::KrakenAdapter.supported_account_types, "Crypto"
|
||||
assert_not_includes Provider::KrakenAdapter.supported_account_types, "Depository"
|
||||
end
|
||||
|
||||
test "returns fallback connection config when no credentials exist yet" do
|
||||
family = families(:empty)
|
||||
configs = Provider::KrakenAdapter.connection_configs(family: family)
|
||||
|
||||
assert_equal 1, configs.length
|
||||
assert_equal "kraken", configs.first[:key]
|
||||
assert_equal I18n.t("kraken_items.provider_connection.default_name"), configs.first[:name]
|
||||
assert configs.first[:can_connect]
|
||||
end
|
||||
|
||||
test "returns one connection config per credentialed kraken item" do
|
||||
family = families(:dylan_family)
|
||||
first_item = kraken_items(:one)
|
||||
second_item = KrakenItem.create!(
|
||||
family: family,
|
||||
name: "Business Kraken",
|
||||
api_key: "second_kraken_key",
|
||||
api_secret: "second_kraken_secret"
|
||||
)
|
||||
|
||||
configs = Provider::KrakenAdapter.connection_configs(family: family)
|
||||
|
||||
assert_equal [ "kraken_#{second_item.id}", "kraken_#{first_item.id}" ], configs.map { |config| config[:key] }
|
||||
assert_equal [
|
||||
I18n.t("kraken_items.provider_connection.name", name: second_item.name),
|
||||
I18n.t("kraken_items.provider_connection.name", name: first_item.name)
|
||||
], configs.map { |config| config[:name] }
|
||||
|
||||
new_account_uri = URI.parse(configs.first[:new_account_path].call("Crypto", "/accounts"))
|
||||
assert_equal "/kraken_items/select_accounts", new_account_uri.path
|
||||
assert_includes new_account_uri.query, "kraken_item_id=#{second_item.id}"
|
||||
|
||||
existing_account_uri = URI.parse(configs.first[:existing_account_path].call(accounts(:crypto).id))
|
||||
assert_equal "/kraken_items/select_existing_account", existing_account_uri.path
|
||||
assert_includes existing_account_uri.query, "kraken_item_id=#{second_item.id}"
|
||||
end
|
||||
|
||||
test "connection configs ignore whitespace-only credentials" do
|
||||
family = families(:dylan_family)
|
||||
blank_item = KrakenItem.create!(
|
||||
family: family,
|
||||
name: "Blank Kraken",
|
||||
api_key: "temporary_key",
|
||||
api_secret: "temporary_secret"
|
||||
)
|
||||
blank_item.update_columns(api_key: " ", api_secret: " ")
|
||||
|
||||
configs = Provider::KrakenAdapter.connection_configs(family: family)
|
||||
|
||||
assert_equal [ "kraken_#{kraken_items(:one).id}" ], configs.map { |config| config[:key] }
|
||||
end
|
||||
|
||||
test "build_provider returns nil when family is nil" do
|
||||
assert_nil Provider::KrakenAdapter.build_provider(family: nil)
|
||||
end
|
||||
|
||||
test "build_provider returns nil when family has no kraken items" do
|
||||
assert_nil Provider::KrakenAdapter.build_provider(family: families(:empty))
|
||||
end
|
||||
|
||||
test "build_provider returns Kraken provider when only one credentialed item exists" do
|
||||
provider = Provider::KrakenAdapter.build_provider(family: families(:dylan_family))
|
||||
|
||||
assert_instance_of Provider::Kraken, provider
|
||||
end
|
||||
|
||||
test "build_provider requires explicit item when multiple credentialed items exist" do
|
||||
family = families(:dylan_family)
|
||||
KrakenItem.create!(
|
||||
family: family,
|
||||
name: "Second Kraken",
|
||||
api_key: "second_kraken_key",
|
||||
api_secret: "second_kraken_secret"
|
||||
)
|
||||
|
||||
assert_nil Provider::KrakenAdapter.build_provider(family: family)
|
||||
end
|
||||
|
||||
test "build_provider uses explicit kraken item credentials" do
|
||||
family = families(:dylan_family)
|
||||
second_item = KrakenItem.create!(
|
||||
family: family,
|
||||
name: "Second Kraken",
|
||||
api_key: " second_kraken_key \n",
|
||||
api_secret: " second_kraken_secret \n"
|
||||
)
|
||||
|
||||
provider = Provider::KrakenAdapter.build_provider(family: family, kraken_item_id: second_item.id)
|
||||
|
||||
assert_instance_of Provider::Kraken, provider
|
||||
assert_equal "second_kraken_key", provider.api_key
|
||||
assert_equal "second_kraken_secret", provider.api_secret
|
||||
end
|
||||
|
||||
test "build_provider refuses kraken items outside the family" do
|
||||
family = families(:dylan_family)
|
||||
other_item = KrakenItem.create!(
|
||||
family: families(:empty),
|
||||
name: "Other Kraken",
|
||||
api_key: "other_kraken_key",
|
||||
api_secret: "other_kraken_secret"
|
||||
)
|
||||
|
||||
assert_nil Provider::KrakenAdapter.build_provider(family: family, kraken_item_id: other_item.id)
|
||||
end
|
||||
|
||||
test "build_provider refuses explicit kraken item without usable credentials" do
|
||||
family = families(:dylan_family)
|
||||
blank_item = KrakenItem.create!(
|
||||
family: family,
|
||||
name: "Blank Kraken",
|
||||
api_key: "temporary_key",
|
||||
api_secret: "temporary_secret"
|
||||
)
|
||||
blank_item.update_columns(api_key: " ", api_secret: " ")
|
||||
|
||||
assert_nil Provider::KrakenAdapter.build_provider(family: family, kraken_item_id: blank_item.id)
|
||||
end
|
||||
end
|
||||
163
test/models/provider/kraken_test.rb
Normal file
163
test/models/provider/kraken_test.rb
Normal file
@@ -0,0 +1,163 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "test_helper"
|
||||
require "base64"
|
||||
|
||||
class Provider::KrakenTest < ActiveSupport::TestCase
|
||||
# Public Kraken docs signing sample, stored as bytes so secret scanners do
|
||||
# not mistake the test vector for an accidentally committed credential.
|
||||
OFFICIAL_SAMPLE_SECRET_BYTES = [
|
||||
145, 1, 249, 29, 111, 252, 167, 91, 134, 57, 88, 219, 129, 96, 59, 22,
|
||||
233, 192, 152, 99, 188, 150, 196, 148, 92, 219, 46, 221, 234, 48, 239,
|
||||
171, 51, 243, 132, 53, 241, 245, 177, 159, 36, 115, 4, 112, 157, 222,
|
||||
151, 121, 156, 79, 106, 107, 223, 71, 1, 155, 110, 102, 232, 250, 23,
|
||||
88, 110, 94
|
||||
].freeze
|
||||
OFFICIAL_SAMPLE_SIGNATURE = "4/dpxb3iT4tp/ZCVEwSnEsLxx0bqyhLpdfOpc6fn7OR8+UClSV5n9E6aSS8MPtnRfp32bAb0nmbRn6H8ndwLUQ=="
|
||||
|
||||
setup do
|
||||
@provider = Provider::Kraken.new(api_key: "test_key", api_secret: official_sample_secret, nonce_generator: -> { "1616492376594" })
|
||||
end
|
||||
|
||||
test "sign matches official Kraken Spot REST sample" do
|
||||
params = {
|
||||
"nonce" => "1616492376594",
|
||||
"ordertype" => "limit",
|
||||
"pair" => "XBTUSD",
|
||||
"price" => "37500",
|
||||
"type" => "buy",
|
||||
"volume" => "1.25"
|
||||
}
|
||||
|
||||
signature = @provider.send(:sign, "/0/private/AddOrder", params)
|
||||
|
||||
assert_equal OFFICIAL_SAMPLE_SIGNATURE, signature
|
||||
end
|
||||
|
||||
test "auth headers include api key and signature" do
|
||||
headers = @provider.send(:auth_headers, "/0/private/BalanceEx", { "nonce" => "1616492376594" })
|
||||
|
||||
assert_equal "test_key", headers["API-Key"]
|
||||
assert headers["API-Sign"].present?
|
||||
assert_equal 64, Base64.strict_decode64(headers["API-Sign"]).bytesize
|
||||
end
|
||||
|
||||
test "private requests send signed post body and auth headers" do
|
||||
response = mock_httparty_response(200, { "error" => [], "result" => { "name" => "Sure read-only" } })
|
||||
|
||||
Provider::Kraken.expects(:post)
|
||||
.with(
|
||||
"/0/private/GetApiKeyInfo",
|
||||
has_entries(
|
||||
body: "nonce=1616492376594",
|
||||
headers: has_entries("API-Key" => "test_key", "Content-Type" => "application/x-www-form-urlencoded")
|
||||
)
|
||||
)
|
||||
.returns(response)
|
||||
|
||||
assert_equal({ "name" => "Sure read-only" }, @provider.get_api_key_info)
|
||||
end
|
||||
|
||||
test "handle response returns result on success" do
|
||||
response = mock_httparty_response(200, { "error" => [], "result" => { "XXBT" => { "balance" => "1.0" } } })
|
||||
|
||||
assert_equal({ "XXBT" => { "balance" => "1.0" } }, @provider.send(:handle_response, response))
|
||||
end
|
||||
|
||||
test "handle response raises api error for non 2xx" do
|
||||
response = mock_httparty_response(500, { "error" => [ "EService:Unavailable" ] })
|
||||
|
||||
assert_raises(Provider::Kraken::ApiError) do
|
||||
@provider.send(:handle_response, response)
|
||||
end
|
||||
end
|
||||
|
||||
test "handle response rejects non-envelope payloads" do
|
||||
response = mock_httparty_response(200, [ "not", "an", "envelope" ])
|
||||
|
||||
error = assert_raises(Provider::Kraken::ApiError) do
|
||||
@provider.send(:handle_response, response)
|
||||
end
|
||||
|
||||
assert_equal "Malformed Kraken API response", error.message
|
||||
end
|
||||
|
||||
test "handle response requires error key" do
|
||||
response = mock_httparty_response(200, { "result" => {} })
|
||||
|
||||
error = assert_raises(Provider::Kraken::ApiError) do
|
||||
@provider.send(:handle_response, response)
|
||||
end
|
||||
|
||||
assert_equal "Malformed Kraken API response: missing error", error.message
|
||||
end
|
||||
|
||||
test "handle response requires result key" do
|
||||
response = mock_httparty_response(200, { "error" => [] })
|
||||
|
||||
error = assert_raises(Provider::Kraken::ApiError) do
|
||||
@provider.send(:handle_response, response)
|
||||
end
|
||||
|
||||
assert_equal "Malformed Kraken API response: missing result", error.message
|
||||
end
|
||||
|
||||
test "handle response maps invalid key errors" do
|
||||
assert_raises(Provider::Kraken::AuthenticationError) do
|
||||
@provider.send(:handle_response, kraken_error_response("EAPI:Invalid key"))
|
||||
end
|
||||
end
|
||||
|
||||
test "handle response maps invalid signature errors" do
|
||||
assert_raises(Provider::Kraken::AuthenticationError) do
|
||||
@provider.send(:handle_response, kraken_error_response("EAPI:Invalid signature"))
|
||||
end
|
||||
end
|
||||
|
||||
test "handle response maps permission errors" do
|
||||
assert_raises(Provider::Kraken::PermissionError) do
|
||||
@provider.send(:handle_response, kraken_error_response("EGeneral:Permission denied"))
|
||||
end
|
||||
end
|
||||
|
||||
test "handle response maps rate limit errors" do
|
||||
assert_raises(Provider::Kraken::RateLimitError) do
|
||||
@provider.send(:handle_response, kraken_error_response("EAPI:Rate limit exceeded"))
|
||||
end
|
||||
end
|
||||
|
||||
test "handle response maps throttled errors as rate limits" do
|
||||
assert_raises(Provider::Kraken::RateLimitError) do
|
||||
@provider.send(:handle_response, kraken_error_response("EService:Throttled: 1770000000"))
|
||||
end
|
||||
end
|
||||
|
||||
test "handle response maps nonce errors" do
|
||||
assert_raises(Provider::Kraken::NonceError) do
|
||||
@provider.send(:handle_response, kraken_error_response("EAPI:Invalid nonce"))
|
||||
end
|
||||
end
|
||||
|
||||
test "handle response maps otp required errors" do
|
||||
assert_raises(Provider::Kraken::OTPRequiredError) do
|
||||
@provider.send(:handle_response, kraken_error_response("EAPI:Invalid arguments:otp required"))
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def official_sample_secret
|
||||
Base64.strict_encode64(OFFICIAL_SAMPLE_SECRET_BYTES.pack("C*"))
|
||||
end
|
||||
|
||||
def kraken_error_response(error)
|
||||
mock_httparty_response(200, { "error" => [ error ], "result" => nil })
|
||||
end
|
||||
|
||||
def mock_httparty_response(code, body)
|
||||
response = mock
|
||||
response.stubs(:code).returns(code)
|
||||
response.stubs(:parsed_response).returns(body)
|
||||
response
|
||||
end
|
||||
end
|
||||
@@ -78,4 +78,15 @@ class ProviderConnectionStatusTest < ActiveSupport::TestCase
|
||||
assert_equal 1, status.dig(:accounts, :linked_count)
|
||||
assert_equal 1, status.dig(:accounts, :unlinked_count)
|
||||
end
|
||||
|
||||
test "kraken provider status is included without credential fields" do
|
||||
statuses = ProviderConnectionStatus.for_family(families(:dylan_family))
|
||||
kraken_status = statuses.find { |status| status[:provider] == "kraken" }
|
||||
|
||||
assert kraken_status
|
||||
assert_equal "KrakenItem", kraken_status[:provider_type]
|
||||
refute_includes kraken_status.keys, :api_key
|
||||
refute_includes kraken_status.keys, :api_secret
|
||||
assert_equal true, kraken_status[:credentials_configured]
|
||||
end
|
||||
end
|
||||
|
||||
@@ -738,4 +738,326 @@ class RecurringTransactionTest < ActiveSupport::TestCase
|
||||
assert recurring_b.present?
|
||||
assert_not_equal recurring_a, recurring_b
|
||||
end
|
||||
|
||||
# ----- Recurring transfers (issue #895 / discussion #1224) -----
|
||||
|
||||
test "transfer? is false when destination_account is absent" do
|
||||
rt = @family.recurring_transactions.create!(
|
||||
account: @account,
|
||||
name: "Spotify",
|
||||
amount: 9.99,
|
||||
currency: "USD",
|
||||
expected_day_of_month: 5,
|
||||
last_occurrence_date: Date.current,
|
||||
next_expected_date: 5.days.from_now.to_date,
|
||||
manual: true
|
||||
)
|
||||
assert_not rt.transfer?
|
||||
end
|
||||
|
||||
test "transfer? is true when destination_account is present" do
|
||||
destination = accounts(:credit_card)
|
||||
rt = @family.recurring_transactions.create!(
|
||||
account: @account,
|
||||
destination_account: destination,
|
||||
name: "Transfer to #{destination.name}",
|
||||
amount: 500,
|
||||
currency: "USD",
|
||||
expected_day_of_month: 1,
|
||||
last_occurrence_date: Date.current,
|
||||
next_expected_date: 1.month.from_now.to_date,
|
||||
manual: true
|
||||
)
|
||||
assert rt.transfer?
|
||||
end
|
||||
|
||||
test "validation rejects same source and destination accounts" do
|
||||
rt = @family.recurring_transactions.build(
|
||||
account: @account,
|
||||
destination_account: @account,
|
||||
name: "Self-transfer",
|
||||
amount: 100,
|
||||
currency: "USD",
|
||||
expected_day_of_month: 1,
|
||||
last_occurrence_date: Date.current,
|
||||
next_expected_date: 1.month.from_now.to_date,
|
||||
manual: true
|
||||
)
|
||||
assert_not rt.valid?
|
||||
assert_includes rt.errors[:destination_account], "cannot be the same as the source account"
|
||||
end
|
||||
|
||||
test "validation rejects dangling source account_id (account does not exist)" do
|
||||
rt = @family.recurring_transactions.build(
|
||||
account_id: SecureRandom.uuid, # references nothing
|
||||
destination_account: accounts(:credit_card),
|
||||
name: "Phantom source",
|
||||
amount: 100,
|
||||
currency: "USD",
|
||||
expected_day_of_month: 1,
|
||||
last_occurrence_date: Date.current,
|
||||
next_expected_date: 1.month.from_now.to_date,
|
||||
manual: true
|
||||
)
|
||||
assert_not rt.valid?
|
||||
assert_includes rt.errors[:account], "must exist"
|
||||
end
|
||||
|
||||
test "validation rejects dangling destination_account_id (account does not exist)" do
|
||||
rt = @family.recurring_transactions.build(
|
||||
account: @account,
|
||||
destination_account_id: SecureRandom.uuid, # references nothing
|
||||
name: "Phantom transfer",
|
||||
amount: 100,
|
||||
currency: "USD",
|
||||
expected_day_of_month: 1,
|
||||
last_occurrence_date: Date.current,
|
||||
next_expected_date: 1.month.from_now.to_date,
|
||||
manual: true
|
||||
)
|
||||
assert_not rt.valid?
|
||||
assert_includes rt.errors[:destination_account], "must exist"
|
||||
end
|
||||
|
||||
test "validation rejects destination on different family" do
|
||||
other_family = Family.create!(name: "Other", locale: "en", date_format: "%Y-%m-%d", currency: "USD")
|
||||
other_account = other_family.accounts.create!(name: "Other depository", balance: 0, currency: "USD", accountable: Depository.new)
|
||||
|
||||
rt = @family.recurring_transactions.build(
|
||||
account: @account,
|
||||
destination_account: other_account,
|
||||
name: "Foreign transfer",
|
||||
amount: 100,
|
||||
currency: "USD",
|
||||
expected_day_of_month: 1,
|
||||
last_occurrence_date: Date.current,
|
||||
next_expected_date: 1.month.from_now.to_date,
|
||||
manual: true
|
||||
)
|
||||
assert_not rt.valid?
|
||||
assert_includes rt.errors[:destination_account], "must belong to the same family as the source account"
|
||||
end
|
||||
|
||||
test "create_from_transfer builds a recurring transfer with both endpoints" do
|
||||
source = @account
|
||||
destination = accounts(:credit_card)
|
||||
|
||||
outflow_entry = source.entries.create!(
|
||||
date: 5.days.ago.to_date, amount: 250, currency: "USD",
|
||||
name: "Manual transfer",
|
||||
entryable: Transaction.new(kind: "standard")
|
||||
)
|
||||
inflow_entry = destination.entries.create!(
|
||||
date: 5.days.ago.to_date, amount: -250, currency: "USD",
|
||||
name: "Manual transfer",
|
||||
entryable: Transaction.new(kind: "standard")
|
||||
)
|
||||
transfer = Transfer.create!(
|
||||
outflow_transaction: outflow_entry.entryable,
|
||||
inflow_transaction: inflow_entry.entryable
|
||||
)
|
||||
|
||||
rt = RecurringTransaction.create_from_transfer(transfer)
|
||||
|
||||
assert rt.transfer?
|
||||
assert_equal source, rt.account
|
||||
assert_equal destination, rt.destination_account
|
||||
assert_equal 250, rt.amount
|
||||
assert_equal "USD", rt.currency
|
||||
assert_equal 5.days.ago.to_date.day, rt.expected_day_of_month
|
||||
assert rt.manual?
|
||||
assert_equal "active", rt.status
|
||||
end
|
||||
|
||||
test "projected_entry exposes source and destination on a recurring transfer" do
|
||||
destination = accounts(:credit_card)
|
||||
rt = @family.recurring_transactions.create!(
|
||||
account: @account,
|
||||
destination_account: destination,
|
||||
name: "Transfer to #{destination.name}",
|
||||
amount: 500,
|
||||
currency: "USD",
|
||||
expected_day_of_month: 15,
|
||||
last_occurrence_date: Date.current,
|
||||
next_expected_date: 15.days.from_now.to_date,
|
||||
manual: true
|
||||
)
|
||||
|
||||
projected = rt.projected_entry
|
||||
assert projected.transfer
|
||||
assert_equal @account, projected.source_account
|
||||
assert_equal destination, projected.destination_account
|
||||
assert_equal 500, projected.amount
|
||||
assert_equal "USD", projected.currency
|
||||
end
|
||||
|
||||
test "Identifier skips transfer-kind transactions" do
|
||||
# Three depository transactions tagged as funds_movement (e.g. they're
|
||||
# one half of a Transfer pair). Identifier shouldn't latch onto these
|
||||
# as a single-account "pattern" because the underlying flow is two-
|
||||
# account and is tracked on a different shape (destination_account_id).
|
||||
[ 0, 1, 2 ].each do |months_ago|
|
||||
transaction = Transaction.create!(merchant: @merchant, kind: "funds_movement")
|
||||
@account.entries.create!(
|
||||
date: months_ago.months.ago.beginning_of_month + 5.days,
|
||||
amount: 50.00,
|
||||
currency: "USD",
|
||||
name: "Recurring transfer half",
|
||||
entryable: transaction
|
||||
)
|
||||
end
|
||||
|
||||
assert_no_difference "@family.recurring_transactions.count" do
|
||||
RecurringTransaction.identify_patterns_for!(@family)
|
||||
end
|
||||
end
|
||||
|
||||
test "Identifier creates a pattern from expense halves while ignoring co-resident transfer halves" do
|
||||
# Same merchant, amount, day-of-month: 3 standard expenses + 3 transfer halves.
|
||||
# Without the TRANSFER_KINDS filter, the identifier would either double-count
|
||||
# (six occurrences) or surface a weird pattern. With the filter, only the
|
||||
# expense pattern is created.
|
||||
[ 0, 1, 2 ].each do |months_ago|
|
||||
base_date = months_ago.months.ago.beginning_of_month + 5.days
|
||||
|
||||
@account.entries.create!(
|
||||
date: base_date, amount: 50.00, currency: "USD", name: "Coffee",
|
||||
entryable: Transaction.create!(merchant: @merchant, kind: "standard")
|
||||
)
|
||||
@account.entries.create!(
|
||||
date: base_date, amount: 50.00, currency: "USD", name: "Half of transfer",
|
||||
entryable: Transaction.create!(merchant: @merchant, kind: "funds_movement")
|
||||
)
|
||||
end
|
||||
|
||||
assert_difference "@family.recurring_transactions.count", 1 do
|
||||
RecurringTransaction.identify_patterns_for!(@family)
|
||||
end
|
||||
assert_nil @family.recurring_transactions.last.destination_account_id
|
||||
end
|
||||
|
||||
test "create_from_transfer name reflects Transfer#name (Payment vs Transfer based on destination)" do
|
||||
# Transfer#name returns "Payment to ..." for liability destinations
|
||||
# and "Transfer to ..." otherwise, mirroring Transfer::Creator's
|
||||
# name_prefix logic. The recurring row should pick that up rather
|
||||
# than hard-coding "Transfer to ...".
|
||||
source = @account
|
||||
cc_destination = accounts(:credit_card) # liability
|
||||
outflow = source.entries.create!(
|
||||
date: 5.days.ago.to_date, amount: 100, currency: "USD",
|
||||
name: "raw", entryable: Transaction.new(kind: "standard")
|
||||
)
|
||||
inflow = cc_destination.entries.create!(
|
||||
date: 5.days.ago.to_date, amount: -100, currency: "USD",
|
||||
name: "raw", entryable: Transaction.new(kind: "standard")
|
||||
)
|
||||
transfer = Transfer.create!(
|
||||
outflow_transaction: outflow.entryable, inflow_transaction: inflow.entryable
|
||||
)
|
||||
|
||||
rt = RecurringTransaction.create_from_transfer(transfer)
|
||||
assert_equal "Payment to #{cc_destination.name}", rt.name
|
||||
end
|
||||
|
||||
test "create_from_transfer stores source-side currency on multi-currency transfers" do
|
||||
source = @account # USD depository
|
||||
destination = @family.accounts.create!(
|
||||
name: "EUR cash", balance: 0, currency: "EUR", accountable: Depository.new
|
||||
)
|
||||
outflow_entry = source.entries.create!(
|
||||
date: 5.days.ago.to_date, amount: 100, currency: "USD",
|
||||
name: "FX transfer", entryable: Transaction.new(kind: "standard")
|
||||
)
|
||||
inflow_entry = destination.entries.create!(
|
||||
date: 5.days.ago.to_date, amount: -92, currency: "EUR",
|
||||
name: "FX transfer", entryable: Transaction.new(kind: "standard")
|
||||
)
|
||||
transfer = Transfer.create!(
|
||||
outflow_transaction: outflow_entry.entryable,
|
||||
inflow_transaction: inflow_entry.entryable
|
||||
)
|
||||
|
||||
rt = RecurringTransaction.create_from_transfer(transfer)
|
||||
assert_equal "USD", rt.currency, "stores source-side currency"
|
||||
assert_equal 100, rt.amount, "stores source-side amount"
|
||||
end
|
||||
|
||||
test "destroying the destination account cascades to inbound recurring transfers" do
|
||||
source = @account
|
||||
destination = accounts(:credit_card)
|
||||
rt = @family.recurring_transactions.create!(
|
||||
account: source, destination_account: destination,
|
||||
name: "Transfer to CC", amount: 250, currency: "USD",
|
||||
expected_day_of_month: 1, last_occurrence_date: Date.current,
|
||||
next_expected_date: 1.month.from_now.to_date, manual: true
|
||||
)
|
||||
|
||||
assert_difference -> { RecurringTransaction.count }, -1 do
|
||||
destination.destroy
|
||||
end
|
||||
assert_not RecurringTransaction.exists?(rt.id)
|
||||
end
|
||||
|
||||
test "Cleaner skips recurring transfers so they aren't mistakenly marked inactive" do
|
||||
# `matching_transactions` is single-account name/amount-based and never
|
||||
# matches a Transfer pair, so without the skip the recurring transfer
|
||||
# would flip to inactive at the 6-month threshold even when the user
|
||||
# is still doing the transfer monthly. Issue #1590 tracks the proper
|
||||
# pair-detection fix.
|
||||
rt = @family.recurring_transactions.create!(
|
||||
account: @account, destination_account: accounts(:credit_card),
|
||||
name: "Transfer to CC", amount: 250, currency: "USD",
|
||||
expected_day_of_month: 5,
|
||||
last_occurrence_date: 7.months.ago.to_date,
|
||||
next_expected_date: 5.days.from_now.to_date,
|
||||
manual: true
|
||||
)
|
||||
assert rt.should_be_inactive?, "guard sanity: row would be marked inactive without the skip"
|
||||
|
||||
RecurringTransaction.cleanup_stale_for(@family)
|
||||
assert_equal "active", rt.reload.status
|
||||
end
|
||||
|
||||
test "Identifier#update_manual_recurring_transactions skips recurring transfers" do
|
||||
# Same reasoning as the Cleaner skip. Without the guard, the helper
|
||||
# would call find_matching_transaction_entries (single-account, by
|
||||
# name) on a transfer row and silently overwrite its variance /
|
||||
# occurrence_count with []. The variance fields should stay nil.
|
||||
rt = @family.recurring_transactions.create!(
|
||||
account: @account, destination_account: accounts(:credit_card),
|
||||
name: "Transfer to CC", amount: 500, currency: "USD",
|
||||
expected_day_of_month: 1,
|
||||
last_occurrence_date: Date.current,
|
||||
next_expected_date: 1.month.from_now.to_date,
|
||||
manual: true,
|
||||
occurrence_count: 7
|
||||
)
|
||||
|
||||
RecurringTransaction.identify_patterns_for!(@family)
|
||||
|
||||
rt.reload
|
||||
assert_nil rt.expected_amount_min
|
||||
assert_nil rt.expected_amount_max
|
||||
assert_nil rt.expected_amount_avg
|
||||
assert_equal 7, rt.occurrence_count, "occurrence_count must not be overwritten by the manual-recurring update path"
|
||||
end
|
||||
|
||||
test "unique partial index still de-duplicates non-transfer recurring rows after destination widening" do
|
||||
base_attrs = {
|
||||
account: @account,
|
||||
merchant: @merchant,
|
||||
amount: 15.99,
|
||||
currency: "USD",
|
||||
expected_day_of_month: 5,
|
||||
last_occurrence_date: Date.current,
|
||||
next_expected_date: 1.month.from_now.to_date,
|
||||
manual: false,
|
||||
occurrence_count: 3
|
||||
}
|
||||
@family.recurring_transactions.create!(base_attrs)
|
||||
|
||||
assert_raises(ActiveRecord::RecordNotUnique) do
|
||||
@family.recurring_transactions.create!(base_attrs)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -80,6 +80,69 @@ class SimplefinAccount::Investments::HoldingsProcessorTest < ActiveSupport::Test
|
||||
assert_equal "total_cost", source_key
|
||||
end
|
||||
|
||||
test "cost_basis from a known total-basis institution is divided by qty" do
|
||||
# Issue #1718 / #1182: Vanguard populates cost_basis with the total
|
||||
# position cost. When the institution is on the allowlist we divide.
|
||||
cost_basis = @processor.send(
|
||||
:normalize_cost_basis,
|
||||
BigDecimal("22004.40"),
|
||||
BigDecimal("139.00"),
|
||||
"cost_basis",
|
||||
true # institution_reports_total_basis?
|
||||
)
|
||||
|
||||
assert_in_delta 158.30, cost_basis.to_f, 0.01
|
||||
end
|
||||
|
||||
test "basis from a known total-basis institution is divided by qty" do
|
||||
cost_basis = @processor.send(
|
||||
:normalize_cost_basis,
|
||||
BigDecimal("9000.00"),
|
||||
BigDecimal("200"),
|
||||
"basis",
|
||||
true
|
||||
)
|
||||
|
||||
assert_equal BigDecimal("45.00"), cost_basis
|
||||
end
|
||||
|
||||
test "cost_basis from a compliant institution is kept untouched (no false divide)" do
|
||||
# Codex regression: a legitimate per-share basis on a holding with a
|
||||
# large unrealized loss (e.g. $100/share basis now worth $5/share) must
|
||||
# NOT be divided by qty. Per the SimpleFIN spec, cost_basis is per-share
|
||||
# — only the institution allowlist should override that.
|
||||
cost_basis = @processor.send(
|
||||
:normalize_cost_basis,
|
||||
BigDecimal("100.00"),
|
||||
BigDecimal("100"),
|
||||
"cost_basis",
|
||||
false
|
||||
)
|
||||
|
||||
assert_equal BigDecimal("100.00"), cost_basis
|
||||
end
|
||||
|
||||
test "institution_reports_total_basis? matches Vanguard and Fidelity org metadata" do
|
||||
cases = {
|
||||
{ "name" => "Vanguard" } => true,
|
||||
{ "name" => "VANGUARD BROKERAGE" } => true,
|
||||
{ "name" => "Fidelity Investments" } => true,
|
||||
{ "domain" => "vanguard.com" } => true,
|
||||
{ "domain" => "401k.fidelity.com" } => true,
|
||||
{ "name" => "Charles Schwab", "domain" => "schwab.com" } => false,
|
||||
{ "name" => "Chase" } => false,
|
||||
{} => false
|
||||
}
|
||||
|
||||
cases.each do |org, expected|
|
||||
account = Struct.new(:org_data).new(org)
|
||||
processor = SimplefinAccount::Investments::HoldingsProcessor.new(account)
|
||||
assert_equal expected,
|
||||
processor.send(:institution_reports_total_basis?),
|
||||
"org_data #{org.inspect} expected #{expected}"
|
||||
end
|
||||
end
|
||||
|
||||
test "missing cost basis fields return nil" do
|
||||
payload = {
|
||||
"market_value" => "10108.16"
|
||||
|
||||
@@ -70,7 +70,7 @@ class SnaptradeAccount::ActivitiesProcessorTest < ActiveSupport::TestCase
|
||||
assert_equal "Sell", trade.investment_activity_label
|
||||
end
|
||||
|
||||
test "processes dividend cash activity" do
|
||||
test "processes dividend cash activity as negative inflow" do
|
||||
@snaptrade_account.update!(raw_activities_payload: [
|
||||
build_cash_activity(
|
||||
id: "div_001",
|
||||
@@ -89,10 +89,11 @@ class SnaptradeAccount::ActivitiesProcessorTest < ActiveSupport::TestCase
|
||||
assert entry.entryable.is_a?(Transaction), "Entry should be a Transaction"
|
||||
|
||||
transaction = entry.entryable
|
||||
assert_equal(-25.50, entry.amount.to_f)
|
||||
assert_equal "Dividend", transaction.investment_activity_label
|
||||
end
|
||||
|
||||
test "processes contribution with positive amount" do
|
||||
test "processes contribution with negative inflow amount" do
|
||||
@snaptrade_account.update!(raw_activities_payload: [
|
||||
build_cash_activity(
|
||||
id: "contrib_001",
|
||||
@@ -107,12 +108,11 @@ class SnaptradeAccount::ActivitiesProcessorTest < ActiveSupport::TestCase
|
||||
|
||||
entry = @account.entries.find_by(external_id: "contrib_001", source: "snaptrade")
|
||||
assert_not_nil entry
|
||||
# Amount is on entry, not transaction
|
||||
assert_equal 500.00, entry.amount.to_f # Positive for contributions
|
||||
assert_equal(-500.00, entry.amount.to_f)
|
||||
assert_equal "Contribution", entry.entryable.investment_activity_label
|
||||
end
|
||||
|
||||
test "processes withdrawal with negative amount" do
|
||||
test "processes withdrawal with positive outflow amount" do
|
||||
@snaptrade_account.update!(raw_activities_payload: [
|
||||
build_cash_activity(
|
||||
id: "withdraw_001",
|
||||
@@ -127,10 +127,40 @@ class SnaptradeAccount::ActivitiesProcessorTest < ActiveSupport::TestCase
|
||||
|
||||
entry = @account.entries.find_by(external_id: "withdraw_001", source: "snaptrade")
|
||||
assert_not_nil entry
|
||||
assert_equal(-200.00, entry.amount.to_f) # Negative for withdrawals
|
||||
assert_equal 200.00, entry.amount.to_f
|
||||
assert_equal "Withdrawal", entry.entryable.investment_activity_label
|
||||
end
|
||||
|
||||
test "processes transfers with Sure sign convention" do
|
||||
@snaptrade_account.update!(raw_activities_payload: [
|
||||
build_cash_activity(
|
||||
id: "transfer_in_001",
|
||||
type: "TRANSFER_IN",
|
||||
amount: 300.00,
|
||||
settlement_date: Date.current.to_s
|
||||
),
|
||||
build_cash_activity(
|
||||
id: "transfer_out_001",
|
||||
type: "TRANSFER_OUT",
|
||||
amount: 125.00,
|
||||
settlement_date: Date.current.to_s
|
||||
)
|
||||
])
|
||||
|
||||
processor = SnaptradeAccount::ActivitiesProcessor.new(@snaptrade_account)
|
||||
processor.process
|
||||
|
||||
transfer_in = @account.entries.find_by(external_id: "transfer_in_001", source: "snaptrade")
|
||||
transfer_out = @account.entries.find_by(external_id: "transfer_out_001", source: "snaptrade")
|
||||
|
||||
assert_not_nil transfer_in
|
||||
assert_not_nil transfer_out
|
||||
assert_equal(-300.00, transfer_in.amount.to_f)
|
||||
assert_equal 125.00, transfer_out.amount.to_f
|
||||
assert_equal "Transfer", transfer_in.entryable.investment_activity_label
|
||||
assert_equal "Transfer", transfer_out.entryable.investment_activity_label
|
||||
end
|
||||
|
||||
test "maps all known activity types correctly" do
|
||||
type_mappings = {
|
||||
"BUY" => "Buy",
|
||||
|
||||
@@ -129,6 +129,36 @@ class SnaptradeAccountProcessorTest < ActiveSupport::TestCase
|
||||
assert_equal 0, @account.holdings.count
|
||||
end
|
||||
|
||||
test "processor trusts API total for multi-currency holdings" do
|
||||
security = securities(:aapl)
|
||||
Account.any_instance.stubs(:set_current_balance)
|
||||
|
||||
@snaptrade_account.update!(
|
||||
currency: "CHF",
|
||||
current_balance: BigDecimal("15000.00"),
|
||||
cash_balance: BigDecimal("1000.00"),
|
||||
raw_holdings_payload: [
|
||||
{
|
||||
"symbol" => {
|
||||
"symbol" => { "symbol" => security.ticker, "description" => security.name }
|
||||
},
|
||||
"units" => "10",
|
||||
"price" => "150.00",
|
||||
"currency" => "USD",
|
||||
"average_purchase_price" => "125.50"
|
||||
}
|
||||
],
|
||||
raw_activities_payload: []
|
||||
)
|
||||
|
||||
SnaptradeAccount::Processor.new(@snaptrade_account).process
|
||||
|
||||
@account.reload
|
||||
assert_equal BigDecimal("15000.00"), @account.balance
|
||||
assert_equal BigDecimal("1000.00"), @account.cash_balance
|
||||
assert_equal "CHF", @account.currency
|
||||
end
|
||||
|
||||
# === ActivitiesProcessor Tests ===
|
||||
|
||||
test "activities processor maps BUY type to Buy label" do
|
||||
@@ -210,7 +240,7 @@ class SnaptradeAccountProcessorTest < ActiveSupport::TestCase
|
||||
assert_equal "Dividend", tx_entry.entryable.investment_activity_label
|
||||
end
|
||||
|
||||
test "activities processor normalizes withdrawal as negative amount" do
|
||||
test "activities processor normalizes withdrawal as positive outflow amount" do
|
||||
@snaptrade_account.update!(
|
||||
raw_activities_payload: [
|
||||
{
|
||||
@@ -228,7 +258,7 @@ class SnaptradeAccountProcessorTest < ActiveSupport::TestCase
|
||||
|
||||
assert_equal 1, result[:transactions]
|
||||
tx_entry = @account.entries.find_by(external_id: "activity_withdraw_1")
|
||||
assert tx_entry.amount.negative?
|
||||
assert_equal 1000.00, tx_entry.amount.to_f
|
||||
end
|
||||
|
||||
test "activities processor skips activities without external_id" do
|
||||
|
||||
@@ -75,4 +75,42 @@ class SnaptradeItemTest < ActiveSupport::TestCase
|
||||
provider = item.snaptrade_provider
|
||||
assert_instance_of Provider::Snaptrade, provider
|
||||
end
|
||||
|
||||
test "orphaned_users only includes users for the same family" do
|
||||
item = SnaptradeItem.new(
|
||||
family: @family,
|
||||
name: "Test",
|
||||
client_id: "test",
|
||||
consumer_key: "test",
|
||||
snaptrade_user_id: "family_#{@family.id}_111",
|
||||
snaptrade_user_secret: "secret"
|
||||
)
|
||||
|
||||
item.stubs(:list_all_users).returns([
|
||||
"family_#{@family.id}_111",
|
||||
"family_#{@family.id}_222",
|
||||
"family_999_333",
|
||||
"legacy_user_444"
|
||||
])
|
||||
|
||||
assert_equal([ "family_#{@family.id}_222" ], item.orphaned_users)
|
||||
end
|
||||
|
||||
test "delete_orphaned_user rejects users outside the current family namespace" do
|
||||
item = SnaptradeItem.new(
|
||||
family: @family,
|
||||
name: "Test",
|
||||
client_id: "test",
|
||||
consumer_key: "test",
|
||||
snaptrade_user_id: "family_#{@family.id}_111",
|
||||
snaptrade_user_secret: "secret"
|
||||
)
|
||||
|
||||
provider = mock
|
||||
provider.expects(:delete_user).never
|
||||
item.stubs(:snaptrade_provider).returns(provider)
|
||||
|
||||
assert_not item.delete_orphaned_user("family_999_222")
|
||||
assert_not item.delete_orphaned_user("legacy_user_333")
|
||||
end
|
||||
end
|
||||
|
||||
@@ -37,9 +37,37 @@ class SureImportTest < ActiveSupport::TestCase
|
||||
end
|
||||
|
||||
test "max_row_count is higher than standard imports" do
|
||||
assert_equal 100_000, SureImport.max_row_count
|
||||
assert_equal 100_000, @import.max_row_count
|
||||
end
|
||||
|
||||
test "dry_run totals can be derived from existing line type counts" do
|
||||
counts = {
|
||||
"Account" => 2,
|
||||
"Transaction" => 3,
|
||||
"UnknownType" => 4
|
||||
}
|
||||
|
||||
dry_run = SureImport.dry_run_totals_from_line_type_counts(counts)
|
||||
|
||||
assert_equal 2, dry_run[:accounts]
|
||||
assert_equal 3, dry_run[:transactions]
|
||||
assert_equal 0, dry_run[:categories]
|
||||
assert_not dry_run.key?(:unknown_type)
|
||||
end
|
||||
|
||||
test "ndjson line type counts ignore records without data" do
|
||||
ndjson = [
|
||||
{ type: "Account", data: { id: "uuid-1" } },
|
||||
{ type: "Transaction" },
|
||||
{ data: { id: "uuid-2" } }
|
||||
].map(&:to_json).join("\n")
|
||||
|
||||
counts = SureImport.ndjson_line_type_counts(ndjson)
|
||||
|
||||
assert_equal({ "Account" => 1 }, counts)
|
||||
end
|
||||
|
||||
test "csv_template returns nil" do
|
||||
assert_nil @import.csv_template
|
||||
end
|
||||
|
||||
@@ -52,6 +52,30 @@ class TradeTest < ActiveSupport::TestCase
|
||||
assert_equal 0, trade.fee
|
||||
end
|
||||
|
||||
test "exchange_rate setter stores normalized numeric value in extra" do
|
||||
trade = Trade.new
|
||||
trade.exchange_rate = "0.91"
|
||||
|
||||
assert_equal 0.91, trade.exchange_rate
|
||||
assert_equal 0.91, trade.extra["exchange_rate"]
|
||||
end
|
||||
|
||||
test "exchange_rate validation rejects invalid values" do
|
||||
trade = Trade.new
|
||||
trade.exchange_rate = "invalid"
|
||||
|
||||
assert_not trade.valid?
|
||||
assert_includes trade.errors[:exchange_rate], "must be a number"
|
||||
end
|
||||
|
||||
test "exchange_rate validation rejects non-finite values" do
|
||||
trade = Trade.new
|
||||
trade.exchange_rate = "NaN"
|
||||
|
||||
assert_not trade.valid?
|
||||
assert_includes trade.errors[:exchange_rate], "must be a number"
|
||||
end
|
||||
|
||||
test "price is rounded to 10 decimal places" do
|
||||
security = Security.create!(ticker: "TEST", exchange_operating_mic: "XNAS")
|
||||
|
||||
|
||||
28
test/models/transaction/activity_security_preloader_test.rb
Normal file
28
test/models/transaction/activity_security_preloader_test.rb
Normal file
@@ -0,0 +1,28 @@
|
||||
require "test_helper"
|
||||
|
||||
class Transaction::ActivitySecurityPreloaderTest < ActiveSupport::TestCase
|
||||
test "preloads activity securities for transactions" do
|
||||
transaction = Transaction.new(extra: { "security_id" => securities(:aapl).id })
|
||||
|
||||
Transaction::ActivitySecurityPreloader.new([ transaction ]).preload
|
||||
|
||||
assert_equal securities(:aapl), transaction.activity_security
|
||||
end
|
||||
|
||||
test "preloads activity securities for entry collections" do
|
||||
transaction = Transaction.new(extra: { "security_id" => securities(:aapl).id })
|
||||
entry = Entry.new(account: accounts(:depository), entryable: transaction, date: Date.current, name: "Dividend", amount: 10, currency: "USD")
|
||||
|
||||
Transaction::ActivitySecurityPreloader.new([ entry ]).preload
|
||||
|
||||
assert_equal securities(:aapl), transaction.activity_security
|
||||
end
|
||||
|
||||
test "sets nil when the referenced security cannot be found" do
|
||||
transaction = Transaction.new(extra: { "security_id" => SecureRandom.uuid })
|
||||
|
||||
Transaction::ActivitySecurityPreloader.new([ transaction ]).preload
|
||||
|
||||
assert_nil transaction.activity_security
|
||||
end
|
||||
end
|
||||
@@ -100,7 +100,7 @@ class TransactionImportTest < ActiveSupport::TestCase
|
||||
@import.publish
|
||||
end
|
||||
|
||||
assert_equal [ -100, 200, -300 ], @import.entries.map(&:amount)
|
||||
assert_equal [ -100, 200, -300 ], @import.entries.order(:date).map(&:amount)
|
||||
end
|
||||
|
||||
test "does not create duplicate when matching transaction exists with same name" do
|
||||
|
||||
@@ -100,6 +100,23 @@ class TransactionTest < ActiveSupport::TestCase
|
||||
assert transaction.extra["exchange_rate_invalid"]
|
||||
end
|
||||
|
||||
test "exchange_rate setter rejects non-finite input" do
|
||||
transaction = Transaction.new
|
||||
transaction.exchange_rate = "Infinity"
|
||||
|
||||
assert_equal "Infinity", transaction.extra["exchange_rate"]
|
||||
assert transaction.extra["exchange_rate_invalid"]
|
||||
end
|
||||
|
||||
test "exchange_rate setter clears invalid flag for valid input" do
|
||||
transaction = Transaction.new
|
||||
transaction.exchange_rate = "not a number"
|
||||
transaction.exchange_rate = "1.5"
|
||||
|
||||
assert_equal 1.5, transaction.exchange_rate
|
||||
assert_equal false, transaction.extra["exchange_rate_invalid"]
|
||||
end
|
||||
|
||||
test "exchange_rate validation rejects non-numeric input" do
|
||||
transaction = Transaction.new(
|
||||
category: categories(:income),
|
||||
@@ -139,4 +156,27 @@ class TransactionTest < ActiveSupport::TestCase
|
||||
|
||||
assert transaction.valid?
|
||||
end
|
||||
|
||||
test "activity_security returns the referenced security from extra metadata" do
|
||||
security = securities(:aapl)
|
||||
transaction = Transaction.new(extra: { "security_id" => security.id })
|
||||
|
||||
assert_equal security, transaction.activity_security
|
||||
end
|
||||
|
||||
test "activity_security returns nil when no security metadata is present" do
|
||||
transaction = Transaction.new(extra: {})
|
||||
|
||||
assert_nil transaction.activity_security
|
||||
end
|
||||
|
||||
test "activity_security refreshes when security metadata changes on the same instance" do
|
||||
transaction = Transaction.new(extra: { "security_id" => securities(:aapl).id })
|
||||
|
||||
assert_equal securities(:aapl), transaction.activity_security
|
||||
|
||||
transaction.extra["security_id"] = securities(:msft).id
|
||||
|
||||
assert_equal securities(:msft), transaction.activity_security
|
||||
end
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user