mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-07-19 05:09:38 +02:00
Breaks our Plaid sync process out into more manageable classes. Notably, this moves the sync process to a distinct, 2-step flow: 1. Import stage - we first make API calls and import Plaid data to "mirror" tables 2. Processing stage - read the raw data, apply business rules, build internal domain models and sync balances This provides several benefits: - Plaid syncs can now be "replayed" without fetching API data again - Mirror tables provide better audit and debugging capabilities - Eliminates the "all or nothing" sync behavior that is currently in place, which is brittle
214 lines
6 KiB
Ruby
214 lines
6 KiB
Ruby
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
|