mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-08-05 05:25:24 +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
|
@ -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
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue