mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-07-19 05:09:38 +02:00
Only fetch needed Plaid products, improve Plaid tests and mocks
This commit is contained in:
parent
03a146222d
commit
6935ffa3d1
5 changed files with 188 additions and 246 deletions
|
@ -62,18 +62,32 @@ class PlaidItem::AccountsSnapshot
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def can_fetch_transactions?
|
||||||
|
plaid_item.supports_product?("transactions") && accounts.any?
|
||||||
|
end
|
||||||
|
|
||||||
def transactions_data
|
def transactions_data
|
||||||
return nil unless plaid_item.supports_product?("transactions")
|
return nil unless can_fetch_transactions?
|
||||||
@transactions_data ||= plaid_provider.get_transactions(plaid_item.access_token)
|
@transactions_data ||= plaid_provider.get_transactions(plaid_item.access_token)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def can_fetch_investments?
|
||||||
|
plaid_item.supports_product?("investments") &&
|
||||||
|
accounts.any? { |a| a.type == "investment" }
|
||||||
|
end
|
||||||
|
|
||||||
def investments_data
|
def investments_data
|
||||||
return nil unless plaid_item.supports_product?("investments")
|
return nil unless can_fetch_investments?
|
||||||
@investments_data ||= plaid_provider.get_item_investments(plaid_item.access_token)
|
@investments_data ||= plaid_provider.get_item_investments(plaid_item.access_token)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def can_fetch_liabilities?
|
||||||
|
plaid_item.supports_product?("liabilities") &&
|
||||||
|
accounts.any? { |a| [ "credit", "loan" ].include?(a.type) }
|
||||||
|
end
|
||||||
|
|
||||||
def liabilities_data
|
def liabilities_data
|
||||||
return nil unless plaid_item.supports_product?("liabilities")
|
return nil unless can_fetch_liabilities?
|
||||||
@liabilities_data ||= plaid_provider.get_item_liabilities(plaid_item.access_token)
|
@liabilities_data ||= plaid_provider.get_item_liabilities(plaid_item.access_token)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -2,34 +2,45 @@ require "test_helper"
|
||||||
|
|
||||||
class PlaidAccount::ImporterTest < ActiveSupport::TestCase
|
class PlaidAccount::ImporterTest < ActiveSupport::TestCase
|
||||||
setup do
|
setup do
|
||||||
@mock_provider = PlaidMock.new
|
|
||||||
@plaid_account = plaid_accounts(:one)
|
@plaid_account = plaid_accounts(:one)
|
||||||
@plaid_item = @plaid_account.plaid_item
|
@mock_account_snapshot = mock
|
||||||
|
|
||||||
@accounts_snapshot = PlaidItem::AccountsSnapshot.new(@plaid_item, plaid_provider: @mock_provider)
|
|
||||||
@account_snapshot = @accounts_snapshot.get_account_data(@plaid_account.plaid_id)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
test "imports account data" do
|
test "imports account data" do
|
||||||
PlaidAccount::Importer.new(@plaid_account, account_snapshot: @account_snapshot).import
|
account_data = OpenStruct.new(
|
||||||
|
account_id: "acc_1",
|
||||||
|
name: "Test Account",
|
||||||
|
mask: "1234",
|
||||||
|
)
|
||||||
|
|
||||||
assert_equal @account_snapshot.account_data.account_id, @plaid_account.plaid_id
|
transactions_data = OpenStruct.new(
|
||||||
assert_equal @account_snapshot.account_data.name, @plaid_account.name
|
added: [],
|
||||||
assert_equal @account_snapshot.account_data.mask, @plaid_account.mask
|
modified: [],
|
||||||
assert_equal @account_snapshot.account_data.type, @plaid_account.plaid_type
|
removed: [],
|
||||||
assert_equal @account_snapshot.account_data.subtype, @plaid_account.plaid_subtype
|
)
|
||||||
|
|
||||||
# This account has transactions data
|
investments_data = OpenStruct.new(
|
||||||
assert_equal PlaidMock::TRANSACTIONS.count, @plaid_account.raw_transactions_payload["added"].count
|
holdings: [],
|
||||||
|
transactions: [],
|
||||||
|
securities: [],
|
||||||
|
)
|
||||||
|
|
||||||
# This account does not have investment data
|
liabilities_data = OpenStruct.new(
|
||||||
assert_equal 0, @plaid_account.raw_investments_payload["holdings"].count
|
credit: [],
|
||||||
assert_equal 0, @plaid_account.raw_investments_payload["securities"].count
|
mortgage: [],
|
||||||
assert_equal 0, @plaid_account.raw_investments_payload["transactions"].count
|
student: [],
|
||||||
|
)
|
||||||
|
|
||||||
# This account is a credit card, so it should have liability data
|
@mock_account_snapshot.expects(:account_data).returns(account_data).at_least_once
|
||||||
assert_equal @plaid_account.plaid_id, @plaid_account.raw_liabilities_payload["credit"]["account_id"]
|
@mock_account_snapshot.expects(:transactions_data).returns(transactions_data).at_least_once
|
||||||
assert_nil @plaid_account.raw_liabilities_payload["mortgage"]
|
@mock_account_snapshot.expects(:investments_data).returns(investments_data).at_least_once
|
||||||
assert_nil @plaid_account.raw_liabilities_payload["student"]
|
@mock_account_snapshot.expects(:liabilities_data).returns(liabilities_data).at_least_once
|
||||||
|
|
||||||
|
@plaid_account.expects(:upsert_plaid_snapshot!).with(account_data)
|
||||||
|
@plaid_account.expects(:upsert_plaid_transactions_snapshot!).with(transactions_data)
|
||||||
|
@plaid_account.expects(:upsert_plaid_investments_snapshot!).with(investments_data)
|
||||||
|
@plaid_account.expects(:upsert_plaid_liabilities_snapshot!).with(liabilities_data)
|
||||||
|
|
||||||
|
PlaidAccount::Importer.new(@plaid_account, account_snapshot: @mock_account_snapshot).import
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
105
test/models/plaid_item/accounts_snapshot_test.rb
Normal file
105
test/models/plaid_item/accounts_snapshot_test.rb
Normal file
|
@ -0,0 +1,105 @@
|
||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class PlaidItem::AccountsSnapshotTest < ActiveSupport::TestCase
|
||||||
|
setup do
|
||||||
|
@plaid_item = plaid_items(:one)
|
||||||
|
@plaid_item.plaid_accounts.destroy_all # Clean slate
|
||||||
|
|
||||||
|
@plaid_provider = mock
|
||||||
|
@snapshot = PlaidItem::AccountsSnapshot.new(@plaid_item, plaid_provider: @plaid_provider)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "fetches accounts" do
|
||||||
|
@plaid_provider.expects(:get_item_accounts).with(@plaid_item.access_token).returns(
|
||||||
|
OpenStruct.new(accounts: [])
|
||||||
|
)
|
||||||
|
@snapshot.accounts
|
||||||
|
end
|
||||||
|
|
||||||
|
test "fetches transactions data if item supports transactions and any accounts present" do
|
||||||
|
@plaid_item.update!(available_products: [ "transactions" ], billed_products: [])
|
||||||
|
|
||||||
|
@snapshot.expects(:accounts).returns([
|
||||||
|
OpenStruct.new(
|
||||||
|
account_id: "123",
|
||||||
|
type: "depository"
|
||||||
|
)
|
||||||
|
]).at_least_once
|
||||||
|
|
||||||
|
@plaid_provider.expects(:get_transactions).with(@plaid_item.access_token).once
|
||||||
|
@plaid_provider.expects(:get_item_investments).never
|
||||||
|
@plaid_provider.expects(:get_item_liabilities).never
|
||||||
|
|
||||||
|
@snapshot.get_account_data("123")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "does not fetch transactions if no accounts" do
|
||||||
|
@plaid_item.update!(available_products: [ "transactions" ], billed_products: [])
|
||||||
|
|
||||||
|
@snapshot.expects(:accounts).returns([]).at_least_once
|
||||||
|
|
||||||
|
@plaid_provider.expects(:get_transactions).never
|
||||||
|
@plaid_provider.expects(:get_item_investments).never
|
||||||
|
@plaid_provider.expects(:get_item_liabilities).never
|
||||||
|
|
||||||
|
@snapshot.get_account_data("123")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "fetches investments data if item supports investments and investment accounts present" do
|
||||||
|
@plaid_item.update!(available_products: [ "investments" ], billed_products: [])
|
||||||
|
|
||||||
|
@snapshot.expects(:accounts).returns([
|
||||||
|
OpenStruct.new(
|
||||||
|
account_id: "123",
|
||||||
|
type: "investment"
|
||||||
|
)
|
||||||
|
]).at_least_once
|
||||||
|
|
||||||
|
@plaid_provider.expects(:get_transactions).never
|
||||||
|
@plaid_provider.expects(:get_item_investments).with(@plaid_item.access_token).once
|
||||||
|
@plaid_provider.expects(:get_item_liabilities).never
|
||||||
|
|
||||||
|
@snapshot.get_account_data("123")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "does not fetch investments if no investment accounts" do
|
||||||
|
@plaid_item.update!(available_products: [ "investments" ], billed_products: [])
|
||||||
|
|
||||||
|
@snapshot.expects(:accounts).returns([]).at_least_once
|
||||||
|
|
||||||
|
@plaid_provider.expects(:get_transactions).never
|
||||||
|
@plaid_provider.expects(:get_item_investments).never
|
||||||
|
@plaid_provider.expects(:get_item_liabilities).never
|
||||||
|
|
||||||
|
@snapshot.get_account_data("123")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "fetches liabilities data if item supports liabilities and liabilities accounts present" do
|
||||||
|
@plaid_item.update!(available_products: [ "liabilities" ], billed_products: [])
|
||||||
|
|
||||||
|
@snapshot.expects(:accounts).returns([
|
||||||
|
OpenStruct.new(
|
||||||
|
account_id: "123",
|
||||||
|
type: "credit"
|
||||||
|
)
|
||||||
|
]).at_least_once
|
||||||
|
|
||||||
|
@plaid_provider.expects(:get_transactions).never
|
||||||
|
@plaid_provider.expects(:get_item_investments).never
|
||||||
|
@plaid_provider.expects(:get_item_liabilities).with(@plaid_item.access_token).once
|
||||||
|
|
||||||
|
@snapshot.get_account_data("123")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "does not fetch liabilities if no liabilities accounts" do
|
||||||
|
@plaid_item.update!(available_products: [ "liabilities" ], billed_products: [])
|
||||||
|
|
||||||
|
@snapshot.expects(:accounts).returns([]).at_least_once
|
||||||
|
|
||||||
|
@plaid_provider.expects(:get_transactions).never
|
||||||
|
@plaid_provider.expects(:get_item_investments).never
|
||||||
|
@plaid_provider.expects(:get_item_liabilities).never
|
||||||
|
|
||||||
|
@snapshot.get_account_data("123")
|
||||||
|
end
|
||||||
|
end
|
|
@ -3,21 +3,47 @@ require "ostruct"
|
||||||
|
|
||||||
class PlaidItem::ImporterTest < ActiveSupport::TestCase
|
class PlaidItem::ImporterTest < ActiveSupport::TestCase
|
||||||
setup do
|
setup do
|
||||||
@mock_provider = PlaidMock.new
|
@mock_provider = mock("Provider::Plaid")
|
||||||
@plaid_item = plaid_items(:one)
|
@plaid_item = plaid_items(:one)
|
||||||
@importer = PlaidItem::Importer.new(@plaid_item, plaid_provider: @mock_provider)
|
@importer = PlaidItem::Importer.new(@plaid_item, plaid_provider: @mock_provider)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "imports item metadata" do
|
test "imports item metadata" do
|
||||||
PlaidAccount::Importer.any_instance.expects(:import).times(PlaidMock::ACCOUNTS.count)
|
item_data = OpenStruct.new(
|
||||||
|
item_id: "item_1",
|
||||||
|
available_products: [ "transactions", "investments", "liabilities" ],
|
||||||
|
billed_products: [],
|
||||||
|
institution_id: "ins_1",
|
||||||
|
institution_name: "First Platypus Bank",
|
||||||
|
)
|
||||||
|
|
||||||
PlaidItem::Importer.new(@plaid_item, plaid_provider: @mock_provider).import
|
@mock_provider.expects(:get_item).with(@plaid_item.access_token).returns(
|
||||||
|
OpenStruct.new(item: item_data)
|
||||||
|
)
|
||||||
|
|
||||||
assert_equal PlaidMock::ITEM.institution_id, @plaid_item.institution_id
|
institution_data = OpenStruct.new(
|
||||||
assert_equal PlaidMock::ITEM.available_products, @plaid_item.available_products
|
institution_id: "ins_1",
|
||||||
assert_equal PlaidMock::ITEM.billed_products, @plaid_item.billed_products
|
institution_name: "First Platypus Bank",
|
||||||
|
)
|
||||||
|
|
||||||
assert_equal PlaidMock::ITEM.item_id, @plaid_item.raw_payload["item_id"]
|
@mock_provider.expects(:get_institution).with("ins_1").returns(
|
||||||
assert_equal PlaidMock::INSTITUTION.institution_id, @plaid_item.raw_institution_payload["institution_id"]
|
OpenStruct.new(institution: institution_data)
|
||||||
|
)
|
||||||
|
|
||||||
|
PlaidItem::AccountsSnapshot.any_instance.expects(:accounts).returns([
|
||||||
|
OpenStruct.new(
|
||||||
|
account_id: "acc_1",
|
||||||
|
type: "depository"
|
||||||
|
)
|
||||||
|
]).at_least_once
|
||||||
|
|
||||||
|
PlaidItem::AccountsSnapshot.any_instance.expects(:get_account_data).with("acc_1").once
|
||||||
|
|
||||||
|
PlaidAccount::Importer.any_instance.expects(:import).once
|
||||||
|
|
||||||
|
@plaid_item.expects(:upsert_plaid_snapshot!).with(item_data)
|
||||||
|
@plaid_item.expects(:upsert_plaid_institution_snapshot!).with(institution_data)
|
||||||
|
|
||||||
|
@importer.import
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -1,214 +0,0 @@
|
||||||
require "ostruct"
|
|
||||||
|
|
||||||
# Lightweight wrapper that allows Ostruct objects to properly serialize to JSON
|
|
||||||
# for storage on PlaidItem / PlaidAccount JSONB columns
|
|
||||||
class MockData < OpenStruct
|
|
||||||
def as_json(options = {})
|
|
||||||
@table.as_json(options)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# A basic Plaid provider mock that returns static payloads for testing
|
|
||||||
class PlaidMock
|
|
||||||
TransactionSyncResponse = Struct.new(:added, :modified, :removed, :cursor, keyword_init: true)
|
|
||||||
InvestmentsResponse = Struct.new(:holdings, :transactions, :securities, keyword_init: true)
|
|
||||||
|
|
||||||
ITEM = MockData.new(
|
|
||||||
item_id: "item_mock_1",
|
|
||||||
institution_id: "ins_mock",
|
|
||||||
institution_name: "Mock Institution",
|
|
||||||
available_products: [],
|
|
||||||
billed_products: %w[transactions investments liabilities]
|
|
||||||
)
|
|
||||||
|
|
||||||
INSTITUTION = MockData.new(
|
|
||||||
institution_id: "ins_mock",
|
|
||||||
institution_name: "Mock Institution"
|
|
||||||
)
|
|
||||||
|
|
||||||
ACCOUNTS = [
|
|
||||||
MockData.new(
|
|
||||||
account_id: "acc_mock_1",
|
|
||||||
name: "Mock Checking",
|
|
||||||
mask: "1111",
|
|
||||||
type: "depository",
|
|
||||||
subtype: "checking",
|
|
||||||
balances: MockData.new(
|
|
||||||
current: 1_000.00,
|
|
||||||
available: 800.00,
|
|
||||||
iso_currency_code: "USD"
|
|
||||||
)
|
|
||||||
),
|
|
||||||
MockData.new(
|
|
||||||
account_id: "acc_mock_2",
|
|
||||||
name: "Mock Brokerage",
|
|
||||||
mask: "2222",
|
|
||||||
type: "investment",
|
|
||||||
subtype: "brokerage",
|
|
||||||
balances: MockData.new(
|
|
||||||
current: 15_000.00,
|
|
||||||
available: 15_000.00,
|
|
||||||
iso_currency_code: "USD"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
SECURITIES = [
|
|
||||||
MockData.new(
|
|
||||||
security_id: "sec_mock_1",
|
|
||||||
ticker_symbol: "AAPL",
|
|
||||||
proxy_security_id: nil,
|
|
||||||
market_identifier_code: "XNAS",
|
|
||||||
type: "equity",
|
|
||||||
is_cash_equivalent: false
|
|
||||||
),
|
|
||||||
# Cash security representation – used to exclude cash-equivalent holdings
|
|
||||||
MockData.new(
|
|
||||||
security_id: "sec_mock_cash",
|
|
||||||
ticker_symbol: "CUR:USD",
|
|
||||||
proxy_security_id: nil,
|
|
||||||
market_identifier_code: nil,
|
|
||||||
type: "cash",
|
|
||||||
is_cash_equivalent: true
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
TRANSACTIONS = [
|
|
||||||
MockData.new(
|
|
||||||
transaction_id: "txn_mock_1",
|
|
||||||
account_id: "acc_mock_1",
|
|
||||||
merchant_name: "Mock Coffee",
|
|
||||||
original_description: "MOCK COFFEE SHOP",
|
|
||||||
amount: 4.50,
|
|
||||||
iso_currency_code: "USD",
|
|
||||||
date: Date.current.to_s,
|
|
||||||
personal_finance_category: OpenStruct.new(primary: "FOOD_AND_DRINK", detailed: "COFFEE_SHOP"),
|
|
||||||
website: "https://coffee.example.com",
|
|
||||||
logo_url: "https://coffee.example.com/logo.png",
|
|
||||||
merchant_entity_id: "merch_mock_1"
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
INVESTMENT_TRANSACTIONS = [
|
|
||||||
MockData.new(
|
|
||||||
investment_transaction_id: "inv_txn_mock_1",
|
|
||||||
account_id: "acc_mock_2",
|
|
||||||
security_id: "sec_mock_1",
|
|
||||||
type: "buy",
|
|
||||||
name: "BUY AAPL",
|
|
||||||
quantity: 10,
|
|
||||||
price: 150.00,
|
|
||||||
amount: -1_500.00,
|
|
||||||
iso_currency_code: "USD",
|
|
||||||
date: Date.current.to_s
|
|
||||||
),
|
|
||||||
MockData.new(
|
|
||||||
investment_transaction_id: "inv_txn_mock_cash",
|
|
||||||
account_id: "acc_mock_2",
|
|
||||||
security_id: "sec_mock_cash",
|
|
||||||
type: "cash",
|
|
||||||
name: "Cash Dividend",
|
|
||||||
quantity: 1,
|
|
||||||
price: 200.00,
|
|
||||||
amount: 200.00,
|
|
||||||
iso_currency_code: "USD",
|
|
||||||
date: Date.current.to_s
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
HOLDINGS = [
|
|
||||||
MockData.new(
|
|
||||||
account_id: "acc_mock_2",
|
|
||||||
security_id: "sec_mock_1",
|
|
||||||
quantity: 10,
|
|
||||||
institution_price: 150.00,
|
|
||||||
iso_currency_code: "USD"
|
|
||||||
),
|
|
||||||
MockData.new(
|
|
||||||
account_id: "acc_mock_2",
|
|
||||||
security_id: "sec_mock_cash",
|
|
||||||
quantity: 200.0,
|
|
||||||
institution_price: 1.00,
|
|
||||||
iso_currency_code: "USD"
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
LIABILITIES = {
|
|
||||||
credit: [
|
|
||||||
MockData.new(
|
|
||||||
account_id: "acc_mock_1",
|
|
||||||
minimum_payment_amount: 25.00,
|
|
||||||
aprs: [ MockData.new(apr_percentage: 19.99) ]
|
|
||||||
)
|
|
||||||
],
|
|
||||||
mortgage: [
|
|
||||||
MockData.new(
|
|
||||||
account_id: "acc_mock_3",
|
|
||||||
origination_principal_amount: 250_000,
|
|
||||||
origination_date: 10.years.ago.to_date.to_s,
|
|
||||||
interest_rate: MockData.new(type: "fixed", percentage: 3.5)
|
|
||||||
)
|
|
||||||
],
|
|
||||||
student: [
|
|
||||||
MockData.new(
|
|
||||||
account_id: "acc_mock_4",
|
|
||||||
origination_principal_amount: 50_000,
|
|
||||||
origination_date: 6.years.ago.to_date.to_s,
|
|
||||||
interest_rate_percentage: 4.0
|
|
||||||
)
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
def get_link_token(*, **)
|
|
||||||
MockData.new(link_token: "link-mock-123")
|
|
||||||
end
|
|
||||||
|
|
||||||
def create_public_token(username: nil)
|
|
||||||
"public-mock-#{username || 'user'}"
|
|
||||||
end
|
|
||||||
|
|
||||||
def exchange_public_token(_token)
|
|
||||||
MockData.new(access_token: "access-mock-123")
|
|
||||||
end
|
|
||||||
|
|
||||||
def get_item(_access_token)
|
|
||||||
MockData.new(
|
|
||||||
item: ITEM
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
def get_institution(institution_id)
|
|
||||||
MockData.new(
|
|
||||||
institution: INSTITUTION
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
def get_item_accounts(_item_or_token)
|
|
||||||
MockData.new(accounts: ACCOUNTS)
|
|
||||||
end
|
|
||||||
|
|
||||||
def get_transactions(access_token, next_cursor: nil)
|
|
||||||
TransactionSyncResponse.new(
|
|
||||||
added: TRANSACTIONS,
|
|
||||||
modified: [],
|
|
||||||
removed: [],
|
|
||||||
cursor: "cursor-mock-1"
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
def get_item_investments(_item_or_token, **)
|
|
||||||
InvestmentsResponse.new(
|
|
||||||
holdings: HOLDINGS,
|
|
||||||
transactions: INVESTMENT_TRANSACTIONS,
|
|
||||||
securities: SECURITIES
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
def get_item_liabilities(_item_or_token)
|
|
||||||
MockData.new(
|
|
||||||
credit: LIABILITIES[:credit],
|
|
||||||
mortgage: LIABILITIES[:mortgage],
|
|
||||||
student: LIABILITIES[:student]
|
|
||||||
)
|
|
||||||
end
|
|
||||||
end
|
|
Loading…
Add table
Add a link
Reference in a new issue