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