mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-08-08 06:55:21 +02:00
First pass at Plaid sync v2
This commit is contained in:
parent
b970a6b41d
commit
8683026519
9 changed files with 236 additions and 75 deletions
|
@ -8,7 +8,7 @@ class PlaidItem::AccountsSnapshot
|
||||||
end
|
end
|
||||||
|
|
||||||
def accounts
|
def accounts
|
||||||
@accounts ||= plaid_provider.get_item_accounts(plaid_item).accounts
|
@accounts ||= plaid_provider.get_item_accounts(plaid_item.access_token).accounts
|
||||||
end
|
end
|
||||||
|
|
||||||
def get_account_data(account_id)
|
def get_account_data(account_id)
|
||||||
|
@ -60,16 +60,16 @@ class PlaidItem::AccountsSnapshot
|
||||||
|
|
||||||
def transactions_data
|
def transactions_data
|
||||||
return nil unless plaid_item.supports_product?("transactions")
|
return nil unless plaid_item.supports_product?("transactions")
|
||||||
@transactions_data ||= plaid_provider.get_item_transactions(plaid_item)
|
@transactions_data ||= plaid_provider.get_transactions(plaid_item.access_token)
|
||||||
end
|
end
|
||||||
|
|
||||||
def investments_data
|
def investments_data
|
||||||
return nil unless plaid_item.supports_product?("investments")
|
return nil unless plaid_item.supports_product?("investments")
|
||||||
@investments_data ||= plaid_provider.get_item_investments(plaid_item)
|
@investments_data ||= plaid_provider.get_item_investments(plaid_item.access_token)
|
||||||
end
|
end
|
||||||
|
|
||||||
def liabilities_data
|
def liabilities_data
|
||||||
return nil unless plaid_item.supports_product?("liabilities")
|
return nil unless plaid_item.supports_product?("liabilities")
|
||||||
@liabilities_data ||= plaid_provider.get_item_liabilities(plaid_item)
|
@liabilities_data ||= plaid_provider.get_item_liabilities(plaid_item.access_token)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -18,6 +18,9 @@ class Provider
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
PaginatedData = Data.define(:paginated, :first_page, :total_pages)
|
||||||
|
UsageData = Data.define(:used, :limit, :utilization, :plan)
|
||||||
|
|
||||||
def with_provider_response(error_transformer: nil, &block)
|
def with_provider_response(error_transformer: nil, &block)
|
||||||
data = yield
|
data = yield
|
||||||
|
|
||||||
|
|
|
@ -111,7 +111,7 @@ class Provider::Plaid
|
||||||
client.accounts_get(request)
|
client.accounts_get(request)
|
||||||
end
|
end
|
||||||
|
|
||||||
def get_transactions(access_token:, next_cursor: nil)
|
def get_transactions(access_token, next_cursor: nil)
|
||||||
cursor = next_cursor
|
cursor = next_cursor
|
||||||
added = []
|
added = []
|
||||||
modified = []
|
modified = []
|
||||||
|
@ -139,7 +139,7 @@ class Provider::Plaid
|
||||||
TransactionSyncResponse.new(added:, modified:, removed:, cursor:)
|
TransactionSyncResponse.new(added:, modified:, removed:, cursor:)
|
||||||
end
|
end
|
||||||
|
|
||||||
def get_item_investments(access_token:, start_date: nil, end_date: Date.current)
|
def get_item_investments(access_token, start_date: nil, end_date: Date.current)
|
||||||
start_date = start_date || MAX_HISTORY_DAYS.days.ago.to_date
|
start_date = start_date || MAX_HISTORY_DAYS.days.ago.to_date
|
||||||
holdings, holding_securities = get_item_holdings(access_token: access_token)
|
holdings, holding_securities = get_item_holdings(access_token: access_token)
|
||||||
transactions, transaction_securities = get_item_investment_transactions(access_token: access_token, start_date:, end_date:)
|
transactions, transaction_securities = get_item_investment_transactions(access_token: access_token, start_date:, end_date:)
|
||||||
|
@ -149,7 +149,7 @@ class Provider::Plaid
|
||||||
InvestmentsResponse.new(holdings:, transactions:, securities: merged_securities)
|
InvestmentsResponse.new(holdings:, transactions:, securities: merged_securities)
|
||||||
end
|
end
|
||||||
|
|
||||||
def get_item_liabilities(access_token:)
|
def get_item_liabilities(access_token)
|
||||||
request = Plaid::LiabilitiesGetRequest.new({ access_token: access_token })
|
request = Plaid::LiabilitiesGetRequest.new({ access_token: access_token })
|
||||||
response = client.liabilities_get(request)
|
response = client.liabilities_get(request)
|
||||||
response.liabilities
|
response.liabilities
|
||||||
|
|
2
test/fixtures/plaid_accounts.yml
vendored
2
test/fixtures/plaid_accounts.yml
vendored
|
@ -1,3 +1,3 @@
|
||||||
one:
|
one:
|
||||||
plaid_item: one
|
plaid_item: one
|
||||||
plaid_id: "1234567890"
|
plaid_id: "acc_mock_1"
|
||||||
|
|
2
test/fixtures/plaid_items.yml
vendored
2
test/fixtures/plaid_items.yml
vendored
|
@ -1,6 +1,6 @@
|
||||||
one:
|
one:
|
||||||
family: dylan_family
|
family: dylan_family
|
||||||
plaid_id: "1234567890"
|
plaid_id: "item_mock_1"
|
||||||
access_token: encrypted_token_1
|
access_token: encrypted_token_1
|
||||||
name: "Test Bank"
|
name: "Test Bank"
|
||||||
billed_products: ["transactions", "investments", "liabilities"]
|
billed_products: ["transactions", "investments", "liabilities"]
|
||||||
|
|
|
@ -2,27 +2,21 @@ require "test_helper"
|
||||||
|
|
||||||
class PlaidAccount::ImporterTest < ActiveSupport::TestCase
|
class PlaidAccount::ImporterTest < ActiveSupport::TestCase
|
||||||
setup do
|
setup do
|
||||||
@mock_provider = mock("Provider::Plaid")
|
@mock_provider = PlaidMock.new
|
||||||
@plaid_account = plaid_accounts(:one)
|
@plaid_account = plaid_accounts(:one)
|
||||||
|
@plaid_item = @plaid_account.plaid_item
|
||||||
|
|
||||||
|
@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
|
||||||
raw_payload = OpenStruct.new(
|
PlaidAccount::Importer.new(@plaid_account, account_snapshot: @account_snapshot).import
|
||||||
account_id: "123",
|
|
||||||
name: "Test Account",
|
|
||||||
mask: "1234",
|
|
||||||
type: "checking",
|
|
||||||
subtype: "checking",
|
|
||||||
)
|
|
||||||
|
|
||||||
PlaidAccount::Importer.new(@plaid_account, raw_payload, plaid_provider: @mock_provider).import
|
assert_equal @account_snapshot.account_data.account_id, @plaid_account.plaid_id
|
||||||
|
assert_equal @account_snapshot.account_data.name, @plaid_account.name
|
||||||
@plaid_account.reload
|
assert_equal @account_snapshot.account_data.mask, @plaid_account.mask
|
||||||
|
assert_equal @account_snapshot.account_data.type, @plaid_account.plaid_type
|
||||||
assert_equal "123", @plaid_account.plaid_id
|
assert_equal @account_snapshot.account_data.subtype, @plaid_account.plaid_subtype
|
||||||
assert_equal "Test Account", @plaid_account.name
|
|
||||||
assert_equal "1234", @plaid_account.mask
|
|
||||||
assert_equal "checking", @plaid_account.plaid_type
|
|
||||||
assert_equal "checking", @plaid_account.plaid_subtype
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -3,59 +3,19 @@ require "ostruct"
|
||||||
|
|
||||||
class PlaidItem::ImporterTest < ActiveSupport::TestCase
|
class PlaidItem::ImporterTest < ActiveSupport::TestCase
|
||||||
setup do
|
setup do
|
||||||
@mock_provider = mock("Provider::Plaid")
|
@mock_provider = PlaidMock.new
|
||||||
@plaid_item = plaid_items(:one)
|
@plaid_item = plaid_items(:one)
|
||||||
|
@importer = PlaidItem::Importer.new(@plaid_item, plaid_provider: @mock_provider)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "imports item metadata" do
|
test "imports item metadata" do
|
||||||
mock_institution_id = "123"
|
PlaidAccount::Importer.any_instance.expects(:import).times(PlaidMock::ACCOUNTS.count)
|
||||||
|
|
||||||
raw_item_payload = OpenStruct.new(
|
|
||||||
item: OpenStruct.new(
|
|
||||||
available_products: [],
|
|
||||||
billed_products: %w[transactions investments liabilities],
|
|
||||||
institution_id: mock_institution_id
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
raw_institution_payload = OpenStruct.new(
|
|
||||||
institution: OpenStruct.new(
|
|
||||||
institution_id: mock_institution_id,
|
|
||||||
url: "https://example.com",
|
|
||||||
primary_color: "#000000"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
raw_accounts_payload = OpenStruct.new(
|
|
||||||
accounts: [
|
|
||||||
OpenStruct.new(
|
|
||||||
account_id: "123",
|
|
||||||
name: "Test Account",
|
|
||||||
mask: "1234",
|
|
||||||
type: "checking",
|
|
||||||
subtype: "checking",
|
|
||||||
)
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
@mock_provider.expects(:get_item).returns(raw_item_payload)
|
|
||||||
@mock_provider.expects(:get_institution).with(mock_institution_id).returns(raw_institution_payload)
|
|
||||||
@mock_provider.expects(:get_item_accounts).with(@plaid_item).returns(raw_accounts_payload)
|
|
||||||
@mock_provider.expects(:get_item_transactions).with(@plaid_item).returns(OpenStruct.new(transactions: []))
|
|
||||||
@mock_provider.expects(:get_item_investments).with(@plaid_item).returns(OpenStruct.new(investments: []))
|
|
||||||
@mock_provider.expects(:get_item_liabilities).with(@plaid_item).returns(OpenStruct.new(liabilities: []))
|
|
||||||
|
|
||||||
PlaidAccount::Importer.any_instance.expects(:import).times(raw_accounts_payload.accounts.count)
|
|
||||||
|
|
||||||
PlaidItem::Importer.new(@plaid_item, plaid_provider: @mock_provider).import
|
PlaidItem::Importer.new(@plaid_item, plaid_provider: @mock_provider).import
|
||||||
|
|
||||||
@plaid_item.reload
|
assert_equal PlaidMock::ITEM.institution_id, @plaid_item.institution_id
|
||||||
|
assert_equal PlaidMock::ITEM.available_products, @plaid_item.available_products
|
||||||
assert_equal mock_institution_id, @plaid_item.institution_id
|
assert_equal PlaidMock::ITEM.billed_products, @plaid_item.billed_products
|
||||||
assert_equal "https://example.com", @plaid_item.institution_url
|
|
||||||
assert_equal "#000000", @plaid_item.institution_color
|
|
||||||
assert_equal %w[transactions investments liabilities], @plaid_item.available_products
|
|
||||||
assert_equal %w[transactions investments liabilities], @plaid_item.billed_products
|
|
||||||
assert_not_nil @plaid_item.raw_payload
|
assert_not_nil @plaid_item.raw_payload
|
||||||
assert_not_nil @plaid_item.raw_institution_payload
|
assert_not_nil @plaid_item.raw_institution_payload
|
||||||
end
|
end
|
||||||
|
|
|
@ -61,13 +61,13 @@ class Provider::PlaidTest < ActiveSupport::TestCase
|
||||||
VCR.use_cassette("plaid/get_transactions_with_next_cursor") do
|
VCR.use_cassette("plaid/get_transactions_with_next_cursor") do
|
||||||
access_token = get_access_token
|
access_token = get_access_token
|
||||||
|
|
||||||
transactions_response = @plaid.get_transactions(access_token: access_token)
|
transactions_response = @plaid.get_transactions(access_token)
|
||||||
|
|
||||||
assert transactions_response.added.size > 0
|
assert transactions_response.added.size > 0
|
||||||
|
|
||||||
# Second call, we get only the latest transactions
|
# Second call, we get only the latest transactions
|
||||||
transactions_with_cursor = @plaid.get_transactions(
|
transactions_with_cursor = @plaid.get_transactions(
|
||||||
access_token: access_token,
|
access_token,
|
||||||
next_cursor: transactions_response.cursor
|
next_cursor: transactions_response.cursor
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -80,7 +80,7 @@ class Provider::PlaidTest < ActiveSupport::TestCase
|
||||||
test "gets item investments" do
|
test "gets item investments" do
|
||||||
VCR.use_cassette("plaid/get_item_investments") do
|
VCR.use_cassette("plaid/get_item_investments") do
|
||||||
access_token = get_access_token
|
access_token = get_access_token
|
||||||
investments_response = @plaid.get_item_investments(access_token: access_token)
|
investments_response = @plaid.get_item_investments(access_token)
|
||||||
|
|
||||||
assert_equal 3, investments_response.holdings.size
|
assert_equal 3, investments_response.holdings.size
|
||||||
assert_equal 4, investments_response.transactions.size
|
assert_equal 4, investments_response.transactions.size
|
||||||
|
|
|
@ -1,2 +1,206 @@
|
||||||
|
require "ostruct"
|
||||||
|
|
||||||
|
# A basic Plaid provider mock that returns static payloads for testing
|
||||||
class PlaidMock
|
class PlaidMock
|
||||||
|
TransactionSyncResponse = Struct.new(:added, :modified, :removed, :cursor, keyword_init: true)
|
||||||
|
InvestmentsResponse = Struct.new(:holdings, :transactions, :securities, keyword_init: true)
|
||||||
|
|
||||||
|
ITEM = OpenStruct.new(
|
||||||
|
item_id: "item_mock_1",
|
||||||
|
institution_id: "ins_mock",
|
||||||
|
institution_name: "Mock Institution",
|
||||||
|
available_products: [],
|
||||||
|
billed_products: %w[transactions investments liabilities]
|
||||||
|
)
|
||||||
|
|
||||||
|
INSTITUTION = OpenStruct.new(
|
||||||
|
institution_id: "ins_mock",
|
||||||
|
institution_name: "Mock Institution"
|
||||||
|
)
|
||||||
|
|
||||||
|
ACCOUNTS = [
|
||||||
|
OpenStruct.new(
|
||||||
|
account_id: "acc_mock_1",
|
||||||
|
name: "Mock Checking",
|
||||||
|
mask: "1111",
|
||||||
|
type: "depository",
|
||||||
|
subtype: "checking",
|
||||||
|
balances: OpenStruct.new(
|
||||||
|
current: 1_000.00,
|
||||||
|
available: 800.00,
|
||||||
|
iso_currency_code: "USD"
|
||||||
|
)
|
||||||
|
),
|
||||||
|
OpenStruct.new(
|
||||||
|
account_id: "acc_mock_2",
|
||||||
|
name: "Mock Brokerage",
|
||||||
|
mask: "2222",
|
||||||
|
type: "investment",
|
||||||
|
subtype: "brokerage",
|
||||||
|
balances: OpenStruct.new(
|
||||||
|
current: 15_000.00,
|
||||||
|
available: 15_000.00,
|
||||||
|
iso_currency_code: "USD"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
SECURITIES = [
|
||||||
|
OpenStruct.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
|
||||||
|
OpenStruct.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 = [
|
||||||
|
OpenStruct.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 = [
|
||||||
|
OpenStruct.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
|
||||||
|
),
|
||||||
|
OpenStruct.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 = [
|
||||||
|
OpenStruct.new(
|
||||||
|
account_id: "acc_mock_2",
|
||||||
|
security_id: "sec_mock_1",
|
||||||
|
quantity: 10,
|
||||||
|
institution_price: 150.00,
|
||||||
|
iso_currency_code: "USD"
|
||||||
|
),
|
||||||
|
OpenStruct.new(
|
||||||
|
account_id: "acc_mock_2",
|
||||||
|
security_id: "sec_mock_cash",
|
||||||
|
quantity: 200.0,
|
||||||
|
institution_price: 1.00,
|
||||||
|
iso_currency_code: "USD"
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
LIABILITIES = {
|
||||||
|
credit: [
|
||||||
|
OpenStruct.new(
|
||||||
|
account_id: "acc_mock_1",
|
||||||
|
minimum_payment_amount: 25.00,
|
||||||
|
aprs: [ OpenStruct.new(apr_percentage: 19.99) ]
|
||||||
|
)
|
||||||
|
],
|
||||||
|
mortgage: [
|
||||||
|
OpenStruct.new(
|
||||||
|
account_id: "acc_mock_3",
|
||||||
|
origination_principal_amount: 250_000,
|
||||||
|
origination_date: 10.years.ago.to_date.to_s,
|
||||||
|
interest_rate: OpenStruct.new(type: "fixed", percentage: 3.5)
|
||||||
|
)
|
||||||
|
],
|
||||||
|
student: [
|
||||||
|
OpenStruct.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(*, **)
|
||||||
|
OpenStruct.new(link_token: "link-mock-123")
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_public_token(username: nil)
|
||||||
|
"public-mock-#{username || 'user'}"
|
||||||
|
end
|
||||||
|
|
||||||
|
def exchange_public_token(_token)
|
||||||
|
OpenStruct.new(access_token: "access-mock-123")
|
||||||
|
end
|
||||||
|
|
||||||
|
def get_item(_access_token)
|
||||||
|
OpenStruct.new(
|
||||||
|
item: ITEM
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def get_institution(institution_id)
|
||||||
|
OpenStruct.new(
|
||||||
|
institution: INSTITUTION
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def get_item_accounts(_item_or_token)
|
||||||
|
OpenStruct.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)
|
||||||
|
OpenStruct.new(
|
||||||
|
credit: LIABILITIES[:credit],
|
||||||
|
mortgage: LIABILITIES[:mortgage],
|
||||||
|
student: LIABILITIES[:student]
|
||||||
|
)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue