Include newer providers in automatic family sync (#934)

* 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
This commit is contained in:
David Gil
2026-02-10 23:42:22 +01:00
committed by GitHub
parent 17e2971603
commit 28d99a2b0d
10 changed files with 39 additions and 1 deletions

View File

@@ -30,6 +30,7 @@ class CoinbaseItem < ApplicationRecord
has_many :accounts, through: :coinbase_accounts has_many :accounts, through: :coinbase_accounts
scope :active, -> { where(scheduled_for_deletion: false) } scope :active, -> { where(scheduled_for_deletion: false) }
scope :syncable, -> { active }
scope :ordered, -> { order(created_at: :desc) } scope :ordered, -> { order(created_at: :desc) }
scope :needs_update, -> { where(status: :requires_update) } scope :needs_update, -> { where(status: :requires_update) }

View File

@@ -28,6 +28,7 @@ class CoinstatsItem < ApplicationRecord
has_many :accounts, through: :coinstats_accounts has_many :accounts, through: :coinstats_accounts
scope :active, -> { where(scheduled_for_deletion: false) } scope :active, -> { where(scheduled_for_deletion: false) }
scope :syncable, -> { active }
scope :ordered, -> { order(created_at: :desc) } scope :ordered, -> { order(created_at: :desc) }
scope :needs_update, -> { where(status: :requires_update) } scope :needs_update, -> { where(status: :requires_update) }

View File

@@ -23,6 +23,7 @@ class EnableBankingItem < ApplicationRecord
has_many :accounts, through: :enable_banking_accounts has_many :accounts, through: :enable_banking_accounts
scope :active, -> { where(scheduled_for_deletion: false) } scope :active, -> { where(scheduled_for_deletion: false) }
scope :syncable, -> { active }
scope :ordered, -> { order(created_at: :desc) } scope :ordered, -> { order(created_at: :desc) }
scope :needs_update, -> { where(status: :requires_update) } scope :needs_update, -> { where(status: :requires_update) }

View File

@@ -1,6 +1,25 @@
class Family::Syncer class Family::Syncer
attr_reader :family 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) def initialize(family)
@family = family @family = family
end end
@@ -25,7 +44,15 @@ class Family::Syncer
end end
private 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 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
end end

View File

@@ -30,6 +30,7 @@ class IndexaCapitalItem < ApplicationRecord
has_many :accounts, through: :indexa_capital_accounts has_many :accounts, through: :indexa_capital_accounts
scope :active, -> { where(scheduled_for_deletion: false) } scope :active, -> { where(scheduled_for_deletion: false) }
scope :syncable, -> { active }
scope :ordered, -> { order(created_at: :desc) } scope :ordered, -> { order(created_at: :desc) }
scope :needs_update, -> { where(status: :requires_update) } scope :needs_update, -> { where(status: :requires_update) }

View File

@@ -20,6 +20,7 @@ class LunchflowItem < ApplicationRecord
has_many :accounts, through: :lunchflow_accounts has_many :accounts, through: :lunchflow_accounts
scope :active, -> { where(scheduled_for_deletion: false) } scope :active, -> { where(scheduled_for_deletion: false) }
scope :syncable, -> { active }
scope :ordered, -> { order(created_at: :desc) } scope :ordered, -> { order(created_at: :desc) }
scope :needs_update, -> { where(status: :requires_update) } scope :needs_update, -> { where(status: :requires_update) }

View File

@@ -27,6 +27,7 @@ class MercuryItem < ApplicationRecord
has_many :accounts, through: :mercury_accounts has_many :accounts, through: :mercury_accounts
scope :active, -> { where(scheduled_for_deletion: false) } scope :active, -> { where(scheduled_for_deletion: false) }
scope :syncable, -> { active }
scope :ordered, -> { order(created_at: :desc) } scope :ordered, -> { order(created_at: :desc) }
scope :needs_update, -> { where(status: :requires_update) } scope :needs_update, -> { where(status: :requires_update) }

View File

@@ -23,6 +23,7 @@ class PlaidItem < ApplicationRecord
has_many :legacy_accounts, through: :plaid_accounts, source: :account has_many :legacy_accounts, through: :plaid_accounts, source: :account
scope :active, -> { where(scheduled_for_deletion: false) } scope :active, -> { where(scheduled_for_deletion: false) }
scope :syncable, -> { active }
scope :ordered, -> { order(created_at: :desc) } scope :ordered, -> { order(created_at: :desc) }
scope :needs_update, -> { where(status: :requires_update) } scope :needs_update, -> { where(status: :requires_update) }

View File

@@ -26,6 +26,7 @@ class SimplefinItem < ApplicationRecord
has_many :legacy_accounts, through: :simplefin_accounts, source: :account has_many :legacy_accounts, through: :simplefin_accounts, source: :account
scope :active, -> { where(scheduled_for_deletion: false) } scope :active, -> { where(scheduled_for_deletion: false) }
scope :syncable, -> { active }
scope :ordered, -> { order(created_at: :desc) } scope :ordered, -> { order(created_at: :desc) }
scope :needs_update, -> { where(status: :requires_update) } scope :needs_update, -> { where(status: :requires_update) }

View File

@@ -35,6 +35,9 @@ class SnaptradeItem < ApplicationRecord
has_many :linked_accounts, through: :snaptrade_accounts has_many :linked_accounts, through: :snaptrade_accounts
scope :active, -> { where(scheduled_for_deletion: false) } 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 :ordered, -> { order(created_at: :desc) }
scope :needs_update, -> { where(status: :requires_update) } scope :needs_update, -> { where(status: :requires_update) }