mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-07-18 20:59:39 +02:00
Plaid sync domain improvements (#2267)
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
This commit is contained in:
parent
5c82af0e8c
commit
03a146222d
72 changed files with 3763 additions and 706 deletions
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
|
@ -77,6 +77,8 @@ jobs:
|
|||
timeout-minutes: 10
|
||||
|
||||
env:
|
||||
PLAID_CLIENT_ID: foo
|
||||
PLAID_SECRET: bar
|
||||
DATABASE_URL: postgres://postgres:postgres@localhost:5432
|
||||
RAILS_ENV: test
|
||||
|
||||
|
|
|
@ -6,7 +6,7 @@ class WebhooksController < ApplicationController
|
|||
webhook_body = request.body.read
|
||||
plaid_verification_header = request.headers["Plaid-Verification"]
|
||||
|
||||
client = Provider::Plaid.new(Rails.application.config.plaid, region: :us)
|
||||
client = Provider::Registry.plaid_provider_for_region(:us)
|
||||
|
||||
client.validate_webhook!(plaid_verification_header, webhook_body)
|
||||
client.process_webhook(webhook_body)
|
||||
|
@ -21,7 +21,7 @@ class WebhooksController < ApplicationController
|
|||
webhook_body = request.body.read
|
||||
plaid_verification_header = request.headers["Plaid-Verification"]
|
||||
|
||||
client = Provider::Plaid.new(Rails.application.config.plaid_eu, region: :eu)
|
||||
client = Provider::Registry.plaid_provider_for_region(:eu)
|
||||
|
||||
client.validate_webhook!(plaid_verification_header, webhook_body)
|
||||
client.process_webhook(webhook_body)
|
||||
|
|
|
@ -27,14 +27,23 @@ module Enrichable
|
|||
enrich_attributes({ attr => value }, source:, metadata:)
|
||||
end
|
||||
|
||||
# Enriches all attributes that haven't been locked yet
|
||||
# Enriches and logs all attributes that:
|
||||
# - Are not locked
|
||||
# - Are not ignored
|
||||
# - Have changed value from the last saved value
|
||||
def enrich_attributes(attrs, source:, metadata: {})
|
||||
enrichable_attrs = Array(attrs).reject { |k, _v| locked?(k) }
|
||||
enrichable_attrs = Array(attrs).reject do |attr_key, attr_value|
|
||||
locked?(attr_key) || ignored_enrichable_attributes.include?(attr_key) || self[attr_key.to_s] == attr_value
|
||||
end
|
||||
|
||||
ActiveRecord::Base.transaction do
|
||||
enrichable_attrs.each do |attr, value|
|
||||
self.send("#{attr}=", value)
|
||||
log_enrichment(attribute_name: attr, attribute_value: value, source: source, metadata: metadata)
|
||||
|
||||
# If it's a new record, this isn't technically an "enrichment". No logging necessary.
|
||||
unless self.new_record?
|
||||
log_enrichment(attribute_name: attr, attribute_value: value, source: source, metadata: metadata)
|
||||
end
|
||||
end
|
||||
|
||||
save
|
||||
|
|
|
@ -1,6 +1,10 @@
|
|||
class CreditCard < ApplicationRecord
|
||||
include Accountable
|
||||
|
||||
SUBTYPES = {
|
||||
"credit_card" => { short: "Credit Card", long: "Credit Card" }
|
||||
}.freeze
|
||||
|
||||
class << self
|
||||
def color
|
||||
"#F13636"
|
||||
|
|
|
@ -3,7 +3,10 @@ class Depository < ApplicationRecord
|
|||
|
||||
SUBTYPES = {
|
||||
"checking" => { short: "Checking", long: "Checking" },
|
||||
"savings" => { short: "Savings", long: "Savings" }
|
||||
"savings" => { short: "Savings", long: "Savings" },
|
||||
"hsa" => { short: "HSA", long: "Health Savings Account" },
|
||||
"cd" => { short: "CD", long: "Certificate of Deposit" },
|
||||
"money_market" => { short: "MM", long: "Money Market" }
|
||||
}.freeze
|
||||
|
||||
class << self
|
||||
|
|
|
@ -56,6 +56,10 @@ class Entry < ApplicationRecord
|
|||
Balance::TrendCalculator.new(self, entries, balances).trend
|
||||
end
|
||||
|
||||
def linked?
|
||||
plaid_id.present?
|
||||
end
|
||||
|
||||
class << self
|
||||
def search(params)
|
||||
EntrySearch.new(params).build_query(all)
|
||||
|
|
|
@ -70,8 +70,7 @@ class Family::AutoCategorizer
|
|||
amount: transaction.entry.amount.abs,
|
||||
classification: transaction.entry.classification,
|
||||
description: transaction.entry.name,
|
||||
merchant: transaction.merchant&.name,
|
||||
hint: transaction.plaid_category_detailed
|
||||
merchant: transaction.merchant&.name
|
||||
}
|
||||
end
|
||||
end
|
||||
|
|
|
@ -6,9 +6,7 @@ module Family::PlaidConnectable
|
|||
end
|
||||
|
||||
def create_plaid_item!(public_token:, item_name:, region:)
|
||||
provider = plaid_provider_for_region(region)
|
||||
|
||||
public_token_response = provider.exchange_public_token(public_token)
|
||||
public_token_response = plaid(region).exchange_public_token(public_token)
|
||||
|
||||
plaid_item = plaid_items.create!(
|
||||
name: item_name,
|
||||
|
@ -23,11 +21,9 @@ module Family::PlaidConnectable
|
|||
end
|
||||
|
||||
def get_link_token(webhooks_url:, redirect_url:, accountable_type: nil, region: :us, access_token: nil)
|
||||
return nil unless plaid_us || plaid_eu
|
||||
return nil unless plaid(region)
|
||||
|
||||
provider = plaid_provider_for_region(region)
|
||||
|
||||
provider.get_link_token(
|
||||
plaid(region).get_link_token(
|
||||
user_id: self.id,
|
||||
webhooks_url: webhooks_url,
|
||||
redirect_url: redirect_url,
|
||||
|
@ -37,15 +33,7 @@ module Family::PlaidConnectable
|
|||
end
|
||||
|
||||
private
|
||||
def plaid_us
|
||||
@plaid ||= Provider::Registry.get_provider(:plaid_us)
|
||||
end
|
||||
|
||||
def plaid_eu
|
||||
@plaid_eu ||= Provider::Registry.get_provider(:plaid_eu)
|
||||
end
|
||||
|
||||
def plaid_provider_for_region(region)
|
||||
region.to_sym == :eu ? plaid_eu : plaid_us
|
||||
def plaid(region)
|
||||
@plaid ||= Provider::Registry.plaid_provider_for_region(region)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -6,12 +6,11 @@ class Investment < ApplicationRecord
|
|||
"pension" => { short: "Pension", long: "Pension" },
|
||||
"retirement" => { short: "Retirement", long: "Retirement" },
|
||||
"401k" => { short: "401(k)", long: "401(k)" },
|
||||
"traditional_401k" => { short: "Traditional 401(k)", long: "Traditional 401(k)" },
|
||||
"roth_401k" => { short: "Roth 401(k)", long: "Roth 401(k)" },
|
||||
"529_plan" => { short: "529 Plan", long: "529 Plan" },
|
||||
"hsa" => { short: "HSA", long: "Health Savings Account" },
|
||||
"mutual_fund" => { short: "Mutual Fund", long: "Mutual Fund" },
|
||||
"traditional_ira" => { short: "Traditional IRA", long: "Traditional IRA" },
|
||||
"ira" => { short: "IRA", long: "Traditional IRA" },
|
||||
"roth_ira" => { short: "Roth IRA", long: "Roth IRA" },
|
||||
"angel" => { short: "Angel", long: "Angel" }
|
||||
}.freeze
|
||||
|
|
|
@ -1,6 +1,13 @@
|
|||
class Loan < ApplicationRecord
|
||||
include Accountable
|
||||
|
||||
SUBTYPES = {
|
||||
"mortgage" => { short: "Mortgage", long: "Mortgage" },
|
||||
"student" => { short: "Student", long: "Student Loan" },
|
||||
"auto" => { short: "Auto", long: "Auto Loan" },
|
||||
"other" => { short: "Other", long: "Other Loan" }
|
||||
}.freeze
|
||||
|
||||
def monthly_payment
|
||||
return nil if term_months.nil? || interest_rate.nil? || rate_type.nil? || rate_type != "fixed"
|
||||
return Money.new(0, account.currency) if account.loan.original_balance.amount.zero? || term_months.zero?
|
||||
|
|
|
@ -1,167 +1,54 @@
|
|||
class PlaidAccount < ApplicationRecord
|
||||
TYPE_MAPPING = {
|
||||
"depository" => Depository,
|
||||
"credit" => CreditCard,
|
||||
"loan" => Loan,
|
||||
"investment" => Investment,
|
||||
"other" => OtherAsset
|
||||
}
|
||||
|
||||
belongs_to :plaid_item
|
||||
|
||||
has_one :account, dependent: :destroy
|
||||
|
||||
accepts_nested_attributes_for :account
|
||||
validates :name, :plaid_type, :currency, presence: true
|
||||
validate :has_balance
|
||||
|
||||
class << self
|
||||
def find_or_create_from_plaid_data!(plaid_data, family)
|
||||
PlaidAccount.transaction do
|
||||
plaid_account = find_or_create_by!(plaid_id: plaid_data.account_id)
|
||||
|
||||
internal_account = family.accounts.find_or_initialize_by(plaid_account_id: plaid_account.id)
|
||||
|
||||
# Only set the name for new records or if the name is not locked
|
||||
if internal_account.new_record? || internal_account.enrichable?(:name)
|
||||
internal_account.name = plaid_data.name
|
||||
end
|
||||
internal_account.balance = plaid_data.balances.current || plaid_data.balances.available
|
||||
internal_account.currency = plaid_data.balances.iso_currency_code
|
||||
internal_account.accountable = TYPE_MAPPING[plaid_data.type].new
|
||||
|
||||
internal_account.save!
|
||||
plaid_account.save!
|
||||
|
||||
plaid_account
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def sync_account_data!(plaid_account_data)
|
||||
update!(
|
||||
current_balance: plaid_account_data.balances.current,
|
||||
available_balance: plaid_account_data.balances.available,
|
||||
currency: plaid_account_data.balances.iso_currency_code,
|
||||
plaid_type: plaid_account_data.type,
|
||||
plaid_subtype: plaid_account_data.subtype,
|
||||
account_attributes: {
|
||||
id: account.id,
|
||||
# Plaid guarantees at least 1 of these
|
||||
balance: plaid_account_data.balances.current || plaid_account_data.balances.available,
|
||||
cash_balance: derive_plaid_cash_balance(plaid_account_data.balances)
|
||||
}
|
||||
def upsert_plaid_snapshot!(account_snapshot)
|
||||
assign_attributes(
|
||||
current_balance: account_snapshot.balances.current,
|
||||
available_balance: account_snapshot.balances.available,
|
||||
currency: account_snapshot.balances.iso_currency_code,
|
||||
plaid_type: account_snapshot.type,
|
||||
plaid_subtype: account_snapshot.subtype,
|
||||
name: account_snapshot.name,
|
||||
mask: account_snapshot.mask,
|
||||
raw_payload: account_snapshot
|
||||
)
|
||||
|
||||
save!
|
||||
end
|
||||
|
||||
def sync_investments!(transactions:, holdings:, securities:)
|
||||
PlaidInvestmentSync.new(self).sync!(transactions:, holdings:, securities:)
|
||||
end
|
||||
|
||||
def sync_credit_data!(plaid_credit_data)
|
||||
account.update!(
|
||||
accountable_attributes: {
|
||||
id: account.accountable_id,
|
||||
minimum_payment: plaid_credit_data.minimum_payment_amount,
|
||||
apr: plaid_credit_data.aprs.first&.apr_percentage
|
||||
}
|
||||
def upsert_plaid_transactions_snapshot!(transactions_snapshot)
|
||||
assign_attributes(
|
||||
raw_transactions_payload: transactions_snapshot
|
||||
)
|
||||
|
||||
save!
|
||||
end
|
||||
|
||||
def sync_mortgage_data!(plaid_mortgage_data)
|
||||
create_initial_loan_balance(plaid_mortgage_data)
|
||||
|
||||
account.update!(
|
||||
accountable_attributes: {
|
||||
id: account.accountable_id,
|
||||
rate_type: plaid_mortgage_data.interest_rate&.type,
|
||||
interest_rate: plaid_mortgage_data.interest_rate&.percentage
|
||||
}
|
||||
def upsert_plaid_investments_snapshot!(investments_snapshot)
|
||||
assign_attributes(
|
||||
raw_investments_payload: investments_snapshot
|
||||
)
|
||||
|
||||
save!
|
||||
end
|
||||
|
||||
def sync_student_loan_data!(plaid_student_loan_data)
|
||||
create_initial_loan_balance(plaid_student_loan_data)
|
||||
|
||||
account.update!(
|
||||
accountable_attributes: {
|
||||
id: account.accountable_id,
|
||||
rate_type: "fixed",
|
||||
interest_rate: plaid_student_loan_data.interest_rate_percentage
|
||||
}
|
||||
def upsert_plaid_liabilities_snapshot!(liabilities_snapshot)
|
||||
assign_attributes(
|
||||
raw_liabilities_payload: liabilities_snapshot
|
||||
)
|
||||
end
|
||||
|
||||
def sync_transactions!(added:, modified:, removed:)
|
||||
added.each do |plaid_txn|
|
||||
account.entries.find_or_create_by!(plaid_id: plaid_txn.transaction_id) do |t|
|
||||
t.name = plaid_txn.merchant_name || plaid_txn.original_description
|
||||
t.amount = plaid_txn.amount
|
||||
t.currency = plaid_txn.iso_currency_code
|
||||
t.date = plaid_txn.date
|
||||
t.entryable = Transaction.new(
|
||||
plaid_category: plaid_txn.personal_finance_category.primary,
|
||||
plaid_category_detailed: plaid_txn.personal_finance_category.detailed,
|
||||
merchant: find_or_create_merchant(plaid_txn)
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
modified.each do |plaid_txn|
|
||||
existing_txn = account.entries.find_by(plaid_id: plaid_txn.transaction_id)
|
||||
|
||||
existing_txn.update!(
|
||||
amount: plaid_txn.amount,
|
||||
date: plaid_txn.date,
|
||||
entryable_attributes: {
|
||||
plaid_category: plaid_txn.personal_finance_category.primary,
|
||||
plaid_category_detailed: plaid_txn.personal_finance_category.detailed,
|
||||
merchant: find_or_create_merchant(plaid_txn)
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
removed.each do |plaid_txn|
|
||||
account.entries.find_by(plaid_id: plaid_txn.transaction_id)&.destroy
|
||||
end
|
||||
save!
|
||||
end
|
||||
|
||||
private
|
||||
def family
|
||||
plaid_item.family
|
||||
end
|
||||
|
||||
def create_initial_loan_balance(loan_data)
|
||||
if loan_data.origination_principal_amount.present? && loan_data.origination_date.present?
|
||||
account.entries.find_or_create_by!(plaid_id: loan_data.account_id) do |e|
|
||||
e.name = "Initial Principal"
|
||||
e.amount = loan_data.origination_principal_amount
|
||||
e.currency = account.currency
|
||||
e.date = loan_data.origination_date
|
||||
e.entryable = Valuation.new
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def find_or_create_merchant(plaid_txn)
|
||||
unless plaid_txn.merchant_entity_id.present? && plaid_txn.merchant_name.present?
|
||||
return nil
|
||||
end
|
||||
|
||||
ProviderMerchant.find_or_create_by!(
|
||||
source: "plaid",
|
||||
name: plaid_txn.merchant_name,
|
||||
) do |m|
|
||||
m.provider_merchant_id = plaid_txn.merchant_entity_id
|
||||
m.website_url = plaid_txn.website
|
||||
m.logo_url = plaid_txn.logo_url
|
||||
end
|
||||
end
|
||||
|
||||
def derive_plaid_cash_balance(plaid_balances)
|
||||
if account.investment?
|
||||
plaid_balances.available || 0
|
||||
else
|
||||
# For now, we will not distinguish between "cash" and "overall" balance for non-investment accounts
|
||||
plaid_balances.current || plaid_balances.available
|
||||
end
|
||||
# Plaid guarantees at least one of these. This validation is a sanity check for that guarantee.
|
||||
def has_balance
|
||||
return if current_balance.present? || available_balance.present?
|
||||
errors.add(:base, "Plaid account must have either current or available balance")
|
||||
end
|
||||
end
|
||||
|
|
34
app/models/plaid_account/importer.rb
Normal file
34
app/models/plaid_account/importer.rb
Normal file
|
@ -0,0 +1,34 @@
|
|||
class PlaidAccount::Importer
|
||||
def initialize(plaid_account, account_snapshot:)
|
||||
@plaid_account = plaid_account
|
||||
@account_snapshot = account_snapshot
|
||||
end
|
||||
|
||||
def import
|
||||
PlaidAccount.transaction do
|
||||
import_account_info
|
||||
import_transactions if account_snapshot.transactions_data.present?
|
||||
import_investments if account_snapshot.investments_data.present?
|
||||
import_liabilities if account_snapshot.liabilities_data.present?
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
attr_reader :plaid_account, :account_snapshot
|
||||
|
||||
def import_account_info
|
||||
plaid_account.upsert_plaid_snapshot!(account_snapshot.account_data)
|
||||
end
|
||||
|
||||
def import_transactions
|
||||
plaid_account.upsert_plaid_transactions_snapshot!(account_snapshot.transactions_data)
|
||||
end
|
||||
|
||||
def import_investments
|
||||
plaid_account.upsert_plaid_investments_snapshot!(account_snapshot.investments_data)
|
||||
end
|
||||
|
||||
def import_liabilities
|
||||
plaid_account.upsert_plaid_liabilities_snapshot!(account_snapshot.liabilities_data)
|
||||
end
|
||||
end
|
71
app/models/plaid_account/investments/balance_calculator.rb
Normal file
71
app/models/plaid_account/investments/balance_calculator.rb
Normal file
|
@ -0,0 +1,71 @@
|
|||
# Plaid Investment balances have a ton of edge cases. This processor is responsible
|
||||
# for deriving "brokerage cash" vs. "total value" based on Plaid's reported balances and holdings.
|
||||
class PlaidAccount::Investments::BalanceCalculator
|
||||
NegativeCashBalanceError = Class.new(StandardError)
|
||||
NegativeTotalValueError = Class.new(StandardError)
|
||||
|
||||
def initialize(plaid_account, security_resolver:)
|
||||
@plaid_account = plaid_account
|
||||
@security_resolver = security_resolver
|
||||
end
|
||||
|
||||
def balance
|
||||
total_value = total_investment_account_value
|
||||
|
||||
if total_value.negative?
|
||||
Sentry.capture_exception(
|
||||
NegativeTotalValueError.new("Total value is negative for plaid investment account"),
|
||||
level: :warning
|
||||
)
|
||||
end
|
||||
|
||||
total_value
|
||||
end
|
||||
|
||||
# Plaid considers "brokerage cash" and "cash equivalent holdings" to all be part of "cash balance"
|
||||
#
|
||||
# Internally, we DO NOT. Maybe clearly distinguishes between "brokerage cash" vs. "holdings (i.e. invested cash)"
|
||||
# For this reason, we must manually calculate the cash balance based on "total value" and "holdings value"
|
||||
# See PlaidAccount::Investments::SecurityResolver for more details.
|
||||
def cash_balance
|
||||
cash_balance = calculate_investment_brokerage_cash
|
||||
|
||||
if cash_balance.negative?
|
||||
Sentry.capture_exception(
|
||||
NegativeCashBalanceError.new("Cash balance is negative for plaid investment account"),
|
||||
level: :warning
|
||||
)
|
||||
end
|
||||
|
||||
cash_balance
|
||||
end
|
||||
|
||||
private
|
||||
attr_reader :plaid_account, :security_resolver
|
||||
|
||||
def holdings
|
||||
plaid_account.raw_investments_payload["holdings"] || []
|
||||
end
|
||||
|
||||
def calculate_investment_brokerage_cash
|
||||
total_investment_account_value - true_holdings_value
|
||||
end
|
||||
|
||||
# This is our source of truth. We assume Plaid's `current_balance` reporting is 100% accurate
|
||||
# Plaid guarantees `current_balance` AND/OR `available_balance` is always present, and based on the docs,
|
||||
# `current_balance` should represent "total account value".
|
||||
def total_investment_account_value
|
||||
plaid_account.current_balance || plaid_account.available_balance
|
||||
end
|
||||
|
||||
# Plaid holdings summed up, LESS "brokerage cash" holdings (that we've manually identified)
|
||||
def true_holdings_value
|
||||
# True holdings are holdings *less* Plaid's "pseudo-securities" (e.g. `CUR:USD` brokerage cash "holding")
|
||||
true_holdings = holdings.reject do |h|
|
||||
security = security_resolver.resolve(plaid_security_id: h["security_id"])
|
||||
security.brokerage_cash?
|
||||
end
|
||||
|
||||
true_holdings.sum { |h| h["quantity"] * h["institution_price"] }
|
||||
end
|
||||
end
|
39
app/models/plaid_account/investments/holdings_processor.rb
Normal file
39
app/models/plaid_account/investments/holdings_processor.rb
Normal file
|
@ -0,0 +1,39 @@
|
|||
class PlaidAccount::Investments::HoldingsProcessor
|
||||
def initialize(plaid_account, security_resolver:)
|
||||
@plaid_account = plaid_account
|
||||
@security_resolver = security_resolver
|
||||
end
|
||||
|
||||
def process
|
||||
holdings.each do |plaid_holding|
|
||||
resolved_security_result = security_resolver.resolve(plaid_security_id: plaid_holding["security_id"])
|
||||
|
||||
return unless resolved_security_result.security.present?
|
||||
|
||||
holding = account.holdings.find_or_initialize_by(
|
||||
security: resolved_security_result.security,
|
||||
date: Date.current,
|
||||
currency: plaid_holding["iso_currency_code"]
|
||||
)
|
||||
|
||||
holding.assign_attributes(
|
||||
qty: plaid_holding["quantity"],
|
||||
price: plaid_holding["institution_price"],
|
||||
amount: plaid_holding["quantity"] * plaid_holding["institution_price"]
|
||||
)
|
||||
|
||||
holding.save!
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
attr_reader :plaid_account, :security_resolver
|
||||
|
||||
def account
|
||||
plaid_account.account
|
||||
end
|
||||
|
||||
def holdings
|
||||
plaid_account.raw_investments_payload["holdings"] || []
|
||||
end
|
||||
end
|
93
app/models/plaid_account/investments/security_resolver.rb
Normal file
93
app/models/plaid_account/investments/security_resolver.rb
Normal file
|
@ -0,0 +1,93 @@
|
|||
# Resolves a Plaid security to an internal Security record, or nil
|
||||
class PlaidAccount::Investments::SecurityResolver
|
||||
UnresolvablePlaidSecurityError = Class.new(StandardError)
|
||||
|
||||
def initialize(plaid_account)
|
||||
@plaid_account = plaid_account
|
||||
@security_cache = {}
|
||||
end
|
||||
|
||||
# Resolves an internal Security record for a given Plaid security ID
|
||||
def resolve(plaid_security_id:)
|
||||
response = @security_cache[plaid_security_id]
|
||||
return response if response.present?
|
||||
|
||||
plaid_security = get_plaid_security(plaid_security_id)
|
||||
|
||||
if plaid_security.nil?
|
||||
report_unresolvable_security(plaid_security_id)
|
||||
response = Response.new(security: nil, cash_equivalent?: false, brokerage_cash?: false)
|
||||
elsif brokerage_cash?(plaid_security)
|
||||
response = Response.new(security: nil, cash_equivalent?: true, brokerage_cash?: true)
|
||||
else
|
||||
security = Security::Resolver.new(
|
||||
plaid_security["ticker_symbol"],
|
||||
exchange_operating_mic: plaid_security["market_identifier_code"]
|
||||
).resolve
|
||||
|
||||
response = Response.new(
|
||||
security: security,
|
||||
cash_equivalent?: cash_equivalent?(plaid_security),
|
||||
brokerage_cash?: false
|
||||
)
|
||||
end
|
||||
|
||||
@security_cache[plaid_security_id] = response
|
||||
|
||||
response
|
||||
end
|
||||
|
||||
private
|
||||
attr_reader :plaid_account, :security_cache
|
||||
|
||||
Response = Struct.new(:security, :cash_equivalent?, :brokerage_cash?, keyword_init: true)
|
||||
|
||||
def securities
|
||||
plaid_account.raw_investments_payload["securities"] || []
|
||||
end
|
||||
|
||||
# Tries to find security, or returns the "proxy security" (common with options contracts that have underlying securities)
|
||||
def get_plaid_security(plaid_security_id)
|
||||
security = securities.find { |s| s["security_id"] == plaid_security_id && s["ticker_symbol"].present? }
|
||||
|
||||
return security if security.present?
|
||||
|
||||
securities.find { |s| s["proxy_security_id"] == plaid_security_id }
|
||||
end
|
||||
|
||||
def report_unresolvable_security(plaid_security_id)
|
||||
Sentry.capture_exception(UnresolvablePlaidSecurityError.new("Could not resolve Plaid security from provided data")) do |scope|
|
||||
scope.set_context("plaid_security", {
|
||||
plaid_security_id: plaid_security_id
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
# Plaid treats "brokerage cash" differently than us. Internally, Maybe treats "brokerage cash"
|
||||
# as "uninvested cash" (i.e. cash that doesn't have a corresponding Security and can be withdrawn).
|
||||
#
|
||||
# Plaid treats everything as a "holding" with a corresponding Security. For example, "brokerage cash" (USD)
|
||||
# in Plaids data model would be represented as:
|
||||
#
|
||||
# - A Security with ticker `CUR:USD`
|
||||
# - A holding, linked to the `CUR:USD` Security, with an institution price of $1
|
||||
#
|
||||
# Internally, we store brokerage cash balance as `account.cash_balance`, NOT as a holding + security.
|
||||
# This allows us to properly build historical cash balances and holdings values separately and accurately.
|
||||
#
|
||||
# These help identify these "special case" securities for various calculations.
|
||||
#
|
||||
def known_plaid_brokerage_cash_tickers
|
||||
[ "CUR:USD" ]
|
||||
end
|
||||
|
||||
def brokerage_cash?(plaid_security)
|
||||
return false unless plaid_security["ticker_symbol"].present?
|
||||
known_plaid_brokerage_cash_tickers.include?(plaid_security["ticker_symbol"])
|
||||
end
|
||||
|
||||
def cash_equivalent?(plaid_security)
|
||||
return false unless plaid_security["type"].present?
|
||||
plaid_security["type"] == "cash" || plaid_security["is_cash_equivalent"] == true
|
||||
end
|
||||
end
|
|
@ -0,0 +1,90 @@
|
|||
class PlaidAccount::Investments::TransactionsProcessor
|
||||
SecurityNotFoundError = Class.new(StandardError)
|
||||
|
||||
def initialize(plaid_account, security_resolver:)
|
||||
@plaid_account = plaid_account
|
||||
@security_resolver = security_resolver
|
||||
end
|
||||
|
||||
def process
|
||||
transactions.each do |transaction|
|
||||
if cash_transaction?(transaction)
|
||||
find_or_create_cash_entry(transaction)
|
||||
else
|
||||
find_or_create_trade_entry(transaction)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
attr_reader :plaid_account, :security_resolver
|
||||
|
||||
def account
|
||||
plaid_account.account
|
||||
end
|
||||
|
||||
def cash_transaction?(transaction)
|
||||
transaction["type"] == "cash" || transaction["type"] == "fee"
|
||||
end
|
||||
|
||||
def find_or_create_trade_entry(transaction)
|
||||
resolved_security_result = security_resolver.resolve(plaid_security_id: transaction["security_id"])
|
||||
|
||||
unless resolved_security_result.security.present?
|
||||
Sentry.capture_exception(SecurityNotFoundError.new("Could not find security for plaid trade")) do |scope|
|
||||
scope.set_tags(plaid_account_id: plaid_account.id)
|
||||
end
|
||||
|
||||
return # We can't process a non-cash transaction without a security
|
||||
end
|
||||
|
||||
entry = account.entries.find_or_initialize_by(plaid_id: transaction["investment_transaction_id"]) do |e|
|
||||
e.entryable = Trade.new
|
||||
end
|
||||
|
||||
entry.assign_attributes(
|
||||
amount: transaction["quantity"] * transaction["price"],
|
||||
currency: transaction["iso_currency_code"],
|
||||
date: transaction["date"]
|
||||
)
|
||||
|
||||
entry.trade.assign_attributes(
|
||||
security: resolved_security_result.security,
|
||||
qty: transaction["quantity"],
|
||||
price: transaction["price"],
|
||||
currency: transaction["iso_currency_code"]
|
||||
)
|
||||
|
||||
entry.enrich_attribute(
|
||||
:name,
|
||||
transaction["name"],
|
||||
source: "plaid"
|
||||
)
|
||||
|
||||
entry.save!
|
||||
end
|
||||
|
||||
def find_or_create_cash_entry(transaction)
|
||||
entry = account.entries.find_or_initialize_by(plaid_id: transaction["investment_transaction_id"]) do |e|
|
||||
e.entryable = Transaction.new
|
||||
end
|
||||
|
||||
entry.assign_attributes(
|
||||
amount: transaction["amount"],
|
||||
currency: transaction["iso_currency_code"],
|
||||
date: transaction["date"]
|
||||
)
|
||||
|
||||
entry.enrich_attribute(
|
||||
:name,
|
||||
transaction["name"],
|
||||
source: "plaid"
|
||||
)
|
||||
|
||||
entry.save!
|
||||
end
|
||||
|
||||
def transactions
|
||||
plaid_account.raw_investments_payload["transactions"] || []
|
||||
end
|
||||
end
|
25
app/models/plaid_account/liabilities/credit_processor.rb
Normal file
25
app/models/plaid_account/liabilities/credit_processor.rb
Normal file
|
@ -0,0 +1,25 @@
|
|||
class PlaidAccount::Liabilities::CreditProcessor
|
||||
def initialize(plaid_account)
|
||||
@plaid_account = plaid_account
|
||||
end
|
||||
|
||||
def process
|
||||
return unless credit_data.present?
|
||||
|
||||
account.credit_card.update!(
|
||||
minimum_payment: credit_data.dig("minimum_payment_amount"),
|
||||
apr: credit_data.dig("aprs", 0, "apr_percentage")
|
||||
)
|
||||
end
|
||||
|
||||
private
|
||||
attr_reader :plaid_account
|
||||
|
||||
def account
|
||||
plaid_account.account
|
||||
end
|
||||
|
||||
def credit_data
|
||||
plaid_account.raw_liabilities_payload["credit"]
|
||||
end
|
||||
end
|
25
app/models/plaid_account/liabilities/mortgage_processor.rb
Normal file
25
app/models/plaid_account/liabilities/mortgage_processor.rb
Normal file
|
@ -0,0 +1,25 @@
|
|||
class PlaidAccount::Liabilities::MortgageProcessor
|
||||
def initialize(plaid_account)
|
||||
@plaid_account = plaid_account
|
||||
end
|
||||
|
||||
def process
|
||||
return unless mortgage_data.present?
|
||||
|
||||
account.loan.update!(
|
||||
rate_type: mortgage_data.dig("interest_rate", "type"),
|
||||
interest_rate: mortgage_data.dig("interest_rate", "percentage")
|
||||
)
|
||||
end
|
||||
|
||||
private
|
||||
attr_reader :plaid_account
|
||||
|
||||
def account
|
||||
plaid_account.account
|
||||
end
|
||||
|
||||
def mortgage_data
|
||||
plaid_account.raw_liabilities_payload["mortgage"]
|
||||
end
|
||||
end
|
|
@ -0,0 +1,50 @@
|
|||
class PlaidAccount::Liabilities::StudentLoanProcessor
|
||||
def initialize(plaid_account)
|
||||
@plaid_account = plaid_account
|
||||
end
|
||||
|
||||
def process
|
||||
return unless student_loan_data.present?
|
||||
|
||||
account.loan.update!(
|
||||
rate_type: "fixed",
|
||||
interest_rate: student_loan_data["interest_rate_percentage"],
|
||||
initial_balance: student_loan_data["origination_principal_amount"],
|
||||
term_months: term_months
|
||||
)
|
||||
end
|
||||
|
||||
private
|
||||
attr_reader :plaid_account
|
||||
|
||||
def account
|
||||
plaid_account.account
|
||||
end
|
||||
|
||||
def term_months
|
||||
return nil unless origination_date && expected_payoff_date
|
||||
|
||||
((expected_payoff_date - origination_date).to_i / 30).to_i
|
||||
end
|
||||
|
||||
def origination_date
|
||||
parse_date(student_loan_data["origination_date"])
|
||||
end
|
||||
|
||||
def expected_payoff_date
|
||||
parse_date(student_loan_data["expected_payoff_date"])
|
||||
end
|
||||
|
||||
def parse_date(value)
|
||||
return value if value.is_a?(Date)
|
||||
return nil unless value.present?
|
||||
|
||||
Date.parse(value.to_s)
|
||||
rescue ArgumentError
|
||||
nil
|
||||
end
|
||||
|
||||
def student_loan_data
|
||||
plaid_account.raw_liabilities_payload["student"]
|
||||
end
|
||||
end
|
99
app/models/plaid_account/processor.rb
Normal file
99
app/models/plaid_account/processor.rb
Normal file
|
@ -0,0 +1,99 @@
|
|||
class PlaidAccount::Processor
|
||||
include PlaidAccount::TypeMappable
|
||||
|
||||
attr_reader :plaid_account
|
||||
|
||||
def initialize(plaid_account)
|
||||
@plaid_account = plaid_account
|
||||
end
|
||||
|
||||
# Each step represents a different Plaid API endpoint / "product"
|
||||
#
|
||||
# Processing the account is the first step and if it fails, we halt the entire processor
|
||||
# Each subsequent step can fail independently, but we continue processing the rest of the steps
|
||||
def process
|
||||
process_account!
|
||||
process_transactions
|
||||
process_investments
|
||||
process_liabilities
|
||||
end
|
||||
|
||||
private
|
||||
def family
|
||||
plaid_account.plaid_item.family
|
||||
end
|
||||
|
||||
# Shared securities reader and resolver
|
||||
def security_resolver
|
||||
@security_resolver ||= PlaidAccount::Investments::SecurityResolver.new(plaid_account)
|
||||
end
|
||||
|
||||
def process_account!
|
||||
PlaidAccount.transaction do
|
||||
account = family.accounts.find_or_initialize_by(
|
||||
plaid_account_id: plaid_account.id
|
||||
)
|
||||
|
||||
# Name and subtype are the only attributes a user can override for Plaid accounts
|
||||
account.enrich_attributes(
|
||||
{
|
||||
name: plaid_account.name,
|
||||
subtype: map_subtype(plaid_account.plaid_type, plaid_account.plaid_subtype)
|
||||
},
|
||||
source: "plaid"
|
||||
)
|
||||
|
||||
account.assign_attributes(
|
||||
accountable: map_accountable(plaid_account.plaid_type),
|
||||
balance: balance_calculator.balance,
|
||||
currency: plaid_account.currency,
|
||||
cash_balance: balance_calculator.cash_balance
|
||||
)
|
||||
|
||||
account.save!
|
||||
end
|
||||
end
|
||||
|
||||
def process_transactions
|
||||
PlaidAccount::Transactions::Processor.new(plaid_account).process
|
||||
rescue => e
|
||||
report_exception(e)
|
||||
end
|
||||
|
||||
def process_investments
|
||||
PlaidAccount::Investments::TransactionsProcessor.new(plaid_account, security_resolver: security_resolver).process
|
||||
PlaidAccount::Investments::HoldingsProcessor.new(plaid_account, security_resolver: security_resolver).process
|
||||
rescue => e
|
||||
report_exception(e)
|
||||
end
|
||||
|
||||
def process_liabilities
|
||||
case [ plaid_account.plaid_type, plaid_account.plaid_subtype ]
|
||||
when [ "credit", "credit card" ]
|
||||
PlaidAccount::Liabilities::CreditProcessor.new(plaid_account).process
|
||||
when [ "loan", "mortgage" ]
|
||||
PlaidAccount::Liabilities::MortgageProcessor.new(plaid_account).process
|
||||
when [ "loan", "student" ]
|
||||
PlaidAccount::Liabilities::StudentLoanProcessor.new(plaid_account).process
|
||||
end
|
||||
rescue => e
|
||||
report_exception(e)
|
||||
end
|
||||
|
||||
def balance_calculator
|
||||
if plaid_account.plaid_type == "investment"
|
||||
@balance_calculator ||= PlaidAccount::Investments::BalanceCalculator.new(plaid_account, security_resolver: security_resolver)
|
||||
else
|
||||
OpenStruct.new(
|
||||
balance: plaid_account.current_balance || plaid_account.available_balance,
|
||||
cash_balance: plaid_account.available_balance || 0
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def report_exception(error)
|
||||
Sentry.capture_exception(error) do |scope|
|
||||
scope.set_tags(plaid_account_id: plaid_account.id)
|
||||
end
|
||||
end
|
||||
end
|
|
@ -10,10 +10,10 @@
|
|||
#
|
||||
# This class is simply a FAST and CHEAP way to match categories that are high confidence.
|
||||
# Edge cases will be handled by user-defined rules.
|
||||
class Provider::Plaid::CategoryAliasMatcher
|
||||
include Provider::Plaid::CategoryTaxonomy
|
||||
class PlaidAccount::Transactions::CategoryMatcher
|
||||
include PlaidAccount::Transactions::CategoryTaxonomy
|
||||
|
||||
def initialize(user_categories)
|
||||
def initialize(user_categories = [])
|
||||
@user_categories = user_categories
|
||||
end
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
# https://plaid.com/documents/transactions-personal-finance-category-taxonomy.csv
|
||||
module Provider::Plaid::CategoryTaxonomy
|
||||
module PlaidAccount::Transactions::CategoryTaxonomy
|
||||
CATEGORIES_MAP = {
|
||||
income: {
|
||||
classification: :income,
|
60
app/models/plaid_account/transactions/processor.rb
Normal file
60
app/models/plaid_account/transactions/processor.rb
Normal file
|
@ -0,0 +1,60 @@
|
|||
class PlaidAccount::Transactions::Processor
|
||||
def initialize(plaid_account)
|
||||
@plaid_account = plaid_account
|
||||
end
|
||||
|
||||
def process
|
||||
# Each entry is processed inside a transaction, but to avoid locking up the DB when
|
||||
# there are hundreds or thousands of transactions, we process them individually.
|
||||
modified_transactions.each do |transaction|
|
||||
PlaidEntry::Processor.new(
|
||||
transaction,
|
||||
plaid_account: plaid_account,
|
||||
category_matcher: category_matcher
|
||||
).process
|
||||
end
|
||||
|
||||
PlaidAccount.transaction do
|
||||
removed_transactions.each do |transaction|
|
||||
remove_plaid_transaction(transaction)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
attr_reader :plaid_account
|
||||
|
||||
def category_matcher
|
||||
@category_matcher ||= PlaidAccount::Transactions::CategoryMatcher.new(family_categories)
|
||||
end
|
||||
|
||||
def family_categories
|
||||
@family_categories ||= begin
|
||||
if account.family.categories.none?
|
||||
account.family.categories.bootstrap!
|
||||
end
|
||||
|
||||
account.family.categories
|
||||
end
|
||||
end
|
||||
|
||||
def account
|
||||
plaid_account.account
|
||||
end
|
||||
|
||||
def remove_plaid_transaction(raw_transaction)
|
||||
account.entries.find_by(plaid_id: raw_transaction["transaction_id"])&.destroy
|
||||
end
|
||||
|
||||
# Since we find_or_create_by transactions, we don't need a distinction between added/modified
|
||||
def modified_transactions
|
||||
modified = plaid_account.raw_transactions_payload["modified"] || []
|
||||
added = plaid_account.raw_transactions_payload["added"] || []
|
||||
|
||||
modified + added
|
||||
end
|
||||
|
||||
def removed_transactions
|
||||
plaid_account.raw_transactions_payload["removed"] || []
|
||||
end
|
||||
end
|
77
app/models/plaid_account/type_mappable.rb
Normal file
77
app/models/plaid_account/type_mappable.rb
Normal file
|
@ -0,0 +1,77 @@
|
|||
module PlaidAccount::TypeMappable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
UnknownAccountTypeError = Class.new(StandardError)
|
||||
|
||||
def map_accountable(plaid_type)
|
||||
accountable_class = TYPE_MAPPING.dig(
|
||||
plaid_type.to_sym,
|
||||
:accountable
|
||||
)
|
||||
|
||||
unless accountable_class
|
||||
raise UnknownAccountTypeError, "Unknown account type: #{plaid_type}"
|
||||
end
|
||||
|
||||
accountable_class.new
|
||||
end
|
||||
|
||||
def map_subtype(plaid_type, plaid_subtype)
|
||||
TYPE_MAPPING.dig(
|
||||
plaid_type.to_sym,
|
||||
:subtype_mapping,
|
||||
plaid_subtype
|
||||
) || "other"
|
||||
end
|
||||
|
||||
# Plaid Account Types -> Accountable Types
|
||||
# https://plaid.com/docs/api/accounts/#account-type-schema
|
||||
TYPE_MAPPING = {
|
||||
depository: {
|
||||
accountable: Depository,
|
||||
subtype_mapping: {
|
||||
"checking" => "checking",
|
||||
"savings" => "savings",
|
||||
"hsa" => "hsa",
|
||||
"cd" => "cd",
|
||||
"money market" => "money_market"
|
||||
}
|
||||
},
|
||||
credit: {
|
||||
accountable: CreditCard,
|
||||
subtype_mapping: {
|
||||
"credit card" => "credit_card"
|
||||
}
|
||||
},
|
||||
loan: {
|
||||
accountable: Loan,
|
||||
subtype_mapping: {
|
||||
"mortgage" => "mortgage",
|
||||
"student" => "student",
|
||||
"auto" => "auto",
|
||||
"business" => "business",
|
||||
"home equity" => "home_equity",
|
||||
"line of credit" => "line_of_credit"
|
||||
}
|
||||
},
|
||||
investment: {
|
||||
accountable: Investment,
|
||||
subtype_mapping: {
|
||||
"brokerage" => "brokerage",
|
||||
"pension" => "pension",
|
||||
"retirement" => "retirement",
|
||||
"401k" => "401k",
|
||||
"roth 401k" => "roth_401k",
|
||||
"529" => "529_plan",
|
||||
"hsa" => "hsa",
|
||||
"mutual fund" => "mutual_fund",
|
||||
"roth" => "roth_ira",
|
||||
"ira" => "ira"
|
||||
}
|
||||
},
|
||||
other: {
|
||||
accountable: OtherAsset,
|
||||
subtype_mapping: {}
|
||||
}
|
||||
}
|
||||
end
|
95
app/models/plaid_entry/processor.rb
Normal file
95
app/models/plaid_entry/processor.rb
Normal file
|
@ -0,0 +1,95 @@
|
|||
class PlaidEntry::Processor
|
||||
# plaid_transaction is the raw hash fetched from Plaid API and converted to JSONB
|
||||
def initialize(plaid_transaction, plaid_account:, category_matcher:)
|
||||
@plaid_transaction = plaid_transaction
|
||||
@plaid_account = plaid_account
|
||||
@category_matcher = category_matcher
|
||||
end
|
||||
|
||||
def process
|
||||
PlaidAccount.transaction do
|
||||
entry = account.entries.find_or_initialize_by(plaid_id: plaid_id) do |e|
|
||||
e.entryable = Transaction.new
|
||||
end
|
||||
|
||||
entry.assign_attributes(
|
||||
amount: amount,
|
||||
currency: currency,
|
||||
date: date
|
||||
)
|
||||
|
||||
entry.enrich_attribute(
|
||||
:name,
|
||||
name,
|
||||
source: "plaid"
|
||||
)
|
||||
|
||||
if detailed_category
|
||||
matched_category = category_matcher.match(detailed_category)
|
||||
|
||||
if matched_category
|
||||
entry.transaction.enrich_attribute(
|
||||
:category_id,
|
||||
matched_category.id,
|
||||
source: "plaid"
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
if merchant
|
||||
entry.transaction.enrich_attribute(
|
||||
:merchant_id,
|
||||
merchant.id,
|
||||
source: "plaid"
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
attr_reader :plaid_transaction, :plaid_account, :category_matcher
|
||||
|
||||
def account
|
||||
plaid_account.account
|
||||
end
|
||||
|
||||
def plaid_id
|
||||
plaid_transaction["transaction_id"]
|
||||
end
|
||||
|
||||
def name
|
||||
plaid_transaction["merchant_name"] || plaid_transaction["original_description"]
|
||||
end
|
||||
|
||||
def amount
|
||||
plaid_transaction["amount"]
|
||||
end
|
||||
|
||||
def currency
|
||||
plaid_transaction["iso_currency_code"]
|
||||
end
|
||||
|
||||
def date
|
||||
plaid_transaction["date"]
|
||||
end
|
||||
|
||||
def detailed_category
|
||||
plaid_transaction.dig("personal_finance_category", "detailed")
|
||||
end
|
||||
|
||||
def merchant
|
||||
merchant_id = plaid_transaction["merchant_entity_id"]
|
||||
merchant_name = plaid_transaction["merchant_name"]
|
||||
|
||||
return nil unless merchant_id.present? && merchant_name.present?
|
||||
|
||||
ProviderMerchant.find_or_create_by!(
|
||||
source: "plaid",
|
||||
name: merchant_name,
|
||||
) do |m|
|
||||
m.provider_merchant_id = merchant_id
|
||||
m.website_url = plaid_transaction["website"]
|
||||
m.logo_url = plaid_transaction["logo_url"]
|
||||
end
|
||||
end
|
||||
end
|
|
@ -1,115 +0,0 @@
|
|||
class PlaidInvestmentSync
|
||||
attr_reader :plaid_account
|
||||
|
||||
def initialize(plaid_account)
|
||||
@plaid_account = plaid_account
|
||||
end
|
||||
|
||||
def sync!(transactions: [], holdings: [], securities: [])
|
||||
@transactions = transactions
|
||||
@holdings = holdings
|
||||
@securities = securities
|
||||
|
||||
PlaidAccount.transaction do
|
||||
normalize_cash_balance!
|
||||
sync_transactions!
|
||||
sync_holdings!
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
attr_reader :transactions, :holdings, :securities
|
||||
|
||||
# Plaid considers "brokerage cash" and "cash equivalent holdings" to all be part of "cash balance"
|
||||
# Internally, we DO NOT.
|
||||
# Maybe clearly distinguishes between "brokerage cash" vs. "holdings (i.e. invested cash)"
|
||||
# For this reason, we must back out cash + cash equivalent holdings from the reported cash balance to avoid double counting
|
||||
def normalize_cash_balance!
|
||||
excludable_cash_holdings = holdings.select do |h|
|
||||
internal_security, plaid_security = get_security(h.security_id, securities)
|
||||
internal_security.present? && (plaid_security&.is_cash_equivalent || plaid_security&.type == "cash")
|
||||
end
|
||||
|
||||
excludable_cash_holdings_value = excludable_cash_holdings.sum { |h| h.quantity * h.institution_price }
|
||||
|
||||
plaid_account.account.update!(
|
||||
cash_balance: plaid_account.account.cash_balance - excludable_cash_holdings_value
|
||||
)
|
||||
end
|
||||
|
||||
def sync_transactions!
|
||||
transactions.each do |transaction|
|
||||
security, plaid_security = get_security(transaction.security_id, securities)
|
||||
|
||||
next if security.nil? && plaid_security.nil?
|
||||
|
||||
if transaction.type == "cash" || plaid_security.ticker_symbol == "CUR:USD"
|
||||
new_transaction = plaid_account.account.entries.find_or_create_by!(plaid_id: transaction.investment_transaction_id) do |t|
|
||||
t.name = transaction.name
|
||||
t.amount = transaction.amount
|
||||
t.currency = transaction.iso_currency_code
|
||||
t.date = transaction.date
|
||||
t.entryable = Transaction.new
|
||||
end
|
||||
else
|
||||
new_transaction = plaid_account.account.entries.find_or_create_by!(plaid_id: transaction.investment_transaction_id) do |t|
|
||||
t.name = transaction.name
|
||||
t.amount = transaction.quantity * transaction.price
|
||||
t.currency = transaction.iso_currency_code
|
||||
t.date = transaction.date
|
||||
t.entryable = Trade.new(
|
||||
security: security,
|
||||
qty: transaction.quantity,
|
||||
price: transaction.price,
|
||||
currency: transaction.iso_currency_code
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def sync_holdings!
|
||||
# Update only the current day holdings. The account sync will populate historical values based on trades.
|
||||
holdings.each do |holding|
|
||||
internal_security, _plaid_security = get_security(holding.security_id, securities)
|
||||
|
||||
next if internal_security.nil?
|
||||
|
||||
existing_holding = plaid_account.account.holdings.find_or_initialize_by(
|
||||
security: internal_security,
|
||||
date: Date.current,
|
||||
currency: holding.iso_currency_code
|
||||
)
|
||||
|
||||
existing_holding.qty = holding.quantity
|
||||
existing_holding.price = holding.institution_price
|
||||
existing_holding.amount = holding.quantity * holding.institution_price
|
||||
existing_holding.save!
|
||||
end
|
||||
end
|
||||
|
||||
def get_security(plaid_security_id, securities)
|
||||
plaid_security = securities.find { |s| s.security_id == plaid_security_id }
|
||||
|
||||
return [ nil, nil ] if plaid_security.nil?
|
||||
|
||||
plaid_security = if plaid_security.ticker_symbol.present?
|
||||
plaid_security
|
||||
else
|
||||
securities.find { |s| s.security_id == plaid_security.proxy_security_id }
|
||||
end
|
||||
|
||||
return [ nil, nil ] if plaid_security.nil? || plaid_security.ticker_symbol.blank?
|
||||
return [ nil, plaid_security ] if plaid_security.ticker_symbol == "CUR:USD" # internally, we do not consider cash a security and track it separately
|
||||
|
||||
operating_mic = plaid_security.market_identifier_code
|
||||
|
||||
# Find any matching security
|
||||
security = Security.find_or_create_by!(
|
||||
ticker: plaid_security.ticker_symbol&.upcase,
|
||||
exchange_operating_mic: operating_mic&.upcase
|
||||
)
|
||||
|
||||
[ security, plaid_security ]
|
||||
end
|
||||
end
|
|
@ -1,5 +1,5 @@
|
|||
class PlaidItem < ApplicationRecord
|
||||
include Syncable
|
||||
include Syncable, Provided
|
||||
|
||||
enum :plaid_region, { us: "us", eu: "eu" }
|
||||
enum :status, { good: "good", requires_update: "requires_update" }, default: :good
|
||||
|
@ -43,10 +43,6 @@ class PlaidItem < ApplicationRecord
|
|||
end
|
||||
end
|
||||
|
||||
def build_category_alias_matcher(user_categories)
|
||||
Provider::Plaid::CategoryAliasMatcher.new(user_categories)
|
||||
end
|
||||
|
||||
def destroy_later
|
||||
update!(scheduled_for_deletion: true)
|
||||
DestroyJob.perform_later(self)
|
||||
|
@ -60,41 +56,70 @@ class PlaidItem < ApplicationRecord
|
|||
.exists?
|
||||
end
|
||||
|
||||
def auto_match_categories!
|
||||
if family.categories.none?
|
||||
family.categories.bootstrap!
|
||||
end
|
||||
def import_latest_plaid_data
|
||||
PlaidItem::Importer.new(self, plaid_provider: plaid_provider).import
|
||||
end
|
||||
|
||||
alias_matcher = build_category_alias_matcher(family.categories)
|
||||
|
||||
accounts.each do |account|
|
||||
matchable_transactions = account.transactions
|
||||
.where(category_id: nil)
|
||||
.where.not(plaid_category: nil)
|
||||
.enrichable(:category_id)
|
||||
|
||||
matchable_transactions.each do |transaction|
|
||||
category = alias_matcher.match(transaction.plaid_category_detailed)
|
||||
|
||||
if category.present?
|
||||
# Matcher could either return a string or a Category object
|
||||
user_category = if category.is_a?(String)
|
||||
family.categories.find_or_create_by!(name: category)
|
||||
else
|
||||
category
|
||||
end
|
||||
|
||||
transaction.enrich_attribute(:category_id, user_category.id, source: "plaid")
|
||||
end
|
||||
end
|
||||
# Reads the fetched data and updates internal domain objects
|
||||
# Generally, this should only be called within a "sync", but can be called
|
||||
# manually to "re-sync" the already fetched data
|
||||
def process_accounts
|
||||
plaid_accounts.each do |plaid_account|
|
||||
PlaidAccount::Processor.new(plaid_account).process
|
||||
end
|
||||
end
|
||||
|
||||
# Once all the data is fetched, we can schedule account syncs to calculate historical balances
|
||||
def schedule_account_syncs(parent_sync: nil, window_start_date: nil, window_end_date: nil)
|
||||
accounts.each do |account|
|
||||
account.sync_later(
|
||||
parent_sync: parent_sync,
|
||||
window_start_date: window_start_date,
|
||||
window_end_date: window_end_date
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
# Saves the raw data fetched from Plaid API for this item
|
||||
def upsert_plaid_snapshot!(item_snapshot)
|
||||
assign_attributes(
|
||||
available_products: item_snapshot.available_products,
|
||||
billed_products: item_snapshot.billed_products,
|
||||
raw_payload: item_snapshot,
|
||||
)
|
||||
|
||||
save!
|
||||
end
|
||||
|
||||
# Saves the raw data fetched from Plaid API for this item's institution
|
||||
def upsert_plaid_institution_snapshot!(institution_snapshot)
|
||||
assign_attributes(
|
||||
institution_id: institution_snapshot.institution_id,
|
||||
institution_url: institution_snapshot.url,
|
||||
institution_color: institution_snapshot.primary_color,
|
||||
raw_institution_payload: institution_snapshot
|
||||
)
|
||||
|
||||
save!
|
||||
end
|
||||
|
||||
def supports_product?(product)
|
||||
supported_products.include?(product)
|
||||
end
|
||||
|
||||
private
|
||||
# Silently swallow and report error so that we don't block the user from deleting the item
|
||||
def remove_plaid_item
|
||||
plaid_provider.remove_item(access_token)
|
||||
rescue StandardError => e
|
||||
Rails.logger.warn("Failed to remove Plaid item #{id}: #{e.message}")
|
||||
Sentry.capture_exception(e)
|
||||
end
|
||||
|
||||
# Plaid returns mutually exclusive arrays here. If the item has made a request for a product,
|
||||
# it is put in the billed_products array. If it is supported, but not yet used, it goes in the
|
||||
# available_products array.
|
||||
def supported_products
|
||||
available_products + billed_products
|
||||
end
|
||||
|
||||
class PlaidConnectionLostError < StandardError; end
|
||||
|
|
79
app/models/plaid_item/accounts_snapshot.rb
Normal file
79
app/models/plaid_item/accounts_snapshot.rb
Normal file
|
@ -0,0 +1,79 @@
|
|||
# All Plaid data is fetched at the item-level. This class is a simple wrapper that
|
||||
# providers a convenience method, get_account_data which scopes the item-level payload
|
||||
# to each Plaid Account
|
||||
class PlaidItem::AccountsSnapshot
|
||||
def initialize(plaid_item, plaid_provider:)
|
||||
@plaid_item = plaid_item
|
||||
@plaid_provider = plaid_provider
|
||||
end
|
||||
|
||||
def accounts
|
||||
@accounts ||= plaid_provider.get_item_accounts(plaid_item.access_token).accounts
|
||||
end
|
||||
|
||||
def get_account_data(account_id)
|
||||
AccountData.new(
|
||||
account_data: accounts.find { |a| a.account_id == account_id },
|
||||
transactions_data: account_scoped_transactions_data(account_id),
|
||||
investments_data: account_scoped_investments_data(account_id),
|
||||
liabilities_data: account_scoped_liabilities_data(account_id)
|
||||
)
|
||||
end
|
||||
|
||||
private
|
||||
attr_reader :plaid_item, :plaid_provider
|
||||
|
||||
TransactionsData = Data.define(:added, :modified, :removed)
|
||||
LiabilitiesData = Data.define(:credit, :mortgage, :student)
|
||||
InvestmentsData = Data.define(:transactions, :holdings, :securities)
|
||||
AccountData = Data.define(:account_data, :transactions_data, :investments_data, :liabilities_data)
|
||||
|
||||
def account_scoped_transactions_data(account_id)
|
||||
return nil unless transactions_data
|
||||
|
||||
TransactionsData.new(
|
||||
added: transactions_data.added.select { |t| t.account_id == account_id },
|
||||
modified: transactions_data.modified.select { |t| t.account_id == account_id },
|
||||
removed: transactions_data.removed.select { |t| t.account_id == account_id }
|
||||
)
|
||||
end
|
||||
|
||||
def account_scoped_investments_data(account_id)
|
||||
return nil unless investments_data
|
||||
|
||||
transactions = investments_data.transactions.select { |t| t.account_id == account_id }
|
||||
holdings = investments_data.holdings.select { |h| h.account_id == account_id }
|
||||
securities = transactions.count > 0 && holdings.count > 0 ? investments_data.securities : []
|
||||
|
||||
InvestmentsData.new(
|
||||
transactions: transactions,
|
||||
holdings: holdings,
|
||||
securities: securities
|
||||
)
|
||||
end
|
||||
|
||||
def account_scoped_liabilities_data(account_id)
|
||||
return nil unless liabilities_data
|
||||
|
||||
LiabilitiesData.new(
|
||||
credit: liabilities_data.credit&.find { |c| c.account_id == account_id },
|
||||
mortgage: liabilities_data.mortgage&.find { |m| m.account_id == account_id },
|
||||
student: liabilities_data.student&.find { |s| s.account_id == account_id }
|
||||
)
|
||||
end
|
||||
|
||||
def transactions_data
|
||||
return nil unless plaid_item.supports_product?("transactions")
|
||||
@transactions_data ||= plaid_provider.get_transactions(plaid_item.access_token)
|
||||
end
|
||||
|
||||
def investments_data
|
||||
return nil unless plaid_item.supports_product?("investments")
|
||||
@investments_data ||= plaid_provider.get_item_investments(plaid_item.access_token)
|
||||
end
|
||||
|
||||
def liabilities_data
|
||||
return nil unless plaid_item.supports_product?("liabilities")
|
||||
@liabilities_data ||= plaid_provider.get_item_liabilities(plaid_item.access_token)
|
||||
end
|
||||
end
|
53
app/models/plaid_item/importer.rb
Normal file
53
app/models/plaid_item/importer.rb
Normal file
|
@ -0,0 +1,53 @@
|
|||
class PlaidItem::Importer
|
||||
def initialize(plaid_item, plaid_provider:)
|
||||
@plaid_item = plaid_item
|
||||
@plaid_provider = plaid_provider
|
||||
end
|
||||
|
||||
def import
|
||||
fetch_and_import_item_data
|
||||
fetch_and_import_accounts_data
|
||||
rescue Plaid::ApiError => e
|
||||
handle_plaid_error(e)
|
||||
end
|
||||
|
||||
private
|
||||
attr_reader :plaid_item, :plaid_provider
|
||||
|
||||
# All errors that should halt the import should be re-raised after handling
|
||||
# These errors will propagate up to the Sync record and mark it as failed.
|
||||
def handle_plaid_error(error)
|
||||
error_body = JSON.parse(error.response_body)
|
||||
|
||||
case error_body["error_code"]
|
||||
when "ITEM_LOGIN_REQUIRED"
|
||||
plaid_item.update!(status: :requires_update)
|
||||
raise error
|
||||
else
|
||||
raise error
|
||||
end
|
||||
end
|
||||
|
||||
def fetch_and_import_item_data
|
||||
item_data = plaid_provider.get_item(plaid_item.access_token).item
|
||||
institution_data = plaid_provider.get_institution(item_data.institution_id).institution
|
||||
|
||||
plaid_item.upsert_plaid_snapshot!(item_data)
|
||||
plaid_item.upsert_plaid_institution_snapshot!(institution_data)
|
||||
end
|
||||
|
||||
def fetch_and_import_accounts_data
|
||||
snapshot = PlaidItem::AccountsSnapshot.new(plaid_item, plaid_provider: plaid_provider)
|
||||
|
||||
snapshot.accounts.each do |raw_account|
|
||||
plaid_account = plaid_item.plaid_accounts.find_or_initialize_by(
|
||||
plaid_id: raw_account.account_id
|
||||
)
|
||||
|
||||
PlaidAccount::Importer.new(
|
||||
plaid_account,
|
||||
account_snapshot: snapshot.get_account_data(raw_account.account_id)
|
||||
).import
|
||||
end
|
||||
end
|
||||
end
|
7
app/models/plaid_item/provided.rb
Normal file
7
app/models/plaid_item/provided.rb
Normal file
|
@ -0,0 +1,7 @@
|
|||
module PlaidItem::Provided
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
def plaid_provider
|
||||
@plaid_provider ||= Provider::Registry.plaid_provider_for_region(self.plaid_region)
|
||||
end
|
||||
end
|
|
@ -6,144 +6,21 @@ class PlaidItem::Syncer
|
|||
end
|
||||
|
||||
def perform_sync(sync)
|
||||
begin
|
||||
Rails.logger.info("Fetching and loading Plaid data")
|
||||
fetch_and_load_plaid_data
|
||||
plaid_item.update!(status: :good) if plaid_item.requires_update?
|
||||
# Loads item metadata, accounts, transactions, and other data to our DB
|
||||
plaid_item.import_latest_plaid_data
|
||||
|
||||
plaid_item.accounts.each do |account|
|
||||
account.sync_later(parent_sync: sync, window_start_date: sync.window_start_date, window_end_date: sync.window_end_date)
|
||||
end
|
||||
# Processes the raw Plaid data and updates internal domain objects
|
||||
plaid_item.process_accounts
|
||||
|
||||
Rails.logger.info("Plaid data fetched and loaded")
|
||||
rescue Plaid::ApiError => e
|
||||
handle_plaid_error(e)
|
||||
raise e
|
||||
end
|
||||
# All data is synced, so we can now run an account sync to calculate historical balances and more
|
||||
plaid_item.schedule_account_syncs(
|
||||
parent_sync: sync,
|
||||
window_start_date: sync.window_start_date,
|
||||
window_end_date: sync.window_end_date
|
||||
)
|
||||
end
|
||||
|
||||
def perform_post_sync
|
||||
plaid_item.auto_match_categories!
|
||||
# no-op
|
||||
end
|
||||
|
||||
private
|
||||
def plaid
|
||||
plaid_item.plaid_region == "eu" ? plaid_eu : plaid_us
|
||||
end
|
||||
|
||||
def plaid_eu
|
||||
@plaid_eu ||= Provider::Registry.get_provider(:plaid_eu)
|
||||
end
|
||||
|
||||
def plaid_us
|
||||
@plaid_us ||= Provider::Registry.get_provider(:plaid_us)
|
||||
end
|
||||
|
||||
def safe_fetch_plaid_data(method)
|
||||
begin
|
||||
plaid.send(method, plaid_item)
|
||||
rescue Plaid::ApiError => e
|
||||
Rails.logger.warn("Error fetching #{method} for item #{plaid_item.id}: #{e.message}")
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
def handle_plaid_error(error)
|
||||
error_body = JSON.parse(error.response_body)
|
||||
|
||||
if error_body["error_code"] == "ITEM_LOGIN_REQUIRED"
|
||||
plaid_item.update!(status: :requires_update)
|
||||
end
|
||||
end
|
||||
|
||||
def fetch_and_load_plaid_data
|
||||
data = {}
|
||||
|
||||
# Log what we're about to fetch
|
||||
Rails.logger.info "Starting Plaid data fetch (accounts, transactions, investments, liabilities)"
|
||||
|
||||
item = plaid.get_item(plaid_item.access_token).item
|
||||
plaid_item.update!(available_products: item.available_products, billed_products: item.billed_products)
|
||||
|
||||
# Institution details
|
||||
if item.institution_id.present?
|
||||
begin
|
||||
Rails.logger.info "Fetching Plaid institution details for #{item.institution_id}"
|
||||
institution = plaid.get_institution(item.institution_id)
|
||||
plaid_item.update!(
|
||||
institution_id: item.institution_id,
|
||||
institution_url: institution.institution.url,
|
||||
institution_color: institution.institution.primary_color
|
||||
)
|
||||
rescue Plaid::ApiError => e
|
||||
Rails.logger.warn "Failed to fetch Plaid institution details: #{e.message}"
|
||||
end
|
||||
end
|
||||
|
||||
# Accounts
|
||||
fetched_accounts = plaid.get_item_accounts(plaid_item).accounts
|
||||
data[:accounts] = fetched_accounts || []
|
||||
Rails.logger.info "Processing Plaid accounts (count: #{fetched_accounts.size})"
|
||||
|
||||
internal_plaid_accounts = fetched_accounts.map do |account|
|
||||
internal_plaid_account = plaid_item.plaid_accounts.find_or_create_from_plaid_data!(account, plaid_item.family)
|
||||
internal_plaid_account.sync_account_data!(account)
|
||||
internal_plaid_account
|
||||
end
|
||||
|
||||
# Transactions
|
||||
fetched_transactions = safe_fetch_plaid_data(:get_item_transactions)
|
||||
data[:transactions] = fetched_transactions || []
|
||||
|
||||
if fetched_transactions
|
||||
Rails.logger.info "Processing Plaid transactions (added: #{fetched_transactions.added.size}, modified: #{fetched_transactions.modified.size}, removed: #{fetched_transactions.removed.size})"
|
||||
PlaidItem.transaction do
|
||||
internal_plaid_accounts.each do |internal_plaid_account|
|
||||
added = fetched_transactions.added.select { |t| t.account_id == internal_plaid_account.plaid_id }
|
||||
modified = fetched_transactions.modified.select { |t| t.account_id == internal_plaid_account.plaid_id }
|
||||
removed = fetched_transactions.removed.select { |t| t.account_id == internal_plaid_account.plaid_id }
|
||||
|
||||
internal_plaid_account.sync_transactions!(added:, modified:, removed:)
|
||||
end
|
||||
|
||||
plaid_item.update!(next_cursor: fetched_transactions.cursor)
|
||||
end
|
||||
end
|
||||
|
||||
# Investments
|
||||
fetched_investments = safe_fetch_plaid_data(:get_item_investments)
|
||||
data[:investments] = fetched_investments || []
|
||||
|
||||
if fetched_investments
|
||||
Rails.logger.info "Processing Plaid investments (transactions: #{fetched_investments.transactions.size}, holdings: #{fetched_investments.holdings.size}, securities: #{fetched_investments.securities.size})"
|
||||
PlaidItem.transaction do
|
||||
internal_plaid_accounts.each do |internal_plaid_account|
|
||||
transactions = fetched_investments.transactions.select { |t| t.account_id == internal_plaid_account.plaid_id }
|
||||
holdings = fetched_investments.holdings.select { |h| h.account_id == internal_plaid_account.plaid_id }
|
||||
securities = fetched_investments.securities
|
||||
|
||||
internal_plaid_account.sync_investments!(transactions:, holdings:, securities:)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Liabilities
|
||||
fetched_liabilities = safe_fetch_plaid_data(:get_item_liabilities)
|
||||
data[:liabilities] = fetched_liabilities || []
|
||||
|
||||
if fetched_liabilities
|
||||
Rails.logger.info "Processing Plaid liabilities (credit: #{fetched_liabilities.credit&.size || 0}, mortgage: #{fetched_liabilities.mortgage&.size || 0}, student: #{fetched_liabilities.student&.size || 0})"
|
||||
PlaidItem.transaction do
|
||||
internal_plaid_accounts.each do |internal_plaid_account|
|
||||
credit = fetched_liabilities.credit&.find { |l| l.account_id == internal_plaid_account.plaid_id }
|
||||
mortgage = fetched_liabilities.mortgage&.find { |l| l.account_id == internal_plaid_account.plaid_id }
|
||||
student = fetched_liabilities.student&.find { |l| l.account_id == internal_plaid_account.plaid_id }
|
||||
|
||||
internal_plaid_account.sync_credit_data!(credit) if credit
|
||||
internal_plaid_account.sync_mortgage_data!(mortgage) if mortgage
|
||||
internal_plaid_account.sync_student_loan_data!(student) if student
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -106,13 +106,13 @@ class Provider::Plaid
|
|||
client.item_remove(request)
|
||||
end
|
||||
|
||||
def get_item_accounts(item)
|
||||
request = Plaid::AccountsGetRequest.new(access_token: item.access_token)
|
||||
def get_item_accounts(access_token)
|
||||
request = Plaid::AccountsGetRequest.new(access_token: access_token)
|
||||
client.accounts_get(request)
|
||||
end
|
||||
|
||||
def get_item_transactions(item)
|
||||
cursor = item.next_cursor
|
||||
def get_transactions(access_token, next_cursor: nil)
|
||||
cursor = next_cursor
|
||||
added = []
|
||||
modified = []
|
||||
removed = []
|
||||
|
@ -120,7 +120,7 @@ class Provider::Plaid
|
|||
|
||||
while has_more
|
||||
request = Plaid::TransactionsSyncRequest.new(
|
||||
access_token: item.access_token,
|
||||
access_token: access_token,
|
||||
cursor: cursor,
|
||||
options: {
|
||||
include_original_description: true
|
||||
|
@ -139,18 +139,18 @@ class Provider::Plaid
|
|||
TransactionSyncResponse.new(added:, modified:, removed:, cursor:)
|
||||
end
|
||||
|
||||
def get_item_investments(item, 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
|
||||
holdings, holding_securities = get_item_holdings(item)
|
||||
transactions, transaction_securities = get_item_investment_transactions(item, start_date:, end_date:)
|
||||
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:)
|
||||
|
||||
merged_securities = ((holding_securities || []) + (transaction_securities || [])).uniq { |s| s.security_id }
|
||||
|
||||
InvestmentsResponse.new(holdings:, transactions:, securities: merged_securities)
|
||||
end
|
||||
|
||||
def get_item_liabilities(item)
|
||||
request = Plaid::LiabilitiesGetRequest.new({ access_token: item.access_token })
|
||||
def get_item_liabilities(access_token)
|
||||
request = Plaid::LiabilitiesGetRequest.new({ access_token: access_token })
|
||||
response = client.liabilities_get(request)
|
||||
response.liabilities
|
||||
end
|
||||
|
@ -170,21 +170,21 @@ class Provider::Plaid
|
|||
TransactionSyncResponse = Struct.new :added, :modified, :removed, :cursor, keyword_init: true
|
||||
InvestmentsResponse = Struct.new :holdings, :transactions, :securities, keyword_init: true
|
||||
|
||||
def get_item_holdings(item)
|
||||
request = Plaid::InvestmentsHoldingsGetRequest.new({ access_token: item.access_token })
|
||||
def get_item_holdings(access_token:)
|
||||
request = Plaid::InvestmentsHoldingsGetRequest.new({ access_token: access_token })
|
||||
response = client.investments_holdings_get(request)
|
||||
|
||||
[ response.holdings, response.securities ]
|
||||
end
|
||||
|
||||
def get_item_investment_transactions(item, start_date:, end_date:)
|
||||
def get_item_investment_transactions(access_token:, start_date:, end_date:)
|
||||
transactions = []
|
||||
securities = []
|
||||
offset = 0
|
||||
|
||||
loop do
|
||||
request = Plaid::InvestmentsTransactionsGetRequest.new(
|
||||
access_token: item.access_token,
|
||||
access_token: access_token,
|
||||
start_date: start_date.to_s,
|
||||
end_date: end_date.to_s,
|
||||
options: { offset: offset }
|
||||
|
|
|
@ -3,6 +3,19 @@ class Provider::PlaidSandbox < Provider::Plaid
|
|||
|
||||
def initialize
|
||||
@client = create_client
|
||||
@region = :us
|
||||
end
|
||||
|
||||
def create_public_token(username: nil)
|
||||
client.sandbox_public_token_create(
|
||||
Plaid::SandboxPublicTokenCreateRequest.new(
|
||||
institution_id: "ins_109508", # "First Platypus Bank" (Plaid's sandbox institution that works with all products)
|
||||
initial_products: [ "transactions", "investments", "liabilities" ],
|
||||
options: {
|
||||
override_username: username || "custom_test"
|
||||
}
|
||||
)
|
||||
).public_token
|
||||
end
|
||||
|
||||
def fire_webhook(item, type: "TRANSACTIONS", code: "SYNC_UPDATES_AVAILABLE")
|
||||
|
|
|
@ -18,6 +18,10 @@ class Provider::Registry
|
|||
raise Error.new("Provider '#{name}' not found in registry")
|
||||
end
|
||||
|
||||
def plaid_provider_for_region(region)
|
||||
region.to_sym == :us ? plaid_us : plaid_eu
|
||||
end
|
||||
|
||||
private
|
||||
def stripe
|
||||
secret_key = ENV["STRIPE_SECRET_KEY"]
|
||||
|
|
|
@ -34,6 +34,7 @@
|
|||
min: options[:min] || -99999999999999,
|
||||
max: options[:max] || 99999999999999,
|
||||
step: currency.step,
|
||||
disabled: options[:disabled],
|
||||
data: {
|
||||
"money-field-target": "amount",
|
||||
"auto-submit-form-target": ("auto" if options[:auto_submit])
|
||||
|
|
|
@ -16,6 +16,12 @@
|
|||
<%= entry.currency %>
|
||||
</span>
|
||||
</h3>
|
||||
|
||||
<% if entry.linked? %>
|
||||
<span title="Linked with Plaid">
|
||||
<%= icon("refresh-ccw", size: "sm") %>
|
||||
</span>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<span class="text-sm text-secondary">
|
||||
|
|
|
@ -15,20 +15,22 @@
|
|||
<%= f.date_field :date,
|
||||
label: t(".date_label"),
|
||||
max: Date.current,
|
||||
disabled: @entry.linked?,
|
||||
"data-auto-submit-form-target": "auto" %>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<%= f.select :nature,
|
||||
[["Buy", "outflow"], ["Sell", "inflow"]],
|
||||
{ container_class: "w-1/3", label: "Type", selected: @entry.amount.negative? ? "inflow" : "outflow" },
|
||||
{ data: { "auto-submit-form-target": "auto" } } %>
|
||||
{ data: { "auto-submit-form-target": "auto" }, disabled: @entry.linked? } %>
|
||||
|
||||
<%= f.fields_for :entryable do |ef| %>
|
||||
<%= ef.number_field :qty,
|
||||
label: t(".quantity_label"),
|
||||
step: "any",
|
||||
value: trade.qty.abs,
|
||||
"data-auto-submit-form-target": "auto" %>
|
||||
"data-auto-submit-form-target": "auto",
|
||||
disabled: @entry.linked? %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
|
@ -37,7 +39,8 @@
|
|||
label: t(".cost_per_share_label"),
|
||||
disable_currency: true,
|
||||
auto_submit: true,
|
||||
min: 0 %>
|
||||
min: 0,
|
||||
disabled: @entry.linked? %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
|
|
@ -15,6 +15,12 @@
|
|||
<% if entry.transaction.transfer? %>
|
||||
<%= icon "arrow-left-right", class: "mt-1" %>
|
||||
<% end %>
|
||||
|
||||
<% if entry.linked? %>
|
||||
<span title="Linked with Plaid">
|
||||
<%= icon("refresh-ccw", size: "sm") %>
|
||||
</span>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<span class="text-sm text-secondary">
|
||||
|
|
|
@ -18,6 +18,7 @@
|
|||
<%= f.date_field :date,
|
||||
label: t(".date_label"),
|
||||
max: Date.current,
|
||||
disabled: @entry.linked?,
|
||||
"data-auto-submit-form-target": "auto" %>
|
||||
|
||||
<% unless @entry.transaction.transfer? %>
|
||||
|
@ -25,13 +26,15 @@
|
|||
<%= f.select :nature,
|
||||
[["Expense", "outflow"], ["Income", "inflow"]],
|
||||
{ container_class: "w-1/3", label: t(".nature"), selected: @entry.amount.negative? ? "inflow" : "outflow" },
|
||||
{ data: { "auto-submit-form-target": "auto" } } %>
|
||||
{ data: { "auto-submit-form-target": "auto" }, disabled: @entry.linked? } %>
|
||||
|
||||
<%= f.money_field :amount, label: t(".amount"),
|
||||
container_class: "w-2/3",
|
||||
auto_submit: true,
|
||||
min: 0,
|
||||
value: @entry.amount.abs %>
|
||||
value: @entry.amount.abs,
|
||||
disabled: @entry.linked?,
|
||||
disable_currency: @entry.linked? %>
|
||||
</div>
|
||||
|
||||
<%= f.fields_for :entryable do |ef| %>
|
||||
|
@ -66,7 +69,7 @@
|
|||
<%= f.fields_for :entryable do |ef| %>
|
||||
|
||||
<%= ef.collection_select :merchant_id,
|
||||
Current.family.merchants.alphabetically,
|
||||
[@entry.transaction.merchant, *Current.family.merchants.alphabetically].compact,
|
||||
:id, :name,
|
||||
{ include_blank: t(".none"),
|
||||
label: t(".merchant_label"),
|
||||
|
|
|
@ -0,0 +1,24 @@
|
|||
class AddRawPayloadsToPlaidAccounts < ActiveRecord::Migration[7.2]
|
||||
def change
|
||||
add_column :plaid_items, :raw_payload, :jsonb, default: {}
|
||||
add_column :plaid_items, :raw_institution_payload, :jsonb, default: {}
|
||||
|
||||
change_column_null :plaid_items, :plaid_id, false
|
||||
add_index :plaid_items, :plaid_id, unique: true
|
||||
|
||||
add_column :plaid_accounts, :raw_payload, :jsonb, default: {}
|
||||
add_column :plaid_accounts, :raw_transactions_payload, :jsonb, default: {}
|
||||
add_column :plaid_accounts, :raw_investments_payload, :jsonb, default: {}
|
||||
add_column :plaid_accounts, :raw_liabilities_payload, :jsonb, default: {}
|
||||
|
||||
change_column_null :plaid_accounts, :plaid_id, false
|
||||
change_column_null :plaid_accounts, :plaid_type, false
|
||||
change_column_null :plaid_accounts, :currency, false
|
||||
change_column_null :plaid_accounts, :name, false
|
||||
add_index :plaid_accounts, :plaid_id, unique: true
|
||||
|
||||
# No longer need to store on transaction model because it is stored in raw_transactions_payload
|
||||
remove_column :transactions, :plaid_category, :string
|
||||
remove_column :transactions, :plaid_category_detailed, :string
|
||||
end
|
||||
end
|
22
db/schema.rb
generated
22
db/schema.rb
generated
|
@ -10,7 +10,7 @@
|
|||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[7.2].define(version: 2025_05_22_201031) do
|
||||
ActiveRecord::Schema[7.2].define(version: 2025_05_23_131455) do
|
||||
# These are extensions that must be enabled in order to support this database
|
||||
enable_extension "pgcrypto"
|
||||
enable_extension "plpgsql"
|
||||
|
@ -420,23 +420,28 @@ ActiveRecord::Schema[7.2].define(version: 2025_05_22_201031) do
|
|||
|
||||
create_table "plaid_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||
t.uuid "plaid_item_id", null: false
|
||||
t.string "plaid_id"
|
||||
t.string "plaid_type"
|
||||
t.string "plaid_id", null: false
|
||||
t.string "plaid_type", null: false
|
||||
t.string "plaid_subtype"
|
||||
t.decimal "current_balance", precision: 19, scale: 4
|
||||
t.decimal "available_balance", precision: 19, scale: 4
|
||||
t.string "currency"
|
||||
t.string "name"
|
||||
t.string "currency", null: false
|
||||
t.string "name", null: false
|
||||
t.string "mask"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.jsonb "raw_payload", default: {}
|
||||
t.jsonb "raw_transactions_payload", default: {}
|
||||
t.jsonb "raw_investments_payload", default: {}
|
||||
t.jsonb "raw_liabilities_payload", default: {}
|
||||
t.index ["plaid_id"], name: "index_plaid_accounts_on_plaid_id", unique: true
|
||||
t.index ["plaid_item_id"], name: "index_plaid_accounts_on_plaid_item_id"
|
||||
end
|
||||
|
||||
create_table "plaid_items", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||
t.uuid "family_id", null: false
|
||||
t.string "access_token"
|
||||
t.string "plaid_id"
|
||||
t.string "plaid_id", null: false
|
||||
t.string "name"
|
||||
t.string "next_cursor"
|
||||
t.boolean "scheduled_for_deletion", default: false
|
||||
|
@ -449,7 +454,10 @@ ActiveRecord::Schema[7.2].define(version: 2025_05_22_201031) do
|
|||
t.string "institution_id"
|
||||
t.string "institution_color"
|
||||
t.string "status", default: "good", null: false
|
||||
t.jsonb "raw_payload", default: {}
|
||||
t.jsonb "raw_institution_payload", default: {}
|
||||
t.index ["family_id"], name: "index_plaid_items_on_family_id"
|
||||
t.index ["plaid_id"], name: "index_plaid_items_on_plaid_id", unique: true
|
||||
end
|
||||
|
||||
create_table "properties", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||
|
@ -637,8 +645,6 @@ ActiveRecord::Schema[7.2].define(version: 2025_05_22_201031) do
|
|||
t.uuid "category_id"
|
||||
t.uuid "merchant_id"
|
||||
t.jsonb "locked_attributes", default: {}
|
||||
t.string "plaid_category"
|
||||
t.string "plaid_category_detailed"
|
||||
t.index ["category_id"], name: "index_transactions_on_category_id"
|
||||
t.index ["merchant_id"], name: "index_transactions_on_merchant_id"
|
||||
end
|
||||
|
|
|
@ -8,7 +8,7 @@ class PlaidItemsControllerTest < ActionDispatch::IntegrationTest
|
|||
|
||||
test "create" do
|
||||
@plaid_provider = mock
|
||||
Provider::Registry.expects(:get_provider).with(:plaid_us).returns(@plaid_provider)
|
||||
Provider::Registry.expects(:plaid_provider_for_region).with("us").returns(@plaid_provider)
|
||||
|
||||
public_token = "public-sandbox-1234"
|
||||
|
||||
|
|
3
test/fixtures/accounts.yml
vendored
3
test/fixtures/accounts.yml
vendored
|
@ -24,9 +24,10 @@ depository:
|
|||
|
||||
connected:
|
||||
family: dylan_family
|
||||
name: Connected Account
|
||||
name: Plaid Depository Account
|
||||
balance: 5000
|
||||
currency: USD
|
||||
subtype: checking
|
||||
accountable_type: Depository
|
||||
accountable: two
|
||||
plaid_account: one
|
||||
|
|
8
test/fixtures/plaid_accounts.yml
vendored
8
test/fixtures/plaid_accounts.yml
vendored
|
@ -1,3 +1,9 @@
|
|||
one:
|
||||
current_balance: 1000
|
||||
available_balance: 1000
|
||||
currency: USD
|
||||
name: Plaid Depository Account
|
||||
plaid_item: one
|
||||
plaid_id: "1234567890"
|
||||
plaid_id: "acc_mock_1"
|
||||
plaid_type: depository
|
||||
plaid_subtype: checking
|
6
test/fixtures/plaid_items.yml
vendored
6
test/fixtures/plaid_items.yml
vendored
|
@ -1,5 +1,7 @@
|
|||
one:
|
||||
family: dylan_family
|
||||
plaid_id: "1234567890"
|
||||
plaid_id: "item_mock_1"
|
||||
access_token: encrypted_token_1
|
||||
name: "Test Bank"
|
||||
name: "Test Bank"
|
||||
billed_products: ["transactions", "investments", "liabilities"]
|
||||
available_products: []
|
35
test/models/plaid_account/importer_test.rb
Normal file
35
test/models/plaid_account/importer_test.rb
Normal file
|
@ -0,0 +1,35 @@
|
|||
require "test_helper"
|
||||
|
||||
class PlaidAccount::ImporterTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@mock_provider = PlaidMock.new
|
||||
@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
|
||||
|
||||
test "imports account data" do
|
||||
PlaidAccount::Importer.new(@plaid_account, account_snapshot: @account_snapshot).import
|
||||
|
||||
assert_equal @account_snapshot.account_data.account_id, @plaid_account.plaid_id
|
||||
assert_equal @account_snapshot.account_data.name, @plaid_account.name
|
||||
assert_equal @account_snapshot.account_data.mask, @plaid_account.mask
|
||||
assert_equal @account_snapshot.account_data.type, @plaid_account.plaid_type
|
||||
assert_equal @account_snapshot.account_data.subtype, @plaid_account.plaid_subtype
|
||||
|
||||
# This account has transactions data
|
||||
assert_equal PlaidMock::TRANSACTIONS.count, @plaid_account.raw_transactions_payload["added"].count
|
||||
|
||||
# This account does not have investment data
|
||||
assert_equal 0, @plaid_account.raw_investments_payload["holdings"].count
|
||||
assert_equal 0, @plaid_account.raw_investments_payload["securities"].count
|
||||
assert_equal 0, @plaid_account.raw_investments_payload["transactions"].count
|
||||
|
||||
# This account is a credit card, so it should have liability data
|
||||
assert_equal @plaid_account.plaid_id, @plaid_account.raw_liabilities_payload["credit"]["account_id"]
|
||||
assert_nil @plaid_account.raw_liabilities_payload["mortgage"]
|
||||
assert_nil @plaid_account.raw_liabilities_payload["student"]
|
||||
end
|
||||
end
|
|
@ -0,0 +1,83 @@
|
|||
require "test_helper"
|
||||
|
||||
class PlaidAccount::Investments::BalanceCalculatorTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@plaid_account = plaid_accounts(:one)
|
||||
|
||||
@plaid_account.update!(
|
||||
plaid_type: "investment",
|
||||
current_balance: 4000,
|
||||
available_balance: 2000 # We ignore this since we have current_balance + holdings
|
||||
)
|
||||
end
|
||||
|
||||
test "calculates total balance from cash and positions" do
|
||||
brokerage_cash_security_id = "plaid_brokerage_cash" # Plaid's brokerage cash security
|
||||
cash_equivalent_security_id = "plaid_cash_equivalent" # Cash equivalent security (i.e. money market fund)
|
||||
aapl_security_id = "plaid_aapl_security" # Regular stock security
|
||||
|
||||
test_investments = {
|
||||
transactions: [], # Irrelevant for balance calcs, leave empty
|
||||
holdings: [
|
||||
# $1,000 in brokerage cash
|
||||
{
|
||||
security_id: brokerage_cash_security_id,
|
||||
cost_basis: 1000,
|
||||
institution_price: 1,
|
||||
institution_value: 1000,
|
||||
quantity: 1000
|
||||
},
|
||||
# $1,000 in money market funds
|
||||
{
|
||||
security_id: cash_equivalent_security_id,
|
||||
cost_basis: 1000,
|
||||
institution_price: 1,
|
||||
institution_value: 1000,
|
||||
quantity: 1000
|
||||
},
|
||||
# $2,000 worth of AAPL stock
|
||||
{
|
||||
security_id: aapl_security_id,
|
||||
cost_basis: 2000,
|
||||
institution_price: 200,
|
||||
institution_value: 2000,
|
||||
quantity: 10
|
||||
}
|
||||
],
|
||||
securities: [
|
||||
{
|
||||
security_id: brokerage_cash_security_id,
|
||||
ticker_symbol: "CUR:USD",
|
||||
is_cash_equivalent: true,
|
||||
type: "cash"
|
||||
},
|
||||
{
|
||||
security_id: cash_equivalent_security_id,
|
||||
ticker_symbol: "VMFXX", # Vanguard Money Market Reserves
|
||||
is_cash_equivalent: true,
|
||||
type: "mutual fund"
|
||||
},
|
||||
{
|
||||
security_id: aapl_security_id,
|
||||
ticker_symbol: "AAPL",
|
||||
is_cash_equivalent: false,
|
||||
type: "equity",
|
||||
market_identifier_code: "XNAS"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@plaid_account.update!(raw_investments_payload: test_investments)
|
||||
|
||||
security_resolver = PlaidAccount::Investments::SecurityResolver.new(@plaid_account)
|
||||
balance_calculator = PlaidAccount::Investments::BalanceCalculator.new(@plaid_account, security_resolver: security_resolver)
|
||||
|
||||
# We set this equal to `current_balance`
|
||||
assert_equal 4000, balance_calculator.balance
|
||||
|
||||
# This is the sum of "non-brokerage-cash-holdings". In the above test case, this means
|
||||
# we're summing up $2,000 of AAPL + $1,000 Vanguard MM for $3,000 in holdings value.
|
||||
# We back this $3,000 from the $4,000 total to get $1,000 in cash balance.
|
||||
assert_equal 1000, balance_calculator.cash_balance
|
||||
end
|
||||
end
|
|
@ -0,0 +1,49 @@
|
|||
require "test_helper"
|
||||
|
||||
class PlaidAccount::Investments::HoldingsProcessorTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@plaid_account = plaid_accounts(:one)
|
||||
@security_resolver = PlaidAccount::Investments::SecurityResolver.new(@plaid_account)
|
||||
end
|
||||
|
||||
test "creates holding records from Plaid holdings snapshot" do
|
||||
test_investments_payload = {
|
||||
securities: [], # mocked
|
||||
holdings: [
|
||||
{
|
||||
"security_id" => "123",
|
||||
"quantity" => 100,
|
||||
"institution_price" => 100,
|
||||
"iso_currency_code" => "USD"
|
||||
}
|
||||
],
|
||||
transactions: [] # not relevant for test
|
||||
}
|
||||
|
||||
@plaid_account.update!(raw_investments_payload: test_investments_payload)
|
||||
|
||||
@security_resolver.expects(:resolve)
|
||||
.with(plaid_security_id: "123")
|
||||
.returns(
|
||||
OpenStruct.new(
|
||||
security: securities(:aapl),
|
||||
cash_equivalent?: false,
|
||||
brokerage_cash?: false
|
||||
)
|
||||
)
|
||||
|
||||
processor = PlaidAccount::Investments::HoldingsProcessor.new(@plaid_account, security_resolver: @security_resolver)
|
||||
|
||||
assert_difference "Holding.count" do
|
||||
processor.process
|
||||
end
|
||||
|
||||
holding = Holding.order(created_at: :desc).first
|
||||
|
||||
assert_equal 100, holding.qty
|
||||
assert_equal 100, holding.price
|
||||
assert_equal "USD", holding.currency
|
||||
assert_equal securities(:aapl), holding.security
|
||||
assert_equal Date.current, holding.date
|
||||
end
|
||||
end
|
115
test/models/plaid_account/investments/security_resolver_test.rb
Normal file
115
test/models/plaid_account/investments/security_resolver_test.rb
Normal file
|
@ -0,0 +1,115 @@
|
|||
require "test_helper"
|
||||
|
||||
class PlaidAccount::Investments::SecurityResolverTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@upstream_resolver = mock("Security::Resolver")
|
||||
@plaid_account = plaid_accounts(:one)
|
||||
@resolver = PlaidAccount::Investments::SecurityResolver.new(@plaid_account)
|
||||
end
|
||||
|
||||
test "handles missing plaid security" do
|
||||
missing_id = "missing_security_id"
|
||||
|
||||
# Ensure there are *no* securities that reference the missing ID
|
||||
@plaid_account.update!(raw_investments_payload: {
|
||||
securities: [
|
||||
{
|
||||
"security_id" => "some_other_id",
|
||||
"ticker_symbol" => "FOO",
|
||||
"type" => "equity",
|
||||
"market_identifier_code" => "XNAS"
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
Security::Resolver.expects(:new).never
|
||||
Sentry.stubs(:capture_exception)
|
||||
|
||||
response = @resolver.resolve(plaid_security_id: missing_id)
|
||||
|
||||
assert_nil response.security
|
||||
refute response.cash_equivalent?
|
||||
refute response.brokerage_cash?
|
||||
end
|
||||
|
||||
test "identifies brokerage cash plaid securities" do
|
||||
brokerage_cash_id = "brokerage_cash_security_id"
|
||||
|
||||
@plaid_account.update!(raw_investments_payload: {
|
||||
securities: [
|
||||
{
|
||||
"security_id" => brokerage_cash_id,
|
||||
"ticker_symbol" => "CUR:USD", # Plaid brokerage cash ticker
|
||||
"type" => "cash",
|
||||
"is_cash_equivalent" => true
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
Security::Resolver.expects(:new).never
|
||||
|
||||
response = @resolver.resolve(plaid_security_id: brokerage_cash_id)
|
||||
|
||||
assert_nil response.security
|
||||
assert response.cash_equivalent?
|
||||
assert response.brokerage_cash?
|
||||
end
|
||||
|
||||
test "identifies cash equivalent plaid securities" do
|
||||
mmf_security_id = "money_market_security_id"
|
||||
|
||||
@plaid_account.update!(raw_investments_payload: {
|
||||
securities: [
|
||||
{
|
||||
"security_id" => mmf_security_id,
|
||||
"ticker_symbol" => "VMFXX", # Vanguard Federal Money Market Fund
|
||||
"type" => "mutual fund",
|
||||
"is_cash_equivalent" => true,
|
||||
"market_identifier_code" => "XNAS"
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
resolved_security = Security.create!(ticker: "VMFXX", exchange_operating_mic: "XNAS")
|
||||
|
||||
Security::Resolver.expects(:new)
|
||||
.with("VMFXX", exchange_operating_mic: "XNAS")
|
||||
.returns(@upstream_resolver)
|
||||
@upstream_resolver.expects(:resolve).returns(resolved_security)
|
||||
|
||||
response = @resolver.resolve(plaid_security_id: mmf_security_id)
|
||||
|
||||
assert_equal resolved_security, response.security
|
||||
assert response.cash_equivalent?
|
||||
refute response.brokerage_cash?
|
||||
end
|
||||
|
||||
test "resolves normal plaid securities" do
|
||||
security_id = "regular_security_id"
|
||||
|
||||
@plaid_account.update!(raw_investments_payload: {
|
||||
securities: [
|
||||
{
|
||||
"security_id" => security_id,
|
||||
"ticker_symbol" => "IVV",
|
||||
"type" => "etf",
|
||||
"is_cash_equivalent" => false,
|
||||
"market_identifier_code" => "XNAS"
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
resolved_security = Security.create!(ticker: "IVV", exchange_operating_mic: "XNAS")
|
||||
|
||||
Security::Resolver.expects(:new)
|
||||
.with("IVV", exchange_operating_mic: "XNAS")
|
||||
.returns(@upstream_resolver)
|
||||
@upstream_resolver.expects(:resolve).returns(resolved_security)
|
||||
|
||||
response = @resolver.resolve(plaid_security_id: security_id)
|
||||
|
||||
assert_equal resolved_security, response.security
|
||||
refute response.cash_equivalent? # Normal securities are not cash equivalent
|
||||
refute response.brokerage_cash?
|
||||
end
|
||||
end
|
|
@ -0,0 +1,111 @@
|
|||
require "test_helper"
|
||||
|
||||
class PlaidAccount::Investments::TransactionsProcessorTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@plaid_account = plaid_accounts(:one)
|
||||
@security_resolver = PlaidAccount::Investments::SecurityResolver.new(@plaid_account)
|
||||
end
|
||||
|
||||
|
||||
test "creates regular trade entries" do
|
||||
test_investments_payload = {
|
||||
transactions: [
|
||||
{
|
||||
"transaction_id" => "123",
|
||||
"security_id" => "123",
|
||||
"type" => "buy",
|
||||
"quantity" => 1, # Positive, so "buy 1 share"
|
||||
"price" => 100,
|
||||
"iso_currency_code" => "USD",
|
||||
"date" => Date.current,
|
||||
"name" => "Buy 1 share of AAPL"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@plaid_account.update!(raw_investments_payload: test_investments_payload)
|
||||
|
||||
@security_resolver.stubs(:resolve).returns(OpenStruct.new(
|
||||
security: securities(:aapl)
|
||||
))
|
||||
|
||||
processor = PlaidAccount::Investments::TransactionsProcessor.new(@plaid_account, security_resolver: @security_resolver)
|
||||
|
||||
assert_difference [ "Entry.count", "Trade.count" ], 1 do
|
||||
processor.process
|
||||
end
|
||||
|
||||
entry = Entry.order(created_at: :desc).first
|
||||
|
||||
assert_equal 100, entry.amount
|
||||
assert_equal "USD", entry.currency
|
||||
assert_equal Date.current, entry.date
|
||||
assert_equal "Buy 1 share of AAPL", entry.name
|
||||
end
|
||||
|
||||
test "creates cash transactions" do
|
||||
test_investments_payload = {
|
||||
transactions: [
|
||||
{
|
||||
"transaction_id" => "123",
|
||||
"type" => "cash",
|
||||
"subtype" => "withdrawal",
|
||||
"amount" => 100, # Positive, so moving money OUT of the account
|
||||
"iso_currency_code" => "USD",
|
||||
"date" => Date.current,
|
||||
"name" => "Withdrawal"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@plaid_account.update!(raw_investments_payload: test_investments_payload)
|
||||
|
||||
@security_resolver.expects(:resolve).never # Cash transactions don't have a security
|
||||
|
||||
processor = PlaidAccount::Investments::TransactionsProcessor.new(@plaid_account, security_resolver: @security_resolver)
|
||||
|
||||
assert_difference [ "Entry.count", "Transaction.count" ], 1 do
|
||||
processor.process
|
||||
end
|
||||
|
||||
entry = Entry.order(created_at: :desc).first
|
||||
|
||||
assert_equal 100, entry.amount
|
||||
assert_equal "USD", entry.currency
|
||||
assert_equal Date.current, entry.date
|
||||
assert_equal "Withdrawal", entry.name
|
||||
end
|
||||
|
||||
test "creates fee transactions" do
|
||||
test_investments_payload = {
|
||||
transactions: [
|
||||
{
|
||||
"transaction_id" => "123",
|
||||
"type" => "fee",
|
||||
"subtype" => "miscellaneous fee",
|
||||
"amount" => 10.25,
|
||||
"iso_currency_code" => "USD",
|
||||
"date" => Date.current,
|
||||
"name" => "Miscellaneous fee"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@plaid_account.update!(raw_investments_payload: test_investments_payload)
|
||||
|
||||
@security_resolver.expects(:resolve).never # Cash transactions don't have a security
|
||||
|
||||
processor = PlaidAccount::Investments::TransactionsProcessor.new(@plaid_account, security_resolver: @security_resolver)
|
||||
|
||||
assert_difference [ "Entry.count", "Transaction.count" ], 1 do
|
||||
processor.process
|
||||
end
|
||||
|
||||
entry = Entry.order(created_at: :desc).first
|
||||
|
||||
assert_equal 10.25, entry.amount
|
||||
assert_equal "USD", entry.currency
|
||||
assert_equal Date.current, entry.date
|
||||
assert_equal "Miscellaneous fee", entry.name
|
||||
end
|
||||
end
|
|
@ -0,0 +1,39 @@
|
|||
require "test_helper"
|
||||
|
||||
class PlaidAccount::Liabilities::CreditProcessorTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@plaid_account = plaid_accounts(:one)
|
||||
@plaid_account.update!(
|
||||
plaid_type: "credit",
|
||||
plaid_subtype: "credit_card"
|
||||
)
|
||||
|
||||
@plaid_account.account.update!(
|
||||
accountable: CreditCard.new,
|
||||
)
|
||||
end
|
||||
|
||||
test "updates credit card minimum payment and APR from Plaid data" do
|
||||
@plaid_account.update!(raw_liabilities_payload: {
|
||||
credit: {
|
||||
minimum_payment_amount: 100,
|
||||
aprs: [ { apr_percentage: 15.0 } ]
|
||||
}
|
||||
})
|
||||
|
||||
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
|
||||
end
|
||||
|
||||
test "does nothing when liability data absent" do
|
||||
@plaid_account.update!(raw_liabilities_payload: {})
|
||||
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
|
||||
end
|
||||
end
|
|
@ -0,0 +1,44 @@
|
|||
require "test_helper"
|
||||
|
||||
class PlaidAccount::Liabilities::MortgageProcessorTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@plaid_account = plaid_accounts(:one)
|
||||
@plaid_account.update!(
|
||||
plaid_type: "loan",
|
||||
plaid_subtype: "mortgage"
|
||||
)
|
||||
|
||||
@plaid_account.account.update!(accountable: Loan.new)
|
||||
end
|
||||
|
||||
test "updates loan interest rate and type from Plaid data" do
|
||||
@plaid_account.update!(raw_liabilities_payload: {
|
||||
mortgage: {
|
||||
interest_rate: {
|
||||
type: "fixed",
|
||||
percentage: 4.25
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
processor = PlaidAccount::Liabilities::MortgageProcessor.new(@plaid_account)
|
||||
processor.process
|
||||
|
||||
loan = @plaid_account.account.loan
|
||||
|
||||
assert_equal "fixed", loan.rate_type
|
||||
assert_equal 4.25, loan.interest_rate
|
||||
end
|
||||
|
||||
test "does nothing when mortgage data absent" do
|
||||
@plaid_account.update!(raw_liabilities_payload: {})
|
||||
|
||||
processor = PlaidAccount::Liabilities::MortgageProcessor.new(@plaid_account)
|
||||
processor.process
|
||||
|
||||
loan = @plaid_account.account.loan
|
||||
|
||||
assert_nil loan.rate_type
|
||||
assert_nil loan.interest_rate
|
||||
end
|
||||
end
|
|
@ -0,0 +1,68 @@
|
|||
require "test_helper"
|
||||
|
||||
class PlaidAccount::Liabilities::StudentLoanProcessorTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@plaid_account = plaid_accounts(:one)
|
||||
@plaid_account.update!(
|
||||
plaid_type: "loan",
|
||||
plaid_subtype: "student"
|
||||
)
|
||||
|
||||
# Change the underlying accountable to a Loan so the helper method `loan` is available
|
||||
@plaid_account.account.update!(accountable: Loan.new)
|
||||
end
|
||||
|
||||
test "updates loan details including term months from Plaid data" do
|
||||
@plaid_account.update!(raw_liabilities_payload: {
|
||||
student: {
|
||||
interest_rate_percentage: 5.5,
|
||||
origination_principal_amount: 20000,
|
||||
origination_date: Date.new(2020, 1, 1),
|
||||
expected_payoff_date: Date.new(2022, 1, 1)
|
||||
}
|
||||
})
|
||||
|
||||
processor = PlaidAccount::Liabilities::StudentLoanProcessor.new(@plaid_account)
|
||||
processor.process
|
||||
|
||||
loan = @plaid_account.account.loan
|
||||
|
||||
assert_equal "fixed", loan.rate_type
|
||||
assert_equal 5.5, loan.interest_rate
|
||||
assert_equal 20000, loan.initial_balance
|
||||
assert_equal 24, loan.term_months
|
||||
end
|
||||
|
||||
test "handles missing payoff dates gracefully" do
|
||||
@plaid_account.update!(raw_liabilities_payload: {
|
||||
student: {
|
||||
interest_rate_percentage: 4.8,
|
||||
origination_principal_amount: 15000,
|
||||
origination_date: Date.new(2021, 6, 1)
|
||||
# expected_payoff_date omitted
|
||||
}
|
||||
})
|
||||
|
||||
processor = PlaidAccount::Liabilities::StudentLoanProcessor.new(@plaid_account)
|
||||
processor.process
|
||||
|
||||
loan = @plaid_account.account.loan
|
||||
|
||||
assert_nil loan.term_months
|
||||
assert_equal 4.8, loan.interest_rate
|
||||
assert_equal 15000, loan.initial_balance
|
||||
end
|
||||
|
||||
test "does nothing when loan data absent" do
|
||||
@plaid_account.update!(raw_liabilities_payload: {})
|
||||
|
||||
processor = PlaidAccount::Liabilities::StudentLoanProcessor.new(@plaid_account)
|
||||
processor.process
|
||||
|
||||
loan = @plaid_account.account.loan
|
||||
|
||||
assert_nil loan.interest_rate
|
||||
assert_nil loan.initial_balance
|
||||
assert_nil loan.term_months
|
||||
end
|
||||
end
|
172
test/models/plaid_account/processor_test.rb
Normal file
172
test/models/plaid_account/processor_test.rb
Normal file
|
@ -0,0 +1,172 @@
|
|||
require "test_helper"
|
||||
|
||||
class PlaidAccount::ProcessorTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@plaid_account = plaid_accounts(:one)
|
||||
end
|
||||
|
||||
test "processes new account and assigns attributes" do
|
||||
Account.destroy_all # Clear out internal accounts so we start fresh
|
||||
|
||||
expect_default_subprocessor_calls
|
||||
|
||||
@plaid_account.update!(
|
||||
plaid_id: "test_plaid_id",
|
||||
plaid_type: "depository",
|
||||
plaid_subtype: "checking",
|
||||
current_balance: 1000,
|
||||
available_balance: 1000,
|
||||
currency: "USD",
|
||||
name: "Test Plaid Account",
|
||||
mask: "1234"
|
||||
)
|
||||
|
||||
assert_difference "Account.count" do
|
||||
PlaidAccount::Processor.new(@plaid_account).process
|
||||
end
|
||||
|
||||
@plaid_account.reload
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
@plaid_account.account.update!(
|
||||
name: "User updated name",
|
||||
subtype: "savings",
|
||||
balance: 2000 # User cannot override balance. This will be overridden by the processor on next processing
|
||||
)
|
||||
|
||||
@plaid_account.account.lock_attr!(:name)
|
||||
@plaid_account.account.lock_attr!(:subtype)
|
||||
@plaid_account.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
|
||||
end
|
||||
|
||||
@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
|
||||
end
|
||||
|
||||
test "account processing failure halts further processing" do
|
||||
Account.any_instance.stubs(:save!).raises(StandardError.new("Test error"))
|
||||
|
||||
PlaidAccount::Transactions::Processor.any_instance.expects(:process).never
|
||||
PlaidAccount::Investments::TransactionsProcessor.any_instance.expects(:process).never
|
||||
PlaidAccount::Investments::HoldingsProcessor.any_instance.expects(:process).never
|
||||
|
||||
expect_no_investment_balance_calculator_calls
|
||||
expect_no_liability_processor_calls
|
||||
|
||||
assert_raises(StandardError) do
|
||||
PlaidAccount::Processor.new(@plaid_account).process
|
||||
end
|
||||
end
|
||||
|
||||
test "product processing failure reports exception and continues processing" do
|
||||
PlaidAccount::Transactions::Processor.any_instance.stubs(:process).raises(StandardError.new("Test error"))
|
||||
|
||||
# Subsequent product processors still run
|
||||
expect_investment_product_processor_calls
|
||||
|
||||
assert_nothing_raised do
|
||||
PlaidAccount::Processor.new(@plaid_account).process
|
||||
end
|
||||
end
|
||||
|
||||
test "calculates balance using BalanceCalculator for investment accounts" do
|
||||
@plaid_account.update!(plaid_type: "investment")
|
||||
|
||||
PlaidAccount::Investments::BalanceCalculator.any_instance.expects(:balance).returns(1000).once
|
||||
PlaidAccount::Investments::BalanceCalculator.any_instance.expects(:cash_balance).returns(1000).once
|
||||
|
||||
PlaidAccount::Processor.new(@plaid_account).process
|
||||
end
|
||||
|
||||
test "processes credit liability data" do
|
||||
expect_investment_product_processor_calls
|
||||
expect_no_investment_balance_calculator_calls
|
||||
expect_depository_product_processor_calls
|
||||
|
||||
@plaid_account.update!(plaid_type: "credit", plaid_subtype: "credit card")
|
||||
|
||||
PlaidAccount::Liabilities::CreditProcessor.any_instance.expects(:process).once
|
||||
PlaidAccount::Liabilities::MortgageProcessor.any_instance.expects(:process).never
|
||||
PlaidAccount::Liabilities::StudentLoanProcessor.any_instance.expects(:process).never
|
||||
|
||||
PlaidAccount::Processor.new(@plaid_account).process
|
||||
end
|
||||
|
||||
test "processes mortgage liability data" do
|
||||
expect_investment_product_processor_calls
|
||||
expect_no_investment_balance_calculator_calls
|
||||
expect_depository_product_processor_calls
|
||||
|
||||
@plaid_account.update!(plaid_type: "loan", plaid_subtype: "mortgage")
|
||||
|
||||
PlaidAccount::Liabilities::CreditProcessor.any_instance.expects(:process).never
|
||||
PlaidAccount::Liabilities::MortgageProcessor.any_instance.expects(:process).once
|
||||
PlaidAccount::Liabilities::StudentLoanProcessor.any_instance.expects(:process).never
|
||||
|
||||
PlaidAccount::Processor.new(@plaid_account).process
|
||||
end
|
||||
|
||||
test "processes student loan liability data" do
|
||||
expect_investment_product_processor_calls
|
||||
expect_no_investment_balance_calculator_calls
|
||||
expect_depository_product_processor_calls
|
||||
|
||||
@plaid_account.update!(plaid_type: "loan", plaid_subtype: "student")
|
||||
|
||||
PlaidAccount::Liabilities::CreditProcessor.any_instance.expects(:process).never
|
||||
PlaidAccount::Liabilities::MortgageProcessor.any_instance.expects(:process).never
|
||||
PlaidAccount::Liabilities::StudentLoanProcessor.any_instance.expects(:process).once
|
||||
|
||||
PlaidAccount::Processor.new(@plaid_account).process
|
||||
end
|
||||
|
||||
private
|
||||
def expect_investment_product_processor_calls
|
||||
PlaidAccount::Investments::TransactionsProcessor.any_instance.expects(:process).once
|
||||
PlaidAccount::Investments::HoldingsProcessor.any_instance.expects(:process).once
|
||||
end
|
||||
|
||||
def expect_depository_product_processor_calls
|
||||
PlaidAccount::Transactions::Processor.any_instance.expects(:process).once
|
||||
end
|
||||
|
||||
def expect_no_investment_balance_calculator_calls
|
||||
PlaidAccount::Investments::BalanceCalculator.any_instance.expects(:balance).never
|
||||
PlaidAccount::Investments::BalanceCalculator.any_instance.expects(:cash_balance).never
|
||||
end
|
||||
|
||||
def expect_no_liability_processor_calls
|
||||
PlaidAccount::Liabilities::CreditProcessor.any_instance.expects(:process).never
|
||||
PlaidAccount::Liabilities::MortgageProcessor.any_instance.expects(:process).never
|
||||
PlaidAccount::Liabilities::StudentLoanProcessor.any_instance.expects(:process).never
|
||||
end
|
||||
|
||||
def expect_default_subprocessor_calls
|
||||
expect_depository_product_processor_calls
|
||||
expect_investment_product_processor_calls
|
||||
expect_no_investment_balance_calculator_calls
|
||||
expect_no_liability_processor_calls
|
||||
end
|
||||
end
|
|
@ -1,6 +1,6 @@
|
|||
require "test_helper"
|
||||
|
||||
class Provider::Plaid::CategoryAliasMatcherTest < ActiveSupport::TestCase
|
||||
class PlaidAccount::Transactions::CategoryMatcherTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@family = families(:empty)
|
||||
|
||||
|
@ -32,7 +32,7 @@ class Provider::Plaid::CategoryAliasMatcherTest < ActiveSupport::TestCase
|
|||
|
||||
@giving = @family.categories.create!(name: "Giving")
|
||||
|
||||
@matcher = Provider::Plaid::CategoryAliasMatcher.new(@family.categories)
|
||||
@matcher = PlaidAccount::Transactions::CategoryMatcher.new(@family.categories)
|
||||
end
|
||||
|
||||
test "matches expense categories" do
|
63
test/models/plaid_account/transactions/processor_test.rb
Normal file
63
test/models/plaid_account/transactions/processor_test.rb
Normal file
|
@ -0,0 +1,63 @@
|
|||
require "test_helper"
|
||||
|
||||
class PlaidAccount::Transactions::ProcessorTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@plaid_account = plaid_accounts(:one)
|
||||
end
|
||||
|
||||
test "processes added and modified plaid transactions" do
|
||||
added_transactions = [ { "transaction_id" => "123" } ]
|
||||
modified_transactions = [ { "transaction_id" => "456" } ]
|
||||
|
||||
@plaid_account.update!(raw_transactions_payload: {
|
||||
added: added_transactions,
|
||||
modified: modified_transactions,
|
||||
removed: []
|
||||
})
|
||||
|
||||
mock_processor = mock("PlaidEntry::Processor")
|
||||
category_matcher_mock = mock("PlaidAccount::Transactions::CategoryMatcher")
|
||||
|
||||
PlaidAccount::Transactions::CategoryMatcher.stubs(:new).returns(category_matcher_mock)
|
||||
PlaidEntry::Processor.expects(:new)
|
||||
.with(added_transactions.first, plaid_account: @plaid_account, category_matcher: category_matcher_mock)
|
||||
.returns(mock_processor)
|
||||
.once
|
||||
|
||||
PlaidEntry::Processor.expects(:new)
|
||||
.with(modified_transactions.first, plaid_account: @plaid_account, category_matcher: category_matcher_mock)
|
||||
.returns(mock_processor)
|
||||
.once
|
||||
|
||||
mock_processor.expects(:process).twice
|
||||
|
||||
processor = PlaidAccount::Transactions::Processor.new(@plaid_account)
|
||||
processor.process
|
||||
end
|
||||
|
||||
test "removes transactions no longer in plaid" do
|
||||
destroyable_transaction_id = "destroy_me"
|
||||
@plaid_account.account.entries.create!(
|
||||
plaid_id: destroyable_transaction_id,
|
||||
date: Date.current,
|
||||
amount: 100,
|
||||
name: "Destroy me",
|
||||
currency: "USD",
|
||||
entryable: Transaction.new
|
||||
)
|
||||
|
||||
@plaid_account.update!(raw_transactions_payload: {
|
||||
added: [],
|
||||
modified: [],
|
||||
removed: [ { "transaction_id" => destroyable_transaction_id } ]
|
||||
})
|
||||
|
||||
processor = PlaidAccount::Transactions::Processor.new(@plaid_account)
|
||||
|
||||
assert_difference [ "Entry.count", "Transaction.count" ], -1 do
|
||||
processor.process
|
||||
end
|
||||
|
||||
assert_nil Entry.find_by(plaid_id: destroyable_transaction_id)
|
||||
end
|
||||
end
|
35
test/models/plaid_account/type_mappable_test.rb
Normal file
35
test/models/plaid_account/type_mappable_test.rb
Normal file
|
@ -0,0 +1,35 @@
|
|||
require "test_helper"
|
||||
|
||||
class PlaidAccount::TypeMappableTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
class MockProcessor
|
||||
include PlaidAccount::TypeMappable
|
||||
end
|
||||
|
||||
@mock_processor = MockProcessor.new
|
||||
end
|
||||
|
||||
test "maps types to accountables" do
|
||||
assert_instance_of Depository, @mock_processor.map_accountable("depository")
|
||||
assert_instance_of Investment, @mock_processor.map_accountable("investment")
|
||||
assert_instance_of CreditCard, @mock_processor.map_accountable("credit")
|
||||
assert_instance_of Loan, @mock_processor.map_accountable("loan")
|
||||
assert_instance_of OtherAsset, @mock_processor.map_accountable("other")
|
||||
end
|
||||
|
||||
test "maps subtypes" do
|
||||
assert_equal "checking", @mock_processor.map_subtype("depository", "checking")
|
||||
assert_equal "roth_ira", @mock_processor.map_subtype("investment", "roth")
|
||||
end
|
||||
|
||||
test "raises on invalid types" do
|
||||
assert_raises PlaidAccount::TypeMappable::UnknownAccountTypeError do
|
||||
@mock_processor.map_accountable("unknown")
|
||||
end
|
||||
end
|
||||
|
||||
test "handles nil subtypes" do
|
||||
assert_equal "other", @mock_processor.map_subtype("depository", nil)
|
||||
assert_equal "other", @mock_processor.map_subtype("depository", "unknown")
|
||||
end
|
||||
end
|
91
test/models/plaid_entry/processor_test.rb
Normal file
91
test/models/plaid_entry/processor_test.rb
Normal file
|
@ -0,0 +1,91 @@
|
|||
require "test_helper"
|
||||
|
||||
class PlaidEntry::ProcessorTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@plaid_account = plaid_accounts(:one)
|
||||
@category_matcher = mock("PlaidAccount::Transactions::CategoryMatcher")
|
||||
end
|
||||
|
||||
test "creates new entry transaction" do
|
||||
plaid_transaction = {
|
||||
"transaction_id" => "123",
|
||||
"merchant_name" => "Amazon", # this is used for merchant and entry name
|
||||
"amount" => 100,
|
||||
"date" => Date.current,
|
||||
"iso_currency_code" => "USD",
|
||||
"personal_finance_category" => {
|
||||
"detailed" => "Food"
|
||||
},
|
||||
"merchant_entity_id" => "123"
|
||||
}
|
||||
|
||||
@category_matcher.expects(:match).with("Food").returns(categories(:food_and_drink))
|
||||
|
||||
processor = PlaidEntry::Processor.new(
|
||||
plaid_transaction,
|
||||
plaid_account: @plaid_account,
|
||||
category_matcher: @category_matcher
|
||||
)
|
||||
|
||||
assert_difference [ "Entry.count", "Transaction.count", "ProviderMerchant.count" ], 1 do
|
||||
processor.process
|
||||
end
|
||||
|
||||
entry = Entry.order(created_at: :desc).first
|
||||
|
||||
assert_equal 100, entry.amount
|
||||
assert_equal "USD", entry.currency
|
||||
assert_equal Date.current, entry.date
|
||||
assert_equal "Amazon", entry.name
|
||||
assert_equal categories(:food_and_drink).id, entry.transaction.category_id
|
||||
|
||||
provider_merchant = ProviderMerchant.order(created_at: :desc).first
|
||||
|
||||
assert_equal "Amazon", provider_merchant.name
|
||||
end
|
||||
|
||||
test "updates existing entry transaction" do
|
||||
existing_plaid_id = "existing_plaid_id"
|
||||
|
||||
plaid_transaction = {
|
||||
"transaction_id" => existing_plaid_id,
|
||||
"merchant_name" => "Amazon", # this is used for merchant and entry name
|
||||
"amount" => 200, # Changed amount will be updated
|
||||
"date" => 1.day.ago.to_date, # Changed date will be updated
|
||||
"iso_currency_code" => "USD",
|
||||
"personal_finance_category" => {
|
||||
"detailed" => "Food"
|
||||
}
|
||||
}
|
||||
|
||||
@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,
|
||||
amount: 100,
|
||||
currency: "USD",
|
||||
date: Date.current,
|
||||
name: "Amazon",
|
||||
entryable: Transaction.new
|
||||
)
|
||||
|
||||
processor = PlaidEntry::Processor.new(
|
||||
plaid_transaction,
|
||||
plaid_account: @plaid_account,
|
||||
category_matcher: @category_matcher
|
||||
)
|
||||
|
||||
assert_no_difference [ "Entry.count", "Transaction.count", "ProviderMerchant.count" ] do
|
||||
processor.process
|
||||
end
|
||||
|
||||
entry = Entry.order(created_at: :desc).first
|
||||
|
||||
assert_equal 200, entry.amount
|
||||
assert_equal "USD", entry.currency
|
||||
assert_equal 1.day.ago.to_date, entry.date
|
||||
assert_equal "Amazon", entry.name
|
||||
assert_equal categories(:food_and_drink).id, entry.transaction.category_id
|
||||
end
|
||||
end
|
|
@ -1,82 +0,0 @@
|
|||
require "test_helper"
|
||||
|
||||
class PlaidInvestmentSyncTest < ActiveSupport::TestCase
|
||||
include PlaidTestHelper
|
||||
|
||||
setup do
|
||||
@plaid_account = plaid_accounts(:one)
|
||||
end
|
||||
|
||||
test "syncs basic investments and handles cash holding" do
|
||||
assert_equal 0, @plaid_account.account.entries.count
|
||||
assert_equal 0, @plaid_account.account.holdings.count
|
||||
|
||||
plaid_aapl_id = "aapl_id"
|
||||
|
||||
transactions = [
|
||||
create_plaid_investment_transaction({
|
||||
investment_transaction_id: "inv_txn_1",
|
||||
security_id: plaid_aapl_id,
|
||||
quantity: 10,
|
||||
price: 200,
|
||||
date: 5.days.ago.to_date,
|
||||
type: "buy"
|
||||
})
|
||||
]
|
||||
|
||||
holdings = [
|
||||
create_plaid_cash_holding,
|
||||
create_plaid_holding({
|
||||
security_id: plaid_aapl_id,
|
||||
quantity: 10,
|
||||
institution_price: 200,
|
||||
cost_basis: 2000
|
||||
})
|
||||
]
|
||||
|
||||
securities = [
|
||||
create_plaid_security({
|
||||
security_id: plaid_aapl_id,
|
||||
close_price: 200,
|
||||
ticker_symbol: "AAPL"
|
||||
})
|
||||
]
|
||||
|
||||
# Cash holding should be ignored, resulting in 1, NOT 2 total holdings after sync
|
||||
assert_difference -> { Trade.count } => 1,
|
||||
-> { Transaction.count } => 0,
|
||||
-> { Holding.count } => 1,
|
||||
-> { Security.count } => 0 do
|
||||
PlaidInvestmentSync.new(@plaid_account).sync!(
|
||||
transactions: transactions,
|
||||
holdings: holdings,
|
||||
securities: securities
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
# Some cash transactions from Plaid are labeled as type: "cash" while others are linked to a "cash" security
|
||||
# In both cases, we should treat them as cash-only transactions (not trades)
|
||||
test "handles cash investment transactions" do
|
||||
transactions = [
|
||||
create_plaid_investment_transaction({
|
||||
price: 1,
|
||||
quantity: 5,
|
||||
amount: 5,
|
||||
type: "fee",
|
||||
subtype: "miscellaneous fee",
|
||||
security_id: PLAID_TEST_CASH_SECURITY_ID
|
||||
})
|
||||
]
|
||||
|
||||
assert_difference -> { Trade.count } => 0,
|
||||
-> { Transaction.count } => 1,
|
||||
-> { Security.count } => 0 do
|
||||
PlaidInvestmentSync.new(@plaid_account).sync!(
|
||||
transactions: transactions,
|
||||
holdings: [ create_plaid_cash_holding ],
|
||||
securities: [ create_plaid_cash_security ]
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
23
test/models/plaid_item/importer_test.rb
Normal file
23
test/models/plaid_item/importer_test.rb
Normal file
|
@ -0,0 +1,23 @@
|
|||
require "test_helper"
|
||||
require "ostruct"
|
||||
|
||||
class PlaidItem::ImporterTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@mock_provider = PlaidMock.new
|
||||
@plaid_item = plaid_items(:one)
|
||||
@importer = PlaidItem::Importer.new(@plaid_item, plaid_provider: @mock_provider)
|
||||
end
|
||||
|
||||
test "imports item metadata" do
|
||||
PlaidAccount::Importer.any_instance.expects(:import).times(PlaidMock::ACCOUNTS.count)
|
||||
|
||||
PlaidItem::Importer.new(@plaid_item, plaid_provider: @mock_provider).import
|
||||
|
||||
assert_equal PlaidMock::ITEM.institution_id, @plaid_item.institution_id
|
||||
assert_equal PlaidMock::ITEM.available_products, @plaid_item.available_products
|
||||
assert_equal PlaidMock::ITEM.billed_products, @plaid_item.billed_products
|
||||
|
||||
assert_equal PlaidMock::ITEM.item_id, @plaid_item.raw_payload["item_id"]
|
||||
assert_equal PlaidMock::INSTITUTION.institution_id, @plaid_item.raw_institution_payload["institution_id"]
|
||||
end
|
||||
end
|
|
@ -5,11 +5,11 @@ class PlaidItemTest < ActiveSupport::TestCase
|
|||
|
||||
setup do
|
||||
@plaid_item = @syncable = plaid_items(:one)
|
||||
@plaid_provider = mock
|
||||
Provider::Registry.stubs(:plaid_provider_for_region).returns(@plaid_provider)
|
||||
end
|
||||
|
||||
test "removes plaid item when destroyed" do
|
||||
@plaid_provider = mock
|
||||
@plaid_item.stubs(:plaid_provider).returns(@plaid_provider)
|
||||
@plaid_provider.expects(:remove_item).with(@plaid_item.access_token).once
|
||||
|
||||
assert_difference "PlaidItem.count", -1 do
|
||||
|
@ -18,8 +18,6 @@ class PlaidItemTest < ActiveSupport::TestCase
|
|||
end
|
||||
|
||||
test "if plaid item not found, silently continues with deletion" do
|
||||
@plaid_provider = mock
|
||||
@plaid_item.stubs(:plaid_provider).returns(@plaid_provider)
|
||||
@plaid_provider.expects(:remove_item).with(@plaid_item.access_token).raises(Plaid::ApiError.new("Item not found"))
|
||||
|
||||
assert_difference "PlaidItem.count", -1 do
|
||||
|
|
80
test/models/provider/plaid_test.rb
Normal file
80
test/models/provider/plaid_test.rb
Normal file
|
@ -0,0 +1,80 @@
|
|||
require "test_helper"
|
||||
|
||||
class Provider::PlaidTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
# Do not change, this is whitelisted in the Plaid Dashboard for local dev
|
||||
@redirect_url = "http://localhost:3000/accounts"
|
||||
|
||||
# A specialization of Plaid client with sandbox-only extensions
|
||||
@plaid = Provider::PlaidSandbox.new
|
||||
end
|
||||
|
||||
test "gets link token" do
|
||||
VCR.use_cassette("plaid/link_token") do
|
||||
link_token = @plaid.get_link_token(
|
||||
user_id: "test-user-id",
|
||||
webhooks_url: "https://example.com/webhooks",
|
||||
redirect_url: @redirect_url
|
||||
)
|
||||
|
||||
assert_match /link-sandbox-.*/, link_token.link_token
|
||||
end
|
||||
end
|
||||
|
||||
test "exchanges public token" do
|
||||
VCR.use_cassette("plaid/exchange_public_token") do
|
||||
public_token = @plaid.create_public_token
|
||||
exchange_response = @plaid.exchange_public_token(public_token)
|
||||
|
||||
assert_match /access-sandbox-.*/, exchange_response.access_token
|
||||
end
|
||||
end
|
||||
|
||||
test "gets item" do
|
||||
VCR.use_cassette("plaid/get_item") do
|
||||
access_token = get_access_token
|
||||
item = @plaid.get_item(access_token).item
|
||||
|
||||
assert_equal "ins_109508", item.institution_id
|
||||
assert_equal "First Platypus Bank", item.institution_name
|
||||
end
|
||||
end
|
||||
|
||||
test "gets item accounts" do
|
||||
VCR.use_cassette("plaid/get_item_accounts") do
|
||||
access_token = get_access_token
|
||||
accounts_response = @plaid.get_item_accounts(access_token)
|
||||
|
||||
assert_equal 4, accounts_response.accounts.size
|
||||
end
|
||||
end
|
||||
|
||||
test "gets item investments" do
|
||||
VCR.use_cassette("plaid/get_item_investments") do
|
||||
access_token = get_access_token
|
||||
investments_response = @plaid.get_item_investments(access_token)
|
||||
|
||||
assert_equal 3, investments_response.holdings.size
|
||||
assert_equal 4, investments_response.transactions.size
|
||||
end
|
||||
end
|
||||
|
||||
test "gets item liabilities" do
|
||||
VCR.use_cassette("plaid/get_item_liabilities") do
|
||||
access_token = get_access_token
|
||||
liabilities_response = @plaid.get_item_liabilities(access_token)
|
||||
|
||||
assert liabilities_response.credit.count > 0
|
||||
assert liabilities_response.student.count > 0
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def get_access_token
|
||||
VCR.use_cassette("plaid/access_token") do
|
||||
public_token = @plaid.create_public_token
|
||||
exchange_response = @plaid.exchange_public_token(public_token)
|
||||
exchange_response.access_token
|
||||
end
|
||||
end
|
||||
end
|
214
test/support/plaid_mock.rb
Normal file
214
test/support/plaid_mock.rb
Normal file
|
@ -0,0 +1,214 @@
|
|||
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
|
|
@ -1,128 +0,0 @@
|
|||
require "ostruct"
|
||||
|
||||
module PlaidTestHelper
|
||||
PLAID_TEST_ACCOUNT_ID = "plaid_test_account_id"
|
||||
PLAID_TEST_CASH_SECURITY_ID = "plaid_test_cash_security_id"
|
||||
|
||||
# Special case
|
||||
def create_plaid_cash_security(attributes = {})
|
||||
default_attributes = {
|
||||
close_price: nil,
|
||||
close_price_as_of: nil,
|
||||
cusip: nil,
|
||||
fixed_income: nil,
|
||||
industry: nil,
|
||||
institution_id: nil,
|
||||
institution_security_id: nil,
|
||||
is_cash_equivalent: false, # Plaid sometimes returns false here (bad data), so we should not rely on it
|
||||
isin: nil,
|
||||
iso_currency_code: "USD",
|
||||
market_identifier_code: nil,
|
||||
name: "US Dollar",
|
||||
option_contract: nil,
|
||||
proxy_security_id: nil,
|
||||
sector: nil,
|
||||
security_id: PLAID_TEST_CASH_SECURITY_ID,
|
||||
sedol: nil,
|
||||
ticker_symbol: "CUR:USD",
|
||||
type: "cash",
|
||||
unofficial_currency_code: nil,
|
||||
update_datetime: nil
|
||||
}
|
||||
|
||||
OpenStruct.new(
|
||||
default_attributes.merge(attributes)
|
||||
)
|
||||
end
|
||||
|
||||
def create_plaid_security(attributes = {})
|
||||
default_attributes = {
|
||||
close_price: 606.71,
|
||||
close_price_as_of: Date.current,
|
||||
cusip: nil,
|
||||
fixed_income: nil,
|
||||
industry: "Mutual Funds",
|
||||
institution_id: nil,
|
||||
institution_security_id: nil,
|
||||
is_cash_equivalent: false,
|
||||
isin: nil,
|
||||
iso_currency_code: "USD",
|
||||
market_identifier_code: "XNAS",
|
||||
name: "iShares S&P 500 Index",
|
||||
option_contract: nil,
|
||||
proxy_security_id: nil,
|
||||
sector: "Financial",
|
||||
security_id: "plaid_test_security_id",
|
||||
sedol: "2593025",
|
||||
ticker_symbol: "IVV",
|
||||
type: "etf",
|
||||
unofficial_currency_code: nil,
|
||||
update_datetime: nil
|
||||
}
|
||||
|
||||
OpenStruct.new(
|
||||
default_attributes.merge(attributes)
|
||||
)
|
||||
end
|
||||
|
||||
def create_plaid_cash_holding(attributes = {})
|
||||
default_attributes = {
|
||||
account_id: PLAID_TEST_ACCOUNT_ID,
|
||||
cost_basis: 1000,
|
||||
institution_price: 1,
|
||||
institution_price_as_of: Date.current,
|
||||
iso_currency_code: "USD",
|
||||
quantity: 1000,
|
||||
security_id: PLAID_TEST_CASH_SECURITY_ID,
|
||||
unofficial_currency_code: nil,
|
||||
vested_quantity: nil,
|
||||
vested_value: nil
|
||||
}
|
||||
|
||||
OpenStruct.new(
|
||||
default_attributes.merge(attributes)
|
||||
)
|
||||
end
|
||||
|
||||
def create_plaid_holding(attributes = {})
|
||||
default_attributes = {
|
||||
account_id: PLAID_TEST_ACCOUNT_ID,
|
||||
cost_basis: 2000,
|
||||
institution_price: 200,
|
||||
institution_price_as_of: Date.current,
|
||||
iso_currency_code: "USD",
|
||||
quantity: 10,
|
||||
security_id: "plaid_test_security_id",
|
||||
unofficial_currency_code: nil,
|
||||
vested_quantity: nil,
|
||||
vested_value: nil
|
||||
}
|
||||
|
||||
OpenStruct.new(
|
||||
default_attributes.merge(attributes)
|
||||
)
|
||||
end
|
||||
|
||||
def create_plaid_investment_transaction(attributes = {})
|
||||
default_attributes = {
|
||||
account_id: PLAID_TEST_ACCOUNT_ID,
|
||||
amount: 500,
|
||||
cancel_transaction_id: nil,
|
||||
date: 5.days.ago.to_date,
|
||||
fees: 0,
|
||||
investment_transaction_id: "plaid_test_investment_transaction_id",
|
||||
iso_currency_code: "USD",
|
||||
name: "Buy 100 shares of IVV",
|
||||
price: 606.71,
|
||||
quantity: 100,
|
||||
security_id: "plaid_test_security_id",
|
||||
type: "buy",
|
||||
subtype: "buy",
|
||||
unofficial_currency_code: nil
|
||||
}
|
||||
|
||||
OpenStruct.new(
|
||||
default_attributes.merge(attributes)
|
||||
)
|
||||
end
|
||||
end
|
|
@ -29,6 +29,8 @@ VCR.configure do |config|
|
|||
config.filter_sensitive_data("<OPENAI_ORGANIZATION_ID>") { ENV["OPENAI_ORGANIZATION_ID"] }
|
||||
config.filter_sensitive_data("<STRIPE_SECRET_KEY>") { ENV["STRIPE_SECRET_KEY"] }
|
||||
config.filter_sensitive_data("<STRIPE_WEBHOOK_SECRET>") { ENV["STRIPE_WEBHOOK_SECRET"] }
|
||||
config.filter_sensitive_data("<PLAID_CLIENT_ID>") { ENV["PLAID_CLIENT_ID"] }
|
||||
config.filter_sensitive_data("<PLAID_SECRET>") { ENV["PLAID_SECRET"] }
|
||||
end
|
||||
|
||||
module ActiveSupport
|
||||
|
|
124
test/vcr_cassettes/plaid/access_token.yml
Normal file
124
test/vcr_cassettes/plaid/access_token.yml
Normal file
|
@ -0,0 +1,124 @@
|
|||
---
|
||||
http_interactions:
|
||||
- request:
|
||||
method: post
|
||||
uri: https://sandbox.plaid.com/sandbox/public_token/create
|
||||
body:
|
||||
encoding: UTF-8
|
||||
string: '{"institution_id":"ins_109508","initial_products":["transactions","investments","liabilities"],"options":{"override_username":"custom_test"}}'
|
||||
headers:
|
||||
Content-Type:
|
||||
- application/json
|
||||
User-Agent:
|
||||
- Plaid Ruby v38.0.0
|
||||
Accept:
|
||||
- application/json
|
||||
Plaid-Client-Id:
|
||||
- "<PLAID_CLIENT_ID>"
|
||||
Plaid-Version:
|
||||
- '2020-09-14'
|
||||
Plaid-Secret:
|
||||
- "<PLAID_SECRET>"
|
||||
Accept-Encoding:
|
||||
- gzip;q=1.0,deflate;q=0.6,identity;q=0.3
|
||||
response:
|
||||
status:
|
||||
code: 200
|
||||
message: OK
|
||||
headers:
|
||||
Server:
|
||||
- nginx
|
||||
Date:
|
||||
- Mon, 19 May 2025 17:24:03 GMT
|
||||
Content-Type:
|
||||
- application/json; charset=utf-8
|
||||
Content-Length:
|
||||
- '110'
|
||||
Connection:
|
||||
- keep-alive
|
||||
Plaid-Version:
|
||||
- '2020-09-14'
|
||||
Vary:
|
||||
- Accept-Encoding
|
||||
X-Envoy-Upstream-Service-Time:
|
||||
- '2892'
|
||||
X-Envoy-Decorator-Operation:
|
||||
- default.svc-apiv2:8080/*
|
||||
Strict-Transport-Security:
|
||||
- max-age=31536000; includeSubDomains; preload
|
||||
X-Content-Type-Options:
|
||||
- nosniff
|
||||
X-Frame-Options:
|
||||
- DENY
|
||||
X-Xss-Protection:
|
||||
- 1; mode=block
|
||||
body:
|
||||
encoding: ASCII-8BIT
|
||||
string: |-
|
||||
{
|
||||
"public_token": "public-sandbox-0463cb9d-8bdb-4e01-9b33-243e1370623c",
|
||||
"request_id": "FaSopSLAyHsZM9O"
|
||||
}
|
||||
recorded_at: Mon, 19 May 2025 17:24:03 GMT
|
||||
- request:
|
||||
method: post
|
||||
uri: https://sandbox.plaid.com/item/public_token/exchange
|
||||
body:
|
||||
encoding: UTF-8
|
||||
string: '{"public_token":"public-sandbox-0463cb9d-8bdb-4e01-9b33-243e1370623c"}'
|
||||
headers:
|
||||
Content-Type:
|
||||
- application/json
|
||||
User-Agent:
|
||||
- Plaid Ruby v38.0.0
|
||||
Accept:
|
||||
- application/json
|
||||
Plaid-Client-Id:
|
||||
- "<PLAID_CLIENT_ID>"
|
||||
Plaid-Version:
|
||||
- '2020-09-14'
|
||||
Plaid-Secret:
|
||||
- "<PLAID_SECRET>"
|
||||
Accept-Encoding:
|
||||
- gzip;q=1.0,deflate;q=0.6,identity;q=0.3
|
||||
response:
|
||||
status:
|
||||
code: 200
|
||||
message: OK
|
||||
headers:
|
||||
Server:
|
||||
- nginx
|
||||
Date:
|
||||
- Mon, 19 May 2025 17:24:03 GMT
|
||||
Content-Type:
|
||||
- application/json; charset=utf-8
|
||||
Content-Length:
|
||||
- '164'
|
||||
Connection:
|
||||
- keep-alive
|
||||
Plaid-Version:
|
||||
- '2020-09-14'
|
||||
Vary:
|
||||
- Accept-Encoding
|
||||
X-Envoy-Upstream-Service-Time:
|
||||
- '171'
|
||||
X-Envoy-Decorator-Operation:
|
||||
- default.svc-apiv2:8080/*
|
||||
Strict-Transport-Security:
|
||||
- max-age=31536000; includeSubDomains; preload
|
||||
X-Content-Type-Options:
|
||||
- nosniff
|
||||
X-Frame-Options:
|
||||
- DENY
|
||||
X-Xss-Protection:
|
||||
- 1; mode=block
|
||||
body:
|
||||
encoding: ASCII-8BIT
|
||||
string: |-
|
||||
{
|
||||
"access_token": "access-sandbox-0af2c971-41a6-4fc5-a97d-f2b27ab0a648",
|
||||
"item_id": "n7XKpjRmDkHENymaBw7rU71wxQnrW4i6DDrQP",
|
||||
"request_id": "2e1nOnm2CoOXVcH"
|
||||
}
|
||||
recorded_at: Mon, 19 May 2025 17:24:03 GMT
|
||||
recorded_with: VCR 6.3.1
|
124
test/vcr_cassettes/plaid/exchange_public_token.yml
Normal file
124
test/vcr_cassettes/plaid/exchange_public_token.yml
Normal file
|
@ -0,0 +1,124 @@
|
|||
---
|
||||
http_interactions:
|
||||
- request:
|
||||
method: post
|
||||
uri: https://sandbox.plaid.com/sandbox/public_token/create
|
||||
body:
|
||||
encoding: UTF-8
|
||||
string: '{"institution_id":"ins_109508","initial_products":["transactions","investments","liabilities"],"options":{"override_username":"custom_test"}}'
|
||||
headers:
|
||||
Content-Type:
|
||||
- application/json
|
||||
User-Agent:
|
||||
- Plaid Ruby v38.0.0
|
||||
Accept:
|
||||
- application/json
|
||||
Plaid-Client-Id:
|
||||
- "<PLAID_CLIENT_ID>"
|
||||
Plaid-Version:
|
||||
- '2020-09-14'
|
||||
Plaid-Secret:
|
||||
- "<PLAID_SECRET>"
|
||||
Accept-Encoding:
|
||||
- gzip;q=1.0,deflate;q=0.6,identity;q=0.3
|
||||
response:
|
||||
status:
|
||||
code: 200
|
||||
message: OK
|
||||
headers:
|
||||
Server:
|
||||
- nginx
|
||||
Date:
|
||||
- Mon, 19 May 2025 17:24:09 GMT
|
||||
Content-Type:
|
||||
- application/json; charset=utf-8
|
||||
Content-Length:
|
||||
- '110'
|
||||
Connection:
|
||||
- keep-alive
|
||||
Plaid-Version:
|
||||
- '2020-09-14'
|
||||
Vary:
|
||||
- Accept-Encoding
|
||||
X-Envoy-Upstream-Service-Time:
|
||||
- '3086'
|
||||
X-Envoy-Decorator-Operation:
|
||||
- default.svc-apiv2:8080/*
|
||||
Strict-Transport-Security:
|
||||
- max-age=31536000; includeSubDomains; preload
|
||||
X-Content-Type-Options:
|
||||
- nosniff
|
||||
X-Frame-Options:
|
||||
- DENY
|
||||
X-Xss-Protection:
|
||||
- 1; mode=block
|
||||
body:
|
||||
encoding: ASCII-8BIT
|
||||
string: |-
|
||||
{
|
||||
"public_token": "public-sandbox-29a5644f-001d-4bf5-abae-d26ecf8ee211",
|
||||
"request_id": "6dz2Xo7zoyT9W9R"
|
||||
}
|
||||
recorded_at: Mon, 19 May 2025 17:24:09 GMT
|
||||
- request:
|
||||
method: post
|
||||
uri: https://sandbox.plaid.com/item/public_token/exchange
|
||||
body:
|
||||
encoding: UTF-8
|
||||
string: '{"public_token":"public-sandbox-29a5644f-001d-4bf5-abae-d26ecf8ee211"}'
|
||||
headers:
|
||||
Content-Type:
|
||||
- application/json
|
||||
User-Agent:
|
||||
- Plaid Ruby v38.0.0
|
||||
Accept:
|
||||
- application/json
|
||||
Plaid-Client-Id:
|
||||
- "<PLAID_CLIENT_ID>"
|
||||
Plaid-Version:
|
||||
- '2020-09-14'
|
||||
Plaid-Secret:
|
||||
- "<PLAID_SECRET>"
|
||||
Accept-Encoding:
|
||||
- gzip;q=1.0,deflate;q=0.6,identity;q=0.3
|
||||
response:
|
||||
status:
|
||||
code: 200
|
||||
message: OK
|
||||
headers:
|
||||
Server:
|
||||
- nginx
|
||||
Date:
|
||||
- Mon, 19 May 2025 17:24:09 GMT
|
||||
Content-Type:
|
||||
- application/json; charset=utf-8
|
||||
Content-Length:
|
||||
- '164'
|
||||
Connection:
|
||||
- keep-alive
|
||||
Plaid-Version:
|
||||
- '2020-09-14'
|
||||
Vary:
|
||||
- Accept-Encoding
|
||||
X-Envoy-Upstream-Service-Time:
|
||||
- '152'
|
||||
X-Envoy-Decorator-Operation:
|
||||
- default.svc-apiv2:8080/*
|
||||
Strict-Transport-Security:
|
||||
- max-age=31536000; includeSubDomains; preload
|
||||
X-Content-Type-Options:
|
||||
- nosniff
|
||||
X-Frame-Options:
|
||||
- DENY
|
||||
X-Xss-Protection:
|
||||
- 1; mode=block
|
||||
body:
|
||||
encoding: ASCII-8BIT
|
||||
string: |-
|
||||
{
|
||||
"access_token": "access-sandbox-fb7bb5da-e3e2-464e-8644-4eeafbf6541f",
|
||||
"item_id": "bd9d3lAbjqhWyRz7bl61s9R7npPJ87HVzAyvn",
|
||||
"request_id": "GqA99rziFZduKYg"
|
||||
}
|
||||
recorded_at: Mon, 19 May 2025 17:24:09 GMT
|
||||
recorded_with: VCR 6.3.1
|
106
test/vcr_cassettes/plaid/get_item.yml
Normal file
106
test/vcr_cassettes/plaid/get_item.yml
Normal file
|
@ -0,0 +1,106 @@
|
|||
---
|
||||
http_interactions:
|
||||
- request:
|
||||
method: post
|
||||
uri: https://sandbox.plaid.com/item/get
|
||||
body:
|
||||
encoding: UTF-8
|
||||
string: '{"access_token":"access-sandbox-0af2c971-41a6-4fc5-a97d-f2b27ab0a648"}'
|
||||
headers:
|
||||
Content-Type:
|
||||
- application/json
|
||||
User-Agent:
|
||||
- Plaid Ruby v38.0.0
|
||||
Accept:
|
||||
- application/json
|
||||
Plaid-Client-Id:
|
||||
- "<PLAID_CLIENT_ID>"
|
||||
Plaid-Version:
|
||||
- '2020-09-14'
|
||||
Plaid-Secret:
|
||||
- "<PLAID_SECRET>"
|
||||
Accept-Encoding:
|
||||
- gzip;q=1.0,deflate;q=0.6,identity;q=0.3
|
||||
response:
|
||||
status:
|
||||
code: 200
|
||||
message: OK
|
||||
headers:
|
||||
Server:
|
||||
- nginx
|
||||
Date:
|
||||
- Mon, 19 May 2025 17:24:03 GMT
|
||||
Content-Type:
|
||||
- application/json; charset=utf-8
|
||||
Content-Length:
|
||||
- '1050'
|
||||
Connection:
|
||||
- keep-alive
|
||||
Plaid-Version:
|
||||
- '2020-09-14'
|
||||
Vary:
|
||||
- Accept-Encoding
|
||||
X-Envoy-Upstream-Service-Time:
|
||||
- '157'
|
||||
X-Envoy-Decorator-Operation:
|
||||
- default.svc-apiv2:8080/*
|
||||
Strict-Transport-Security:
|
||||
- max-age=31536000; includeSubDomains; preload
|
||||
X-Content-Type-Options:
|
||||
- nosniff
|
||||
X-Frame-Options:
|
||||
- DENY
|
||||
X-Xss-Protection:
|
||||
- 1; mode=block
|
||||
body:
|
||||
encoding: ASCII-8BIT
|
||||
string: |-
|
||||
{
|
||||
"item": {
|
||||
"available_products": [
|
||||
"assets",
|
||||
"auth",
|
||||
"balance",
|
||||
"credit_details",
|
||||
"identity",
|
||||
"identity_match",
|
||||
"income",
|
||||
"income_verification",
|
||||
"recurring_transactions",
|
||||
"signal",
|
||||
"statements"
|
||||
],
|
||||
"billed_products": [
|
||||
"investments",
|
||||
"liabilities",
|
||||
"transactions"
|
||||
],
|
||||
"consent_expiration_time": null,
|
||||
"created_at": "2025-05-19T17:24:00Z",
|
||||
"error": null,
|
||||
"institution_id": "ins_109508",
|
||||
"institution_name": "First Platypus Bank",
|
||||
"item_id": "n7XKpjRmDkHENymaBw7rU71wxQnrW4i6DDrQP",
|
||||
"products": [
|
||||
"investments",
|
||||
"liabilities",
|
||||
"transactions"
|
||||
],
|
||||
"update_type": "background",
|
||||
"webhook": ""
|
||||
},
|
||||
"request_id": "dpcY8geAZ93oxJm",
|
||||
"status": {
|
||||
"investments": {
|
||||
"last_failed_update": null,
|
||||
"last_successful_update": "2025-05-19T17:24:01.861Z"
|
||||
},
|
||||
"last_webhook": null,
|
||||
"transactions": {
|
||||
"last_failed_update": null,
|
||||
"last_successful_update": null
|
||||
}
|
||||
}
|
||||
}
|
||||
recorded_at: Mon, 19 May 2025 17:24:03 GMT
|
||||
recorded_with: VCR 6.3.1
|
160
test/vcr_cassettes/plaid/get_item_accounts.yml
Normal file
160
test/vcr_cassettes/plaid/get_item_accounts.yml
Normal file
|
@ -0,0 +1,160 @@
|
|||
---
|
||||
http_interactions:
|
||||
- request:
|
||||
method: post
|
||||
uri: https://sandbox.plaid.com/accounts/get
|
||||
body:
|
||||
encoding: UTF-8
|
||||
string: '{"access_token":"access-sandbox-0af2c971-41a6-4fc5-a97d-f2b27ab0a648"}'
|
||||
headers:
|
||||
Content-Type:
|
||||
- application/json
|
||||
User-Agent:
|
||||
- Plaid Ruby v38.0.0
|
||||
Accept:
|
||||
- application/json
|
||||
Plaid-Client-Id:
|
||||
- "<PLAID_CLIENT_ID>"
|
||||
Plaid-Version:
|
||||
- '2020-09-14'
|
||||
Plaid-Secret:
|
||||
- "<PLAID_SECRET>"
|
||||
Accept-Encoding:
|
||||
- gzip;q=1.0,deflate;q=0.6,identity;q=0.3
|
||||
response:
|
||||
status:
|
||||
code: 200
|
||||
message: OK
|
||||
headers:
|
||||
Server:
|
||||
- nginx
|
||||
Date:
|
||||
- Mon, 19 May 2025 17:24:04 GMT
|
||||
Content-Type:
|
||||
- application/json; charset=utf-8
|
||||
Content-Length:
|
||||
- '2578'
|
||||
Connection:
|
||||
- keep-alive
|
||||
Plaid-Version:
|
||||
- '2020-09-14'
|
||||
Vary:
|
||||
- Accept-Encoding
|
||||
X-Envoy-Upstream-Service-Time:
|
||||
- '191'
|
||||
X-Envoy-Decorator-Operation:
|
||||
- default.svc-apiv2:8080/*
|
||||
Strict-Transport-Security:
|
||||
- max-age=31536000; includeSubDomains; preload
|
||||
X-Content-Type-Options:
|
||||
- nosniff
|
||||
X-Frame-Options:
|
||||
- DENY
|
||||
X-Xss-Protection:
|
||||
- 1; mode=block
|
||||
body:
|
||||
encoding: ASCII-8BIT
|
||||
string: |-
|
||||
{
|
||||
"accounts": [
|
||||
{
|
||||
"account_id": "vor45kgbDjfqa1BMD8QRU4om8adWNWtqbzQJe",
|
||||
"balances": {
|
||||
"available": 8000,
|
||||
"current": 10000,
|
||||
"iso_currency_code": "USD",
|
||||
"limit": null,
|
||||
"unofficial_currency_code": null
|
||||
},
|
||||
"holder_category": "personal",
|
||||
"mask": "1122",
|
||||
"name": "Test Brokerage Account",
|
||||
"official_name": "Plaid brokerage",
|
||||
"subtype": "brokerage",
|
||||
"type": "investment"
|
||||
},
|
||||
{
|
||||
"account_id": "RperV9wJMNiDWKljGMkPCkwDGJb7q7FaNlVMp",
|
||||
"balances": {
|
||||
"available": 9372.38,
|
||||
"current": 1000,
|
||||
"iso_currency_code": "USD",
|
||||
"limit": 10500,
|
||||
"unofficial_currency_code": null
|
||||
},
|
||||
"holder_category": "personal",
|
||||
"mask": "1219",
|
||||
"name": "Test Credit Card Account",
|
||||
"official_name": "Plaid credit card",
|
||||
"subtype": "credit card",
|
||||
"type": "credit"
|
||||
},
|
||||
{
|
||||
"account_id": "9mvxVZRW7LUD67QbEBm1CPZ6XlqkmkF4oGNBo",
|
||||
"balances": {
|
||||
"available": 10000,
|
||||
"current": 10000,
|
||||
"iso_currency_code": "USD",
|
||||
"limit": null,
|
||||
"unofficial_currency_code": null
|
||||
},
|
||||
"holder_category": "personal",
|
||||
"mask": "4243",
|
||||
"name": "Test Depository Account",
|
||||
"official_name": "Plaid checking",
|
||||
"subtype": "checking",
|
||||
"type": "depository"
|
||||
},
|
||||
{
|
||||
"account_id": "6Gwzm7nJ6ZU4VbqEyKzZszyPQ8keRet8Q97k7",
|
||||
"balances": {
|
||||
"available": 15000,
|
||||
"current": 15000,
|
||||
"iso_currency_code": "USD",
|
||||
"limit": null,
|
||||
"unofficial_currency_code": null
|
||||
},
|
||||
"holder_category": "personal",
|
||||
"mask": "9572",
|
||||
"name": "Test Student Loan Account",
|
||||
"official_name": "Plaid student",
|
||||
"subtype": "student",
|
||||
"type": "loan"
|
||||
}
|
||||
],
|
||||
"item": {
|
||||
"available_products": [
|
||||
"assets",
|
||||
"auth",
|
||||
"balance",
|
||||
"credit_details",
|
||||
"identity",
|
||||
"identity_match",
|
||||
"income",
|
||||
"income_verification",
|
||||
"recurring_transactions",
|
||||
"signal",
|
||||
"statements"
|
||||
],
|
||||
"billed_products": [
|
||||
"investments",
|
||||
"liabilities",
|
||||
"transactions"
|
||||
],
|
||||
"consent_expiration_time": null,
|
||||
"error": null,
|
||||
"institution_id": "ins_109508",
|
||||
"institution_name": "First Platypus Bank",
|
||||
"item_id": "n7XKpjRmDkHENymaBw7rU71wxQnrW4i6DDrQP",
|
||||
"products": [
|
||||
"investments",
|
||||
"liabilities",
|
||||
"transactions"
|
||||
],
|
||||
"update_type": "background",
|
||||
"webhook": ""
|
||||
},
|
||||
"request_id": "EWD5MMMYV0o9cZ0"
|
||||
}
|
||||
recorded_at: Mon, 19 May 2025 17:24:04 GMT
|
||||
recorded_with: VCR 6.3.1
|
570
test/vcr_cassettes/plaid/get_item_investments.yml
Normal file
570
test/vcr_cassettes/plaid/get_item_investments.yml
Normal file
|
@ -0,0 +1,570 @@
|
|||
---
|
||||
http_interactions:
|
||||
- request:
|
||||
method: post
|
||||
uri: https://sandbox.plaid.com/investments/holdings/get
|
||||
body:
|
||||
encoding: UTF-8
|
||||
string: '{"access_token":"access-sandbox-0af2c971-41a6-4fc5-a97d-f2b27ab0a648"}'
|
||||
headers:
|
||||
Content-Type:
|
||||
- application/json
|
||||
User-Agent:
|
||||
- Plaid Ruby v38.0.0
|
||||
Accept:
|
||||
- application/json
|
||||
Plaid-Client-Id:
|
||||
- "<PLAID_CLIENT_ID>"
|
||||
Plaid-Version:
|
||||
- '2020-09-14'
|
||||
Plaid-Secret:
|
||||
- "<PLAID_SECRET>"
|
||||
Accept-Encoding:
|
||||
- gzip;q=1.0,deflate;q=0.6,identity;q=0.3
|
||||
response:
|
||||
status:
|
||||
code: 200
|
||||
message: OK
|
||||
headers:
|
||||
Server:
|
||||
- nginx
|
||||
Date:
|
||||
- Mon, 19 May 2025 17:24:05 GMT
|
||||
Content-Type:
|
||||
- application/json; charset=utf-8
|
||||
Content-Length:
|
||||
- '6199'
|
||||
Connection:
|
||||
- keep-alive
|
||||
Plaid-Version:
|
||||
- '2020-09-14'
|
||||
Vary:
|
||||
- Accept-Encoding
|
||||
X-Envoy-Upstream-Service-Time:
|
||||
- '324'
|
||||
X-Envoy-Decorator-Operation:
|
||||
- default.svc-apiv2:8080/*
|
||||
Strict-Transport-Security:
|
||||
- max-age=31536000; includeSubDomains; preload
|
||||
X-Content-Type-Options:
|
||||
- nosniff
|
||||
X-Frame-Options:
|
||||
- DENY
|
||||
X-Xss-Protection:
|
||||
- 1; mode=block
|
||||
body:
|
||||
encoding: ASCII-8BIT
|
||||
string: |-
|
||||
{
|
||||
"accounts": [
|
||||
{
|
||||
"account_id": "vor45kgbDjfqa1BMD8QRU4om8adWNWtqbzQJe",
|
||||
"balances": {
|
||||
"available": 8000,
|
||||
"current": 10000,
|
||||
"iso_currency_code": "USD",
|
||||
"limit": null,
|
||||
"unofficial_currency_code": null
|
||||
},
|
||||
"holder_category": "personal",
|
||||
"mask": "1122",
|
||||
"name": "Test Brokerage Account",
|
||||
"official_name": "Plaid brokerage",
|
||||
"subtype": "brokerage",
|
||||
"type": "investment"
|
||||
},
|
||||
{
|
||||
"account_id": "RperV9wJMNiDWKljGMkPCkwDGJb7q7FaNlVMp",
|
||||
"balances": {
|
||||
"available": 9372.38,
|
||||
"current": 1000,
|
||||
"iso_currency_code": "USD",
|
||||
"limit": 10500,
|
||||
"unofficial_currency_code": null
|
||||
},
|
||||
"holder_category": "personal",
|
||||
"mask": "1219",
|
||||
"name": "Test Credit Card Account",
|
||||
"official_name": "Plaid credit card",
|
||||
"subtype": "credit card",
|
||||
"type": "credit"
|
||||
},
|
||||
{
|
||||
"account_id": "9mvxVZRW7LUD67QbEBm1CPZ6XlqkmkF4oGNBo",
|
||||
"balances": {
|
||||
"available": 10000,
|
||||
"current": 10000,
|
||||
"iso_currency_code": "USD",
|
||||
"limit": null,
|
||||
"unofficial_currency_code": null
|
||||
},
|
||||
"holder_category": "personal",
|
||||
"mask": "4243",
|
||||
"name": "Test Depository Account",
|
||||
"official_name": "Plaid checking",
|
||||
"subtype": "checking",
|
||||
"type": "depository"
|
||||
},
|
||||
{
|
||||
"account_id": "6Gwzm7nJ6ZU4VbqEyKzZszyPQ8keRet8Q97k7",
|
||||
"balances": {
|
||||
"available": 15000,
|
||||
"current": 15000,
|
||||
"iso_currency_code": "USD",
|
||||
"limit": null,
|
||||
"unofficial_currency_code": null
|
||||
},
|
||||
"holder_category": "personal",
|
||||
"mask": "9572",
|
||||
"name": "Test Student Loan Account",
|
||||
"official_name": "Plaid student",
|
||||
"subtype": "student",
|
||||
"type": "loan"
|
||||
}
|
||||
],
|
||||
"holdings": [
|
||||
{
|
||||
"account_id": "vor45kgbDjfqa1BMD8QRU4om8adWNWtqbzQJe",
|
||||
"cost_basis": 2000,
|
||||
"institution_price": 100,
|
||||
"institution_price_as_of": "2025-05-08",
|
||||
"institution_price_datetime": null,
|
||||
"institution_value": 2000,
|
||||
"iso_currency_code": "USD",
|
||||
"quantity": 20,
|
||||
"security_id": "xnL3QM3Ax4fP9lVmNLblTDaMkVMqN3fmxrXRd",
|
||||
"unofficial_currency_code": null,
|
||||
"vested_quantity": null,
|
||||
"vested_value": null
|
||||
},
|
||||
{
|
||||
"account_id": "vor45kgbDjfqa1BMD8QRU4om8adWNWtqbzQJe",
|
||||
"cost_basis": 3000,
|
||||
"institution_price": 1,
|
||||
"institution_price_as_of": "2025-05-08",
|
||||
"institution_price_datetime": null,
|
||||
"institution_value": 3000,
|
||||
"iso_currency_code": "USD",
|
||||
"quantity": 3000,
|
||||
"security_id": "EDRxkXxj7mtj8EzBBxllUqpy7KyNDBugoZrMX",
|
||||
"unofficial_currency_code": null,
|
||||
"vested_quantity": null,
|
||||
"vested_value": null
|
||||
},
|
||||
{
|
||||
"account_id": "vor45kgbDjfqa1BMD8QRU4om8adWNWtqbzQJe",
|
||||
"cost_basis": 5000,
|
||||
"institution_price": 1,
|
||||
"institution_price_as_of": "2025-05-08",
|
||||
"institution_price_datetime": null,
|
||||
"institution_value": 5000,
|
||||
"iso_currency_code": "USD",
|
||||
"quantity": 5000,
|
||||
"security_id": "7Dv19k16PZtEaexk6EZyFxP95o9ynrF4REalG",
|
||||
"unofficial_currency_code": null,
|
||||
"vested_quantity": null,
|
||||
"vested_value": null
|
||||
}
|
||||
],
|
||||
"item": {
|
||||
"available_products": [
|
||||
"assets",
|
||||
"auth",
|
||||
"balance",
|
||||
"credit_details",
|
||||
"identity",
|
||||
"identity_match",
|
||||
"income",
|
||||
"income_verification",
|
||||
"recurring_transactions",
|
||||
"signal",
|
||||
"statements"
|
||||
],
|
||||
"billed_products": [
|
||||
"investments",
|
||||
"liabilities",
|
||||
"transactions"
|
||||
],
|
||||
"consent_expiration_time": null,
|
||||
"error": null,
|
||||
"institution_id": "ins_109508",
|
||||
"institution_name": "First Platypus Bank",
|
||||
"item_id": "n7XKpjRmDkHENymaBw7rU71wxQnrW4i6DDrQP",
|
||||
"products": [
|
||||
"investments",
|
||||
"liabilities",
|
||||
"transactions"
|
||||
],
|
||||
"update_type": "background",
|
||||
"webhook": ""
|
||||
},
|
||||
"request_id": "uRzq5c4Y37RCNNj",
|
||||
"securities": [
|
||||
{
|
||||
"close_price": 1,
|
||||
"close_price_as_of": "2025-04-28",
|
||||
"cusip": null,
|
||||
"fixed_income": null,
|
||||
"industry": "Investment Trusts or Mutual Funds",
|
||||
"institution_id": null,
|
||||
"institution_security_id": null,
|
||||
"is_cash_equivalent": true,
|
||||
"isin": null,
|
||||
"iso_currency_code": "USD",
|
||||
"market_identifier_code": null,
|
||||
"name": "Vanguard Money Market Reserves - Federal Money Market Fd USD MNT",
|
||||
"option_contract": null,
|
||||
"proxy_security_id": null,
|
||||
"sector": "Miscellaneous",
|
||||
"security_id": "7Dv19k16PZtEaexk6EZyFxP95o9ynrF4REalG",
|
||||
"sedol": "2571678",
|
||||
"ticker_symbol": "VMFXX",
|
||||
"type": "mutual fund",
|
||||
"unofficial_currency_code": null,
|
||||
"update_datetime": null
|
||||
},
|
||||
{
|
||||
"close_price": 1,
|
||||
"close_price_as_of": "2025-05-18",
|
||||
"cusip": null,
|
||||
"fixed_income": null,
|
||||
"industry": null,
|
||||
"institution_id": null,
|
||||
"institution_security_id": null,
|
||||
"is_cash_equivalent": true,
|
||||
"isin": null,
|
||||
"iso_currency_code": "USD",
|
||||
"market_identifier_code": null,
|
||||
"name": "U S Dollar",
|
||||
"option_contract": null,
|
||||
"proxy_security_id": null,
|
||||
"sector": null,
|
||||
"security_id": "EDRxkXxj7mtj8EzBBxllUqpy7KyNDBugoZrMX",
|
||||
"sedol": null,
|
||||
"ticker_symbol": "CUR:USD",
|
||||
"type": "cash",
|
||||
"unofficial_currency_code": null,
|
||||
"update_datetime": null
|
||||
},
|
||||
{
|
||||
"close_price": 211.26,
|
||||
"close_price_as_of": "2025-05-16",
|
||||
"cusip": null,
|
||||
"fixed_income": null,
|
||||
"industry": "Telecommunications Equipment",
|
||||
"institution_id": null,
|
||||
"institution_security_id": null,
|
||||
"is_cash_equivalent": false,
|
||||
"isin": null,
|
||||
"iso_currency_code": "USD",
|
||||
"market_identifier_code": "XNAS",
|
||||
"name": "Apple Inc",
|
||||
"option_contract": null,
|
||||
"proxy_security_id": null,
|
||||
"sector": "Electronic Technology",
|
||||
"security_id": "xnL3QM3Ax4fP9lVmNLblTDaMkVMqN3fmxrXRd",
|
||||
"sedol": "2046251",
|
||||
"ticker_symbol": "AAPL",
|
||||
"type": "equity",
|
||||
"unofficial_currency_code": null,
|
||||
"update_datetime": null
|
||||
}
|
||||
]
|
||||
}
|
||||
recorded_at: Mon, 19 May 2025 17:24:05 GMT
|
||||
- request:
|
||||
method: post
|
||||
uri: https://sandbox.plaid.com/investments/transactions/get
|
||||
body:
|
||||
encoding: UTF-8
|
||||
string: '{"access_token":"access-sandbox-0af2c971-41a6-4fc5-a97d-f2b27ab0a648","start_date":"2023-05-20","end_date":"2025-05-19","options":{"offset":0}}'
|
||||
headers:
|
||||
Content-Type:
|
||||
- application/json
|
||||
User-Agent:
|
||||
- Plaid Ruby v38.0.0
|
||||
Accept:
|
||||
- application/json
|
||||
Plaid-Client-Id:
|
||||
- "<PLAID_CLIENT_ID>"
|
||||
Plaid-Version:
|
||||
- '2020-09-14'
|
||||
Plaid-Secret:
|
||||
- "<PLAID_SECRET>"
|
||||
Accept-Encoding:
|
||||
- gzip;q=1.0,deflate;q=0.6,identity;q=0.3
|
||||
response:
|
||||
status:
|
||||
code: 200
|
||||
message: OK
|
||||
headers:
|
||||
Server:
|
||||
- nginx
|
||||
Date:
|
||||
- Mon, 19 May 2025 17:24:05 GMT
|
||||
Content-Type:
|
||||
- application/json; charset=utf-8
|
||||
Content-Length:
|
||||
- '6964'
|
||||
Connection:
|
||||
- keep-alive
|
||||
Plaid-Version:
|
||||
- '2020-09-14'
|
||||
Vary:
|
||||
- Accept-Encoding
|
||||
X-Envoy-Upstream-Service-Time:
|
||||
- '334'
|
||||
X-Envoy-Decorator-Operation:
|
||||
- default.svc-apiv2:8080/*
|
||||
Strict-Transport-Security:
|
||||
- max-age=31536000; includeSubDomains; preload
|
||||
X-Content-Type-Options:
|
||||
- nosniff
|
||||
X-Frame-Options:
|
||||
- DENY
|
||||
X-Xss-Protection:
|
||||
- 1; mode=block
|
||||
body:
|
||||
encoding: ASCII-8BIT
|
||||
string: |-
|
||||
{
|
||||
"accounts": [
|
||||
{
|
||||
"account_id": "vor45kgbDjfqa1BMD8QRU4om8adWNWtqbzQJe",
|
||||
"balances": {
|
||||
"available": 8000,
|
||||
"current": 10000,
|
||||
"iso_currency_code": "USD",
|
||||
"limit": null,
|
||||
"unofficial_currency_code": null
|
||||
},
|
||||
"holder_category": "personal",
|
||||
"mask": "1122",
|
||||
"name": "Test Brokerage Account",
|
||||
"official_name": "Plaid brokerage",
|
||||
"subtype": "brokerage",
|
||||
"type": "investment"
|
||||
},
|
||||
{
|
||||
"account_id": "RperV9wJMNiDWKljGMkPCkwDGJb7q7FaNlVMp",
|
||||
"balances": {
|
||||
"available": 9372.38,
|
||||
"current": 1000,
|
||||
"iso_currency_code": "USD",
|
||||
"limit": 10500,
|
||||
"unofficial_currency_code": null
|
||||
},
|
||||
"holder_category": "personal",
|
||||
"mask": "1219",
|
||||
"name": "Test Credit Card Account",
|
||||
"official_name": "Plaid credit card",
|
||||
"subtype": "credit card",
|
||||
"type": "credit"
|
||||
},
|
||||
{
|
||||
"account_id": "9mvxVZRW7LUD67QbEBm1CPZ6XlqkmkF4oGNBo",
|
||||
"balances": {
|
||||
"available": 10000,
|
||||
"current": 10000,
|
||||
"iso_currency_code": "USD",
|
||||
"limit": null,
|
||||
"unofficial_currency_code": null
|
||||
},
|
||||
"holder_category": "personal",
|
||||
"mask": "4243",
|
||||
"name": "Test Depository Account",
|
||||
"official_name": "Plaid checking",
|
||||
"subtype": "checking",
|
||||
"type": "depository"
|
||||
},
|
||||
{
|
||||
"account_id": "6Gwzm7nJ6ZU4VbqEyKzZszyPQ8keRet8Q97k7",
|
||||
"balances": {
|
||||
"available": 15000,
|
||||
"current": 15000,
|
||||
"iso_currency_code": "USD",
|
||||
"limit": null,
|
||||
"unofficial_currency_code": null
|
||||
},
|
||||
"holder_category": "personal",
|
||||
"mask": "9572",
|
||||
"name": "Test Student Loan Account",
|
||||
"official_name": "Plaid student",
|
||||
"subtype": "student",
|
||||
"type": "loan"
|
||||
}
|
||||
],
|
||||
"investment_transactions": [
|
||||
{
|
||||
"account_id": "vor45kgbDjfqa1BMD8QRU4om8adWNWtqbzQJe",
|
||||
"amount": -5000,
|
||||
"cancel_transaction_id": null,
|
||||
"date": "2025-05-03",
|
||||
"fees": 0,
|
||||
"investment_transaction_id": "eBqoazM4XkiXx5gZbmD7UKRZ3jE3ABUreq4R1",
|
||||
"iso_currency_code": "USD",
|
||||
"name": "retirement contribution",
|
||||
"price": 1,
|
||||
"quantity": -5000,
|
||||
"security_id": "EDRxkXxj7mtj8EzBBxllUqpy7KyNDBugoZrMX",
|
||||
"subtype": "contribution",
|
||||
"type": "cash",
|
||||
"unofficial_currency_code": null
|
||||
},
|
||||
{
|
||||
"account_id": "vor45kgbDjfqa1BMD8QRU4om8adWNWtqbzQJe",
|
||||
"amount": 5000,
|
||||
"cancel_transaction_id": null,
|
||||
"date": "2025-05-03",
|
||||
"fees": 0,
|
||||
"investment_transaction_id": "QLeKVkpQM4ck1qMRGp6PUPp7obKowGtwRN547",
|
||||
"iso_currency_code": "USD",
|
||||
"name": "buy money market shares with contribution cash",
|
||||
"price": 1,
|
||||
"quantity": 5000,
|
||||
"security_id": "7Dv19k16PZtEaexk6EZyFxP95o9ynrF4REalG",
|
||||
"subtype": "contribution",
|
||||
"type": "buy",
|
||||
"unofficial_currency_code": null
|
||||
},
|
||||
{
|
||||
"account_id": "vor45kgbDjfqa1BMD8QRU4om8adWNWtqbzQJe",
|
||||
"amount": 2000,
|
||||
"cancel_transaction_id": null,
|
||||
"date": "2025-05-02",
|
||||
"fees": 0,
|
||||
"investment_transaction_id": "ZnxNgJEwM1ig5476JqZxUKeJLXNLnMUe9o6Al",
|
||||
"iso_currency_code": "USD",
|
||||
"name": "buy AAPL stock",
|
||||
"price": 100,
|
||||
"quantity": 20,
|
||||
"security_id": "xnL3QM3Ax4fP9lVmNLblTDaMkVMqN3fmxrXRd",
|
||||
"subtype": "buy",
|
||||
"type": "buy",
|
||||
"unofficial_currency_code": null
|
||||
},
|
||||
{
|
||||
"account_id": "vor45kgbDjfqa1BMD8QRU4om8adWNWtqbzQJe",
|
||||
"amount": -5000,
|
||||
"cancel_transaction_id": null,
|
||||
"date": "2025-05-01",
|
||||
"fees": 0,
|
||||
"investment_transaction_id": "MQ1Awmg943IKyWlQjRXgUqXrxD6xo3CLGjJw1",
|
||||
"iso_currency_code": "USD",
|
||||
"name": "Deposit cash into brokerage account",
|
||||
"price": 1,
|
||||
"quantity": -5000,
|
||||
"security_id": "EDRxkXxj7mtj8EzBBxllUqpy7KyNDBugoZrMX",
|
||||
"subtype": "deposit",
|
||||
"type": "cash",
|
||||
"unofficial_currency_code": null
|
||||
}
|
||||
],
|
||||
"item": {
|
||||
"available_products": [
|
||||
"assets",
|
||||
"auth",
|
||||
"balance",
|
||||
"credit_details",
|
||||
"identity",
|
||||
"identity_match",
|
||||
"income",
|
||||
"income_verification",
|
||||
"recurring_transactions",
|
||||
"signal",
|
||||
"statements"
|
||||
],
|
||||
"billed_products": [
|
||||
"investments",
|
||||
"liabilities",
|
||||
"transactions"
|
||||
],
|
||||
"consent_expiration_time": null,
|
||||
"error": null,
|
||||
"institution_id": "ins_109508",
|
||||
"institution_name": "First Platypus Bank",
|
||||
"item_id": "n7XKpjRmDkHENymaBw7rU71wxQnrW4i6DDrQP",
|
||||
"products": [
|
||||
"investments",
|
||||
"liabilities",
|
||||
"transactions"
|
||||
],
|
||||
"update_type": "background",
|
||||
"webhook": ""
|
||||
},
|
||||
"request_id": "dTc49uKiBZWzxHS",
|
||||
"securities": [
|
||||
{
|
||||
"close_price": 1,
|
||||
"close_price_as_of": "2025-04-28",
|
||||
"cusip": null,
|
||||
"fixed_income": null,
|
||||
"industry": "Investment Trusts or Mutual Funds",
|
||||
"institution_id": null,
|
||||
"institution_security_id": null,
|
||||
"is_cash_equivalent": true,
|
||||
"isin": null,
|
||||
"iso_currency_code": "USD",
|
||||
"market_identifier_code": null,
|
||||
"name": "Vanguard Money Market Reserves - Federal Money Market Fd USD MNT",
|
||||
"option_contract": null,
|
||||
"proxy_security_id": null,
|
||||
"sector": "Miscellaneous",
|
||||
"security_id": "7Dv19k16PZtEaexk6EZyFxP95o9ynrF4REalG",
|
||||
"sedol": "2571678",
|
||||
"ticker_symbol": "VMFXX",
|
||||
"type": "mutual fund",
|
||||
"unofficial_currency_code": null,
|
||||
"update_datetime": null
|
||||
},
|
||||
{
|
||||
"close_price": 1,
|
||||
"close_price_as_of": "2025-05-18",
|
||||
"cusip": null,
|
||||
"fixed_income": null,
|
||||
"industry": null,
|
||||
"institution_id": null,
|
||||
"institution_security_id": null,
|
||||
"is_cash_equivalent": true,
|
||||
"isin": null,
|
||||
"iso_currency_code": "USD",
|
||||
"market_identifier_code": null,
|
||||
"name": "U S Dollar",
|
||||
"option_contract": null,
|
||||
"proxy_security_id": null,
|
||||
"sector": null,
|
||||
"security_id": "EDRxkXxj7mtj8EzBBxllUqpy7KyNDBugoZrMX",
|
||||
"sedol": null,
|
||||
"ticker_symbol": "CUR:USD",
|
||||
"type": "cash",
|
||||
"unofficial_currency_code": null,
|
||||
"update_datetime": null
|
||||
},
|
||||
{
|
||||
"close_price": 211.26,
|
||||
"close_price_as_of": "2025-05-16",
|
||||
"cusip": null,
|
||||
"fixed_income": null,
|
||||
"industry": "Telecommunications Equipment",
|
||||
"institution_id": null,
|
||||
"institution_security_id": null,
|
||||
"is_cash_equivalent": false,
|
||||
"isin": null,
|
||||
"iso_currency_code": "USD",
|
||||
"market_identifier_code": "XNAS",
|
||||
"name": "Apple Inc",
|
||||
"option_contract": null,
|
||||
"proxy_security_id": null,
|
||||
"sector": "Electronic Technology",
|
||||
"security_id": "xnL3QM3Ax4fP9lVmNLblTDaMkVMqN3fmxrXRd",
|
||||
"sedol": "2046251",
|
||||
"ticker_symbol": "AAPL",
|
||||
"type": "equity",
|
||||
"unofficial_currency_code": null,
|
||||
"update_datetime": null
|
||||
}
|
||||
],
|
||||
"total_investment_transactions": 4
|
||||
}
|
||||
recorded_at: Mon, 19 May 2025 17:24:05 GMT
|
||||
recorded_with: VCR 6.3.1
|
236
test/vcr_cassettes/plaid/get_item_liabilities.yml
Normal file
236
test/vcr_cassettes/plaid/get_item_liabilities.yml
Normal file
|
@ -0,0 +1,236 @@
|
|||
---
|
||||
http_interactions:
|
||||
- request:
|
||||
method: post
|
||||
uri: https://sandbox.plaid.com/liabilities/get
|
||||
body:
|
||||
encoding: UTF-8
|
||||
string: '{"access_token":"access-sandbox-0af2c971-41a6-4fc5-a97d-f2b27ab0a648"}'
|
||||
headers:
|
||||
Content-Type:
|
||||
- application/json
|
||||
User-Agent:
|
||||
- Plaid Ruby v38.0.0
|
||||
Accept:
|
||||
- application/json
|
||||
Plaid-Client-Id:
|
||||
- "<PLAID_CLIENT_ID>"
|
||||
Plaid-Version:
|
||||
- '2020-09-14'
|
||||
Plaid-Secret:
|
||||
- "<PLAID_SECRET>"
|
||||
Accept-Encoding:
|
||||
- gzip;q=1.0,deflate;q=0.6,identity;q=0.3
|
||||
response:
|
||||
status:
|
||||
code: 200
|
||||
message: OK
|
||||
headers:
|
||||
Server:
|
||||
- nginx
|
||||
Date:
|
||||
- Mon, 19 May 2025 17:24:04 GMT
|
||||
Content-Type:
|
||||
- application/json; charset=utf-8
|
||||
Content-Length:
|
||||
- '4907'
|
||||
Connection:
|
||||
- keep-alive
|
||||
Plaid-Version:
|
||||
- '2020-09-14'
|
||||
Vary:
|
||||
- Accept-Encoding
|
||||
X-Envoy-Upstream-Service-Time:
|
||||
- '253'
|
||||
X-Envoy-Decorator-Operation:
|
||||
- default.svc-apiv2:8080/*
|
||||
Strict-Transport-Security:
|
||||
- max-age=31536000; includeSubDomains; preload
|
||||
X-Content-Type-Options:
|
||||
- nosniff
|
||||
X-Frame-Options:
|
||||
- DENY
|
||||
X-Xss-Protection:
|
||||
- 1; mode=block
|
||||
body:
|
||||
encoding: ASCII-8BIT
|
||||
string: |-
|
||||
{
|
||||
"accounts": [
|
||||
{
|
||||
"account_id": "vor45kgbDjfqa1BMD8QRU4om8adWNWtqbzQJe",
|
||||
"balances": {
|
||||
"available": 8000,
|
||||
"current": 10000,
|
||||
"iso_currency_code": "USD",
|
||||
"limit": null,
|
||||
"unofficial_currency_code": null
|
||||
},
|
||||
"holder_category": "personal",
|
||||
"mask": "1122",
|
||||
"name": "Test Brokerage Account",
|
||||
"official_name": "Plaid brokerage",
|
||||
"subtype": "brokerage",
|
||||
"type": "investment"
|
||||
},
|
||||
{
|
||||
"account_id": "RperV9wJMNiDWKljGMkPCkwDGJb7q7FaNlVMp",
|
||||
"balances": {
|
||||
"available": 9372.38,
|
||||
"current": 1000,
|
||||
"iso_currency_code": "USD",
|
||||
"limit": 10500,
|
||||
"unofficial_currency_code": null
|
||||
},
|
||||
"holder_category": "personal",
|
||||
"mask": "1219",
|
||||
"name": "Test Credit Card Account",
|
||||
"official_name": "Plaid credit card",
|
||||
"subtype": "credit card",
|
||||
"type": "credit"
|
||||
},
|
||||
{
|
||||
"account_id": "9mvxVZRW7LUD67QbEBm1CPZ6XlqkmkF4oGNBo",
|
||||
"balances": {
|
||||
"available": 10000,
|
||||
"current": 10000,
|
||||
"iso_currency_code": "USD",
|
||||
"limit": null,
|
||||
"unofficial_currency_code": null
|
||||
},
|
||||
"holder_category": "personal",
|
||||
"mask": "4243",
|
||||
"name": "Test Depository Account",
|
||||
"official_name": "Plaid checking",
|
||||
"subtype": "checking",
|
||||
"type": "depository"
|
||||
},
|
||||
{
|
||||
"account_id": "6Gwzm7nJ6ZU4VbqEyKzZszyPQ8keRet8Q97k7",
|
||||
"balances": {
|
||||
"available": 15000,
|
||||
"current": 15000,
|
||||
"iso_currency_code": "USD",
|
||||
"limit": null,
|
||||
"unofficial_currency_code": null
|
||||
},
|
||||
"holder_category": "personal",
|
||||
"mask": "9572",
|
||||
"name": "Test Student Loan Account",
|
||||
"official_name": "Plaid student",
|
||||
"subtype": "student",
|
||||
"type": "loan"
|
||||
}
|
||||
],
|
||||
"item": {
|
||||
"available_products": [
|
||||
"assets",
|
||||
"auth",
|
||||
"balance",
|
||||
"credit_details",
|
||||
"identity",
|
||||
"identity_match",
|
||||
"income",
|
||||
"income_verification",
|
||||
"recurring_transactions",
|
||||
"signal",
|
||||
"statements"
|
||||
],
|
||||
"billed_products": [
|
||||
"investments",
|
||||
"liabilities",
|
||||
"transactions"
|
||||
],
|
||||
"consent_expiration_time": null,
|
||||
"error": null,
|
||||
"institution_id": "ins_109508",
|
||||
"institution_name": "First Platypus Bank",
|
||||
"item_id": "n7XKpjRmDkHENymaBw7rU71wxQnrW4i6DDrQP",
|
||||
"products": [
|
||||
"investments",
|
||||
"liabilities",
|
||||
"transactions"
|
||||
],
|
||||
"update_type": "background",
|
||||
"webhook": ""
|
||||
},
|
||||
"liabilities": {
|
||||
"credit": [
|
||||
{
|
||||
"account_id": "RperV9wJMNiDWKljGMkPCkwDGJb7q7FaNlVMp",
|
||||
"aprs": [
|
||||
{
|
||||
"apr_percentage": 12.5,
|
||||
"apr_type": "purchase_apr",
|
||||
"balance_subject_to_apr": null,
|
||||
"interest_charge_amount": null
|
||||
},
|
||||
{
|
||||
"apr_percentage": 27.95,
|
||||
"apr_type": "cash_apr",
|
||||
"balance_subject_to_apr": null,
|
||||
"interest_charge_amount": null
|
||||
}
|
||||
],
|
||||
"is_overdue": false,
|
||||
"last_payment_amount": null,
|
||||
"last_payment_date": "2025-04-24",
|
||||
"last_statement_balance": 1000,
|
||||
"last_statement_issue_date": "2025-05-19",
|
||||
"minimum_payment_amount": 50,
|
||||
"next_payment_due_date": "2025-06-19"
|
||||
}
|
||||
],
|
||||
"mortgage": null,
|
||||
"student": [
|
||||
{
|
||||
"account_id": "6Gwzm7nJ6ZU4VbqEyKzZszyPQ8keRet8Q97k7",
|
||||
"account_number": "3117529572",
|
||||
"disbursement_dates": [
|
||||
"2023-05-01"
|
||||
],
|
||||
"expected_payoff_date": "2036-05-01",
|
||||
"guarantor": "DEPT OF ED",
|
||||
"interest_rate_percentage": 5.25,
|
||||
"is_overdue": false,
|
||||
"last_payment_amount": null,
|
||||
"last_payment_date": null,
|
||||
"last_statement_balance": 16577.16,
|
||||
"last_statement_issue_date": "2025-05-01",
|
||||
"loan_name": "Consolidation",
|
||||
"loan_status": {
|
||||
"end_date": null,
|
||||
"type": "in school"
|
||||
},
|
||||
"minimum_payment_amount": 25,
|
||||
"next_payment_due_date": "2025-06-01",
|
||||
"origination_date": "2023-05-01",
|
||||
"origination_principal_amount": 15000,
|
||||
"outstanding_interest_amount": 1577.16,
|
||||
"payment_reference_number": "3117529572",
|
||||
"pslf_status": {
|
||||
"estimated_eligibility_date": null,
|
||||
"payments_made": null,
|
||||
"payments_remaining": null
|
||||
},
|
||||
"repayment_plan": {
|
||||
"description": "Standard Repayment",
|
||||
"type": "standard"
|
||||
},
|
||||
"sequence_number": "1",
|
||||
"servicer_address": {
|
||||
"city": "San Matias",
|
||||
"country": "US",
|
||||
"postal_code": "99415",
|
||||
"region": "CA",
|
||||
"street": "123 Relaxation Road"
|
||||
},
|
||||
"ytd_interest_paid": 0,
|
||||
"ytd_principal_paid": 0
|
||||
}
|
||||
]
|
||||
},
|
||||
"request_id": "nFlL291sKIy1LkJ"
|
||||
}
|
||||
recorded_at: Mon, 19 May 2025 17:24:04 GMT
|
||||
recorded_with: VCR 6.3.1
|
64
test/vcr_cassettes/plaid/link_token.yml
Normal file
64
test/vcr_cassettes/plaid/link_token.yml
Normal file
|
@ -0,0 +1,64 @@
|
|||
---
|
||||
http_interactions:
|
||||
- request:
|
||||
method: post
|
||||
uri: https://sandbox.plaid.com/link/token/create
|
||||
body:
|
||||
encoding: UTF-8
|
||||
string: '{"client_name":"Maybe Finance","language":"en","country_codes":["US","CA"],"user":{"client_user_id":"test-user-id"},"products":["transactions"],"additional_consented_products":["investments","liabilities"],"webhook":"https://example.com/webhooks","redirect_uri":"http://localhost:3000/accounts","transactions":{"days_requested":730}}'
|
||||
headers:
|
||||
Content-Type:
|
||||
- application/json
|
||||
User-Agent:
|
||||
- Plaid Ruby v38.0.0
|
||||
Accept:
|
||||
- application/json
|
||||
Plaid-Client-Id:
|
||||
- "<PLAID_CLIENT_ID>"
|
||||
Plaid-Version:
|
||||
- '2020-09-14'
|
||||
Plaid-Secret:
|
||||
- "<PLAID_SECRET>"
|
||||
Accept-Encoding:
|
||||
- gzip;q=1.0,deflate;q=0.6,identity;q=0.3
|
||||
response:
|
||||
status:
|
||||
code: 200
|
||||
message: OK
|
||||
headers:
|
||||
Server:
|
||||
- nginx
|
||||
Date:
|
||||
- Mon, 19 May 2025 17:24:04 GMT
|
||||
Content-Type:
|
||||
- application/json; charset=utf-8
|
||||
Content-Length:
|
||||
- '146'
|
||||
Connection:
|
||||
- keep-alive
|
||||
Plaid-Version:
|
||||
- '2020-09-14'
|
||||
Vary:
|
||||
- Accept-Encoding
|
||||
X-Envoy-Upstream-Service-Time:
|
||||
- '70'
|
||||
X-Envoy-Decorator-Operation:
|
||||
- default.svc-apiv2:8080/*
|
||||
Strict-Transport-Security:
|
||||
- max-age=31536000; includeSubDomains; preload
|
||||
X-Content-Type-Options:
|
||||
- nosniff
|
||||
X-Frame-Options:
|
||||
- DENY
|
||||
X-Xss-Protection:
|
||||
- 1; mode=block
|
||||
body:
|
||||
encoding: ASCII-8BIT
|
||||
string: |-
|
||||
{
|
||||
"expiration": "2025-05-19T21:24:04Z",
|
||||
"link_token": "link-sandbox-33432e02-32e2-415d-8f00-e626c6f4c6a6",
|
||||
"request_id": "Gys5pGY7tIPDrlL"
|
||||
}
|
||||
recorded_at: Mon, 19 May 2025 17:24:04 GMT
|
||||
recorded_with: VCR 6.3.1
|
Loading…
Add table
Add a link
Reference in a new issue