mirror of
https://github.com/we-promise/sure.git
synced 2026-04-19 12:04:08 +00:00
Providers factory (#250)
* Implement providers factory * Multiple providers sync support - Proper Multi-Provider Syncing: When you click sync on an account with multiple providers (e.g., both Plaid and SimpleFin), all provider items are synced - Better API: The existing account.providers method already returns all providers, and account.provider returns the first one for backward compatibility - Correct Holdings Deletion Logic: Holdings can only be deleted if ALL providers allow it, preventing accidental deletions that would be recreated on next sync TODO: validate this is the way we want to go? We would need to check holdings belong to which account, and then check provider allows deletion. More complex - Database Constraints: The existing validations ensure an account can have at most one provider of each type (one PlaidAccount, one SimplefinAccount, etc.) * Add generic provider_import_adapter * Finish unified import strategy * Update app/models/plaid_account.rb Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Signed-off-by: soky srm <sokysrm@gmail.com> * Update app/models/provider/factory.rb Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Signed-off-by: soky srm <sokysrm@gmail.com> * Fix account linked by plaid_id instead of external_id * Parse numerics to BigDecimal Parse numerics to BigDecimal before computing amount; guard nils. Avoid String * String and float drift; also normalize date. * Fix incorrect usage of assert_raises. * Fix linter * Fix processor test. * Update current_balance_manager.rb * Test fixes * Fix plaid linked account test * Add support for holding per account_provider * Fix proper account access Also fix account deletion for simpefin too * FIX match tests for consistency * Some more factory updates * Fix account schema for multipe providers Can do: - Account #1 → PlaidAccount + SimplefinAccount (multiple different providers) - Account #2 → PlaidAccount only - Account #3 → SimplefinAccount only Cannot do: - Account #1 → PlaidAccount + PlaidAccount (duplicate provider type) - PlaidAccount #123 → Account #1 + Account #2 (provider linked to multiple accounts) * Fix account setup - An account CAN have multiple providers (the schema shows account_providers with unique index on [account_id, provider_type]) - Each provider should maintain its own separate entries - We should NOT update one provider's entry when another provider syncs * Fix linter and guard migration * FIX linter issues. * Fixes - Remove duplicated index - Pass account_provider_id - Guard holdings call to avoid NoMethodError * Update schema and provider import fix * Plaid doesn't allow holdings deletion * Use ClimateControl for proper env setup * No need for this in .git --------- Signed-off-by: soky srm <sokysrm@gmail.com> Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
This commit is contained in:
@@ -107,13 +107,17 @@ class Account::CurrentBalanceManager
|
||||
end
|
||||
|
||||
def create_current_anchor(balance)
|
||||
account.entries.create!(
|
||||
entry = account.entries.create!(
|
||||
date: Date.current,
|
||||
name: Valuation.build_current_anchor_name(account.accountable_type),
|
||||
amount: balance,
|
||||
currency: account.currency,
|
||||
entryable: Valuation.new(kind: "current_anchor")
|
||||
)
|
||||
|
||||
# Reload associations and clear memoized value so it gets the new anchor
|
||||
account.valuations.reload
|
||||
@current_anchor_valuation = nil
|
||||
end
|
||||
|
||||
def update_current_anchor(balance)
|
||||
|
||||
@@ -2,13 +2,17 @@ module Account::Linkable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
# New generic provider association
|
||||
has_many :account_providers, dependent: :destroy
|
||||
|
||||
# Legacy provider associations - kept for backward compatibility during migration
|
||||
belongs_to :plaid_account, optional: true
|
||||
belongs_to :simplefin_account, optional: true
|
||||
end
|
||||
|
||||
# A "linked" account gets transaction and balance data from a third party like Plaid or SimpleFin
|
||||
def linked?
|
||||
plaid_account_id.present? || simplefin_account_id.present?
|
||||
account_providers.any?
|
||||
end
|
||||
|
||||
# An "offline" or "unlinked" account is one where the user tracks values and
|
||||
@@ -17,4 +21,42 @@ module Account::Linkable
|
||||
!linked?
|
||||
end
|
||||
alias_method :manual?, :unlinked?
|
||||
|
||||
# Returns the primary provider adapter for this account
|
||||
# If multiple providers exist, returns the first one
|
||||
def provider
|
||||
return nil unless linked?
|
||||
|
||||
@provider ||= account_providers.first&.adapter
|
||||
end
|
||||
|
||||
# Returns all provider adapters for this account
|
||||
def providers
|
||||
@providers ||= account_providers.map(&:adapter).compact
|
||||
end
|
||||
|
||||
# Returns the provider adapter for a specific provider type
|
||||
def provider_for(provider_type)
|
||||
account_provider = account_providers.find_by(provider_type: provider_type)
|
||||
account_provider&.adapter
|
||||
end
|
||||
|
||||
# Convenience method to get the provider name
|
||||
def provider_name
|
||||
provider&.provider_name
|
||||
end
|
||||
|
||||
# Check if account is linked to a specific provider
|
||||
def linked_to?(provider_type)
|
||||
account_providers.exists?(provider_type: provider_type)
|
||||
end
|
||||
|
||||
# Check if holdings can be deleted
|
||||
# If account has multiple providers, returns true only if ALL providers allow deletion
|
||||
# This prevents deleting holdings that would be recreated on next sync
|
||||
def can_delete_holdings?
|
||||
return true if unlinked?
|
||||
|
||||
providers.all?(&:can_delete_holdings?)
|
||||
end
|
||||
end
|
||||
|
||||
255
app/models/account/provider_import_adapter.rb
Normal file
255
app/models/account/provider_import_adapter.rb
Normal file
@@ -0,0 +1,255 @@
|
||||
class Account::ProviderImportAdapter
|
||||
attr_reader :account
|
||||
|
||||
def initialize(account)
|
||||
@account = account
|
||||
end
|
||||
|
||||
# Imports a transaction from a provider
|
||||
#
|
||||
# @param external_id [String] Unique identifier from the provider (e.g., "plaid_12345", "simplefin_abc")
|
||||
# @param amount [BigDecimal, Numeric] Transaction amount
|
||||
# @param currency [String] Currency code (e.g., "USD")
|
||||
# @param date [Date, String] Transaction date
|
||||
# @param name [String] Transaction name/description
|
||||
# @param source [String] Provider name (e.g., "plaid", "simplefin")
|
||||
# @param category_id [Integer, nil] Optional category ID
|
||||
# @param merchant [Merchant, nil] Optional merchant object
|
||||
# @return [Entry] The created or updated entry
|
||||
def import_transaction(external_id:, amount:, currency:, date:, name:, source:, category_id: nil, merchant: nil)
|
||||
raise ArgumentError, "external_id is required" if external_id.blank?
|
||||
raise ArgumentError, "source is required" if source.blank?
|
||||
|
||||
Account.transaction do
|
||||
# Find or initialize by both external_id AND source
|
||||
# This allows multiple providers to sync same account with separate entries
|
||||
entry = account.entries.find_or_initialize_by(external_id: external_id, source: source) do |e|
|
||||
e.entryable = Transaction.new
|
||||
end
|
||||
|
||||
# Validate entryable type matches to prevent external_id collisions
|
||||
if entry.persisted? && !entry.entryable.is_a?(Transaction)
|
||||
raise ArgumentError, "Entry with external_id '#{external_id}' already exists with different entryable type: #{entry.entryable_type}"
|
||||
end
|
||||
|
||||
entry.assign_attributes(
|
||||
amount: amount,
|
||||
currency: currency,
|
||||
date: date
|
||||
)
|
||||
|
||||
# Use enrichment pattern to respect user overrides
|
||||
entry.enrich_attribute(:name, name, source: source)
|
||||
|
||||
# Enrich transaction-specific attributes
|
||||
if category_id
|
||||
entry.transaction.enrich_attribute(:category_id, category_id, source: source)
|
||||
end
|
||||
|
||||
if merchant
|
||||
entry.transaction.enrich_attribute(:merchant_id, merchant.id, source: source)
|
||||
end
|
||||
|
||||
entry.save!
|
||||
entry
|
||||
end
|
||||
end
|
||||
|
||||
# Finds or creates a merchant from provider data
|
||||
#
|
||||
# @param provider_merchant_id [String] Provider's merchant ID
|
||||
# @param name [String] Merchant name
|
||||
# @param source [String] Provider name (e.g., "plaid", "simplefin")
|
||||
# @param website_url [String, nil] Optional merchant website
|
||||
# @param logo_url [String, nil] Optional merchant logo URL
|
||||
# @return [ProviderMerchant, nil] The merchant object or nil if data is insufficient
|
||||
def find_or_create_merchant(provider_merchant_id:, name:, source:, website_url: nil, logo_url: nil)
|
||||
return nil unless provider_merchant_id.present? && name.present?
|
||||
|
||||
ProviderMerchant.find_or_create_by!(
|
||||
provider_merchant_id: provider_merchant_id,
|
||||
source: source
|
||||
) do |m|
|
||||
m.name = name
|
||||
m.website_url = website_url
|
||||
m.logo_url = logo_url
|
||||
end
|
||||
end
|
||||
|
||||
# Updates account balance from provider data
|
||||
#
|
||||
# @param balance [BigDecimal, Numeric] Total balance
|
||||
# @param cash_balance [BigDecimal, Numeric] Cash balance (for investment accounts)
|
||||
# @param source [String] Provider name (for logging/debugging)
|
||||
def update_balance(balance:, cash_balance: nil, source: nil)
|
||||
account.update!(
|
||||
balance: balance,
|
||||
cash_balance: cash_balance || balance
|
||||
)
|
||||
end
|
||||
|
||||
# Imports or updates a holding (investment position) from a provider
|
||||
#
|
||||
# @param security [Security] The security object
|
||||
# @param quantity [BigDecimal, Numeric] Number of shares/units
|
||||
# @param amount [BigDecimal, Numeric] Total value in account currency
|
||||
# @param currency [String] Currency code
|
||||
# @param date [Date, String] Holding date
|
||||
# @param price [BigDecimal, Numeric, nil] Price per share (optional)
|
||||
# @param cost_basis [BigDecimal, Numeric, nil] Cost basis (optional)
|
||||
# @param external_id [String, nil] Provider's unique ID (optional, for deduplication)
|
||||
# @param source [String] Provider name
|
||||
# @param account_provider_id [String, nil] The AccountProvider ID that owns this holding (optional)
|
||||
# @param delete_future_holdings [Boolean] Whether to delete holdings after this date (default: false)
|
||||
# @return [Holding] The created or updated holding
|
||||
def import_holding(security:, quantity:, amount:, currency:, date:, price: nil, cost_basis: nil, external_id: nil, source:, account_provider_id: nil, delete_future_holdings: false)
|
||||
raise ArgumentError, "security is required" if security.nil?
|
||||
raise ArgumentError, "source is required" if source.blank?
|
||||
|
||||
Account.transaction do
|
||||
# Two strategies for finding/creating holdings:
|
||||
# 1. By external_id (SimpleFin approach) - tracks each holding uniquely
|
||||
# 2. By security+date+currency (Plaid approach) - overwrites holdings for same security/date
|
||||
holding = if external_id.present?
|
||||
account.holdings.find_or_initialize_by(external_id: external_id) do |h|
|
||||
h.security = security
|
||||
h.date = date
|
||||
h.currency = currency
|
||||
end
|
||||
else
|
||||
account.holdings.find_or_initialize_by(
|
||||
security: security,
|
||||
date: date,
|
||||
currency: currency
|
||||
)
|
||||
end
|
||||
|
||||
holding.assign_attributes(
|
||||
security: security,
|
||||
date: date,
|
||||
currency: currency,
|
||||
qty: quantity,
|
||||
price: price,
|
||||
amount: amount,
|
||||
cost_basis: cost_basis,
|
||||
account_provider_id: account_provider_id
|
||||
)
|
||||
|
||||
holding.save!
|
||||
|
||||
# Optionally delete future holdings for this security (Plaid behavior)
|
||||
# Only delete if ALL providers allow deletion (cross-provider check)
|
||||
if delete_future_holdings
|
||||
unless account.can_delete_holdings?
|
||||
Rails.logger.warn(
|
||||
"Skipping future holdings deletion for account #{account.id} " \
|
||||
"because not all providers allow deletion"
|
||||
)
|
||||
return holding
|
||||
end
|
||||
|
||||
# Build base query for future holdings
|
||||
future_holdings_query = account.holdings
|
||||
.where(security: security)
|
||||
.where("date > ?", date)
|
||||
|
||||
# If account_provider_id is provided, only delete holdings from this provider
|
||||
# This prevents deleting positions imported by other providers
|
||||
if account_provider_id.present?
|
||||
future_holdings_query = future_holdings_query.where(account_provider_id: account_provider_id)
|
||||
end
|
||||
|
||||
future_holdings_query.destroy_all
|
||||
end
|
||||
|
||||
holding
|
||||
end
|
||||
end
|
||||
|
||||
# Imports a trade (investment transaction) from a provider
|
||||
#
|
||||
# @param security [Security] The security object
|
||||
# @param quantity [BigDecimal, Numeric] Number of shares (negative for sells, positive for buys)
|
||||
# @param price [BigDecimal, Numeric] Price per share
|
||||
# @param amount [BigDecimal, Numeric] Total trade value
|
||||
# @param currency [String] Currency code
|
||||
# @param date [Date, String] Trade date
|
||||
# @param name [String, nil] Optional custom name for the trade
|
||||
# @param external_id [String, nil] Provider's unique ID (optional, for deduplication)
|
||||
# @param source [String] Provider name
|
||||
# @return [Entry] The created entry with trade
|
||||
def import_trade(security:, quantity:, price:, amount:, currency:, date:, name: nil, external_id: nil, source:)
|
||||
raise ArgumentError, "security is required" if security.nil?
|
||||
raise ArgumentError, "source is required" if source.blank?
|
||||
|
||||
Account.transaction do
|
||||
# Generate name if not provided
|
||||
trade_name = if name.present?
|
||||
name
|
||||
else
|
||||
trade_type = quantity.negative? ? "sell" : "buy"
|
||||
Trade.build_name(trade_type, quantity, security.ticker)
|
||||
end
|
||||
|
||||
# Use find_or_initialize_by with external_id if provided, otherwise create new
|
||||
entry = if external_id.present?
|
||||
# Find or initialize by both external_id AND source
|
||||
# This allows multiple providers to sync same account with separate entries
|
||||
account.entries.find_or_initialize_by(external_id: external_id, source: source) do |e|
|
||||
e.entryable = Trade.new
|
||||
end
|
||||
else
|
||||
account.entries.new(
|
||||
entryable: Trade.new,
|
||||
source: source
|
||||
)
|
||||
end
|
||||
|
||||
# Validate entryable type matches to prevent external_id collisions
|
||||
if entry.persisted? && !entry.entryable.is_a?(Trade)
|
||||
raise ArgumentError, "Entry with external_id '#{external_id}' already exists with different entryable type: #{entry.entryable_type}"
|
||||
end
|
||||
|
||||
# Always update Trade attributes (works for both new and existing records)
|
||||
entry.entryable.assign_attributes(
|
||||
security: security,
|
||||
qty: quantity,
|
||||
price: price,
|
||||
currency: currency
|
||||
)
|
||||
|
||||
entry.assign_attributes(
|
||||
date: date,
|
||||
amount: amount,
|
||||
currency: currency,
|
||||
name: trade_name
|
||||
)
|
||||
|
||||
entry.save!
|
||||
entry
|
||||
end
|
||||
end
|
||||
|
||||
# Updates accountable-specific attributes (e.g., credit card details, loan details)
|
||||
#
|
||||
# @param attributes [Hash] Hash of attributes to update on the accountable
|
||||
# @param source [String] Provider name (for logging/debugging)
|
||||
# @return [Boolean] Whether the update was successful
|
||||
def update_accountable_attributes(attributes:, source:)
|
||||
return false unless account.accountable.present?
|
||||
return false if attributes.blank?
|
||||
|
||||
# Filter out nil values and only update attributes that exist on the accountable
|
||||
valid_attributes = attributes.compact.select do |key, _|
|
||||
account.accountable.respond_to?("#{key}=")
|
||||
end
|
||||
|
||||
return false if valid_attributes.empty?
|
||||
|
||||
account.accountable.update!(valid_attributes)
|
||||
true
|
||||
rescue => e
|
||||
Rails.logger.error("Failed to update #{account.accountable_type} attributes from #{source}: #{e.message}")
|
||||
false
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user