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:
soky srm
2025-10-28 19:32:27 +01:00
committed by GitHub
parent 72e7d7736b
commit 4fb0a3856e
67 changed files with 2338 additions and 315 deletions

View File

@@ -11,40 +11,82 @@ class PlaidAccount::Investments::HoldingsProcessor
next unless resolved_security_result.security.present?
security = resolved_security_result.security
holding_date = plaid_holding["institution_price_as_of"] || Date.current
holding = account.holdings.find_or_initialize_by(
# Parse quantity and price into BigDecimal for proper arithmetic
quantity_bd = parse_decimal(plaid_holding["quantity"])
price_bd = parse_decimal(plaid_holding["institution_price"])
# Skip if essential values are missing
next if quantity_bd.nil? || price_bd.nil?
# Compute amount using BigDecimal arithmetic to avoid floating point drift
amount_bd = quantity_bd * price_bd
# Normalize date - handle string, Date, or nil
holding_date = parse_date(plaid_holding["institution_price_as_of"]) || Date.current
import_adapter.import_holding(
security: security,
quantity: quantity_bd,
amount: amount_bd,
currency: plaid_holding["iso_currency_code"] || account.currency,
date: holding_date,
currency: plaid_holding["iso_currency_code"]
price: price_bd,
account_provider_id: plaid_account.account_provider&.id,
source: "plaid",
delete_future_holdings: false # Plaid doesn't allow holdings deletion
)
holding.assign_attributes(
qty: plaid_holding["quantity"],
price: plaid_holding["institution_price"],
amount: plaid_holding["quantity"] * plaid_holding["institution_price"]
)
ActiveRecord::Base.transaction do
holding.save!
# Delete all holdings for this security after the institution price date
account.holdings
.where(security: security)
.where("date > ?", holding_date)
.destroy_all
end
end
end
private
attr_reader :plaid_account, :security_resolver
def import_adapter
@import_adapter ||= Account::ProviderImportAdapter.new(account)
end
def account
plaid_account.account
plaid_account.current_account
end
def holdings
plaid_account.raw_investments_payload["holdings"] || []
plaid_account.raw_investments_payload&.[]("holdings") || []
end
def parse_decimal(value)
return nil if value.nil?
case value
when BigDecimal
value
when String
BigDecimal(value)
when Numeric
BigDecimal(value.to_s)
else
nil
end
rescue ArgumentError => e
Rails.logger.error("Failed to parse Plaid holding decimal value: #{value.inspect} - #{e.message}")
nil
end
def parse_date(date_value)
return nil if date_value.nil?
case date_value
when Date
date_value
when String
Date.parse(date_value)
when Time, DateTime
date_value.to_date
else
nil
end
rescue ArgumentError, TypeError => e
Rails.logger.error("Failed to parse Plaid holding date: #{date_value.inspect} - #{e.message}")
nil
end
end

View File

@@ -19,8 +19,12 @@ class PlaidAccount::Investments::TransactionsProcessor
private
attr_reader :plaid_account, :security_resolver
def import_adapter
@import_adapter ||= Account::ProviderImportAdapter.new(account)
end
def account
plaid_account.account
plaid_account.current_account
end
def cash_transaction?(transaction)
@@ -38,50 +42,34 @@ class PlaidAccount::Investments::TransactionsProcessor
return # We can't process a non-cash transaction without a security
end
entry = account.entries.find_or_initialize_by(plaid_id: transaction["investment_transaction_id"]) do |e|
e.entryable = Trade.new
end
external_id = transaction["investment_transaction_id"]
return if external_id.blank?
entry.assign_attributes(
import_adapter.import_trade(
external_id: external_id,
security: resolved_security_result.security,
quantity: derived_qty(transaction),
price: transaction["price"],
amount: derived_qty(transaction) * transaction["price"],
currency: transaction["iso_currency_code"],
date: transaction["date"]
)
entry.trade.assign_attributes(
security: resolved_security_result.security,
qty: derived_qty(transaction),
price: transaction["price"],
currency: transaction["iso_currency_code"]
)
entry.enrich_attribute(
:name,
transaction["name"],
date: transaction["date"],
name: transaction["name"],
source: "plaid"
)
entry.save!
end
def find_or_create_cash_entry(transaction)
entry = account.entries.find_or_initialize_by(plaid_id: transaction["investment_transaction_id"]) do |e|
e.entryable = Transaction.new
end
external_id = transaction["investment_transaction_id"]
return if external_id.blank?
entry.assign_attributes(
import_adapter.import_transaction(
external_id: external_id,
amount: transaction["amount"],
currency: transaction["iso_currency_code"],
date: transaction["date"]
)
entry.enrich_attribute(
:name,
transaction["name"],
date: transaction["date"],
name: transaction["name"],
source: "plaid"
)
entry.save!
end
def transactions

View File

@@ -6,17 +6,24 @@ class PlaidAccount::Liabilities::CreditProcessor
def process
return unless credit_data.present?
account.credit_card.update!(
minimum_payment: credit_data.dig("minimum_payment_amount"),
apr: credit_data.dig("aprs", 0, "apr_percentage")
import_adapter.update_accountable_attributes(
attributes: {
minimum_payment: credit_data.dig("minimum_payment_amount"),
apr: credit_data.dig("aprs", 0, "apr_percentage")
},
source: "plaid"
)
end
private
attr_reader :plaid_account
def import_adapter
@import_adapter ||= Account::ProviderImportAdapter.new(account)
end
def account
plaid_account.account
plaid_account.current_account
end
def credit_data

View File

@@ -16,7 +16,7 @@ class PlaidAccount::Liabilities::MortgageProcessor
attr_reader :plaid_account
def account
plaid_account.account
plaid_account.current_account
end
def mortgage_data

View File

@@ -18,7 +18,7 @@ class PlaidAccount::Liabilities::StudentLoanProcessor
attr_reader :plaid_account
def account
plaid_account.account
plaid_account.current_account
end
def term_months

View File

@@ -30,9 +30,20 @@ class PlaidAccount::Processor
def process_account!
PlaidAccount.transaction do
account = family.accounts.find_or_initialize_by(
plaid_account_id: plaid_account.id
)
# Find existing account through account_provider or legacy plaid_account_id
account_provider = AccountProvider.find_by(provider: plaid_account)
account = if account_provider
account_provider.account
else
# Legacy fallback: find by plaid_account_id if it still exists
family.accounts.find_by(plaid_account_id: plaid_account.id)
end
# Initialize new account if not found
if account.nil?
account = family.accounts.new
account.accountable = map_accountable(plaid_account.plaid_type)
end
# Create or assign the accountable if needed
if account.accountable.nil?
@@ -65,6 +76,15 @@ class PlaidAccount::Processor
account.save!
# Create account provider link if it doesn't exist
unless account_provider
AccountProvider.find_or_create_by!(
account: account,
provider: plaid_account,
provider_type: "PlaidAccount"
)
end
# Create or update the current balance anchor valuation for event-sourced ledger
# Note: This is a partial implementation. In the future, we'll introduce HoldingValuation
# to properly track the holdings vs. cash breakdown, but for now we're only tracking

View File

@@ -39,7 +39,7 @@ class PlaidAccount::Transactions::Processor
end
def account
plaid_account.account
plaid_account.current_account
end
def remove_plaid_transaction(raw_transaction)