mirror of
https://github.com/we-promise/sure.git
synced 2026-04-07 14:31:25 +00:00
Providers factory (#250)
* Implement providers factory * Multiple providers sync support - Proper Multi-Provider Syncing: When you click sync on an account with multiple providers (e.g., both Plaid and SimpleFin), all provider items are synced - Better API: The existing account.providers method already returns all providers, and account.provider returns the first one for backward compatibility - Correct Holdings Deletion Logic: Holdings can only be deleted if ALL providers allow it, preventing accidental deletions that would be recreated on next sync TODO: validate this is the way we want to go? We would need to check holdings belong to which account, and then check provider allows deletion. More complex - Database Constraints: The existing validations ensure an account can have at most one provider of each type (one PlaidAccount, one SimplefinAccount, etc.) * Add generic provider_import_adapter * Finish unified import strategy * Update app/models/plaid_account.rb Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Signed-off-by: soky srm <sokysrm@gmail.com> * Update app/models/provider/factory.rb Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Signed-off-by: soky srm <sokysrm@gmail.com> * Fix account linked by plaid_id instead of external_id * Parse numerics to BigDecimal Parse numerics to BigDecimal before computing amount; guard nils. Avoid String * String and float drift; also normalize date. * Fix incorrect usage of assert_raises. * Fix linter * Fix processor test. * Update current_balance_manager.rb * Test fixes * Fix plaid linked account test * Add support for holding per account_provider * Fix proper account access Also fix account deletion for simpefin too * FIX match tests for consistency * Some more factory updates * Fix account schema for multipe providers Can do: - Account #1 → PlaidAccount + SimplefinAccount (multiple different providers) - Account #2 → PlaidAccount only - Account #3 → SimplefinAccount only Cannot do: - Account #1 → PlaidAccount + PlaidAccount (duplicate provider type) - PlaidAccount #123 → Account #1 + Account #2 (provider linked to multiple accounts) * Fix account setup - An account CAN have multiple providers (the schema shows account_providers with unique index on [account_id, provider_type]) - Each provider should maintain its own separate entries - We should NOT update one provider's entry when another provider syncs * Fix linter and guard migration * FIX linter issues. * Fixes - Remove duplicated index - Pass account_provider_id - Guard holdings call to avoid NoMethodError * Update schema and provider import fix * Plaid doesn't allow holdings deletion * Use ClimateControl for proper env setup * No need for this in .git --------- Signed-off-by: soky srm <sokysrm@gmail.com> Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
This commit is contained in:
@@ -32,4 +32,29 @@ class AccountsControllerTest < ActionDispatch::IntegrationTest
|
||||
assert_enqueued_with job: DestroyJob
|
||||
assert_equal "Account scheduled for deletion", flash[:notice]
|
||||
end
|
||||
|
||||
test "syncing linked account triggers sync for all provider items" do
|
||||
plaid_account = plaid_accounts(:one)
|
||||
plaid_item = plaid_account.plaid_item
|
||||
AccountProvider.create!(account: @account, provider: plaid_account)
|
||||
|
||||
# Reload to ensure the account has the provider association loaded
|
||||
@account.reload
|
||||
|
||||
# Mock at the class level since controller loads account from DB
|
||||
Account.any_instance.expects(:syncing?).returns(false)
|
||||
PlaidItem.any_instance.expects(:syncing?).returns(false)
|
||||
PlaidItem.any_instance.expects(:sync_later).once
|
||||
|
||||
post sync_account_url(@account)
|
||||
assert_redirected_to account_url(@account)
|
||||
end
|
||||
|
||||
test "syncing unlinked account calls account sync_later" do
|
||||
Account.any_instance.expects(:syncing?).returns(false)
|
||||
Account.any_instance.expects(:sync_later).once
|
||||
|
||||
post sync_account_url(@account)
|
||||
assert_redirected_to account_url(@account)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -179,8 +179,8 @@ class SimplefinItemsControllerTest < ActionDispatch::IntegrationTest
|
||||
# Verify old SimpleFin accounts no longer reference Maybe accounts
|
||||
old_simplefin_account1.reload
|
||||
old_simplefin_account2.reload
|
||||
assert_nil old_simplefin_account1.account
|
||||
assert_nil old_simplefin_account2.account
|
||||
assert_nil old_simplefin_account1.current_account
|
||||
assert_nil old_simplefin_account2.current_account
|
||||
|
||||
# Verify old SimpleFin item is scheduled for deletion
|
||||
@simplefin_item.reload
|
||||
@@ -229,7 +229,7 @@ class SimplefinItemsControllerTest < ActionDispatch::IntegrationTest
|
||||
maybe_account.reload
|
||||
old_simplefin_account.reload
|
||||
assert_equal old_simplefin_account.id, maybe_account.simplefin_account_id
|
||||
assert_equal maybe_account, old_simplefin_account.account
|
||||
assert_equal maybe_account, old_simplefin_account.current_account
|
||||
|
||||
# Old item still scheduled for deletion
|
||||
@simplefin_item.reload
|
||||
|
||||
@@ -4,6 +4,13 @@ class Account::CurrentBalanceManagerTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@family = families(:empty)
|
||||
@linked_account = accounts(:connected)
|
||||
|
||||
# Create account_provider to make the account actually linked
|
||||
# (The fixture has plaid_account but that's the legacy association)
|
||||
@linked_account.account_providers.find_or_create_by!(
|
||||
provider_type: "PlaidAccount",
|
||||
provider_id: plaid_accounts(:one).id
|
||||
)
|
||||
end
|
||||
|
||||
# -------------------------------------------------------------------------------------------------
|
||||
|
||||
69
test/models/account/linkable_test.rb
Normal file
69
test/models/account/linkable_test.rb
Normal file
@@ -0,0 +1,69 @@
|
||||
require "test_helper"
|
||||
|
||||
class Account::LinkableTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@family = families(:dylan_family)
|
||||
@account = accounts(:depository)
|
||||
end
|
||||
|
||||
test "linked? returns true when account has providers" do
|
||||
plaid_account = plaid_accounts(:one)
|
||||
AccountProvider.create!(account: @account, provider: plaid_account)
|
||||
|
||||
assert @account.linked?
|
||||
end
|
||||
|
||||
test "linked? returns false when account has no providers" do
|
||||
assert @account.unlinked?
|
||||
end
|
||||
|
||||
test "providers returns all provider adapters" do
|
||||
plaid_account = plaid_accounts(:one)
|
||||
AccountProvider.create!(account: @account, provider: plaid_account)
|
||||
|
||||
providers = @account.providers
|
||||
assert_equal 1, providers.count
|
||||
assert_kind_of Provider::PlaidAdapter, providers.first
|
||||
end
|
||||
|
||||
test "provider_for returns specific provider adapter" do
|
||||
plaid_account = plaid_accounts(:one)
|
||||
AccountProvider.create!(account: @account, provider: plaid_account)
|
||||
|
||||
adapter = @account.provider_for("PlaidAccount")
|
||||
assert_kind_of Provider::PlaidAdapter, adapter
|
||||
end
|
||||
|
||||
test "linked_to? checks if account is linked to specific provider type" do
|
||||
plaid_account = plaid_accounts(:one)
|
||||
AccountProvider.create!(account: @account, provider: plaid_account)
|
||||
|
||||
assert @account.linked_to?("PlaidAccount")
|
||||
refute @account.linked_to?("SimplefinAccount")
|
||||
end
|
||||
|
||||
test "can_delete_holdings? returns true for unlinked accounts" do
|
||||
assert @account.unlinked?
|
||||
assert @account.can_delete_holdings?
|
||||
end
|
||||
|
||||
test "can_delete_holdings? returns false when any provider disallows deletion" do
|
||||
plaid_account = plaid_accounts(:one)
|
||||
AccountProvider.create!(account: @account, provider: plaid_account)
|
||||
|
||||
# PlaidAdapter.can_delete_holdings? returns false by default
|
||||
refute @account.can_delete_holdings?
|
||||
end
|
||||
|
||||
test "can_delete_holdings? returns true only when all providers allow deletion" do
|
||||
plaid_account = plaid_accounts(:one)
|
||||
AccountProvider.create!(account: @account, provider: plaid_account)
|
||||
|
||||
# Stub all providers to return true
|
||||
@account.providers.each do |provider|
|
||||
provider.stubs(:can_delete_holdings?).returns(true)
|
||||
end
|
||||
|
||||
assert @account.can_delete_holdings?
|
||||
end
|
||||
end
|
||||
569
test/models/account/provider_import_adapter_test.rb
Normal file
569
test/models/account/provider_import_adapter_test.rb
Normal file
@@ -0,0 +1,569 @@
|
||||
require "test_helper"
|
||||
|
||||
class Account::ProviderImportAdapterTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@account = accounts(:depository)
|
||||
@adapter = Account::ProviderImportAdapter.new(@account)
|
||||
@family = families(:dylan_family)
|
||||
end
|
||||
|
||||
test "imports transaction with all parameters" do
|
||||
category = categories(:income)
|
||||
merchant = ProviderMerchant.create!(
|
||||
provider_merchant_id: "test_merchant_123",
|
||||
name: "Test Merchant",
|
||||
source: "plaid"
|
||||
)
|
||||
|
||||
assert_difference "@account.entries.count", 1 do
|
||||
entry = @adapter.import_transaction(
|
||||
external_id: "plaid_test_123",
|
||||
amount: 100.50,
|
||||
currency: "USD",
|
||||
date: Date.today,
|
||||
name: "Test Transaction",
|
||||
source: "plaid",
|
||||
category_id: category.id,
|
||||
merchant: merchant
|
||||
)
|
||||
|
||||
assert_equal 100.50, entry.amount
|
||||
assert_equal "USD", entry.currency
|
||||
assert_equal Date.today, entry.date
|
||||
assert_equal "Test Transaction", entry.name
|
||||
assert_equal category.id, entry.transaction.category_id
|
||||
assert_equal merchant.id, entry.transaction.merchant_id
|
||||
end
|
||||
end
|
||||
|
||||
test "imports transaction with minimal parameters" do
|
||||
assert_difference "@account.entries.count", 1 do
|
||||
entry = @adapter.import_transaction(
|
||||
external_id: "simplefin_abc",
|
||||
amount: 50.00,
|
||||
currency: "USD",
|
||||
date: Date.today,
|
||||
name: "Simple Transaction",
|
||||
source: "simplefin"
|
||||
)
|
||||
|
||||
assert_equal 50.00, entry.amount
|
||||
assert_equal "simplefin_abc", entry.external_id
|
||||
assert_equal "simplefin", entry.source
|
||||
assert_nil entry.transaction.category_id
|
||||
assert_nil entry.transaction.merchant_id
|
||||
end
|
||||
end
|
||||
|
||||
test "updates existing transaction instead of creating duplicate" do
|
||||
# Create initial transaction
|
||||
entry = @adapter.import_transaction(
|
||||
external_id: "plaid_duplicate_test",
|
||||
amount: 100.00,
|
||||
currency: "USD",
|
||||
date: Date.today,
|
||||
name: "Original Name",
|
||||
source: "plaid"
|
||||
)
|
||||
|
||||
# Import again with different data - should update, not create new
|
||||
assert_no_difference "@account.entries.count" do
|
||||
updated_entry = @adapter.import_transaction(
|
||||
external_id: "plaid_duplicate_test",
|
||||
amount: 200.00,
|
||||
currency: "USD",
|
||||
date: Date.today,
|
||||
name: "Updated Name",
|
||||
source: "plaid"
|
||||
)
|
||||
|
||||
assert_equal entry.id, updated_entry.id
|
||||
assert_equal 200.00, updated_entry.amount
|
||||
assert_equal "Updated Name", updated_entry.name
|
||||
end
|
||||
end
|
||||
|
||||
test "allows same external_id from different sources without collision" do
|
||||
# Create transaction from SimpleFin with ID "transaction_123"
|
||||
simplefin_entry = @adapter.import_transaction(
|
||||
external_id: "transaction_123",
|
||||
amount: 100.00,
|
||||
currency: "USD",
|
||||
date: Date.today,
|
||||
name: "SimpleFin Transaction",
|
||||
source: "simplefin"
|
||||
)
|
||||
|
||||
# Create transaction from Plaid with same ID "transaction_123" - should NOT collide
|
||||
# because external_id is unique per (account, source) combination
|
||||
assert_difference "@account.entries.count", 1 do
|
||||
plaid_entry = @adapter.import_transaction(
|
||||
external_id: "transaction_123",
|
||||
amount: 200.00,
|
||||
currency: "USD",
|
||||
date: Date.today,
|
||||
name: "Plaid Transaction",
|
||||
source: "plaid"
|
||||
)
|
||||
|
||||
# Should be different entries
|
||||
assert_not_equal simplefin_entry.id, plaid_entry.id
|
||||
assert_equal "simplefin", simplefin_entry.source
|
||||
assert_equal "plaid", plaid_entry.source
|
||||
assert_equal "transaction_123", simplefin_entry.external_id
|
||||
assert_equal "transaction_123", plaid_entry.external_id
|
||||
end
|
||||
end
|
||||
|
||||
test "raises error when external_id is missing" do
|
||||
exception = assert_raises(ArgumentError) do
|
||||
@adapter.import_transaction(
|
||||
external_id: "",
|
||||
amount: 100.00,
|
||||
currency: "USD",
|
||||
date: Date.today,
|
||||
name: "Test",
|
||||
source: "plaid"
|
||||
)
|
||||
end
|
||||
|
||||
assert_equal "external_id is required", exception.message
|
||||
end
|
||||
|
||||
test "raises error when source is missing" do
|
||||
exception = assert_raises(ArgumentError) do
|
||||
@adapter.import_transaction(
|
||||
external_id: "test_123",
|
||||
amount: 100.00,
|
||||
currency: "USD",
|
||||
date: Date.today,
|
||||
name: "Test",
|
||||
source: ""
|
||||
)
|
||||
end
|
||||
|
||||
assert_equal "source is required", exception.message
|
||||
end
|
||||
|
||||
test "finds or creates merchant with all data" do
|
||||
assert_difference "ProviderMerchant.count", 1 do
|
||||
merchant = @adapter.find_or_create_merchant(
|
||||
provider_merchant_id: "plaid_merchant_123",
|
||||
name: "Test Merchant",
|
||||
source: "plaid",
|
||||
website_url: "https://example.com",
|
||||
logo_url: "https://example.com/logo.png"
|
||||
)
|
||||
|
||||
assert_equal "Test Merchant", merchant.name
|
||||
assert_equal "plaid", merchant.source
|
||||
assert_equal "plaid_merchant_123", merchant.provider_merchant_id
|
||||
assert_equal "https://example.com", merchant.website_url
|
||||
assert_equal "https://example.com/logo.png", merchant.logo_url
|
||||
end
|
||||
end
|
||||
|
||||
test "returns nil when merchant data is insufficient" do
|
||||
merchant = @adapter.find_or_create_merchant(
|
||||
provider_merchant_id: "",
|
||||
name: "",
|
||||
source: "plaid"
|
||||
)
|
||||
|
||||
assert_nil merchant
|
||||
end
|
||||
|
||||
test "finds existing merchant instead of creating duplicate" do
|
||||
existing_merchant = ProviderMerchant.create!(
|
||||
provider_merchant_id: "existing_123",
|
||||
name: "Existing Merchant",
|
||||
source: "plaid"
|
||||
)
|
||||
|
||||
assert_no_difference "ProviderMerchant.count" do
|
||||
merchant = @adapter.find_or_create_merchant(
|
||||
provider_merchant_id: "existing_123",
|
||||
name: "Existing Merchant",
|
||||
source: "plaid"
|
||||
)
|
||||
|
||||
assert_equal existing_merchant.id, merchant.id
|
||||
end
|
||||
end
|
||||
|
||||
test "updates account balance" do
|
||||
@adapter.update_balance(
|
||||
balance: 5000.00,
|
||||
cash_balance: 4500.00,
|
||||
source: "plaid"
|
||||
)
|
||||
|
||||
@account.reload
|
||||
assert_equal 5000.00, @account.balance
|
||||
assert_equal 4500.00, @account.cash_balance
|
||||
end
|
||||
|
||||
test "updates account balance without cash_balance" do
|
||||
@adapter.update_balance(
|
||||
balance: 3000.00,
|
||||
source: "simplefin"
|
||||
)
|
||||
|
||||
@account.reload
|
||||
assert_equal 3000.00, @account.balance
|
||||
assert_equal 3000.00, @account.cash_balance
|
||||
end
|
||||
|
||||
test "imports holding with all parameters" do
|
||||
investment_account = accounts(:investment)
|
||||
adapter = Account::ProviderImportAdapter.new(investment_account)
|
||||
security = securities(:aapl)
|
||||
|
||||
# Use a date that doesn't conflict with fixtures (fixtures use today and 1.day.ago)
|
||||
holding_date = Date.today - 2.days
|
||||
|
||||
assert_difference "investment_account.holdings.count", 1 do
|
||||
holding = adapter.import_holding(
|
||||
security: security,
|
||||
quantity: 10.5,
|
||||
amount: 1575.00,
|
||||
currency: "USD",
|
||||
date: holding_date,
|
||||
price: 150.00,
|
||||
source: "plaid"
|
||||
)
|
||||
|
||||
assert_equal security.id, holding.security_id
|
||||
assert_equal 10.5, holding.qty
|
||||
assert_equal 1575.00, holding.amount
|
||||
assert_equal 150.00, holding.price
|
||||
assert_equal holding_date, holding.date
|
||||
end
|
||||
end
|
||||
|
||||
test "raises error when security is missing for holding import" do
|
||||
exception = assert_raises(ArgumentError) do
|
||||
@adapter.import_holding(
|
||||
security: nil,
|
||||
quantity: 10,
|
||||
amount: 1000,
|
||||
currency: "USD",
|
||||
date: Date.today,
|
||||
source: "plaid"
|
||||
)
|
||||
end
|
||||
|
||||
assert_equal "security is required", exception.message
|
||||
end
|
||||
|
||||
test "imports trade with all parameters" do
|
||||
investment_account = accounts(:investment)
|
||||
adapter = Account::ProviderImportAdapter.new(investment_account)
|
||||
security = securities(:aapl)
|
||||
|
||||
assert_difference "investment_account.entries.count", 1 do
|
||||
entry = adapter.import_trade(
|
||||
security: security,
|
||||
quantity: 5,
|
||||
price: 150.00,
|
||||
amount: 750.00,
|
||||
currency: "USD",
|
||||
date: Date.today,
|
||||
source: "plaid"
|
||||
)
|
||||
|
||||
assert_kind_of Trade, entry.entryable
|
||||
assert_equal 5, entry.entryable.qty
|
||||
assert_equal 150.00, entry.entryable.price
|
||||
assert_equal 750.00, entry.amount
|
||||
assert_match(/Buy.*5.*shares/i, entry.name)
|
||||
end
|
||||
end
|
||||
|
||||
test "raises error when security is missing for trade import" do
|
||||
exception = assert_raises(ArgumentError) do
|
||||
@adapter.import_trade(
|
||||
security: nil,
|
||||
quantity: 5,
|
||||
price: 100,
|
||||
amount: 500,
|
||||
currency: "USD",
|
||||
date: Date.today,
|
||||
source: "plaid"
|
||||
)
|
||||
end
|
||||
|
||||
assert_equal "security is required", exception.message
|
||||
end
|
||||
|
||||
test "stores account_provider_id when importing holding" do
|
||||
investment_account = accounts(:investment)
|
||||
adapter = Account::ProviderImportAdapter.new(investment_account)
|
||||
security = securities(:aapl)
|
||||
account_provider = AccountProvider.create!(
|
||||
account: investment_account,
|
||||
provider: plaid_accounts(:one)
|
||||
)
|
||||
|
||||
holding = adapter.import_holding(
|
||||
security: security,
|
||||
quantity: 10,
|
||||
amount: 1500,
|
||||
currency: "USD",
|
||||
date: Date.today,
|
||||
price: 150,
|
||||
source: "plaid",
|
||||
account_provider_id: account_provider.id
|
||||
)
|
||||
|
||||
assert_equal account_provider.id, holding.account_provider_id
|
||||
end
|
||||
|
||||
test "does not delete future holdings when can_delete_holdings? returns false" do
|
||||
investment_account = accounts(:investment)
|
||||
adapter = Account::ProviderImportAdapter.new(investment_account)
|
||||
security = securities(:aapl)
|
||||
|
||||
# Create a future holding
|
||||
future_holding = investment_account.holdings.create!(
|
||||
security: security,
|
||||
qty: 5,
|
||||
amount: 750,
|
||||
currency: "USD",
|
||||
date: Date.today + 1.day,
|
||||
price: 150
|
||||
)
|
||||
|
||||
# Mock can_delete_holdings? to return false
|
||||
investment_account.expects(:can_delete_holdings?).returns(false)
|
||||
|
||||
# Import a holding with delete_future_holdings flag
|
||||
adapter.import_holding(
|
||||
security: security,
|
||||
quantity: 10,
|
||||
amount: 1500,
|
||||
currency: "USD",
|
||||
date: Date.today,
|
||||
price: 150,
|
||||
source: "plaid",
|
||||
delete_future_holdings: true
|
||||
)
|
||||
|
||||
# Future holding should still exist
|
||||
assert Holding.exists?(future_holding.id)
|
||||
end
|
||||
|
||||
test "deletes only holdings from same provider when account_provider_id is provided" do
|
||||
investment_account = accounts(:investment)
|
||||
adapter = Account::ProviderImportAdapter.new(investment_account)
|
||||
security = securities(:aapl)
|
||||
|
||||
# Create an account provider
|
||||
plaid_account = PlaidAccount.create!(
|
||||
current_balance: 1000,
|
||||
available_balance: 1000,
|
||||
currency: "USD",
|
||||
name: "Test Plaid Account",
|
||||
plaid_item: plaid_items(:one),
|
||||
plaid_id: "acc_mock_test_1",
|
||||
plaid_type: "investment",
|
||||
plaid_subtype: "brokerage"
|
||||
)
|
||||
|
||||
provider = AccountProvider.create!(
|
||||
account: investment_account,
|
||||
provider: plaid_account
|
||||
)
|
||||
|
||||
# Create future holdings - one from the provider, one without a provider
|
||||
future_holding_with_provider = investment_account.holdings.create!(
|
||||
security: security,
|
||||
qty: 5,
|
||||
amount: 750,
|
||||
currency: "USD",
|
||||
date: Date.today + 1.day,
|
||||
price: 150,
|
||||
account_provider_id: provider.id
|
||||
)
|
||||
|
||||
future_holding_without_provider = investment_account.holdings.create!(
|
||||
security: security,
|
||||
qty: 3,
|
||||
amount: 450,
|
||||
currency: "USD",
|
||||
date: Date.today + 2.days,
|
||||
price: 150,
|
||||
account_provider_id: nil
|
||||
)
|
||||
|
||||
# Mock can_delete_holdings? to return true
|
||||
investment_account.expects(:can_delete_holdings?).returns(true)
|
||||
|
||||
# Import a holding with provider ID and delete_future_holdings flag
|
||||
adapter.import_holding(
|
||||
security: security,
|
||||
quantity: 10,
|
||||
amount: 1500,
|
||||
currency: "USD",
|
||||
date: Date.today,
|
||||
price: 150,
|
||||
source: "plaid",
|
||||
account_provider_id: provider.id,
|
||||
delete_future_holdings: true
|
||||
)
|
||||
|
||||
# Only the holding from the same provider should be deleted
|
||||
assert_not Holding.exists?(future_holding_with_provider.id)
|
||||
assert Holding.exists?(future_holding_without_provider.id)
|
||||
end
|
||||
|
||||
test "deletes all future holdings when account_provider_id is not provided and can_delete_holdings? returns true" do
|
||||
investment_account = accounts(:investment)
|
||||
adapter = Account::ProviderImportAdapter.new(investment_account)
|
||||
security = securities(:aapl)
|
||||
|
||||
# Create two future holdings
|
||||
future_holding_1 = investment_account.holdings.create!(
|
||||
security: security,
|
||||
qty: 5,
|
||||
amount: 750,
|
||||
currency: "USD",
|
||||
date: Date.today + 1.day,
|
||||
price: 150
|
||||
)
|
||||
|
||||
future_holding_2 = investment_account.holdings.create!(
|
||||
security: security,
|
||||
qty: 3,
|
||||
amount: 450,
|
||||
currency: "USD",
|
||||
date: Date.today + 2.days,
|
||||
price: 150
|
||||
)
|
||||
|
||||
# Mock can_delete_holdings? to return true
|
||||
investment_account.expects(:can_delete_holdings?).returns(true)
|
||||
|
||||
# Import a holding without account_provider_id
|
||||
adapter.import_holding(
|
||||
security: security,
|
||||
quantity: 10,
|
||||
amount: 1500,
|
||||
currency: "USD",
|
||||
date: Date.today,
|
||||
price: 150,
|
||||
source: "plaid",
|
||||
delete_future_holdings: true
|
||||
)
|
||||
|
||||
# All future holdings should be deleted
|
||||
assert_not Holding.exists?(future_holding_1.id)
|
||||
assert_not Holding.exists?(future_holding_2.id)
|
||||
end
|
||||
|
||||
test "updates existing trade attributes instead of keeping stale data" do
|
||||
investment_account = accounts(:investment)
|
||||
adapter = Account::ProviderImportAdapter.new(investment_account)
|
||||
aapl = securities(:aapl)
|
||||
msft = securities(:msft)
|
||||
|
||||
# Create initial trade
|
||||
entry = adapter.import_trade(
|
||||
external_id: "plaid_trade_123",
|
||||
security: aapl,
|
||||
quantity: 5,
|
||||
price: 150.00,
|
||||
amount: 750.00,
|
||||
currency: "USD",
|
||||
date: Date.today,
|
||||
source: "plaid"
|
||||
)
|
||||
|
||||
# Import again with updated attributes - should update Trade, not keep stale data
|
||||
assert_no_difference "investment_account.entries.count" do
|
||||
updated_entry = adapter.import_trade(
|
||||
external_id: "plaid_trade_123",
|
||||
security: msft,
|
||||
quantity: 10,
|
||||
price: 200.00,
|
||||
amount: 2000.00,
|
||||
currency: "USD",
|
||||
date: Date.today,
|
||||
source: "plaid"
|
||||
)
|
||||
|
||||
assert_equal entry.id, updated_entry.id
|
||||
# Trade attributes should be updated
|
||||
assert_equal msft.id, updated_entry.entryable.security_id
|
||||
assert_equal 10, updated_entry.entryable.qty
|
||||
assert_equal 200.00, updated_entry.entryable.price
|
||||
assert_equal "USD", updated_entry.entryable.currency
|
||||
# Entry attributes should also be updated
|
||||
assert_equal 2000.00, updated_entry.amount
|
||||
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)
|
||||
security = securities(:aapl)
|
||||
|
||||
# Create a trade with external_id "collision_test"
|
||||
adapter.import_trade(
|
||||
external_id: "collision_test",
|
||||
security: security,
|
||||
quantity: 5,
|
||||
price: 150.00,
|
||||
amount: 750.00,
|
||||
currency: "USD",
|
||||
date: Date.today,
|
||||
source: "plaid"
|
||||
)
|
||||
|
||||
# Try to create a transaction with the same external_id and source
|
||||
exception = assert_raises(ArgumentError) do
|
||||
adapter.import_transaction(
|
||||
external_id: "collision_test",
|
||||
amount: 100.00,
|
||||
currency: "USD",
|
||||
date: Date.today,
|
||||
name: "Test Transaction",
|
||||
source: "plaid"
|
||||
)
|
||||
end
|
||||
|
||||
assert_match(/Entry with external_id.*already exists with different entryable type/i, exception.message)
|
||||
end
|
||||
|
||||
test "raises error when external_id collision occurs across different entryable types for trade" do
|
||||
investment_account = accounts(:investment)
|
||||
adapter = Account::ProviderImportAdapter.new(investment_account)
|
||||
security = securities(:aapl)
|
||||
|
||||
# Create a transaction with external_id "collision_test_2"
|
||||
adapter.import_transaction(
|
||||
external_id: "collision_test_2",
|
||||
amount: 100.00,
|
||||
currency: "USD",
|
||||
date: Date.today,
|
||||
name: "Test Transaction",
|
||||
source: "plaid"
|
||||
)
|
||||
|
||||
# Try to create a trade with the same external_id and source
|
||||
exception = assert_raises(ArgumentError) do
|
||||
adapter.import_trade(
|
||||
external_id: "collision_test_2",
|
||||
security: security,
|
||||
quantity: 5,
|
||||
price: 150.00,
|
||||
amount: 750.00,
|
||||
currency: "USD",
|
||||
date: Date.today,
|
||||
source: "plaid"
|
||||
)
|
||||
end
|
||||
|
||||
assert_match(/Entry with external_id.*already exists with different entryable type/i, exception.message)
|
||||
end
|
||||
end
|
||||
132
test/models/account_provider_test.rb
Normal file
132
test/models/account_provider_test.rb
Normal file
@@ -0,0 +1,132 @@
|
||||
require "test_helper"
|
||||
|
||||
class AccountProviderTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@account = accounts(:depository)
|
||||
@family = families(:dylan_family)
|
||||
|
||||
# Create provider items
|
||||
@plaid_item = PlaidItem.create!(
|
||||
family: @family,
|
||||
plaid_id: "test_plaid_item",
|
||||
access_token: "test_token",
|
||||
name: "Test Bank"
|
||||
)
|
||||
|
||||
@simplefin_item = SimplefinItem.create!(
|
||||
family: @family,
|
||||
name: "Test SimpleFin Bank",
|
||||
access_url: "https://example.com/access"
|
||||
)
|
||||
|
||||
# Create provider accounts
|
||||
@plaid_account = PlaidAccount.create!(
|
||||
plaid_item: @plaid_item,
|
||||
name: "Plaid Checking",
|
||||
plaid_id: "plaid_123",
|
||||
plaid_type: "depository",
|
||||
currency: "USD",
|
||||
current_balance: 1000
|
||||
)
|
||||
|
||||
@simplefin_account = SimplefinAccount.create!(
|
||||
simplefin_item: @simplefin_item,
|
||||
name: "SimpleFin Checking",
|
||||
account_id: "sf_123",
|
||||
account_type: "checking",
|
||||
currency: "USD",
|
||||
current_balance: 2000
|
||||
)
|
||||
end
|
||||
|
||||
test "allows an account to have multiple different provider types" do
|
||||
# Should be able to link both Plaid and SimpleFin to same account
|
||||
plaid_provider = AccountProvider.create!(
|
||||
account: @account,
|
||||
provider: @plaid_account
|
||||
)
|
||||
|
||||
simplefin_provider = AccountProvider.create!(
|
||||
account: @account,
|
||||
provider: @simplefin_account
|
||||
)
|
||||
|
||||
assert_equal 2, @account.account_providers.count
|
||||
assert_includes @account.account_providers, plaid_provider
|
||||
assert_includes @account.account_providers, simplefin_provider
|
||||
end
|
||||
|
||||
test "prevents duplicate provider type for same account" do
|
||||
# Create first PlaidAccount link
|
||||
AccountProvider.create!(
|
||||
account: @account,
|
||||
provider: @plaid_account
|
||||
)
|
||||
|
||||
# Create another PlaidAccount
|
||||
another_plaid_account = PlaidAccount.create!(
|
||||
plaid_item: @plaid_item,
|
||||
name: "Another Plaid Account",
|
||||
plaid_id: "plaid_456",
|
||||
plaid_type: "savings",
|
||||
currency: "USD",
|
||||
current_balance: 5000
|
||||
)
|
||||
|
||||
# Should not be able to link another PlaidAccount to same account
|
||||
duplicate_provider = AccountProvider.new(
|
||||
account: @account,
|
||||
provider: another_plaid_account
|
||||
)
|
||||
|
||||
assert_not duplicate_provider.valid?
|
||||
assert_includes duplicate_provider.errors[:account_id], "has already been taken"
|
||||
end
|
||||
|
||||
test "prevents same provider account from linking to multiple accounts" do
|
||||
# Link provider to first account
|
||||
AccountProvider.create!(
|
||||
account: @account,
|
||||
provider: @plaid_account
|
||||
)
|
||||
|
||||
# Try to link same provider to another account
|
||||
another_account = accounts(:investment)
|
||||
|
||||
duplicate_link = AccountProvider.new(
|
||||
account: another_account,
|
||||
provider: @plaid_account
|
||||
)
|
||||
|
||||
assert_not duplicate_link.valid?
|
||||
assert_includes duplicate_link.errors[:provider_id], "has already been taken"
|
||||
end
|
||||
|
||||
test "adapter method returns correct adapter" do
|
||||
provider = AccountProvider.create!(
|
||||
account: @account,
|
||||
provider: @plaid_account
|
||||
)
|
||||
|
||||
adapter = provider.adapter
|
||||
|
||||
assert_kind_of Provider::PlaidAdapter, adapter
|
||||
assert_equal "plaid", adapter.provider_name
|
||||
assert_equal @account, adapter.account
|
||||
end
|
||||
|
||||
test "provider_name delegates to adapter" do
|
||||
plaid_provider = AccountProvider.create!(
|
||||
account: @account,
|
||||
provider: @plaid_account
|
||||
)
|
||||
|
||||
simplefin_provider = AccountProvider.create!(
|
||||
account: accounts(:investment),
|
||||
provider: @simplefin_account
|
||||
)
|
||||
|
||||
assert_equal "plaid", plaid_provider.provider_name
|
||||
assert_equal "simplefin", simplefin_provider.provider_name
|
||||
end
|
||||
end
|
||||
@@ -55,7 +55,7 @@ class PlaidAccount::Investments::HoldingsProcessorTest < ActiveSupport::TestCase
|
||||
processor.process
|
||||
end
|
||||
|
||||
holdings = Holding.where(account: @plaid_account.account).order(:date)
|
||||
holdings = Holding.where(account: @plaid_account.current_account).order(:date)
|
||||
|
||||
assert_equal 100, holdings.first.qty
|
||||
assert_equal 100, holdings.first.price
|
||||
@@ -70,31 +70,36 @@ class PlaidAccount::Investments::HoldingsProcessorTest < ActiveSupport::TestCase
|
||||
assert_equal Date.current, holdings.second.date
|
||||
end
|
||||
|
||||
# When Plaid provides holdings data, it includes an "institution_price_as_of" date
|
||||
# which represents when the holdings were last updated. Any holdings in our database
|
||||
# after this date are now stale and should be deleted, as the Plaid data is the
|
||||
# authoritative source of truth for the current holdings.
|
||||
test "deletes stale holdings per security based on institution price date" do
|
||||
account = @plaid_account.account
|
||||
# Plaid does not delete future holdings because it doesn't support holdings deletion
|
||||
# (PlaidAdapter#can_delete_holdings? returns false). This test verifies that future
|
||||
# holdings are NOT deleted when processing Plaid holdings data.
|
||||
test "does not delete future holdings when processing Plaid holdings" do
|
||||
account = @plaid_account.current_account
|
||||
|
||||
# Create account_provider
|
||||
account_provider = AccountProvider.create!(
|
||||
account: account,
|
||||
provider: @plaid_account
|
||||
)
|
||||
|
||||
# Create a third security for testing
|
||||
third_security = Security.create!(ticker: "GOOGL", name: "Google", exchange_operating_mic: "XNAS", country_code: "US")
|
||||
|
||||
# Scenario 3: AAPL has a stale holding that should be deleted
|
||||
stale_aapl_holding = account.holdings.create!(
|
||||
# Create a future AAPL holding that should NOT be deleted
|
||||
future_aapl_holding = account.holdings.create!(
|
||||
security: securities(:aapl),
|
||||
date: Date.current,
|
||||
qty: 80,
|
||||
price: 180,
|
||||
amount: 14400,
|
||||
currency: "USD"
|
||||
currency: "USD",
|
||||
account_provider_id: account_provider.id
|
||||
)
|
||||
|
||||
# Plaid returns 3 holdings with different scenarios
|
||||
# Plaid returns holdings from yesterday - future holdings should remain
|
||||
test_investments_payload = {
|
||||
securities: [],
|
||||
holdings: [
|
||||
# Scenario 1: Current date holding (no deletions needed)
|
||||
{
|
||||
"security_id" => "current",
|
||||
"quantity" => 50,
|
||||
@@ -102,7 +107,6 @@ class PlaidAccount::Investments::HoldingsProcessorTest < ActiveSupport::TestCase
|
||||
"iso_currency_code" => "USD",
|
||||
"institution_price_as_of" => Date.current
|
||||
},
|
||||
# Scenario 2: Yesterday's holding with no future holdings
|
||||
{
|
||||
"security_id" => "clean",
|
||||
"quantity" => 75,
|
||||
@@ -110,9 +114,8 @@ class PlaidAccount::Investments::HoldingsProcessorTest < ActiveSupport::TestCase
|
||||
"iso_currency_code" => "USD",
|
||||
"institution_price_as_of" => 1.day.ago.to_date
|
||||
},
|
||||
# Scenario 3: Yesterday's holding with stale future holding
|
||||
{
|
||||
"security_id" => "stale",
|
||||
"security_id" => "past",
|
||||
"quantity" => 100,
|
||||
"institution_price" => 100,
|
||||
"iso_currency_code" => "USD",
|
||||
@@ -134,17 +137,17 @@ class PlaidAccount::Investments::HoldingsProcessorTest < ActiveSupport::TestCase
|
||||
.returns(OpenStruct.new(security: third_security, cash_equivalent?: false, brokerage_cash?: false))
|
||||
|
||||
@security_resolver.expects(:resolve)
|
||||
.with(plaid_security_id: "stale")
|
||||
.with(plaid_security_id: "past")
|
||||
.returns(OpenStruct.new(security: securities(:aapl), cash_equivalent?: false, brokerage_cash?: false))
|
||||
|
||||
processor = PlaidAccount::Investments::HoldingsProcessor.new(@plaid_account, security_resolver: @security_resolver)
|
||||
processor.process
|
||||
|
||||
# Should have created 3 new holdings
|
||||
assert_equal 3, account.holdings.count
|
||||
# Should have created 3 new holdings PLUS the existing future holding (total 4)
|
||||
assert_equal 4, account.holdings.count
|
||||
|
||||
# Scenario 3: Should have deleted the stale AAPL holding
|
||||
assert_not account.holdings.exists?(stale_aapl_holding.id)
|
||||
# Future AAPL holding should still exist (NOT deleted)
|
||||
assert account.holdings.exists?(future_aapl_holding.id)
|
||||
|
||||
# Should have the correct holdings from Plaid
|
||||
assert account.holdings.exists?(security: securities(:msft), date: Date.current, qty: 50)
|
||||
@@ -192,6 +195,134 @@ class PlaidAccount::Investments::HoldingsProcessorTest < ActiveSupport::TestCase
|
||||
end
|
||||
|
||||
# Should have created the successful holding
|
||||
assert @plaid_account.account.holdings.exists?(security: securities(:aapl), qty: 200)
|
||||
assert @plaid_account.current_account.holdings.exists?(security: securities(:aapl), qty: 200)
|
||||
end
|
||||
|
||||
test "handles string values and computes amount using BigDecimal arithmetic" do
|
||||
test_investments_payload = {
|
||||
securities: [],
|
||||
holdings: [
|
||||
{
|
||||
"security_id" => "string_values",
|
||||
"quantity" => "10.5",
|
||||
"institution_price" => "150.75",
|
||||
"iso_currency_code" => "USD",
|
||||
"institution_price_as_of" => "2025-01-15"
|
||||
}
|
||||
],
|
||||
transactions: []
|
||||
}
|
||||
|
||||
@plaid_account.update!(raw_investments_payload: test_investments_payload)
|
||||
|
||||
@security_resolver.expects(:resolve)
|
||||
.with(plaid_security_id: "string_values")
|
||||
.returns(OpenStruct.new(security: securities(:aapl)))
|
||||
|
||||
processor = PlaidAccount::Investments::HoldingsProcessor.new(@plaid_account, security_resolver: @security_resolver)
|
||||
|
||||
assert_difference "Holding.count", 1 do
|
||||
processor.process
|
||||
end
|
||||
|
||||
holding = @plaid_account.current_account.holdings.find_by(
|
||||
security: securities(:aapl),
|
||||
date: Date.parse("2025-01-15"),
|
||||
currency: "USD"
|
||||
)
|
||||
|
||||
assert_not_nil holding, "Expected to find holding for AAPL on 2025-01-15"
|
||||
assert_equal BigDecimal("10.5"), holding.qty
|
||||
assert_equal BigDecimal("150.75"), holding.price
|
||||
assert_equal BigDecimal("1582.875"), holding.amount # 10.5 * 150.75 using BigDecimal
|
||||
assert_equal Date.parse("2025-01-15"), holding.date
|
||||
end
|
||||
|
||||
test "skips holdings with nil quantity or price" do
|
||||
test_investments_payload = {
|
||||
securities: [],
|
||||
holdings: [
|
||||
{
|
||||
"security_id" => "missing_quantity",
|
||||
"quantity" => nil,
|
||||
"institution_price" => 100,
|
||||
"iso_currency_code" => "USD"
|
||||
},
|
||||
{
|
||||
"security_id" => "missing_price",
|
||||
"quantity" => 100,
|
||||
"institution_price" => nil,
|
||||
"iso_currency_code" => "USD"
|
||||
},
|
||||
{
|
||||
"security_id" => "valid",
|
||||
"quantity" => 50,
|
||||
"institution_price" => 50,
|
||||
"iso_currency_code" => "USD"
|
||||
}
|
||||
],
|
||||
transactions: []
|
||||
}
|
||||
|
||||
@plaid_account.update!(raw_investments_payload: test_investments_payload)
|
||||
|
||||
@security_resolver.expects(:resolve)
|
||||
.with(plaid_security_id: "missing_quantity")
|
||||
.returns(OpenStruct.new(security: securities(:aapl)))
|
||||
|
||||
@security_resolver.expects(:resolve)
|
||||
.with(plaid_security_id: "missing_price")
|
||||
.returns(OpenStruct.new(security: securities(:msft)))
|
||||
|
||||
@security_resolver.expects(:resolve)
|
||||
.with(plaid_security_id: "valid")
|
||||
.returns(OpenStruct.new(security: securities(:aapl)))
|
||||
|
||||
processor = PlaidAccount::Investments::HoldingsProcessor.new(@plaid_account, security_resolver: @security_resolver)
|
||||
|
||||
# Should create only 1 holding (the valid one)
|
||||
assert_difference "Holding.count", 1 do
|
||||
processor.process
|
||||
end
|
||||
|
||||
# Should have created only the valid holding
|
||||
assert @plaid_account.current_account.holdings.exists?(security: securities(:aapl), qty: 50, price: 50)
|
||||
assert_not @plaid_account.current_account.holdings.exists?(security: securities(:msft))
|
||||
end
|
||||
|
||||
test "uses account currency as fallback when Plaid omits iso_currency_code" do
|
||||
account = @plaid_account.current_account
|
||||
|
||||
# Ensure the account has a currency
|
||||
account.update!(currency: "EUR")
|
||||
|
||||
test_investments_payload = {
|
||||
securities: [],
|
||||
holdings: [
|
||||
{
|
||||
"security_id" => "no_currency",
|
||||
"quantity" => 100,
|
||||
"institution_price" => 100,
|
||||
"iso_currency_code" => nil, # Plaid omits currency
|
||||
"institution_price_as_of" => Date.current
|
||||
}
|
||||
],
|
||||
transactions: []
|
||||
}
|
||||
|
||||
@plaid_account.update!(raw_investments_payload: test_investments_payload)
|
||||
|
||||
@security_resolver.expects(:resolve)
|
||||
.with(plaid_security_id: "no_currency")
|
||||
.returns(OpenStruct.new(security: securities(:aapl)))
|
||||
|
||||
processor = PlaidAccount::Investments::HoldingsProcessor.new(@plaid_account, security_resolver: @security_resolver)
|
||||
|
||||
assert_difference "Holding.count", 1 do
|
||||
processor.process
|
||||
end
|
||||
|
||||
holding = account.holdings.find_by(security: securities(:aapl))
|
||||
assert_equal "EUR", holding.currency # Should use account's currency
|
||||
end
|
||||
end
|
||||
|
||||
@@ -10,7 +10,7 @@ class PlaidAccount::Investments::TransactionsProcessorTest < ActiveSupport::Test
|
||||
test_investments_payload = {
|
||||
transactions: [
|
||||
{
|
||||
"transaction_id" => "123",
|
||||
"investment_transaction_id" => "123",
|
||||
"security_id" => "123",
|
||||
"type" => "buy",
|
||||
"quantity" => 1, # Positive, so "buy 1 share"
|
||||
@@ -47,7 +47,7 @@ class PlaidAccount::Investments::TransactionsProcessorTest < ActiveSupport::Test
|
||||
test_investments_payload = {
|
||||
transactions: [
|
||||
{
|
||||
"transaction_id" => "123",
|
||||
"investment_transaction_id" => "cash_123",
|
||||
"type" => "cash",
|
||||
"subtype" => "withdrawal",
|
||||
"amount" => 100, # Positive, so moving money OUT of the account
|
||||
@@ -80,7 +80,7 @@ class PlaidAccount::Investments::TransactionsProcessorTest < ActiveSupport::Test
|
||||
test_investments_payload = {
|
||||
transactions: [
|
||||
{
|
||||
"transaction_id" => "123",
|
||||
"investment_transaction_id" => "fee_123",
|
||||
"type" => "fee",
|
||||
"subtype" => "miscellaneous fee",
|
||||
"amount" => 10.25,
|
||||
@@ -113,7 +113,8 @@ class PlaidAccount::Investments::TransactionsProcessorTest < ActiveSupport::Test
|
||||
test_investments_payload = {
|
||||
transactions: [
|
||||
{
|
||||
"transaction_id" => "123",
|
||||
"investment_transaction_id" => "123",
|
||||
"security_id" => "123",
|
||||
"type" => "sell", # Correct type
|
||||
"subtype" => "sell", # Correct subtype
|
||||
"quantity" => 1, # ***Incorrect signage***, this should be negative
|
||||
|
||||
@@ -8,7 +8,7 @@ class PlaidAccount::Liabilities::CreditProcessorTest < ActiveSupport::TestCase
|
||||
plaid_subtype: "credit_card"
|
||||
)
|
||||
|
||||
@plaid_account.account.update!(
|
||||
@plaid_account.current_account.update!(
|
||||
accountable: CreditCard.new,
|
||||
)
|
||||
end
|
||||
@@ -24,8 +24,8 @@ class PlaidAccount::Liabilities::CreditProcessorTest < ActiveSupport::TestCase
|
||||
processor = PlaidAccount::Liabilities::CreditProcessor.new(@plaid_account)
|
||||
processor.process
|
||||
|
||||
assert_equal 100, @plaid_account.account.credit_card.minimum_payment
|
||||
assert_equal 15.0, @plaid_account.account.credit_card.apr
|
||||
assert_equal 100, @plaid_account.current_account.credit_card.minimum_payment
|
||||
assert_equal 15.0, @plaid_account.current_account.credit_card.apr
|
||||
end
|
||||
|
||||
test "does nothing when liability data absent" do
|
||||
@@ -33,7 +33,7 @@ class PlaidAccount::Liabilities::CreditProcessorTest < ActiveSupport::TestCase
|
||||
processor = PlaidAccount::Liabilities::CreditProcessor.new(@plaid_account)
|
||||
processor.process
|
||||
|
||||
assert_nil @plaid_account.account.credit_card.minimum_payment
|
||||
assert_nil @plaid_account.account.credit_card.apr
|
||||
assert_nil @plaid_account.current_account.credit_card.minimum_payment
|
||||
assert_nil @plaid_account.current_account.credit_card.apr
|
||||
end
|
||||
end
|
||||
|
||||
@@ -8,7 +8,7 @@ class PlaidAccount::Liabilities::MortgageProcessorTest < ActiveSupport::TestCase
|
||||
plaid_subtype: "mortgage"
|
||||
)
|
||||
|
||||
@plaid_account.account.update!(accountable: Loan.new)
|
||||
@plaid_account.current_account.update!(accountable: Loan.new)
|
||||
end
|
||||
|
||||
test "updates loan interest rate and type from Plaid data" do
|
||||
@@ -24,7 +24,7 @@ class PlaidAccount::Liabilities::MortgageProcessorTest < ActiveSupport::TestCase
|
||||
processor = PlaidAccount::Liabilities::MortgageProcessor.new(@plaid_account)
|
||||
processor.process
|
||||
|
||||
loan = @plaid_account.account.loan
|
||||
loan = @plaid_account.current_account.loan
|
||||
|
||||
assert_equal "fixed", loan.rate_type
|
||||
assert_equal 4.25, loan.interest_rate
|
||||
@@ -36,7 +36,7 @@ class PlaidAccount::Liabilities::MortgageProcessorTest < ActiveSupport::TestCase
|
||||
processor = PlaidAccount::Liabilities::MortgageProcessor.new(@plaid_account)
|
||||
processor.process
|
||||
|
||||
loan = @plaid_account.account.loan
|
||||
loan = @plaid_account.current_account.loan
|
||||
|
||||
assert_nil loan.rate_type
|
||||
assert_nil loan.interest_rate
|
||||
|
||||
@@ -9,7 +9,7 @@ class PlaidAccount::Liabilities::StudentLoanProcessorTest < ActiveSupport::TestC
|
||||
)
|
||||
|
||||
# Change the underlying accountable to a Loan so the helper method `loan` is available
|
||||
@plaid_account.account.update!(accountable: Loan.new)
|
||||
@plaid_account.current_account.update!(accountable: Loan.new)
|
||||
end
|
||||
|
||||
test "updates loan details including term months from Plaid data" do
|
||||
@@ -25,7 +25,7 @@ class PlaidAccount::Liabilities::StudentLoanProcessorTest < ActiveSupport::TestC
|
||||
processor = PlaidAccount::Liabilities::StudentLoanProcessor.new(@plaid_account)
|
||||
processor.process
|
||||
|
||||
loan = @plaid_account.account.loan
|
||||
loan = @plaid_account.current_account.loan
|
||||
|
||||
assert_equal "fixed", loan.rate_type
|
||||
assert_equal 5.5, loan.interest_rate
|
||||
@@ -46,7 +46,7 @@ class PlaidAccount::Liabilities::StudentLoanProcessorTest < ActiveSupport::TestC
|
||||
processor = PlaidAccount::Liabilities::StudentLoanProcessor.new(@plaid_account)
|
||||
processor.process
|
||||
|
||||
loan = @plaid_account.account.loan
|
||||
loan = @plaid_account.current_account.loan
|
||||
|
||||
assert_nil loan.term_months
|
||||
assert_equal 4.8, loan.interest_rate
|
||||
@@ -59,7 +59,7 @@ class PlaidAccount::Liabilities::StudentLoanProcessorTest < ActiveSupport::TestC
|
||||
processor = PlaidAccount::Liabilities::StudentLoanProcessor.new(@plaid_account)
|
||||
processor.process
|
||||
|
||||
loan = @plaid_account.account.loan
|
||||
loan = @plaid_account.current_account.loan
|
||||
|
||||
assert_nil loan.interest_rate
|
||||
assert_nil loan.initial_balance
|
||||
|
||||
@@ -29,31 +29,36 @@ class PlaidAccount::ProcessorTest < ActiveSupport::TestCase
|
||||
|
||||
account = Account.order(created_at: :desc).first
|
||||
assert_equal "Test Plaid Account", account.name
|
||||
assert_equal @plaid_account.id, account.plaid_account_id
|
||||
assert_equal "checking", account.subtype
|
||||
assert_equal 1000, account.balance
|
||||
assert_equal 1000, account.cash_balance
|
||||
assert_equal "USD", account.currency
|
||||
assert_equal "Depository", account.accountable_type
|
||||
assert_equal "checking", account.subtype
|
||||
|
||||
# Verify AccountProvider was created
|
||||
assert account.linked?
|
||||
assert_equal 1, account.account_providers.count
|
||||
assert_equal @plaid_account.id, account.account_providers.first.provider_id
|
||||
assert_equal "PlaidAccount", account.account_providers.first.provider_type
|
||||
end
|
||||
|
||||
test "processing is idempotent with updates and enrichments" do
|
||||
expect_default_subprocessor_calls
|
||||
|
||||
assert_equal "Plaid Depository Account", @plaid_account.account.name
|
||||
assert_equal "checking", @plaid_account.account.subtype
|
||||
assert_equal "Plaid Depository Account", @plaid_account.current_account.name
|
||||
assert_equal "checking", @plaid_account.current_account.subtype
|
||||
|
||||
@plaid_account.account.update!(
|
||||
@plaid_account.current_account.update!(
|
||||
name: "User updated name",
|
||||
balance: 2000 # User cannot override balance. This will be overridden by the processor on next processing
|
||||
)
|
||||
|
||||
@plaid_account.account.accountable.update!(subtype: "savings")
|
||||
@plaid_account.current_account.accountable.update!(subtype: "savings")
|
||||
|
||||
@plaid_account.account.lock_attr!(:name)
|
||||
@plaid_account.account.accountable.lock_attr!(:subtype)
|
||||
@plaid_account.account.lock_attr!(:balance) # Even if balance somehow becomes locked, Plaid ignores it and overrides it
|
||||
@plaid_account.current_account.lock_attr!(:name)
|
||||
@plaid_account.current_account.accountable.lock_attr!(:subtype)
|
||||
@plaid_account.current_account.lock_attr!(:balance) # Even if balance somehow becomes locked, Plaid ignores it and overrides it
|
||||
|
||||
assert_no_difference "Account.count" do
|
||||
PlaidAccount::Processor.new(@plaid_account).process
|
||||
@@ -61,9 +66,9 @@ class PlaidAccount::ProcessorTest < ActiveSupport::TestCase
|
||||
|
||||
@plaid_account.reload
|
||||
|
||||
assert_equal "User updated name", @plaid_account.account.name
|
||||
assert_equal "savings", @plaid_account.account.subtype
|
||||
assert_equal @plaid_account.current_balance, @plaid_account.account.balance # Overriden by processor
|
||||
assert_equal "User updated name", @plaid_account.current_account.name
|
||||
assert_equal "savings", @plaid_account.current_account.subtype
|
||||
assert_equal @plaid_account.current_balance, @plaid_account.current_account.balance # Overriden by processor
|
||||
end
|
||||
|
||||
test "account processing failure halts further processing" do
|
||||
@@ -102,7 +107,7 @@ class PlaidAccount::ProcessorTest < ActiveSupport::TestCase
|
||||
PlaidAccount::Processor.new(@plaid_account).process
|
||||
|
||||
# Verify that the balance was set correctly
|
||||
account = @plaid_account.account
|
||||
account = @plaid_account.current_account
|
||||
assert_equal 1000, account.balance
|
||||
assert_equal 1000, account.cash_balance
|
||||
|
||||
@@ -196,7 +201,7 @@ class PlaidAccount::ProcessorTest < ActiveSupport::TestCase
|
||||
expect_default_subprocessor_calls
|
||||
PlaidAccount::Processor.new(@plaid_account).process
|
||||
|
||||
account = @plaid_account.account
|
||||
account = @plaid_account.current_account
|
||||
original_anchor = account.valuations.current_anchor.first
|
||||
assert_not_nil original_anchor
|
||||
original_anchor_id = original_anchor.id
|
||||
|
||||
@@ -37,7 +37,7 @@ class PlaidAccount::Transactions::ProcessorTest < ActiveSupport::TestCase
|
||||
|
||||
test "removes transactions no longer in plaid" do
|
||||
destroyable_transaction_id = "destroy_me"
|
||||
@plaid_account.account.entries.create!(
|
||||
@plaid_account.current_account.entries.create!(
|
||||
plaid_id: destroyable_transaction_id,
|
||||
date: Date.current,
|
||||
amount: 100,
|
||||
|
||||
@@ -61,8 +61,9 @@ class PlaidEntry::ProcessorTest < ActiveSupport::TestCase
|
||||
@category_matcher.expects(:match).with("Food").returns(categories(:food_and_drink))
|
||||
|
||||
# Create an existing entry
|
||||
@plaid_account.account.entries.create!(
|
||||
plaid_id: existing_plaid_id,
|
||||
@plaid_account.current_account.entries.create!(
|
||||
external_id: existing_plaid_id,
|
||||
source: "plaid",
|
||||
amount: 100,
|
||||
currency: "USD",
|
||||
date: Date.current,
|
||||
|
||||
41
test/models/provider/plaid_adapter_test.rb
Normal file
41
test/models/provider/plaid_adapter_test.rb
Normal file
@@ -0,0 +1,41 @@
|
||||
require "test_helper"
|
||||
|
||||
class Provider::PlaidAdapterTest < ActiveSupport::TestCase
|
||||
include ProviderAdapterTestInterface
|
||||
|
||||
setup do
|
||||
@plaid_account = plaid_accounts(:one)
|
||||
@account = accounts(:depository)
|
||||
@adapter = Provider::PlaidAdapter.new(@plaid_account, account: @account)
|
||||
end
|
||||
|
||||
def adapter
|
||||
@adapter
|
||||
end
|
||||
|
||||
# Run shared interface tests
|
||||
test_provider_adapter_interface
|
||||
test_syncable_interface
|
||||
test_institution_metadata_interface
|
||||
|
||||
# Provider-specific tests
|
||||
test "returns correct provider name" do
|
||||
assert_equal "plaid", @adapter.provider_name
|
||||
end
|
||||
|
||||
test "returns correct provider type" do
|
||||
assert_equal "PlaidAccount", @adapter.provider_type
|
||||
end
|
||||
|
||||
test "returns plaid item" do
|
||||
assert_equal @plaid_account.plaid_item, @adapter.item
|
||||
end
|
||||
|
||||
test "returns account" do
|
||||
assert_equal @account, @adapter.account
|
||||
end
|
||||
|
||||
test "can_delete_holdings? returns false" do
|
||||
assert_equal false, @adapter.can_delete_holdings?
|
||||
end
|
||||
end
|
||||
@@ -44,19 +44,22 @@ class Provider::RegistryTest < ActiveSupport::TestCase
|
||||
end
|
||||
|
||||
test "openai provider falls back to Setting when ENV is empty string" do
|
||||
# Simulate ENV being set to empty string (common in Docker/env files)
|
||||
ENV.stubs(:[]).with("OPENAI_ACCESS_TOKEN").returns("")
|
||||
ENV.stubs(:[]).with("OPENAI_URI_BASE").returns("")
|
||||
ENV.stubs(:[]).with("OPENAI_MODEL").returns("")
|
||||
# Mock ENV to return empty string (common in Docker/env files)
|
||||
# Use stub_env helper which properly stubs ENV access
|
||||
ClimateControl.modify(
|
||||
"OPENAI_ACCESS_TOKEN" => "",
|
||||
"OPENAI_URI_BASE" => "",
|
||||
"OPENAI_MODEL" => ""
|
||||
) do
|
||||
Setting.stubs(:openai_access_token).returns("test-token-from-setting")
|
||||
Setting.stubs(:openai_uri_base).returns(nil)
|
||||
Setting.stubs(:openai_model).returns(nil)
|
||||
|
||||
Setting.stubs(:openai_access_token).returns("test-token-from-setting")
|
||||
Setting.stubs(:openai_uri_base).returns(nil)
|
||||
Setting.stubs(:openai_model).returns(nil)
|
||||
provider = Provider::Registry.get_provider(:openai)
|
||||
|
||||
provider = Provider::Registry.get_provider(:openai)
|
||||
|
||||
# Should successfully create provider using Setting value
|
||||
assert_not_nil provider
|
||||
assert_instance_of Provider::Openai, provider
|
||||
# Should successfully create provider using Setting value
|
||||
assert_not_nil provider
|
||||
assert_instance_of Provider::Openai, provider
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
72
test/models/provider/simplefin_adapter_test.rb
Normal file
72
test/models/provider/simplefin_adapter_test.rb
Normal file
@@ -0,0 +1,72 @@
|
||||
require "test_helper"
|
||||
|
||||
class Provider::SimplefinAdapterTest < ActiveSupport::TestCase
|
||||
include ProviderAdapterTestInterface
|
||||
|
||||
setup do
|
||||
@family = families(:dylan_family)
|
||||
@simplefin_item = SimplefinItem.create!(
|
||||
family: @family,
|
||||
name: "Test SimpleFin Bank",
|
||||
access_url: "https://example.com/access_token"
|
||||
)
|
||||
@simplefin_account = SimplefinAccount.create!(
|
||||
simplefin_item: @simplefin_item,
|
||||
name: "SimpleFin Depository Account",
|
||||
account_id: "sf_mock_1",
|
||||
account_type: "checking",
|
||||
currency: "USD",
|
||||
current_balance: 1000,
|
||||
available_balance: 1000,
|
||||
org_data: {
|
||||
"name" => "SimpleFin Test Bank",
|
||||
"domain" => "testbank.com",
|
||||
"url" => "https://testbank.com"
|
||||
}
|
||||
)
|
||||
@account = accounts(:depository)
|
||||
@adapter = Provider::SimplefinAdapter.new(@simplefin_account, account: @account)
|
||||
end
|
||||
|
||||
def adapter
|
||||
@adapter
|
||||
end
|
||||
|
||||
# Run shared interface tests
|
||||
test_provider_adapter_interface
|
||||
test_syncable_interface
|
||||
test_institution_metadata_interface
|
||||
|
||||
# Provider-specific tests
|
||||
test "returns correct provider name" do
|
||||
assert_equal "simplefin", @adapter.provider_name
|
||||
end
|
||||
|
||||
test "returns correct provider type" do
|
||||
assert_equal "SimplefinAccount", @adapter.provider_type
|
||||
end
|
||||
|
||||
test "returns simplefin item" do
|
||||
assert_equal @simplefin_account.simplefin_item, @adapter.item
|
||||
end
|
||||
|
||||
test "returns account" do
|
||||
assert_equal @account, @adapter.account
|
||||
end
|
||||
|
||||
test "can_delete_holdings? returns false" do
|
||||
assert_equal false, @adapter.can_delete_holdings?
|
||||
end
|
||||
|
||||
test "parses institution domain from org_data" do
|
||||
assert_equal "testbank.com", @adapter.institution_domain
|
||||
end
|
||||
|
||||
test "parses institution name from org_data" do
|
||||
assert_equal "SimpleFin Test Bank", @adapter.institution_name
|
||||
end
|
||||
|
||||
test "parses institution url from org_data" do
|
||||
assert_equal "https://testbank.com", @adapter.institution_url
|
||||
end
|
||||
end
|
||||
140
test/support/provider_adapter_test_interface.rb
Normal file
140
test/support/provider_adapter_test_interface.rb
Normal file
@@ -0,0 +1,140 @@
|
||||
# Shared test interface for all provider adapters
|
||||
# Include this module in your provider adapter test to ensure it implements the required interface
|
||||
#
|
||||
# Usage:
|
||||
# class Provider::AcmeAdapterTest < ActiveSupport::TestCase
|
||||
# include ProviderAdapterTestInterface
|
||||
#
|
||||
# setup do
|
||||
# @adapter = Provider::AcmeAdapter.new(...)
|
||||
# end
|
||||
#
|
||||
# def adapter
|
||||
# @adapter
|
||||
# end
|
||||
#
|
||||
# test_provider_adapter_interface
|
||||
# end
|
||||
module ProviderAdapterTestInterface
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
class_methods do
|
||||
# Tests the core provider adapter interface
|
||||
# Call this method in your test class to run all interface tests
|
||||
def test_provider_adapter_interface
|
||||
test "adapter implements provider_name" do
|
||||
assert_respond_to adapter, :provider_name
|
||||
assert_kind_of String, adapter.provider_name
|
||||
assert adapter.provider_name.present?, "provider_name should not be blank"
|
||||
end
|
||||
|
||||
test "adapter implements provider_type" do
|
||||
assert_respond_to adapter, :provider_type
|
||||
assert_kind_of String, adapter.provider_type
|
||||
assert adapter.provider_type.present?, "provider_type should not be blank"
|
||||
end
|
||||
|
||||
test "adapter implements can_delete_holdings?" do
|
||||
assert_respond_to adapter, :can_delete_holdings?
|
||||
assert_includes [ true, false ], adapter.can_delete_holdings?
|
||||
end
|
||||
|
||||
test "adapter implements metadata" do
|
||||
assert_respond_to adapter, :metadata
|
||||
metadata = adapter.metadata
|
||||
|
||||
assert_kind_of Hash, metadata
|
||||
assert_includes metadata.keys, :provider_name
|
||||
assert_includes metadata.keys, :provider_type
|
||||
|
||||
assert_equal adapter.provider_name, metadata[:provider_name]
|
||||
assert_equal adapter.provider_type, metadata[:provider_type]
|
||||
end
|
||||
|
||||
test "adapter implements raw_payload" do
|
||||
assert_respond_to adapter, :raw_payload
|
||||
# raw_payload can be nil or a Hash
|
||||
assert adapter.raw_payload.nil? || adapter.raw_payload.is_a?(Hash)
|
||||
end
|
||||
|
||||
test "adapter is registered with factory" do
|
||||
provider_type = adapter.provider_type
|
||||
assert_includes Provider::Factory.registered_provider_types, provider_type,
|
||||
"#{provider_type} should be registered with Provider::Factory"
|
||||
end
|
||||
end
|
||||
|
||||
# Tests for adapters that include Provider::Syncable
|
||||
def test_syncable_interface
|
||||
test "syncable adapter implements sync_path" do
|
||||
assert_respond_to adapter, :sync_path
|
||||
assert_kind_of String, adapter.sync_path
|
||||
assert adapter.sync_path.present?, "sync_path should not be blank"
|
||||
end
|
||||
|
||||
test "syncable adapter implements item" do
|
||||
assert_respond_to adapter, :item
|
||||
assert_not_nil adapter.item, "item should not be nil for syncable providers"
|
||||
end
|
||||
|
||||
test "syncable adapter implements syncing?" do
|
||||
assert_respond_to adapter, :syncing?
|
||||
assert_includes [ true, false ], adapter.syncing?
|
||||
end
|
||||
|
||||
test "syncable adapter implements status" do
|
||||
assert_respond_to adapter, :status
|
||||
# status can be nil or a String
|
||||
assert adapter.status.nil? || adapter.status.is_a?(String)
|
||||
end
|
||||
|
||||
test "syncable adapter implements requires_update?" do
|
||||
assert_respond_to adapter, :requires_update?
|
||||
assert_includes [ true, false ], adapter.requires_update?
|
||||
end
|
||||
end
|
||||
|
||||
# Tests for adapters that include Provider::InstitutionMetadata
|
||||
def test_institution_metadata_interface
|
||||
test "institution metadata adapter implements institution_domain" do
|
||||
assert_respond_to adapter, :institution_domain
|
||||
# Can be nil or String
|
||||
assert adapter.institution_domain.nil? || adapter.institution_domain.is_a?(String)
|
||||
end
|
||||
|
||||
test "institution metadata adapter implements institution_name" do
|
||||
assert_respond_to adapter, :institution_name
|
||||
# Can be nil or String
|
||||
assert adapter.institution_name.nil? || adapter.institution_name.is_a?(String)
|
||||
end
|
||||
|
||||
test "institution metadata adapter implements institution_url" do
|
||||
assert_respond_to adapter, :institution_url
|
||||
# Can be nil or String
|
||||
assert adapter.institution_url.nil? || adapter.institution_url.is_a?(String)
|
||||
end
|
||||
|
||||
test "institution metadata adapter implements institution_color" do
|
||||
assert_respond_to adapter, :institution_color
|
||||
# Can be nil or String
|
||||
assert adapter.institution_color.nil? || adapter.institution_color.is_a?(String)
|
||||
end
|
||||
|
||||
test "institution metadata adapter implements institution_metadata" do
|
||||
assert_respond_to adapter, :institution_metadata
|
||||
metadata = adapter.institution_metadata
|
||||
|
||||
assert_kind_of Hash, metadata
|
||||
# Metadata should only contain non-nil values
|
||||
metadata.each do |key, value|
|
||||
assert_not_nil value, "#{key} in institution_metadata should not be nil (it should be omitted instead)"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Override this method in your test to provide the adapter instance
|
||||
def adapter
|
||||
raise NotImplementedError, "Test must implement #adapter method"
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user