mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-08-04 21:15:19 +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
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
|
109
app/models/plaid_account/transactions/category_matcher.rb
Normal file
109
app/models/plaid_account/transactions/category_matcher.rb
Normal file
|
@ -0,0 +1,109 @@
|
|||
# The purpose of this matcher is to auto-match Plaid categories to
|
||||
# known internal user categories. Since we allow users to define their own
|
||||
# categories we cannot directly assign Plaid categories as this would overwrite
|
||||
# user data and create a confusing experience.
|
||||
#
|
||||
# Automated category matching in the Maybe app has a hierarchy:
|
||||
# 1. Naive string matching via CategoryAliasMatcher
|
||||
# 2. Rules-based matching set by user
|
||||
# 3. AI-powered matching (also enabled by user via rules)
|
||||
#
|
||||
# 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 PlaidAccount::Transactions::CategoryMatcher
|
||||
include PlaidAccount::Transactions::CategoryTaxonomy
|
||||
|
||||
def initialize(user_categories = [])
|
||||
@user_categories = user_categories
|
||||
end
|
||||
|
||||
def match(plaid_detailed_category)
|
||||
plaid_category_details = get_plaid_category_details(plaid_detailed_category)
|
||||
return nil unless plaid_category_details
|
||||
|
||||
# Try exact name matches first
|
||||
exact_match = normalized_user_categories.find do |category|
|
||||
category[:name] == plaid_category_details[:key].to_s
|
||||
end
|
||||
return user_categories.find { |c| c.id == exact_match[:id] } if exact_match
|
||||
|
||||
# Try detailed aliases matches with fuzzy matching
|
||||
alias_match = normalized_user_categories.find do |category|
|
||||
name = category[:name]
|
||||
plaid_category_details[:aliases].any? do |a|
|
||||
alias_str = a.to_s
|
||||
|
||||
# Try exact match
|
||||
next true if name == alias_str
|
||||
|
||||
# Try plural forms
|
||||
next true if name.singularize == alias_str || name.pluralize == alias_str
|
||||
next true if alias_str.singularize == name || alias_str.pluralize == name
|
||||
|
||||
# Try common forms
|
||||
normalized_name = name.gsub(/(and|&|\s+)/, "").strip
|
||||
normalized_alias = alias_str.gsub(/(and|&|\s+)/, "").strip
|
||||
normalized_name == normalized_alias
|
||||
end
|
||||
end
|
||||
return user_categories.find { |c| c.id == alias_match[:id] } if alias_match
|
||||
|
||||
# Try parent aliases matches with fuzzy matching
|
||||
parent_match = normalized_user_categories.find do |category|
|
||||
name = category[:name]
|
||||
plaid_category_details[:parent_aliases].any? do |a|
|
||||
alias_str = a.to_s
|
||||
|
||||
# Try exact match
|
||||
next true if name == alias_str
|
||||
|
||||
# Try plural forms
|
||||
next true if name.singularize == alias_str || name.pluralize == alias_str
|
||||
next true if alias_str.singularize == name || alias_str.pluralize == name
|
||||
|
||||
# Try common forms
|
||||
normalized_name = name.gsub(/(and|&|\s+)/, "").strip
|
||||
normalized_alias = alias_str.gsub(/(and|&|\s+)/, "").strip
|
||||
normalized_name == normalized_alias
|
||||
end
|
||||
end
|
||||
return user_categories.find { |c| c.id == parent_match[:id] } if parent_match
|
||||
|
||||
nil
|
||||
end
|
||||
|
||||
private
|
||||
attr_reader :user_categories
|
||||
|
||||
def get_plaid_category_details(plaid_category_name)
|
||||
detailed_plaid_categories.find { |c| c[:key] == plaid_category_name.downcase.to_sym }
|
||||
end
|
||||
|
||||
def detailed_plaid_categories
|
||||
CATEGORIES_MAP.flat_map do |parent_key, parent_data|
|
||||
parent_data[:detailed_categories].map do |child_key, child_data|
|
||||
{
|
||||
key: child_key,
|
||||
classification: child_data[:classification],
|
||||
aliases: child_data[:aliases],
|
||||
parent_key: parent_key,
|
||||
parent_aliases: parent_data[:aliases]
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def normalized_user_categories
|
||||
user_categories.map do |user_category|
|
||||
{
|
||||
id: user_category.id,
|
||||
classification: user_category.classification,
|
||||
name: normalize_user_category_name(user_category.name)
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def normalize_user_category_name(name)
|
||||
name.to_s.downcase.gsub(/[^a-z0-9]/, " ").strip
|
||||
end
|
||||
end
|
461
app/models/plaid_account/transactions/category_taxonomy.rb
Normal file
461
app/models/plaid_account/transactions/category_taxonomy.rb
Normal file
|
@ -0,0 +1,461 @@
|
|||
# https://plaid.com/documents/transactions-personal-finance-category-taxonomy.csv
|
||||
module PlaidAccount::Transactions::CategoryTaxonomy
|
||||
CATEGORIES_MAP = {
|
||||
income: {
|
||||
classification: :income,
|
||||
aliases: [ "income", "revenue", "earnings" ],
|
||||
detailed_categories: {
|
||||
income_dividends: {
|
||||
classification: :income,
|
||||
aliases: [ "dividend", "stock income", "dividend income", "dividend earnings" ]
|
||||
},
|
||||
income_interest_earned: {
|
||||
classification: :income,
|
||||
aliases: [ "interest", "bank interest", "interest earned", "interest income" ]
|
||||
},
|
||||
income_retirement_pension: {
|
||||
classification: :income,
|
||||
aliases: [ "retirement", "pension" ]
|
||||
},
|
||||
income_tax_refund: {
|
||||
classification: :income,
|
||||
aliases: [ "tax refund" ]
|
||||
},
|
||||
income_unemployment: {
|
||||
classification: :income,
|
||||
aliases: [ "unemployment" ]
|
||||
},
|
||||
income_wages: {
|
||||
classification: :income,
|
||||
aliases: [ "wage", "salary", "paycheck" ]
|
||||
},
|
||||
income_other_income: {
|
||||
classification: :income,
|
||||
aliases: [ "other income", "misc income" ]
|
||||
}
|
||||
}
|
||||
},
|
||||
loan_payments: {
|
||||
classification: :expense,
|
||||
aliases: [ "loan payment", "debt payment", "loan", "debt", "payment" ],
|
||||
detailed_categories: {
|
||||
loan_payments_car_payment: {
|
||||
classification: :expense,
|
||||
aliases: [ "car payment", "auto loan" ]
|
||||
},
|
||||
loan_payments_credit_card_payment: {
|
||||
classification: :expense,
|
||||
aliases: [ "credit card", "card payment" ]
|
||||
},
|
||||
loan_payments_personal_loan_payment: {
|
||||
classification: :expense,
|
||||
aliases: [ "personal loan", "loan payment" ]
|
||||
},
|
||||
loan_payments_mortgage_payment: {
|
||||
classification: :expense,
|
||||
aliases: [ "mortgage", "home loan" ]
|
||||
},
|
||||
loan_payments_student_loan_payment: {
|
||||
classification: :expense,
|
||||
aliases: [ "student loan", "education loan" ]
|
||||
},
|
||||
loan_payments_other_payment: {
|
||||
classification: :expense,
|
||||
aliases: [ "loan", "loan payment" ]
|
||||
}
|
||||
}
|
||||
},
|
||||
bank_fees: {
|
||||
classification: :expense,
|
||||
aliases: [ "bank fee", "service charge", "fee", "misc fees" ],
|
||||
detailed_categories: {
|
||||
bank_fees_atm_fees: {
|
||||
classification: :expense,
|
||||
aliases: [ "atm fee", "withdrawal fee" ]
|
||||
},
|
||||
bank_fees_foreign_transaction_fees: {
|
||||
classification: :expense,
|
||||
aliases: [ "foreign fee", "international fee" ]
|
||||
},
|
||||
bank_fees_insufficient_funds: {
|
||||
classification: :expense,
|
||||
aliases: [ "nsf fee", "overdraft" ]
|
||||
},
|
||||
bank_fees_interest_charge: {
|
||||
classification: :expense,
|
||||
aliases: [ "interest charge", "finance charge" ]
|
||||
},
|
||||
bank_fees_overdraft_fees: {
|
||||
classification: :expense,
|
||||
aliases: [ "overdraft fee" ]
|
||||
},
|
||||
bank_fees_other_bank_fees: {
|
||||
classification: :expense,
|
||||
aliases: [ "bank fee", "service charge" ]
|
||||
}
|
||||
}
|
||||
},
|
||||
entertainment: {
|
||||
classification: :expense,
|
||||
aliases: [ "entertainment", "recreation" ],
|
||||
detailed_categories: {
|
||||
entertainment_casinos_and_gambling: {
|
||||
classification: :expense,
|
||||
aliases: [ "casino", "gambling" ]
|
||||
},
|
||||
entertainment_music_and_audio: {
|
||||
classification: :expense,
|
||||
aliases: [ "music", "concert" ]
|
||||
},
|
||||
entertainment_sporting_events_amusement_parks_and_museums: {
|
||||
classification: :expense,
|
||||
aliases: [ "event", "amusement", "museum" ]
|
||||
},
|
||||
entertainment_tv_and_movies: {
|
||||
classification: :expense,
|
||||
aliases: [ "movie", "streaming" ]
|
||||
},
|
||||
entertainment_video_games: {
|
||||
classification: :expense,
|
||||
aliases: [ "game", "gaming" ]
|
||||
},
|
||||
entertainment_other_entertainment: {
|
||||
classification: :expense,
|
||||
aliases: [ "entertainment", "recreation" ]
|
||||
}
|
||||
}
|
||||
},
|
||||
food_and_drink: {
|
||||
classification: :expense,
|
||||
aliases: [ "food", "dining", "food and drink", "food & drink" ],
|
||||
detailed_categories: {
|
||||
food_and_drink_beer_wine_and_liquor: {
|
||||
classification: :expense,
|
||||
aliases: [ "alcohol", "liquor", "beer", "wine", "bar", "pub" ]
|
||||
},
|
||||
food_and_drink_coffee: {
|
||||
classification: :expense,
|
||||
aliases: [ "coffee", "cafe", "coffee shop" ]
|
||||
},
|
||||
food_and_drink_fast_food: {
|
||||
classification: :expense,
|
||||
aliases: [ "fast food", "takeout" ]
|
||||
},
|
||||
food_and_drink_groceries: {
|
||||
classification: :expense,
|
||||
aliases: [ "grocery", "supermarket", "grocery store" ]
|
||||
},
|
||||
food_and_drink_restaurant: {
|
||||
classification: :expense,
|
||||
aliases: [ "restaurant", "dining" ]
|
||||
},
|
||||
food_and_drink_vending_machines: {
|
||||
classification: :expense,
|
||||
aliases: [ "vending" ]
|
||||
},
|
||||
food_and_drink_other_food_and_drink: {
|
||||
classification: :expense,
|
||||
aliases: [ "food", "drink" ]
|
||||
}
|
||||
}
|
||||
},
|
||||
general_merchandise: {
|
||||
classification: :expense,
|
||||
aliases: [ "shopping", "retail" ],
|
||||
detailed_categories: {
|
||||
general_merchandise_bookstores_and_newsstands: {
|
||||
classification: :expense,
|
||||
aliases: [ "book", "newsstand" ]
|
||||
},
|
||||
general_merchandise_clothing_and_accessories: {
|
||||
classification: :expense,
|
||||
aliases: [ "clothing", "apparel" ]
|
||||
},
|
||||
general_merchandise_convenience_stores: {
|
||||
classification: :expense,
|
||||
aliases: [ "convenience" ]
|
||||
},
|
||||
general_merchandise_department_stores: {
|
||||
classification: :expense,
|
||||
aliases: [ "department store" ]
|
||||
},
|
||||
general_merchandise_discount_stores: {
|
||||
classification: :expense,
|
||||
aliases: [ "discount store" ]
|
||||
},
|
||||
general_merchandise_electronics: {
|
||||
classification: :expense,
|
||||
aliases: [ "electronic", "computer" ]
|
||||
},
|
||||
general_merchandise_gifts_and_novelties: {
|
||||
classification: :expense,
|
||||
aliases: [ "gift", "souvenir" ]
|
||||
},
|
||||
general_merchandise_office_supplies: {
|
||||
classification: :expense,
|
||||
aliases: [ "office supply" ]
|
||||
},
|
||||
general_merchandise_online_marketplaces: {
|
||||
classification: :expense,
|
||||
aliases: [ "online shopping" ]
|
||||
},
|
||||
general_merchandise_pet_supplies: {
|
||||
classification: :expense,
|
||||
aliases: [ "pet supply", "pet food" ]
|
||||
},
|
||||
general_merchandise_sporting_goods: {
|
||||
classification: :expense,
|
||||
aliases: [ "sporting good", "sport" ]
|
||||
},
|
||||
general_merchandise_superstores: {
|
||||
classification: :expense,
|
||||
aliases: [ "superstore", "retail" ]
|
||||
},
|
||||
general_merchandise_tobacco_and_vape: {
|
||||
classification: :expense,
|
||||
aliases: [ "tobacco", "smoke" ]
|
||||
},
|
||||
general_merchandise_other_general_merchandise: {
|
||||
classification: :expense,
|
||||
aliases: [ "shopping", "merchandise" ]
|
||||
}
|
||||
}
|
||||
},
|
||||
home_improvement: {
|
||||
classification: :expense,
|
||||
aliases: [ "home", "house", "house renovation", "home improvement", "renovation" ],
|
||||
detailed_categories: {
|
||||
home_improvement_furniture: {
|
||||
classification: :expense,
|
||||
aliases: [ "furniture", "furnishing" ]
|
||||
},
|
||||
home_improvement_hardware: {
|
||||
classification: :expense,
|
||||
aliases: [ "hardware", "tool" ]
|
||||
},
|
||||
home_improvement_repair_and_maintenance: {
|
||||
classification: :expense,
|
||||
aliases: [ "repair", "maintenance" ]
|
||||
},
|
||||
home_improvement_security: {
|
||||
classification: :expense,
|
||||
aliases: [ "security", "alarm" ]
|
||||
},
|
||||
home_improvement_other_home_improvement: {
|
||||
classification: :expense,
|
||||
aliases: [ "home improvement", "renovation" ]
|
||||
}
|
||||
}
|
||||
},
|
||||
medical: {
|
||||
classification: :expense,
|
||||
aliases: [ "medical", "healthcare", "health" ],
|
||||
detailed_categories: {
|
||||
medical_dental_care: {
|
||||
classification: :expense,
|
||||
aliases: [ "dental", "dentist" ]
|
||||
},
|
||||
medical_eye_care: {
|
||||
classification: :expense,
|
||||
aliases: [ "eye", "optometrist" ]
|
||||
},
|
||||
medical_nursing_care: {
|
||||
classification: :expense,
|
||||
aliases: [ "nursing", "care" ]
|
||||
},
|
||||
medical_pharmacies_and_supplements: {
|
||||
classification: :expense,
|
||||
aliases: [ "pharmacy", "prescription" ]
|
||||
},
|
||||
medical_primary_care: {
|
||||
classification: :expense,
|
||||
aliases: [ "doctor", "medical" ]
|
||||
},
|
||||
medical_veterinary_services: {
|
||||
classification: :expense,
|
||||
aliases: [ "vet", "veterinary" ]
|
||||
},
|
||||
medical_other_medical: {
|
||||
classification: :expense,
|
||||
aliases: [ "medical", "healthcare" ]
|
||||
}
|
||||
}
|
||||
},
|
||||
personal_care: {
|
||||
classification: :expense,
|
||||
aliases: [ "personal care", "grooming" ],
|
||||
detailed_categories: {
|
||||
personal_care_gyms_and_fitness_centers: {
|
||||
classification: :expense,
|
||||
aliases: [ "gym", "fitness", "exercise", "sport" ]
|
||||
},
|
||||
personal_care_hair_and_beauty: {
|
||||
classification: :expense,
|
||||
aliases: [ "salon", "beauty" ]
|
||||
},
|
||||
personal_care_laundry_and_dry_cleaning: {
|
||||
classification: :expense,
|
||||
aliases: [ "laundry", "cleaning" ]
|
||||
},
|
||||
personal_care_other_personal_care: {
|
||||
classification: :expense,
|
||||
aliases: [ "personal care", "grooming" ]
|
||||
}
|
||||
}
|
||||
},
|
||||
general_services: {
|
||||
classification: :expense,
|
||||
aliases: [ "service", "professional service" ],
|
||||
detailed_categories: {
|
||||
general_services_accounting_and_financial_planning: {
|
||||
classification: :expense,
|
||||
aliases: [ "accountant", "financial advisor" ]
|
||||
},
|
||||
general_services_automotive: {
|
||||
classification: :expense,
|
||||
aliases: [ "auto repair", "mechanic", "vehicle", "car", "car care", "car maintenance", "vehicle maintenance" ]
|
||||
},
|
||||
general_services_childcare: {
|
||||
classification: :expense,
|
||||
aliases: [ "childcare", "daycare" ]
|
||||
},
|
||||
general_services_consulting_and_legal: {
|
||||
classification: :expense,
|
||||
aliases: [ "legal", "attorney" ]
|
||||
},
|
||||
general_services_education: {
|
||||
classification: :expense,
|
||||
aliases: [ "education", "tuition" ]
|
||||
},
|
||||
general_services_insurance: {
|
||||
classification: :expense,
|
||||
aliases: [ "insurance", "premium" ]
|
||||
},
|
||||
general_services_postage_and_shipping: {
|
||||
classification: :expense,
|
||||
aliases: [ "shipping", "postage" ]
|
||||
},
|
||||
general_services_storage: {
|
||||
classification: :expense,
|
||||
aliases: [ "storage" ]
|
||||
},
|
||||
general_services_other_general_services: {
|
||||
classification: :expense,
|
||||
aliases: [ "service" ]
|
||||
}
|
||||
}
|
||||
},
|
||||
government_and_non_profit: {
|
||||
classification: :expense,
|
||||
aliases: [ "government", "non-profit" ],
|
||||
detailed_categories: {
|
||||
government_and_non_profit_donations: {
|
||||
classification: :expense,
|
||||
aliases: [ "donation", "charity", "charitable", "charitable donation", "giving", "gifts and donations", "gifts & donations" ]
|
||||
},
|
||||
government_and_non_profit_government_departments_and_agencies: {
|
||||
classification: :expense,
|
||||
aliases: [ "government", "agency" ]
|
||||
},
|
||||
government_and_non_profit_tax_payment: {
|
||||
classification: :expense,
|
||||
aliases: [ "tax payment", "tax" ]
|
||||
},
|
||||
government_and_non_profit_other_government_and_non_profit: {
|
||||
classification: :expense,
|
||||
aliases: [ "government", "non-profit" ]
|
||||
}
|
||||
}
|
||||
},
|
||||
transportation: {
|
||||
classification: :expense,
|
||||
aliases: [ "transportation", "travel" ],
|
||||
detailed_categories: {
|
||||
transportation_bikes_and_scooters: {
|
||||
classification: :expense,
|
||||
aliases: [ "bike", "scooter" ]
|
||||
},
|
||||
transportation_gas: {
|
||||
classification: :expense,
|
||||
aliases: [ "gas", "fuel" ]
|
||||
},
|
||||
transportation_parking: {
|
||||
classification: :expense,
|
||||
aliases: [ "parking" ]
|
||||
},
|
||||
transportation_public_transit: {
|
||||
classification: :expense,
|
||||
aliases: [ "transit", "bus" ]
|
||||
},
|
||||
transportation_taxis_and_ride_shares: {
|
||||
classification: :expense,
|
||||
aliases: [ "taxi", "rideshare" ]
|
||||
},
|
||||
transportation_tolls: {
|
||||
classification: :expense,
|
||||
aliases: [ "toll" ]
|
||||
},
|
||||
transportation_other_transportation: {
|
||||
classification: :expense,
|
||||
aliases: [ "transportation", "travel" ]
|
||||
}
|
||||
}
|
||||
},
|
||||
travel: {
|
||||
classification: :expense,
|
||||
aliases: [ "travel", "vacation", "trip", "sabbatical" ],
|
||||
detailed_categories: {
|
||||
travel_flights: {
|
||||
classification: :expense,
|
||||
aliases: [ "flight", "airfare" ]
|
||||
},
|
||||
travel_lodging: {
|
||||
classification: :expense,
|
||||
aliases: [ "hotel", "lodging" ]
|
||||
},
|
||||
travel_rental_cars: {
|
||||
classification: :expense,
|
||||
aliases: [ "rental car" ]
|
||||
},
|
||||
travel_other_travel: {
|
||||
classification: :expense,
|
||||
aliases: [ "travel", "trip" ]
|
||||
}
|
||||
}
|
||||
},
|
||||
rent_and_utilities: {
|
||||
classification: :expense,
|
||||
aliases: [ "utilities", "housing", "house", "home", "rent", "rent & utilities" ],
|
||||
detailed_categories: {
|
||||
rent_and_utilities_gas_and_electricity: {
|
||||
classification: :expense,
|
||||
aliases: [ "utility", "electric" ]
|
||||
},
|
||||
rent_and_utilities_internet_and_cable: {
|
||||
classification: :expense,
|
||||
aliases: [ "internet", "cable" ]
|
||||
},
|
||||
rent_and_utilities_rent: {
|
||||
classification: :expense,
|
||||
aliases: [ "rent", "lease" ]
|
||||
},
|
||||
rent_and_utilities_sewage_and_waste_management: {
|
||||
classification: :expense,
|
||||
aliases: [ "sewage", "waste" ]
|
||||
},
|
||||
rent_and_utilities_telephone: {
|
||||
classification: :expense,
|
||||
aliases: [ "phone", "telephone" ]
|
||||
},
|
||||
rent_and_utilities_water: {
|
||||
classification: :expense,
|
||||
aliases: [ "water" ]
|
||||
},
|
||||
rent_and_utilities_other_utilities: {
|
||||
classification: :expense,
|
||||
aliases: [ "utility" ]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
end
|
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
|
Loading…
Add table
Add a link
Reference in a new issue