mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-08-09 07:25:19 +02:00
Sync strategies
This commit is contained in:
parent
3207077f9e
commit
6ddb4d088e
19 changed files with 297 additions and 313 deletions
|
@ -1,26 +0,0 @@
|
||||||
module Account::Syncable
|
|
||||||
extend ActiveSupport::Concern
|
|
||||||
|
|
||||||
include Syncable
|
|
||||||
|
|
||||||
def sync_data(sync, start_date: nil)
|
|
||||||
Rails.logger.info("Processing balances (#{linked? ? 'reverse' : 'forward'})")
|
|
||||||
sync_balances
|
|
||||||
end
|
|
||||||
|
|
||||||
def post_sync(sync)
|
|
||||||
family.remove_syncing_notice!
|
|
||||||
|
|
||||||
accountable.post_sync(sync)
|
|
||||||
|
|
||||||
unless sync.child?
|
|
||||||
family.auto_match_transfers!
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
def sync_balances
|
|
||||||
strategy = linked? ? :reverse : :forward
|
|
||||||
Balance::Syncer.new(self, strategy: strategy).sync_balances
|
|
||||||
end
|
|
||||||
end
|
|
28
app/models/account/syncer.rb
Normal file
28
app/models/account/syncer.rb
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
class Account::Syncer
|
||||||
|
attr_reader :account
|
||||||
|
|
||||||
|
def initialize(account)
|
||||||
|
@account = account
|
||||||
|
end
|
||||||
|
|
||||||
|
def perform_sync(start_date: nil)
|
||||||
|
Rails.logger.info("Processing balances (#{account.linked? ? 'reverse' : 'forward'})")
|
||||||
|
sync_balances
|
||||||
|
end
|
||||||
|
|
||||||
|
def perform_post_sync(sync)
|
||||||
|
account.family.remove_syncing_notice!
|
||||||
|
|
||||||
|
account.accountable.post_sync(sync)
|
||||||
|
|
||||||
|
unless sync.child?
|
||||||
|
account.family.auto_match_transfers!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
def sync_balances
|
||||||
|
strategy = account.linked? ? :reverse : :forward
|
||||||
|
Balance::Syncer.new(account, strategy: strategy).sync_balances
|
||||||
|
end
|
||||||
|
end
|
|
@ -18,14 +18,6 @@ module Syncable
|
||||||
syncs.create!(start_date: start_date).perform
|
syncs.create!(start_date: start_date).perform
|
||||||
end
|
end
|
||||||
|
|
||||||
def sync_data(sync, start_date: nil)
|
|
||||||
raise NotImplementedError, "Subclasses must implement the `sync_data` method"
|
|
||||||
end
|
|
||||||
|
|
||||||
def post_sync(sync)
|
|
||||||
# no-op, syncable can optionally provide implementation
|
|
||||||
end
|
|
||||||
|
|
||||||
def sync_error
|
def sync_error
|
||||||
latest_sync&.error
|
latest_sync&.error
|
||||||
end
|
end
|
||||||
|
|
|
@ -35,6 +35,21 @@ class Family < ApplicationRecord
|
||||||
validates :locale, inclusion: { in: I18n.available_locales.map(&:to_s) }
|
validates :locale, inclusion: { in: I18n.available_locales.map(&:to_s) }
|
||||||
validates :date_format, inclusion: { in: DATE_FORMATS.map(&:last) }
|
validates :date_format, inclusion: { in: DATE_FORMATS.map(&:last) }
|
||||||
|
|
||||||
|
def remove_syncing_notice!
|
||||||
|
broadcast_remove target: "syncing-notice"
|
||||||
|
end
|
||||||
|
|
||||||
|
# If family has any syncs pending/syncing within the last 10 minutes, we show a persistent "syncing" notice.
|
||||||
|
# Ignore syncs older than 10 minutes as they are considered "stale"
|
||||||
|
def syncing?
|
||||||
|
Sync.where(
|
||||||
|
"(syncable_type = 'Family' AND syncable_id = ?) OR
|
||||||
|
(syncable_type = 'Account' AND syncable_id IN (SELECT id FROM accounts WHERE family_id = ? AND plaid_account_id IS NULL)) OR
|
||||||
|
(syncable_type = 'PlaidItem' AND syncable_id IN (SELECT id FROM plaid_items WHERE family_id = ?))",
|
||||||
|
id, id, id
|
||||||
|
).where(status: [ "pending", "syncing" ], created_at: 10.minutes.ago..).exists?
|
||||||
|
end
|
||||||
|
|
||||||
def assigned_merchants
|
def assigned_merchants
|
||||||
merchant_ids = transactions.where.not(merchant_id: nil).pluck(:merchant_id).uniq
|
merchant_ids = transactions.where.not(merchant_id: nil).pluck(:merchant_id).uniq
|
||||||
Merchant.where(id: merchant_ids)
|
Merchant.where(id: merchant_ids)
|
||||||
|
|
|
@ -72,10 +72,9 @@ module Family::Subscribeable
|
||||||
(1 - days_left_in_trial.to_f / Subscription::TRIAL_DAYS) * 100
|
(1 - days_left_in_trial.to_f / Subscription::TRIAL_DAYS) * 100
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
def sync_trial_status!
|
||||||
def sync_trial_status!
|
if subscription&.status == "trialing" && days_left_in_trial < 0
|
||||||
if subscription&.status == "trialing" && days_left_in_trial < 0
|
subscription.update!(status: "paused")
|
||||||
subscription.update!(status: "paused")
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -1,45 +0,0 @@
|
||||||
module Family::Syncable
|
|
||||||
extend ActiveSupport::Concern
|
|
||||||
|
|
||||||
include Syncable
|
|
||||||
|
|
||||||
def sync_data(sync, start_date: nil)
|
|
||||||
# We don't rely on this value to guard the app, but keep it eventually consistent
|
|
||||||
sync_trial_status!
|
|
||||||
|
|
||||||
Rails.logger.info("Syncing accounts for family #{id}")
|
|
||||||
accounts.manual.each do |account|
|
|
||||||
account.sync_later(start_date: start_date, parent_sync: sync)
|
|
||||||
end
|
|
||||||
|
|
||||||
Rails.logger.info("Syncing plaid items for family #{id}")
|
|
||||||
plaid_items.each do |plaid_item|
|
|
||||||
plaid_item.sync_later(start_date: start_date, parent_sync: sync)
|
|
||||||
end
|
|
||||||
|
|
||||||
Rails.logger.info("Applying rules for family #{id}")
|
|
||||||
rules.each do |rule|
|
|
||||||
rule.apply_later
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def remove_syncing_notice!
|
|
||||||
broadcast_remove target: "syncing-notice"
|
|
||||||
end
|
|
||||||
|
|
||||||
def post_sync(sync)
|
|
||||||
auto_match_transfers!
|
|
||||||
broadcast_refresh
|
|
||||||
end
|
|
||||||
|
|
||||||
# If family has any syncs pending/syncing within the last 10 minutes, we show a persistent "syncing" notice.
|
|
||||||
# Ignore syncs older than 10 minutes as they are considered "stale"
|
|
||||||
def syncing?
|
|
||||||
Sync.where(
|
|
||||||
"(syncable_type = 'Family' AND syncable_id = ?) OR
|
|
||||||
(syncable_type = 'Account' AND syncable_id IN (SELECT id FROM accounts WHERE family_id = ? AND plaid_account_id IS NULL)) OR
|
|
||||||
(syncable_type = 'PlaidItem' AND syncable_id IN (SELECT id FROM plaid_items WHERE family_id = ?))",
|
|
||||||
id, id, id
|
|
||||||
).where(status: [ "pending", "syncing" ], created_at: 10.minutes.ago..).exists?
|
|
||||||
end
|
|
||||||
end
|
|
32
app/models/family/syncer.rb
Normal file
32
app/models/family/syncer.rb
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
class Family::Syncer
|
||||||
|
attr_reader :family
|
||||||
|
|
||||||
|
def initialize(family)
|
||||||
|
@family = family
|
||||||
|
end
|
||||||
|
|
||||||
|
def perform_sync(sync, start_date: nil)
|
||||||
|
# We don't rely on this value to guard the app, but keep it eventually consistent
|
||||||
|
family.sync_trial_status!
|
||||||
|
|
||||||
|
Rails.logger.info("Syncing accounts for family #{family.id}")
|
||||||
|
family.accounts.manual.each do |account|
|
||||||
|
account.sync_later(start_date: start_date, parent_sync: sync)
|
||||||
|
end
|
||||||
|
|
||||||
|
Rails.logger.info("Syncing plaid items for family #{family.id}")
|
||||||
|
family.plaid_items.each do |plaid_item|
|
||||||
|
plaid_item.sync_later(start_date: start_date, parent_sync: sync)
|
||||||
|
end
|
||||||
|
|
||||||
|
Rails.logger.info("Applying rules for family #{family.id}")
|
||||||
|
family.rules.each do |rule|
|
||||||
|
rule.apply_later
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def perform_post_sync(sync)
|
||||||
|
family.auto_match_transfers!
|
||||||
|
family.broadcast_refresh
|
||||||
|
end
|
||||||
|
end
|
|
@ -1,5 +1,5 @@
|
||||||
class PlaidItem < ApplicationRecord
|
class PlaidItem < ApplicationRecord
|
||||||
include Provided, Syncable
|
include Syncable
|
||||||
|
|
||||||
enum :plaid_region, { us: "us", eu: "eu" }
|
enum :plaid_region, { us: "us", eu: "eu" }
|
||||||
enum :status, { good: "good", requires_update: "requires_update" }, default: :good
|
enum :status, { good: "good", requires_update: "requires_update" }, default: :good
|
||||||
|
@ -43,6 +43,10 @@ class PlaidItem < ApplicationRecord
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def build_category_alias_matcher(user_categories)
|
||||||
|
Provider::Plaid::CategoryAliasMatcher.new(user_categories)
|
||||||
|
end
|
||||||
|
|
||||||
def destroy_later
|
def destroy_later
|
||||||
update!(scheduled_for_deletion: true)
|
update!(scheduled_for_deletion: true)
|
||||||
DestroyJob.perform_later(self)
|
DestroyJob.perform_later(self)
|
||||||
|
@ -79,123 +83,11 @@ class PlaidItem < ApplicationRecord
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
def fetch_and_load_plaid_data(sync)
|
|
||||||
data = {}
|
|
||||||
|
|
||||||
# Log what we're about to fetch
|
|
||||||
Rails.logger.info "Starting Plaid data fetch (accounts, transactions, investments, liabilities)"
|
|
||||||
|
|
||||||
item = plaid_provider.get_item(access_token).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_provider.get_institution(item.institution_id)
|
|
||||||
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_provider.get_item_accounts(self).accounts
|
|
||||||
data[:accounts] = fetched_accounts || []
|
|
||||||
sync.update!(data: data)
|
|
||||||
Rails.logger.info "Processing Plaid accounts (count: #{fetched_accounts.size})"
|
|
||||||
|
|
||||||
internal_plaid_accounts = fetched_accounts.map do |account|
|
|
||||||
internal_plaid_account = plaid_accounts.find_or_create_from_plaid_data!(account, 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 || []
|
|
||||||
sync.update!(data: data)
|
|
||||||
|
|
||||||
if fetched_transactions
|
|
||||||
Rails.logger.info "Processing Plaid transactions (added: #{fetched_transactions.added.size}, modified: #{fetched_transactions.modified.size}, removed: #{fetched_transactions.removed.size})"
|
|
||||||
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
|
|
||||||
|
|
||||||
update!(next_cursor: fetched_transactions.cursor)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# Investments
|
|
||||||
fetched_investments = safe_fetch_plaid_data(:get_item_investments)
|
|
||||||
data[:investments] = fetched_investments || []
|
|
||||||
sync.update!(data: data)
|
|
||||||
|
|
||||||
if fetched_investments
|
|
||||||
Rails.logger.info "Processing Plaid investments (transactions: #{fetched_investments.transactions.size}, holdings: #{fetched_investments.holdings.size}, securities: #{fetched_investments.securities.size})"
|
|
||||||
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 || []
|
|
||||||
sync.update!(data: data)
|
|
||||||
|
|
||||||
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})"
|
|
||||||
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
|
|
||||||
|
|
||||||
def safe_fetch_plaid_data(method)
|
|
||||||
begin
|
|
||||||
plaid_provider.send(method, self)
|
|
||||||
rescue Plaid::ApiError => e
|
|
||||||
Rails.logger.warn("Error fetching #{method} for item #{id}: #{e.message}")
|
|
||||||
nil
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def remove_plaid_item
|
def remove_plaid_item
|
||||||
plaid_provider.remove_item(access_token)
|
plaid_provider.remove_item(access_token)
|
||||||
rescue StandardError => e
|
rescue StandardError => e
|
||||||
Rails.logger.warn("Failed to remove Plaid item #{id}: #{e.message}")
|
Rails.logger.warn("Failed to remove Plaid item #{id}: #{e.message}")
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_plaid_error(error)
|
|
||||||
error_body = JSON.parse(error.response_body)
|
|
||||||
|
|
||||||
if error_body["error_code"] == "ITEM_LOGIN_REQUIRED"
|
|
||||||
update!(status: :requires_update)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
class PlaidConnectionLostError < StandardError; end
|
class PlaidConnectionLostError < StandardError; end
|
||||||
end
|
end
|
||||||
|
|
|
@ -1,30 +0,0 @@
|
||||||
module PlaidItem::Provided
|
|
||||||
extend ActiveSupport::Concern
|
|
||||||
|
|
||||||
class_methods do
|
|
||||||
def plaid_us_provider
|
|
||||||
Provider::Registry.get_provider(:plaid_us)
|
|
||||||
end
|
|
||||||
|
|
||||||
def plaid_eu_provider
|
|
||||||
Provider::Registry.get_provider(:plaid_eu)
|
|
||||||
end
|
|
||||||
|
|
||||||
def plaid_provider_for_region(region)
|
|
||||||
region.to_sym == :eu ? plaid_eu_provider : plaid_us_provider
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def build_category_alias_matcher(user_categories)
|
|
||||||
Provider::Plaid::CategoryAliasMatcher.new(user_categories)
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
def eu?
|
|
||||||
raise "eu? is not implemented for #{self.class.name}"
|
|
||||||
end
|
|
||||||
|
|
||||||
def plaid_provider
|
|
||||||
eu? ? self.class.plaid_eu_provider : self.class.plaid_us_provider
|
|
||||||
end
|
|
||||||
end
|
|
|
@ -1,28 +0,0 @@
|
||||||
module PlaidItem::Syncable
|
|
||||||
extend ActiveSupport::Concern
|
|
||||||
|
|
||||||
include Syncable
|
|
||||||
|
|
||||||
def sync_data(sync, start_date: nil)
|
|
||||||
begin
|
|
||||||
Rails.logger.info("Fetching and loading Plaid data")
|
|
||||||
fetch_and_load_plaid_data(sync)
|
|
||||||
update!(status: :good) if requires_update?
|
|
||||||
|
|
||||||
# Schedule account syncs
|
|
||||||
accounts.each do |account|
|
|
||||||
account.sync_later(start_date: start_date, parent_sync: sync)
|
|
||||||
end
|
|
||||||
|
|
||||||
Rails.logger.info("Plaid data fetched and loaded")
|
|
||||||
rescue Plaid::ApiError => e
|
|
||||||
handle_plaid_error(e)
|
|
||||||
raise e
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def post_sync(sync)
|
|
||||||
auto_match_categories!
|
|
||||||
family.broadcast_refresh
|
|
||||||
end
|
|
||||||
end
|
|
151
app/models/plaid_item/syncer.rb
Normal file
151
app/models/plaid_item/syncer.rb
Normal file
|
@ -0,0 +1,151 @@
|
||||||
|
class PlaidItem::Syncer
|
||||||
|
attr_reader :plaid_item
|
||||||
|
|
||||||
|
def initialize(plaid_item)
|
||||||
|
@plaid_item = plaid_item
|
||||||
|
end
|
||||||
|
|
||||||
|
def perform_sync(sync, start_date: nil)
|
||||||
|
begin
|
||||||
|
Rails.logger.info("Fetching and loading Plaid data")
|
||||||
|
fetch_and_load_plaid_data
|
||||||
|
plaid_item.update!(status: :good) if plaid_item.requires_update?
|
||||||
|
|
||||||
|
# Schedule account syncs
|
||||||
|
plaid_item.accounts.each do |account|
|
||||||
|
account.sync_later(start_date: start_date, parent_sync: sync)
|
||||||
|
end
|
||||||
|
|
||||||
|
Rails.logger.info("Plaid data fetched and loaded")
|
||||||
|
rescue Plaid::ApiError => e
|
||||||
|
handle_plaid_error(e)
|
||||||
|
raise e
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def perform_post_sync(sync)
|
||||||
|
plaid_item.auto_match_categories!
|
||||||
|
plaid_item.family.broadcast_refresh
|
||||||
|
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, self)
|
||||||
|
rescue Plaid::ApiError => e
|
||||||
|
Rails.logger.warn("Error fetching #{method} for 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"
|
||||||
|
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
|
||||||
|
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)
|
||||||
|
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})"
|
||||||
|
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
|
||||||
|
|
||||||
|
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})"
|
||||||
|
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})"
|
||||||
|
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
|
|
@ -19,12 +19,12 @@ class Sync < ApplicationRecord
|
||||||
start!
|
start!
|
||||||
|
|
||||||
begin
|
begin
|
||||||
syncable.sync_data(self, start_date: start_date)
|
syncer.perform_sync(self, start_date: start_date)
|
||||||
|
|
||||||
unless has_pending_child_syncs?
|
unless has_pending_child_syncs?
|
||||||
complete!
|
complete!
|
||||||
Rails.logger.info("Sync completed, starting post-sync")
|
Rails.logger.info("Sync completed, starting post-sync")
|
||||||
syncable.post_sync(self)
|
syncer.perform_post_sync(self)
|
||||||
Rails.logger.info("Post-sync completed")
|
Rails.logger.info("Post-sync completed")
|
||||||
end
|
end
|
||||||
rescue StandardError => error
|
rescue StandardError => error
|
||||||
|
@ -51,12 +51,16 @@ class Sync < ApplicationRecord
|
||||||
# If this sync is both a child and a parent, we need to notify the parent of completion
|
# If this sync is both a child and a parent, we need to notify the parent of completion
|
||||||
notify_parent_of_completion! if has_parent?
|
notify_parent_of_completion! if has_parent?
|
||||||
|
|
||||||
syncable.post_sync(self)
|
syncer.perform_post_sync(self)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
def syncer
|
||||||
|
"#{syncable_type}::Syncer".constantize.new(syncable)
|
||||||
|
end
|
||||||
|
|
||||||
def has_pending_child_syncs?
|
def has_pending_child_syncs?
|
||||||
children.where(status: [ :pending, :syncing ]).any?
|
children.where(status: [ :pending, :syncing ]).any?
|
||||||
end
|
end
|
||||||
|
|
|
@ -15,11 +15,11 @@
|
||||||
|
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
<% account_group.accounts.each do |account| %>
|
<% account_group.accounts.each do |account| %>
|
||||||
<%= link_to account_path(account),
|
<%= link_to account_path(account),
|
||||||
class: class_names(
|
class: class_names(
|
||||||
"block flex items-center gap-2 px-3 py-2 rounded-lg",
|
"block flex items-center gap-2 px-3 py-2 rounded-lg",
|
||||||
page_active?(account_path(account)) ? "bg-container" : "hover:bg-surface-hover"
|
page_active?(account_path(account)) ? "bg-container" : "hover:bg-surface-hover"
|
||||||
),
|
),
|
||||||
data: { sidebar_tabs_target: "account", action: "click->sidebar-tabs#select" },
|
data: { sidebar_tabs_target: "account", action: "click->sidebar-tabs#select" },
|
||||||
title: account.name do %>
|
title: account.name do %>
|
||||||
<%= render "accounts/logo", account: account, size: "sm", color: account_group.color %>
|
<%= render "accounts/logo", account: account, size: "sm", color: account_group.color %>
|
||||||
|
|
|
@ -2,13 +2,13 @@
|
||||||
<div class="flex flex-col relative" data-controller="list-filter">
|
<div class="flex flex-col relative" data-controller="list-filter">
|
||||||
<div class="grow p-1.5">
|
<div class="grow p-1.5">
|
||||||
<div class="relative flex items-center bg-container border border-secondary rounded-lg">
|
<div class="relative flex items-center bg-container border border-secondary rounded-lg">
|
||||||
<input
|
<input
|
||||||
placeholder="<%= t(".search_placeholder") %>"
|
placeholder="<%= t(".search_placeholder") %>"
|
||||||
autocomplete="nope"
|
autocomplete="nope"
|
||||||
type="search"
|
type="search"
|
||||||
class="bg-container placeholder:text-sm placeholder:text-secondary font-normal h-10 relative pl-10 w-full border-none rounded-lg focus:outline-hidden focus:ring-0"
|
class="bg-container placeholder:text-sm placeholder:text-secondary font-normal h-10 relative pl-10 w-full border-none rounded-lg focus:outline-hidden focus:ring-0"
|
||||||
data-list-filter-target="input"
|
data-list-filter-target="input"
|
||||||
data-action="list-filter#filter" />
|
data-action="list-filter#filter">
|
||||||
<%= icon("search", class: "absolute inset-0 ml-2 transform top-1/2 -translate-y-1/2") %>
|
<%= icon("search", class: "absolute inset-0 ml-2 transform top-1/2 -translate-y-1/2") %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,31 +1,31 @@
|
||||||
<%
|
<%
|
||||||
nav_sections = [
|
nav_sections = [
|
||||||
{
|
{
|
||||||
header: t('.general_section_title'),
|
header: t(".general_section_title"),
|
||||||
items: [
|
items: [
|
||||||
{ label: t('.profile_label'), path: settings_profile_path, icon: 'circle-user' },
|
{ label: t(".profile_label"), path: settings_profile_path, icon: "circle-user" },
|
||||||
{ label: t('.preferences_label'), path: settings_preferences_path, icon: 'bolt' },
|
{ label: t(".preferences_label"), path: settings_preferences_path, icon: "bolt" },
|
||||||
{ label: t('.security_label'), path: settings_security_path, icon: 'shield-check' },
|
{ label: t(".security_label"), path: settings_security_path, icon: "shield-check" },
|
||||||
{ label: t('.self_hosting_label'), path: settings_hosting_path, icon: 'database', if: self_hosted? },
|
{ label: t(".self_hosting_label"), path: settings_hosting_path, icon: "database", if: self_hosted? },
|
||||||
{ label: t('.billing_label'), path: settings_billing_path, icon: 'circle-dollar-sign', if: !self_hosted? },
|
{ label: t(".billing_label"), path: settings_billing_path, icon: "circle-dollar-sign", if: !self_hosted? },
|
||||||
{ label: t('.accounts_label'), path: accounts_path, icon: 'layers' },
|
{ label: t(".accounts_label"), path: accounts_path, icon: "layers" },
|
||||||
{ label: t('.imports_label'), path: imports_path, icon: 'download' }
|
{ label: t(".imports_label"), path: imports_path, icon: "download" }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: t('.transactions_section_title'),
|
header: t(".transactions_section_title"),
|
||||||
items: [
|
items: [
|
||||||
{ label: t('.tags_label'), path: tags_path, icon: 'tags' },
|
{ label: t(".tags_label"), path: tags_path, icon: "tags" },
|
||||||
{ label: t('.categories_label'), path: categories_path, icon: 'shapes' },
|
{ label: t(".categories_label"), path: categories_path, icon: "shapes" },
|
||||||
{ label: t('.rules_label'), path: rules_path, icon: 'git-branch' },
|
{ label: t(".rules_label"), path: rules_path, icon: "git-branch" },
|
||||||
{ label: t('.merchants_label'), path: family_merchants_path, icon: 'store' }
|
{ label: t(".merchants_label"), path: family_merchants_path, icon: "store" }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: t('.other_section_title'),
|
header: t(".other_section_title"),
|
||||||
items: [
|
items: [
|
||||||
{ label: t('.whats_new_label'), path: changelog_path, icon: 'box' },
|
{ label: t(".whats_new_label"), path: changelog_path, icon: "box" },
|
||||||
{ label: t('.feedback_label'), path: feedback_path, icon: 'megaphone' }
|
{ label: t(".feedback_label"), path: feedback_path, icon: "megaphone" }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
|
@ -17,8 +17,4 @@ module SyncableInterfaceTest
|
||||||
@syncable.sync(start_date: 2.days.ago.to_date)
|
@syncable.sync(start_date: 2.days.ago.to_date)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
test "implements sync_data" do
|
|
||||||
assert_respond_to @syncable, :sync_data
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
24
test/models/family/syncer_test.rb
Normal file
24
test/models/family/syncer_test.rb
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class Family::SyncerTest < ActiveSupport::TestCase
|
||||||
|
setup do
|
||||||
|
@family = families(:dylan_family)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "syncs plaid items and manual accounts" do
|
||||||
|
family_sync = syncs(:family)
|
||||||
|
|
||||||
|
manual_accounts_count = @family.accounts.manual.count
|
||||||
|
items_count = @family.plaid_items.count
|
||||||
|
|
||||||
|
Account.any_instance.expects(:sync_later)
|
||||||
|
.with(start_date: nil, parent_sync: family_sync)
|
||||||
|
.times(manual_accounts_count)
|
||||||
|
|
||||||
|
PlaidItem.any_instance.expects(:sync_later)
|
||||||
|
.with(start_date: nil, parent_sync: family_sync)
|
||||||
|
.times(items_count)
|
||||||
|
|
||||||
|
Family::Syncer.new(@family).perform_sync(family_sync, start_date: family_sync.start_date)
|
||||||
|
end
|
||||||
|
end
|
|
@ -1,29 +1,9 @@
|
||||||
require "test_helper"
|
require "test_helper"
|
||||||
require "csv"
|
|
||||||
|
|
||||||
class FamilyTest < ActiveSupport::TestCase
|
class FamilyTest < ActiveSupport::TestCase
|
||||||
include EntriesTestHelper
|
|
||||||
include SyncableInterfaceTest
|
include SyncableInterfaceTest
|
||||||
|
|
||||||
def setup
|
def setup
|
||||||
@family = families(:empty)
|
|
||||||
@syncable = families(:dylan_family)
|
@syncable = families(:dylan_family)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "syncs plaid items and manual accounts" do
|
|
||||||
family_sync = syncs(:family)
|
|
||||||
|
|
||||||
manual_accounts_count = @syncable.accounts.manual.count
|
|
||||||
items_count = @syncable.plaid_items.count
|
|
||||||
|
|
||||||
Account.any_instance.expects(:sync_later)
|
|
||||||
.with(start_date: nil, parent_sync: family_sync)
|
|
||||||
.times(manual_accounts_count)
|
|
||||||
|
|
||||||
PlaidItem.any_instance.expects(:sync_later)
|
|
||||||
.with(start_date: nil, parent_sync: family_sync)
|
|
||||||
.times(items_count)
|
|
||||||
|
|
||||||
@syncable.sync_data(family_sync, start_date: family_sync.start_date)
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
|
@ -7,7 +7,7 @@ class SyncTest < ActiveSupport::TestCase
|
||||||
end
|
end
|
||||||
|
|
||||||
test "runs successful sync" do
|
test "runs successful sync" do
|
||||||
@sync.syncable.expects(:sync_data).with(@sync, start_date: @sync.start_date).once
|
Account::Syncer.any_instance.expects(:perform_sync).with(@sync, start_date: @sync.start_date).once
|
||||||
|
|
||||||
assert_equal "pending", @sync.status
|
assert_equal "pending", @sync.status
|
||||||
|
|
||||||
|
@ -20,7 +20,7 @@ class SyncTest < ActiveSupport::TestCase
|
||||||
end
|
end
|
||||||
|
|
||||||
test "handles sync errors" do
|
test "handles sync errors" do
|
||||||
@sync.syncable.expects(:sync_data).with(@sync, start_date: @sync.start_date).raises(StandardError.new("test sync error"))
|
Account::Syncer.any_instance.expects(:perform_sync).with(@sync, start_date: @sync.start_date).raises(StandardError.new("test sync error"))
|
||||||
|
|
||||||
assert_equal "pending", @sync.status
|
assert_equal "pending", @sync.status
|
||||||
previously_ran_at = @sync.last_ran_at
|
previously_ran_at = @sync.last_ran_at
|
||||||
|
@ -42,10 +42,10 @@ class SyncTest < ActiveSupport::TestCase
|
||||||
child2 = Sync.create!(syncable: family.accounts.second, parent: parent)
|
child2 = Sync.create!(syncable: family.accounts.second, parent: parent)
|
||||||
grandchild = Sync.create!(syncable: family.accounts.last, parent: child2)
|
grandchild = Sync.create!(syncable: family.accounts.last, parent: child2)
|
||||||
|
|
||||||
parent.syncable.expects(:sync_data).returns([]).once
|
Family::Syncer.any_instance.expects(:perform_sync).with(parent, start_date: parent.start_date).once
|
||||||
child1.syncable.expects(:sync_data).returns([]).once
|
Account::Syncer.any_instance.expects(:perform_sync).with(child1, start_date: parent.start_date).once
|
||||||
child2.syncable.expects(:sync_data).returns([]).once
|
Account::Syncer.any_instance.expects(:perform_sync).with(child2, start_date: parent.start_date).once
|
||||||
grandchild.syncable.expects(:sync_data).returns([]).once
|
Account::Syncer.any_instance.expects(:perform_sync).with(grandchild, start_date: parent.start_date).once
|
||||||
|
|
||||||
assert_equal "pending", parent.status
|
assert_equal "pending", parent.status
|
||||||
assert_equal "pending", child1.status
|
assert_equal "pending", child1.status
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue