From 28d99a2b0d488e4879110d1f9cd690ce72c4e8c7 Mon Sep 17 00:00:00 2001 From: David Gil Date: Tue, 10 Feb 2026 23:42:22 +0100 Subject: [PATCH] Include newer providers in automatic family sync (#934) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Include newer providers in automatic family sync Coinbase, CoinStats, Mercury, and SnapTrade all implement Syncable and have Syncer classes but were not listed in child_syncables, meaning their data only refreshed on manual sync button clicks. * refactor(syncer): Open/Closed principle for provider sync - Adding new providers requires modifying child_syncables (violates O/C) - plaid_items missing .active scope (bug: syncs deleted items) - snaptrade_items can exist without user registration → fails on sync - Scattered knowledge about 'ready to sync' logic 1. **Registry pattern**: SYNCABLE_ITEM_ASSOCIATIONS constant lists all provider associations that participate in family sync 2. **Encapsulated sync-readiness**: Each item model defines its own `syncable` scope that knows when it's ready for auto-sync: - Most providers: `syncable = active` (not scheduled for deletion) - SnapTrade: `syncable = active + user_registered` (has API creds) 3. **Single loop**: child_syncables iterates the registry, calling `.syncable` on each association - Adding a provider = add to registry + define syncable scope - Each model owns its 'ready to sync' business logic - Fixes plaid_items bug (now uses .active via .syncable) - Fixes snaptrade auto-sync failures (filters unregistered items) - Easy to extend with new conditions per provider - family/syncer.rb: Registry + dynamic collection - *_item.rb (7 files): Add `scope :syncable, -> { active }` - snaptrade_item.rb: Add syncable with user_registered filter * Fix rubocop bracket spacing in SnaptradeItem syncable scope --- app/models/coinbase_item.rb | 1 + app/models/coinstats_item.rb | 1 + app/models/enable_banking_item.rb | 1 + app/models/family/syncer.rb | 29 ++++++++++++++++++++++++++++- app/models/indexa_capital_item.rb | 1 + app/models/lunchflow_item.rb | 1 + app/models/mercury_item.rb | 1 + app/models/plaid_item.rb | 1 + app/models/simplefin_item.rb | 1 + app/models/snaptrade_item.rb | 3 +++ 10 files changed, 39 insertions(+), 1 deletion(-) diff --git a/app/models/coinbase_item.rb b/app/models/coinbase_item.rb index f67aeb753..6641958e6 100644 --- a/app/models/coinbase_item.rb +++ b/app/models/coinbase_item.rb @@ -30,6 +30,7 @@ class CoinbaseItem < ApplicationRecord has_many :accounts, through: :coinbase_accounts scope :active, -> { where(scheduled_for_deletion: false) } + scope :syncable, -> { active } scope :ordered, -> { order(created_at: :desc) } scope :needs_update, -> { where(status: :requires_update) } diff --git a/app/models/coinstats_item.rb b/app/models/coinstats_item.rb index c2c9f51fd..6df76bf70 100644 --- a/app/models/coinstats_item.rb +++ b/app/models/coinstats_item.rb @@ -28,6 +28,7 @@ class CoinstatsItem < ApplicationRecord has_many :accounts, through: :coinstats_accounts scope :active, -> { where(scheduled_for_deletion: false) } + scope :syncable, -> { active } scope :ordered, -> { order(created_at: :desc) } scope :needs_update, -> { where(status: :requires_update) } diff --git a/app/models/enable_banking_item.rb b/app/models/enable_banking_item.rb index 7de761bde..d1e8684c9 100644 --- a/app/models/enable_banking_item.rb +++ b/app/models/enable_banking_item.rb @@ -23,6 +23,7 @@ class EnableBankingItem < ApplicationRecord has_many :accounts, through: :enable_banking_accounts scope :active, -> { where(scheduled_for_deletion: false) } + scope :syncable, -> { active } scope :ordered, -> { order(created_at: :desc) } scope :needs_update, -> { where(status: :requires_update) } diff --git a/app/models/family/syncer.rb b/app/models/family/syncer.rb index 80d910cc1..858ed9bc4 100644 --- a/app/models/family/syncer.rb +++ b/app/models/family/syncer.rb @@ -1,6 +1,25 @@ class Family::Syncer attr_reader :family + # Registry of item association names that participate in family sync. + # Each model must: + # 1. Include Syncable + # 2. Define a `syncable` scope (items ready for auto-sync) + # + # To add a new provider: add its association name here. + # The model handles its own "ready to sync" logic via the syncable scope. + SYNCABLE_ITEM_ASSOCIATIONS = %i[ + plaid_items + simplefin_items + lunchflow_items + enable_banking_items + indexa_capital_items + coinbase_items + coinstats_items + mercury_items + snaptrade_items + ].freeze + def initialize(family) @family = family end @@ -25,7 +44,15 @@ class Family::Syncer end private + + # Collects all syncable items from registered providers + manual accounts. + # Each provider model defines its own `syncable` scope that encapsulates + # the "ready to sync" business logic (active, configured, etc.) def child_syncables - family.plaid_items + family.simplefin_items.active + family.lunchflow_items.active + family.enable_banking_items.active + family.indexa_capital_items + family.accounts.manual + provider_items = SYNCABLE_ITEM_ASSOCIATIONS.flat_map do |association| + family.public_send(association).syncable + end + + provider_items + family.accounts.manual end end diff --git a/app/models/indexa_capital_item.rb b/app/models/indexa_capital_item.rb index 8272a256e..ccea401b4 100644 --- a/app/models/indexa_capital_item.rb +++ b/app/models/indexa_capital_item.rb @@ -30,6 +30,7 @@ class IndexaCapitalItem < ApplicationRecord has_many :accounts, through: :indexa_capital_accounts scope :active, -> { where(scheduled_for_deletion: false) } + scope :syncable, -> { active } scope :ordered, -> { order(created_at: :desc) } scope :needs_update, -> { where(status: :requires_update) } diff --git a/app/models/lunchflow_item.rb b/app/models/lunchflow_item.rb index 1165ba836..ba9830c6b 100644 --- a/app/models/lunchflow_item.rb +++ b/app/models/lunchflow_item.rb @@ -20,6 +20,7 @@ class LunchflowItem < ApplicationRecord has_many :accounts, through: :lunchflow_accounts scope :active, -> { where(scheduled_for_deletion: false) } + scope :syncable, -> { active } scope :ordered, -> { order(created_at: :desc) } scope :needs_update, -> { where(status: :requires_update) } diff --git a/app/models/mercury_item.rb b/app/models/mercury_item.rb index 2c781265a..eb062b2b9 100644 --- a/app/models/mercury_item.rb +++ b/app/models/mercury_item.rb @@ -27,6 +27,7 @@ class MercuryItem < ApplicationRecord has_many :accounts, through: :mercury_accounts scope :active, -> { where(scheduled_for_deletion: false) } + scope :syncable, -> { active } scope :ordered, -> { order(created_at: :desc) } scope :needs_update, -> { where(status: :requires_update) } diff --git a/app/models/plaid_item.rb b/app/models/plaid_item.rb index 4ac59d0cf..58e39bae2 100644 --- a/app/models/plaid_item.rb +++ b/app/models/plaid_item.rb @@ -23,6 +23,7 @@ class PlaidItem < ApplicationRecord has_many :legacy_accounts, through: :plaid_accounts, source: :account scope :active, -> { where(scheduled_for_deletion: false) } + scope :syncable, -> { active } scope :ordered, -> { order(created_at: :desc) } scope :needs_update, -> { where(status: :requires_update) } diff --git a/app/models/simplefin_item.rb b/app/models/simplefin_item.rb index 253b6098c..4483393f4 100644 --- a/app/models/simplefin_item.rb +++ b/app/models/simplefin_item.rb @@ -26,6 +26,7 @@ class SimplefinItem < ApplicationRecord has_many :legacy_accounts, through: :simplefin_accounts, source: :account scope :active, -> { where(scheduled_for_deletion: false) } + scope :syncable, -> { active } scope :ordered, -> { order(created_at: :desc) } scope :needs_update, -> { where(status: :requires_update) } diff --git a/app/models/snaptrade_item.rb b/app/models/snaptrade_item.rb index 0c627d7d3..f99c763f6 100644 --- a/app/models/snaptrade_item.rb +++ b/app/models/snaptrade_item.rb @@ -35,6 +35,9 @@ class SnaptradeItem < ApplicationRecord has_many :linked_accounts, through: :snaptrade_accounts scope :active, -> { where(scheduled_for_deletion: false) } + # Syncable = active + fully configured (user registered with SnapTrade API) + # Items without user registration will fail sync, so exclude them from auto-sync + scope :syncable, -> { active.where.not(snaptrade_user_id: [ nil, "" ]).where.not(snaptrade_user_secret: [ nil, "" ]) } scope :ordered, -> { order(created_at: :desc) } scope :needs_update, -> { where(status: :requires_update) }