mirror of
https://github.com/we-promise/sure.git
synced 2026-04-13 17:14:05 +00:00
* 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>
329 lines
11 KiB
Ruby
329 lines
11 KiB
Ruby
require "test_helper"
|
|
|
|
class PlaidAccount::Investments::HoldingsProcessorTest < ActiveSupport::TestCase
|
|
setup do
|
|
@plaid_account = plaid_accounts(:one)
|
|
@security_resolver = PlaidAccount::Investments::SecurityResolver.new(@plaid_account)
|
|
end
|
|
|
|
test "creates holding records from Plaid holdings snapshot" do
|
|
test_investments_payload = {
|
|
securities: [], # mocked
|
|
holdings: [
|
|
{
|
|
"security_id" => "123",
|
|
"quantity" => 100,
|
|
"institution_price" => 100,
|
|
"iso_currency_code" => "USD",
|
|
"institution_price_as_of" => 1.day.ago.to_date
|
|
},
|
|
{
|
|
"security_id" => "456",
|
|
"quantity" => 200,
|
|
"institution_price" => 200,
|
|
"iso_currency_code" => "USD"
|
|
}
|
|
],
|
|
transactions: [] # not relevant for test
|
|
}
|
|
|
|
@plaid_account.update!(raw_investments_payload: test_investments_payload)
|
|
|
|
@security_resolver.expects(:resolve)
|
|
.with(plaid_security_id: "123")
|
|
.returns(
|
|
OpenStruct.new(
|
|
security: securities(:aapl),
|
|
cash_equivalent?: false,
|
|
brokerage_cash?: false
|
|
)
|
|
)
|
|
|
|
@security_resolver.expects(:resolve)
|
|
.with(plaid_security_id: "456")
|
|
.returns(
|
|
OpenStruct.new(
|
|
security: securities(:aapl),
|
|
cash_equivalent?: false,
|
|
brokerage_cash?: false
|
|
)
|
|
)
|
|
|
|
processor = PlaidAccount::Investments::HoldingsProcessor.new(@plaid_account, security_resolver: @security_resolver)
|
|
|
|
assert_difference "Holding.count", 2 do
|
|
processor.process
|
|
end
|
|
|
|
holdings = Holding.where(account: @plaid_account.current_account).order(:date)
|
|
|
|
assert_equal 100, holdings.first.qty
|
|
assert_equal 100, holdings.first.price
|
|
assert_equal "USD", holdings.first.currency
|
|
assert_equal securities(:aapl), holdings.first.security
|
|
assert_equal 1.day.ago.to_date, holdings.first.date
|
|
|
|
assert_equal 200, holdings.second.qty
|
|
assert_equal 200, holdings.second.price
|
|
assert_equal "USD", holdings.second.currency
|
|
assert_equal securities(:aapl), holdings.second.security
|
|
assert_equal Date.current, holdings.second.date
|
|
end
|
|
|
|
# Plaid does not delete future holdings because it doesn't support holdings deletion
|
|
# (PlaidAdapter#can_delete_holdings? returns false). This test verifies that future
|
|
# holdings are NOT deleted when processing Plaid holdings data.
|
|
test "does not delete future holdings when processing Plaid holdings" do
|
|
account = @plaid_account.current_account
|
|
|
|
# Create account_provider
|
|
account_provider = AccountProvider.create!(
|
|
account: account,
|
|
provider: @plaid_account
|
|
)
|
|
|
|
# Create a third security for testing
|
|
third_security = Security.create!(ticker: "GOOGL", name: "Google", exchange_operating_mic: "XNAS", country_code: "US")
|
|
|
|
# Create a future AAPL holding that should NOT be deleted
|
|
future_aapl_holding = account.holdings.create!(
|
|
security: securities(:aapl),
|
|
date: Date.current,
|
|
qty: 80,
|
|
price: 180,
|
|
amount: 14400,
|
|
currency: "USD",
|
|
account_provider_id: account_provider.id
|
|
)
|
|
|
|
# Plaid returns holdings from yesterday - future holdings should remain
|
|
test_investments_payload = {
|
|
securities: [],
|
|
holdings: [
|
|
{
|
|
"security_id" => "current",
|
|
"quantity" => 50,
|
|
"institution_price" => 50,
|
|
"iso_currency_code" => "USD",
|
|
"institution_price_as_of" => Date.current
|
|
},
|
|
{
|
|
"security_id" => "clean",
|
|
"quantity" => 75,
|
|
"institution_price" => 75,
|
|
"iso_currency_code" => "USD",
|
|
"institution_price_as_of" => 1.day.ago.to_date
|
|
},
|
|
{
|
|
"security_id" => "past",
|
|
"quantity" => 100,
|
|
"institution_price" => 100,
|
|
"iso_currency_code" => "USD",
|
|
"institution_price_as_of" => 1.day.ago.to_date
|
|
}
|
|
],
|
|
transactions: []
|
|
}
|
|
|
|
@plaid_account.update!(raw_investments_payload: test_investments_payload)
|
|
|
|
# Mock security resolver for all three securities
|
|
@security_resolver.expects(:resolve)
|
|
.with(plaid_security_id: "current")
|
|
.returns(OpenStruct.new(security: securities(:msft), cash_equivalent?: false, brokerage_cash?: false))
|
|
|
|
@security_resolver.expects(:resolve)
|
|
.with(plaid_security_id: "clean")
|
|
.returns(OpenStruct.new(security: third_security, cash_equivalent?: false, brokerage_cash?: false))
|
|
|
|
@security_resolver.expects(:resolve)
|
|
.with(plaid_security_id: "past")
|
|
.returns(OpenStruct.new(security: securities(:aapl), cash_equivalent?: false, brokerage_cash?: false))
|
|
|
|
processor = PlaidAccount::Investments::HoldingsProcessor.new(@plaid_account, security_resolver: @security_resolver)
|
|
processor.process
|
|
|
|
# Should have created 3 new holdings PLUS the existing future holding (total 4)
|
|
assert_equal 4, account.holdings.count
|
|
|
|
# Future AAPL holding should still exist (NOT deleted)
|
|
assert account.holdings.exists?(future_aapl_holding.id)
|
|
|
|
# Should have the correct holdings from Plaid
|
|
assert account.holdings.exists?(security: securities(:msft), date: Date.current, qty: 50)
|
|
assert account.holdings.exists?(security: third_security, date: 1.day.ago.to_date, qty: 75)
|
|
assert account.holdings.exists?(security: securities(:aapl), date: 1.day.ago.to_date, qty: 100)
|
|
end
|
|
|
|
test "continues processing other holdings when security resolution fails" do
|
|
test_investments_payload = {
|
|
securities: [],
|
|
holdings: [
|
|
{
|
|
"security_id" => "fail",
|
|
"quantity" => 100,
|
|
"institution_price" => 100,
|
|
"iso_currency_code" => "USD"
|
|
},
|
|
{
|
|
"security_id" => "success",
|
|
"quantity" => 200,
|
|
"institution_price" => 200,
|
|
"iso_currency_code" => "USD"
|
|
}
|
|
],
|
|
transactions: []
|
|
}
|
|
|
|
@plaid_account.update!(raw_investments_payload: test_investments_payload)
|
|
|
|
# First security fails to resolve
|
|
@security_resolver.expects(:resolve)
|
|
.with(plaid_security_id: "fail")
|
|
.returns(OpenStruct.new(security: nil))
|
|
|
|
# Second security succeeds
|
|
@security_resolver.expects(:resolve)
|
|
.with(plaid_security_id: "success")
|
|
.returns(OpenStruct.new(security: securities(:aapl)))
|
|
|
|
processor = PlaidAccount::Investments::HoldingsProcessor.new(@plaid_account, security_resolver: @security_resolver)
|
|
|
|
# Should create only 1 holding (the successful one)
|
|
assert_difference "Holding.count", 1 do
|
|
processor.process
|
|
end
|
|
|
|
# Should have created the successful holding
|
|
assert @plaid_account.current_account.holdings.exists?(security: securities(:aapl), qty: 200)
|
|
end
|
|
|
|
test "handles string values and computes amount using BigDecimal arithmetic" do
|
|
test_investments_payload = {
|
|
securities: [],
|
|
holdings: [
|
|
{
|
|
"security_id" => "string_values",
|
|
"quantity" => "10.5",
|
|
"institution_price" => "150.75",
|
|
"iso_currency_code" => "USD",
|
|
"institution_price_as_of" => "2025-01-15"
|
|
}
|
|
],
|
|
transactions: []
|
|
}
|
|
|
|
@plaid_account.update!(raw_investments_payload: test_investments_payload)
|
|
|
|
@security_resolver.expects(:resolve)
|
|
.with(plaid_security_id: "string_values")
|
|
.returns(OpenStruct.new(security: securities(:aapl)))
|
|
|
|
processor = PlaidAccount::Investments::HoldingsProcessor.new(@plaid_account, security_resolver: @security_resolver)
|
|
|
|
assert_difference "Holding.count", 1 do
|
|
processor.process
|
|
end
|
|
|
|
holding = @plaid_account.current_account.holdings.find_by(
|
|
security: securities(:aapl),
|
|
date: Date.parse("2025-01-15"),
|
|
currency: "USD"
|
|
)
|
|
|
|
assert_not_nil holding, "Expected to find holding for AAPL on 2025-01-15"
|
|
assert_equal BigDecimal("10.5"), holding.qty
|
|
assert_equal BigDecimal("150.75"), holding.price
|
|
assert_equal BigDecimal("1582.875"), holding.amount # 10.5 * 150.75 using BigDecimal
|
|
assert_equal Date.parse("2025-01-15"), holding.date
|
|
end
|
|
|
|
test "skips holdings with nil quantity or price" do
|
|
test_investments_payload = {
|
|
securities: [],
|
|
holdings: [
|
|
{
|
|
"security_id" => "missing_quantity",
|
|
"quantity" => nil,
|
|
"institution_price" => 100,
|
|
"iso_currency_code" => "USD"
|
|
},
|
|
{
|
|
"security_id" => "missing_price",
|
|
"quantity" => 100,
|
|
"institution_price" => nil,
|
|
"iso_currency_code" => "USD"
|
|
},
|
|
{
|
|
"security_id" => "valid",
|
|
"quantity" => 50,
|
|
"institution_price" => 50,
|
|
"iso_currency_code" => "USD"
|
|
}
|
|
],
|
|
transactions: []
|
|
}
|
|
|
|
@plaid_account.update!(raw_investments_payload: test_investments_payload)
|
|
|
|
@security_resolver.expects(:resolve)
|
|
.with(plaid_security_id: "missing_quantity")
|
|
.returns(OpenStruct.new(security: securities(:aapl)))
|
|
|
|
@security_resolver.expects(:resolve)
|
|
.with(plaid_security_id: "missing_price")
|
|
.returns(OpenStruct.new(security: securities(:msft)))
|
|
|
|
@security_resolver.expects(:resolve)
|
|
.with(plaid_security_id: "valid")
|
|
.returns(OpenStruct.new(security: securities(:aapl)))
|
|
|
|
processor = PlaidAccount::Investments::HoldingsProcessor.new(@plaid_account, security_resolver: @security_resolver)
|
|
|
|
# Should create only 1 holding (the valid one)
|
|
assert_difference "Holding.count", 1 do
|
|
processor.process
|
|
end
|
|
|
|
# Should have created only the valid holding
|
|
assert @plaid_account.current_account.holdings.exists?(security: securities(:aapl), qty: 50, price: 50)
|
|
assert_not @plaid_account.current_account.holdings.exists?(security: securities(:msft))
|
|
end
|
|
|
|
test "uses account currency as fallback when Plaid omits iso_currency_code" do
|
|
account = @plaid_account.current_account
|
|
|
|
# Ensure the account has a currency
|
|
account.update!(currency: "EUR")
|
|
|
|
test_investments_payload = {
|
|
securities: [],
|
|
holdings: [
|
|
{
|
|
"security_id" => "no_currency",
|
|
"quantity" => 100,
|
|
"institution_price" => 100,
|
|
"iso_currency_code" => nil, # Plaid omits currency
|
|
"institution_price_as_of" => Date.current
|
|
}
|
|
],
|
|
transactions: []
|
|
}
|
|
|
|
@plaid_account.update!(raw_investments_payload: test_investments_payload)
|
|
|
|
@security_resolver.expects(:resolve)
|
|
.with(plaid_security_id: "no_currency")
|
|
.returns(OpenStruct.new(security: securities(:aapl)))
|
|
|
|
processor = PlaidAccount::Investments::HoldingsProcessor.new(@plaid_account, security_resolver: @security_resolver)
|
|
|
|
assert_difference "Holding.count", 1 do
|
|
processor.process
|
|
end
|
|
|
|
holding = account.holdings.find_by(security: securities(:aapl))
|
|
assert_equal "EUR", holding.currency # Should use account's currency
|
|
end
|
|
end
|