mirror of
https://github.com/we-promise/sure.git
synced 2026-04-08 06:44:52 +00:00
* Add pending transaction handling and duplicate reconciliation logic - Implemented logic to exclude pending transactions from budgets and analytics calculations. - Introduced mechanisms for reconciling pending transactions with posted versions. - Added duplicate detection with support for merging or dismissing matches. - Updated transaction search filters to include a `status_filter` for pending/confirmed transactions. - Introduced UI elements for reviewing and resolving duplicates. - Enhanced `ProviderSyncSummary` with stats for reconciled and stale pending transactions. * Refactor translation handling and enhance transaction and sync logic - Moved hardcoded strings to locale files for improved translation support. - Refined styling for duplicate transaction indicators and sync summaries. - Improved logic for excluding stale pending transactions and updating timestamps on batch exclusion. - Added unique IDs to status filters for better element targeting in UI. - Optimized database queries to avoid N+1 issues in stale pending calculations. * Add sync settings and enhance pending transaction handling - Introduced a new "Sync Settings" section in hosting settings with UI to toggle inclusion of pending transactions. - Updated handling of pending transactions with improved inference logic for `posted=0` and `transacted_at` in processors. - Added priority order for pending transaction inclusion: explicit argument > environment variable > runtime configurable setting. - Refactored settings and controllers to store updated sync preferences. * Refactor sync settings and pending transaction reconciliation - Extracted logic for pending transaction reconciliation, stale exclusion, and unmatched tracking into dedicated methods for better maintainability. - Updated sync settings to infer defaults from multiple provider environment variables (`SIMPLEFIN_INCLUDE_PENDING`, `PLAID_INCLUDE_PENDING`). - Refined UI and messaging to handle multi-provider configurations in sync settings. # Conflicts: # app/models/simplefin_item/importer.rb * Debounce transaction reconciliation during imports - Added per-run reconciliation debouncing to prevent repeated scans for the same account during chunked history imports. - Trimmed size of reconciliation stats to retain recent details only. - Introduced error tracking for reconciliation steps to improve UI visibility of issues. * Apply ABS() in pending transaction queries and improve error handling - Updated pending transaction logic to use ABS() for consistent handling of negative amounts. - Adjusted amount bounds calculations to ensure accuracy for both positive and negative values. - Refined exception handling in `merge_duplicate` to log failures and update user alert. - Replaced `Date.today` with `Date.current` in tests to ensure timezone consistency. - Minor optimization to avoid COUNT queries by loading limited records directly. * Improve error handling in duplicate suggestion and dismissal logic - Added exception handling for `store_duplicate_suggestion` to log failures and prevent crashes during fuzzy/low-confidence matches. - Enhanced `dismiss_duplicate` action to handle `ActiveRecord::RecordInvalid` and display appropriate user alerts. --------- Co-authored-by: Josh Waldrep <joshua.waldrep5+github@gmail.com>
1267 lines
39 KiB
Ruby
1267 lines
39 KiB
Ruby
require "test_helper"
|
|
|
|
class Account::ProviderImportAdapterTest < ActiveSupport::TestCase
|
|
setup do
|
|
@account = accounts(:depository)
|
|
@adapter = Account::ProviderImportAdapter.new(@account)
|
|
@family = families(:dylan_family)
|
|
end
|
|
|
|
test "imports transaction with all parameters" do
|
|
category = categories(:income)
|
|
merchant = ProviderMerchant.create!(
|
|
provider_merchant_id: "test_merchant_123",
|
|
name: "Test Merchant",
|
|
source: "plaid"
|
|
)
|
|
|
|
assert_difference "@account.entries.count", 1 do
|
|
entry = @adapter.import_transaction(
|
|
external_id: "plaid_test_123",
|
|
amount: 100.50,
|
|
currency: "USD",
|
|
date: Date.today,
|
|
name: "Test Transaction",
|
|
source: "plaid",
|
|
category_id: category.id,
|
|
merchant: merchant
|
|
)
|
|
|
|
assert_equal 100.50, entry.amount
|
|
assert_equal "USD", entry.currency
|
|
assert_equal Date.today, entry.date
|
|
assert_equal "Test Transaction", entry.name
|
|
assert_equal category.id, entry.transaction.category_id
|
|
assert_equal merchant.id, entry.transaction.merchant_id
|
|
end
|
|
end
|
|
|
|
test "imports transaction with minimal parameters" do
|
|
assert_difference "@account.entries.count", 1 do
|
|
entry = @adapter.import_transaction(
|
|
external_id: "simplefin_abc",
|
|
amount: 50.00,
|
|
currency: "USD",
|
|
date: Date.today,
|
|
name: "Simple Transaction",
|
|
source: "simplefin"
|
|
)
|
|
|
|
assert_equal 50.00, entry.amount
|
|
assert_equal "simplefin_abc", entry.external_id
|
|
assert_equal "simplefin", entry.source
|
|
assert_nil entry.transaction.category_id
|
|
assert_nil entry.transaction.merchant_id
|
|
end
|
|
end
|
|
|
|
test "updates existing transaction instead of creating duplicate" do
|
|
# Create initial transaction
|
|
entry = @adapter.import_transaction(
|
|
external_id: "plaid_duplicate_test",
|
|
amount: 100.00,
|
|
currency: "USD",
|
|
date: Date.today,
|
|
name: "Original Name",
|
|
source: "plaid"
|
|
)
|
|
|
|
# Import again with different data - should update, not create new
|
|
assert_no_difference "@account.entries.count" do
|
|
updated_entry = @adapter.import_transaction(
|
|
external_id: "plaid_duplicate_test",
|
|
amount: 200.00,
|
|
currency: "USD",
|
|
date: Date.today,
|
|
name: "Updated Name",
|
|
source: "plaid"
|
|
)
|
|
|
|
assert_equal entry.id, updated_entry.id
|
|
assert_equal 200.00, updated_entry.amount
|
|
assert_equal "Updated Name", updated_entry.name
|
|
end
|
|
end
|
|
|
|
test "allows same external_id from different sources without collision" do
|
|
# Create transaction from SimpleFin with ID "transaction_123"
|
|
simplefin_entry = @adapter.import_transaction(
|
|
external_id: "transaction_123",
|
|
amount: 100.00,
|
|
currency: "USD",
|
|
date: Date.today,
|
|
name: "SimpleFin Transaction",
|
|
source: "simplefin"
|
|
)
|
|
|
|
# Create transaction from Plaid with same ID "transaction_123" - should NOT collide
|
|
# because external_id is unique per (account, source) combination
|
|
assert_difference "@account.entries.count", 1 do
|
|
plaid_entry = @adapter.import_transaction(
|
|
external_id: "transaction_123",
|
|
amount: 200.00,
|
|
currency: "USD",
|
|
date: Date.today,
|
|
name: "Plaid Transaction",
|
|
source: "plaid"
|
|
)
|
|
|
|
# Should be different entries
|
|
assert_not_equal simplefin_entry.id, plaid_entry.id
|
|
assert_equal "simplefin", simplefin_entry.source
|
|
assert_equal "plaid", plaid_entry.source
|
|
assert_equal "transaction_123", simplefin_entry.external_id
|
|
assert_equal "transaction_123", plaid_entry.external_id
|
|
end
|
|
end
|
|
|
|
test "raises error when external_id is missing" do
|
|
exception = assert_raises(ArgumentError) do
|
|
@adapter.import_transaction(
|
|
external_id: "",
|
|
amount: 100.00,
|
|
currency: "USD",
|
|
date: Date.today,
|
|
name: "Test",
|
|
source: "plaid"
|
|
)
|
|
end
|
|
|
|
assert_equal "external_id is required", exception.message
|
|
end
|
|
|
|
test "raises error when source is missing" do
|
|
exception = assert_raises(ArgumentError) do
|
|
@adapter.import_transaction(
|
|
external_id: "test_123",
|
|
amount: 100.00,
|
|
currency: "USD",
|
|
date: Date.today,
|
|
name: "Test",
|
|
source: ""
|
|
)
|
|
end
|
|
|
|
assert_equal "source is required", exception.message
|
|
end
|
|
|
|
test "finds or creates merchant with all data" do
|
|
assert_difference "ProviderMerchant.count", 1 do
|
|
merchant = @adapter.find_or_create_merchant(
|
|
provider_merchant_id: "plaid_merchant_123",
|
|
name: "Test Merchant",
|
|
source: "plaid",
|
|
website_url: "https://example.com",
|
|
logo_url: "https://example.com/logo.png"
|
|
)
|
|
|
|
assert_equal "Test Merchant", merchant.name
|
|
assert_equal "plaid", merchant.source
|
|
assert_equal "plaid_merchant_123", merchant.provider_merchant_id
|
|
assert_equal "https://example.com", merchant.website_url
|
|
assert_equal "https://example.com/logo.png", merchant.logo_url
|
|
end
|
|
end
|
|
|
|
test "returns nil when merchant data is insufficient" do
|
|
merchant = @adapter.find_or_create_merchant(
|
|
provider_merchant_id: "",
|
|
name: "",
|
|
source: "plaid"
|
|
)
|
|
|
|
assert_nil merchant
|
|
end
|
|
|
|
test "finds existing merchant instead of creating duplicate" do
|
|
existing_merchant = ProviderMerchant.create!(
|
|
provider_merchant_id: "existing_123",
|
|
name: "Existing Merchant",
|
|
source: "plaid"
|
|
)
|
|
|
|
assert_no_difference "ProviderMerchant.count" do
|
|
merchant = @adapter.find_or_create_merchant(
|
|
provider_merchant_id: "existing_123",
|
|
name: "Existing Merchant",
|
|
source: "plaid"
|
|
)
|
|
|
|
assert_equal existing_merchant.id, merchant.id
|
|
end
|
|
end
|
|
|
|
test "updates account balance" do
|
|
@adapter.update_balance(
|
|
balance: 5000.00,
|
|
cash_balance: 4500.00,
|
|
source: "plaid"
|
|
)
|
|
|
|
@account.reload
|
|
assert_equal 5000.00, @account.balance
|
|
assert_equal 4500.00, @account.cash_balance
|
|
end
|
|
|
|
test "updates account balance without cash_balance" do
|
|
@adapter.update_balance(
|
|
balance: 3000.00,
|
|
source: "simplefin"
|
|
)
|
|
|
|
@account.reload
|
|
assert_equal 3000.00, @account.balance
|
|
assert_equal 3000.00, @account.cash_balance
|
|
end
|
|
|
|
test "imports holding with all parameters" do
|
|
investment_account = accounts(:investment)
|
|
adapter = Account::ProviderImportAdapter.new(investment_account)
|
|
security = securities(:aapl)
|
|
|
|
# Use a date that doesn't conflict with fixtures (fixtures use today and 1.day.ago)
|
|
holding_date = Date.today - 2.days
|
|
|
|
assert_difference "investment_account.holdings.count", 1 do
|
|
holding = adapter.import_holding(
|
|
security: security,
|
|
quantity: 10.5,
|
|
amount: 1575.00,
|
|
currency: "USD",
|
|
date: holding_date,
|
|
price: 150.00,
|
|
source: "plaid"
|
|
)
|
|
|
|
assert_equal security.id, holding.security_id
|
|
assert_equal 10.5, holding.qty
|
|
assert_equal 1575.00, holding.amount
|
|
assert_equal 150.00, holding.price
|
|
assert_equal holding_date, holding.date
|
|
end
|
|
end
|
|
|
|
test "raises error when security is missing for holding import" do
|
|
exception = assert_raises(ArgumentError) do
|
|
@adapter.import_holding(
|
|
security: nil,
|
|
quantity: 10,
|
|
amount: 1000,
|
|
currency: "USD",
|
|
date: Date.today,
|
|
source: "plaid"
|
|
)
|
|
end
|
|
|
|
assert_equal "security is required", exception.message
|
|
end
|
|
|
|
test "imports trade with all parameters" do
|
|
investment_account = accounts(:investment)
|
|
adapter = Account::ProviderImportAdapter.new(investment_account)
|
|
security = securities(:aapl)
|
|
|
|
assert_difference "investment_account.entries.count", 1 do
|
|
entry = adapter.import_trade(
|
|
security: security,
|
|
quantity: 5,
|
|
price: 150.00,
|
|
amount: 750.00,
|
|
currency: "USD",
|
|
date: Date.today,
|
|
source: "plaid"
|
|
)
|
|
|
|
assert_kind_of Trade, entry.entryable
|
|
assert_equal 5, entry.entryable.qty
|
|
assert_equal 150.00, entry.entryable.price
|
|
assert_equal 750.00, entry.amount
|
|
assert_match(/Buy.*5.*shares/i, entry.name)
|
|
end
|
|
end
|
|
|
|
test "raises error when security is missing for trade import" do
|
|
exception = assert_raises(ArgumentError) do
|
|
@adapter.import_trade(
|
|
security: nil,
|
|
quantity: 5,
|
|
price: 100,
|
|
amount: 500,
|
|
currency: "USD",
|
|
date: Date.today,
|
|
source: "plaid"
|
|
)
|
|
end
|
|
|
|
assert_equal "security is required", exception.message
|
|
end
|
|
|
|
test "stores account_provider_id when importing holding" do
|
|
investment_account = accounts(:investment)
|
|
adapter = Account::ProviderImportAdapter.new(investment_account)
|
|
security = securities(:aapl)
|
|
account_provider = AccountProvider.create!(
|
|
account: investment_account,
|
|
provider: plaid_accounts(:one)
|
|
)
|
|
|
|
holding = adapter.import_holding(
|
|
security: security,
|
|
quantity: 10,
|
|
amount: 1500,
|
|
currency: "USD",
|
|
date: Date.today - 10.days,
|
|
price: 150,
|
|
source: "plaid",
|
|
account_provider_id: account_provider.id
|
|
)
|
|
|
|
assert_equal account_provider.id, holding.account_provider_id
|
|
end
|
|
|
|
test "does not delete future holdings when can_delete_holdings? returns false" do
|
|
investment_account = accounts(:investment)
|
|
adapter = Account::ProviderImportAdapter.new(investment_account)
|
|
security = securities(:aapl)
|
|
|
|
# Create a future holding
|
|
future_holding = investment_account.holdings.create!(
|
|
security: security,
|
|
qty: 5,
|
|
amount: 750,
|
|
currency: "USD",
|
|
date: Date.today + 30.days,
|
|
price: 150
|
|
)
|
|
|
|
# Mock can_delete_holdings? to return false
|
|
investment_account.expects(:can_delete_holdings?).returns(false)
|
|
|
|
# Import a holding with delete_future_holdings flag
|
|
adapter.import_holding(
|
|
security: security,
|
|
quantity: 10,
|
|
amount: 1500,
|
|
currency: "USD",
|
|
date: Date.today,
|
|
price: 150,
|
|
source: "plaid",
|
|
delete_future_holdings: true
|
|
)
|
|
|
|
# Future holding should still exist
|
|
assert Holding.exists?(future_holding.id)
|
|
end
|
|
|
|
test "deletes only holdings from same provider when account_provider_id is provided" do
|
|
investment_account = accounts(:investment)
|
|
adapter = Account::ProviderImportAdapter.new(investment_account)
|
|
security = securities(:aapl)
|
|
|
|
# Create an account provider
|
|
plaid_account = PlaidAccount.create!(
|
|
current_balance: 1000,
|
|
available_balance: 1000,
|
|
currency: "USD",
|
|
name: "Test Plaid Account",
|
|
plaid_item: plaid_items(:one),
|
|
plaid_id: "acc_mock_test_1",
|
|
plaid_type: "investment",
|
|
plaid_subtype: "brokerage"
|
|
)
|
|
|
|
provider = AccountProvider.create!(
|
|
account: investment_account,
|
|
provider: plaid_account
|
|
)
|
|
|
|
# Create future holdings - one from the provider, one without a provider
|
|
future_holding_with_provider = investment_account.holdings.create!(
|
|
security: security,
|
|
qty: 5,
|
|
amount: 750,
|
|
currency: "USD",
|
|
date: Date.today + 120.days,
|
|
price: 150,
|
|
account_provider_id: provider.id
|
|
)
|
|
|
|
future_holding_without_provider = investment_account.holdings.create!(
|
|
security: security,
|
|
qty: 3,
|
|
amount: 450,
|
|
currency: "USD",
|
|
date: Date.today + 2.days,
|
|
price: 150,
|
|
account_provider_id: nil
|
|
)
|
|
|
|
# Mock can_delete_holdings? to return true
|
|
investment_account.expects(:can_delete_holdings?).returns(true)
|
|
|
|
# Import a holding with provider ID and delete_future_holdings flag
|
|
adapter.import_holding(
|
|
security: security,
|
|
quantity: 10,
|
|
amount: 1500,
|
|
currency: "USD",
|
|
date: Date.today - 10.days,
|
|
price: 150,
|
|
source: "plaid",
|
|
account_provider_id: provider.id,
|
|
delete_future_holdings: true
|
|
)
|
|
|
|
# Only the holding from the same provider should be deleted
|
|
assert_not Holding.exists?(future_holding_with_provider.id)
|
|
assert Holding.exists?(future_holding_without_provider.id)
|
|
end
|
|
|
|
test "deletes all future holdings when account_provider_id is not provided and can_delete_holdings? returns true" do
|
|
investment_account = accounts(:investment)
|
|
adapter = Account::ProviderImportAdapter.new(investment_account)
|
|
security = securities(:aapl)
|
|
|
|
# Create two future holdings
|
|
future_holding_1 = investment_account.holdings.create!(
|
|
security: security,
|
|
qty: 5,
|
|
amount: 750,
|
|
currency: "USD",
|
|
date: Date.today + 121.days,
|
|
price: 150
|
|
)
|
|
|
|
future_holding_2 = investment_account.holdings.create!(
|
|
security: security,
|
|
qty: 3,
|
|
amount: 450,
|
|
currency: "USD",
|
|
date: Date.today + 2.days,
|
|
price: 150
|
|
)
|
|
|
|
# Mock can_delete_holdings? to return true
|
|
investment_account.expects(:can_delete_holdings?).returns(true)
|
|
|
|
# Import a holding without account_provider_id
|
|
adapter.import_holding(
|
|
security: security,
|
|
quantity: 10,
|
|
amount: 1500,
|
|
currency: "USD",
|
|
date: Date.today,
|
|
price: 150,
|
|
source: "plaid",
|
|
delete_future_holdings: true
|
|
)
|
|
|
|
# All future holdings should be deleted
|
|
assert_not Holding.exists?(future_holding_1.id)
|
|
assert_not Holding.exists?(future_holding_2.id)
|
|
end
|
|
|
|
test "updates existing trade attributes instead of keeping stale data" do
|
|
investment_account = accounts(:investment)
|
|
adapter = Account::ProviderImportAdapter.new(investment_account)
|
|
aapl = securities(:aapl)
|
|
msft = securities(:msft)
|
|
|
|
# Create initial trade
|
|
entry = adapter.import_trade(
|
|
external_id: "plaid_trade_123",
|
|
security: aapl,
|
|
quantity: 5,
|
|
price: 150.00,
|
|
amount: 750.00,
|
|
currency: "USD",
|
|
date: Date.today,
|
|
source: "plaid"
|
|
)
|
|
|
|
# Import again with updated attributes - should update Trade, not keep stale data
|
|
assert_no_difference "investment_account.entries.count" do
|
|
updated_entry = adapter.import_trade(
|
|
external_id: "plaid_trade_123",
|
|
security: msft,
|
|
quantity: 10,
|
|
price: 200.00,
|
|
amount: 2000.00,
|
|
currency: "USD",
|
|
date: Date.today,
|
|
source: "plaid"
|
|
)
|
|
|
|
assert_equal entry.id, updated_entry.id
|
|
# Trade attributes should be updated
|
|
assert_equal msft.id, updated_entry.entryable.security_id
|
|
assert_equal 10, updated_entry.entryable.qty
|
|
assert_equal 200.00, updated_entry.entryable.price
|
|
assert_equal "USD", updated_entry.entryable.currency
|
|
# Entry attributes should also be updated
|
|
assert_equal 2000.00, updated_entry.amount
|
|
end
|
|
end
|
|
|
|
test "raises error when external_id collision occurs across different entryable types for transaction" do
|
|
investment_account = accounts(:investment)
|
|
adapter = Account::ProviderImportAdapter.new(investment_account)
|
|
security = securities(:aapl)
|
|
|
|
# Create a trade with external_id "collision_test"
|
|
adapter.import_trade(
|
|
external_id: "collision_test",
|
|
security: security,
|
|
quantity: 5,
|
|
price: 150.00,
|
|
amount: 750.00,
|
|
currency: "USD",
|
|
date: Date.today,
|
|
source: "plaid"
|
|
)
|
|
|
|
# Try to create a transaction with the same external_id and source
|
|
exception = assert_raises(ArgumentError) do
|
|
adapter.import_transaction(
|
|
external_id: "collision_test",
|
|
amount: 100.00,
|
|
currency: "USD",
|
|
date: Date.today,
|
|
name: "Test Transaction",
|
|
source: "plaid"
|
|
)
|
|
end
|
|
|
|
assert_match(/Entry with external_id.*already exists with different entryable type/i, exception.message)
|
|
end
|
|
|
|
test "raises error when external_id collision occurs across different entryable types for trade" do
|
|
investment_account = accounts(:investment)
|
|
adapter = Account::ProviderImportAdapter.new(investment_account)
|
|
security = securities(:aapl)
|
|
|
|
# Create a transaction with external_id "collision_test_2"
|
|
adapter.import_transaction(
|
|
external_id: "collision_test_2",
|
|
amount: 100.00,
|
|
currency: "USD",
|
|
date: Date.today,
|
|
name: "Test Transaction",
|
|
source: "plaid"
|
|
)
|
|
|
|
# Try to create a trade with the same external_id and source
|
|
exception = assert_raises(ArgumentError) do
|
|
adapter.import_trade(
|
|
external_id: "collision_test_2",
|
|
security: security,
|
|
quantity: 5,
|
|
price: 150.00,
|
|
amount: 750.00,
|
|
currency: "USD",
|
|
date: Date.today,
|
|
source: "plaid"
|
|
)
|
|
end
|
|
|
|
assert_match(/Entry with external_id.*already exists with different entryable type/i, exception.message)
|
|
end
|
|
|
|
test "claims manual transaction when provider syncs matching transaction" do
|
|
# Create a manual transaction (no external_id or source)
|
|
manual_entry = @account.entries.create!(
|
|
date: Date.today,
|
|
amount: 42.50,
|
|
currency: "USD",
|
|
name: "Coffee Shop",
|
|
entryable: Transaction.new
|
|
)
|
|
|
|
assert_nil manual_entry.external_id
|
|
assert_nil manual_entry.source
|
|
|
|
# Provider syncs a matching transaction - should claim the manual entry, not create new
|
|
assert_no_difference "@account.entries.count" do
|
|
entry = @adapter.import_transaction(
|
|
external_id: "lunchflow_12345",
|
|
amount: 42.50,
|
|
currency: "USD",
|
|
date: Date.today,
|
|
name: "Coffee Shop - Lunchflow",
|
|
source: "lunchflow"
|
|
)
|
|
|
|
# Should be the same entry, now claimed by the provider
|
|
assert_equal manual_entry.id, entry.id
|
|
assert_equal "lunchflow_12345", entry.external_id
|
|
assert_equal "lunchflow", entry.source
|
|
assert_equal "Coffee Shop - Lunchflow", entry.name
|
|
end
|
|
end
|
|
|
|
test "claims CSV imported transaction when provider syncs matching transaction" do
|
|
# Create a CSV imported transaction (has import_id but no external_id)
|
|
import = Import.create!(
|
|
family: @family,
|
|
type: "TransactionImport",
|
|
status: :complete
|
|
)
|
|
|
|
csv_entry = @account.entries.create!(
|
|
date: Date.today - 1.day,
|
|
amount: 125.00,
|
|
currency: "USD",
|
|
name: "Grocery Store",
|
|
import: import,
|
|
entryable: Transaction.new
|
|
)
|
|
|
|
assert_nil csv_entry.external_id
|
|
assert_nil csv_entry.source
|
|
assert_equal import.id, csv_entry.import_id
|
|
|
|
# Provider syncs a matching transaction - should claim the CSV entry
|
|
assert_no_difference "@account.entries.count" do
|
|
entry = @adapter.import_transaction(
|
|
external_id: "plaid_csv_match",
|
|
amount: 125.00,
|
|
currency: "USD",
|
|
date: Date.today - 1.day,
|
|
name: "Grocery Store - Plaid",
|
|
source: "plaid"
|
|
)
|
|
|
|
# Should be the same entry, now claimed by the provider
|
|
assert_equal csv_entry.id, entry.id
|
|
assert_equal "plaid_csv_match", entry.external_id
|
|
assert_equal "plaid", entry.source
|
|
assert_equal import.id, entry.import_id # Should preserve the import_id
|
|
end
|
|
end
|
|
|
|
test "does not claim transaction when date does not match" do
|
|
# Create a manual transaction
|
|
manual_entry = @account.entries.create!(
|
|
date: Date.today - 5.days,
|
|
amount: 50.00,
|
|
currency: "USD",
|
|
name: "Restaurant",
|
|
entryable: Transaction.new
|
|
)
|
|
|
|
# Provider syncs similar transaction but different date - should create new entry
|
|
assert_difference "@account.entries.count", 1 do
|
|
entry = @adapter.import_transaction(
|
|
external_id: "lunchflow_different_date",
|
|
amount: 50.00,
|
|
currency: "USD",
|
|
date: Date.today,
|
|
name: "Restaurant",
|
|
source: "lunchflow"
|
|
)
|
|
|
|
# Should be a different entry
|
|
assert_not_equal manual_entry.id, entry.id
|
|
end
|
|
end
|
|
|
|
test "does not claim transaction when amount does not match" do
|
|
# Create a manual transaction
|
|
manual_entry = @account.entries.create!(
|
|
date: Date.today,
|
|
amount: 50.00,
|
|
currency: "USD",
|
|
name: "Restaurant",
|
|
entryable: Transaction.new
|
|
)
|
|
|
|
# Provider syncs similar transaction but different amount - should create new entry
|
|
assert_difference "@account.entries.count", 1 do
|
|
entry = @adapter.import_transaction(
|
|
external_id: "lunchflow_different_amount",
|
|
amount: 51.00,
|
|
currency: "USD",
|
|
date: Date.today,
|
|
name: "Restaurant",
|
|
source: "lunchflow"
|
|
)
|
|
|
|
# Should be a different entry
|
|
assert_not_equal manual_entry.id, entry.id
|
|
end
|
|
end
|
|
|
|
test "does not claim transaction when currency does not match" do
|
|
# Create a manual transaction
|
|
manual_entry = @account.entries.create!(
|
|
date: Date.today,
|
|
amount: 50.00,
|
|
currency: "EUR",
|
|
name: "Restaurant",
|
|
entryable: Transaction.new
|
|
)
|
|
|
|
# Provider syncs similar transaction but different currency - should create new entry
|
|
assert_difference "@account.entries.count", 1 do
|
|
entry = @adapter.import_transaction(
|
|
external_id: "lunchflow_different_currency",
|
|
amount: 50.00,
|
|
currency: "USD",
|
|
date: Date.today,
|
|
name: "Restaurant",
|
|
source: "lunchflow"
|
|
)
|
|
|
|
# Should be a different entry
|
|
assert_not_equal manual_entry.id, entry.id
|
|
end
|
|
end
|
|
|
|
test "does not claim transaction that already has external_id from different provider" do
|
|
# Create a transaction already synced from SimpleFin
|
|
simplefin_entry = @adapter.import_transaction(
|
|
external_id: "simplefin_123",
|
|
amount: 30.00,
|
|
currency: "USD",
|
|
date: Date.today,
|
|
name: "Gas Station",
|
|
source: "simplefin"
|
|
)
|
|
|
|
# Provider (Lunchflow) syncs matching transaction - should create new entry, not claim SimpleFin's
|
|
assert_difference "@account.entries.count", 1 do
|
|
entry = @adapter.import_transaction(
|
|
external_id: "lunchflow_gas",
|
|
amount: 30.00,
|
|
currency: "USD",
|
|
date: Date.today,
|
|
name: "Gas Station",
|
|
source: "lunchflow"
|
|
)
|
|
|
|
# Should be a different entry because SimpleFin already claimed it
|
|
assert_not_equal simplefin_entry.id, entry.id
|
|
assert_equal "lunchflow", entry.source
|
|
assert_equal "simplefin", simplefin_entry.reload.source
|
|
end
|
|
end
|
|
|
|
test "claims oldest matching manual transaction when multiple exist" do
|
|
# Create multiple manual transactions with same date, amount, currency
|
|
older_entry = @account.entries.create!(
|
|
date: Date.today,
|
|
amount: 20.00,
|
|
currency: "USD",
|
|
name: "Parking - Old",
|
|
entryable: Transaction.new,
|
|
created_at: 2.hours.ago
|
|
)
|
|
|
|
newer_entry = @account.entries.create!(
|
|
date: Date.today,
|
|
amount: 20.00,
|
|
currency: "USD",
|
|
name: "Parking - New",
|
|
entryable: Transaction.new,
|
|
created_at: 1.hour.ago
|
|
)
|
|
|
|
# Provider syncs matching transaction - should claim the oldest one
|
|
assert_no_difference "@account.entries.count" do
|
|
entry = @adapter.import_transaction(
|
|
external_id: "lunchflow_parking",
|
|
amount: 20.00,
|
|
currency: "USD",
|
|
date: Date.today,
|
|
name: "Parking - Provider",
|
|
source: "lunchflow"
|
|
)
|
|
|
|
# Should claim the older entry
|
|
assert_equal older_entry.id, entry.id
|
|
assert_equal "lunchflow_parking", entry.external_id
|
|
|
|
# Newer entry should remain unclaimed
|
|
assert_nil newer_entry.reload.external_id
|
|
end
|
|
end
|
|
|
|
# ============================================================================
|
|
# Pending→Posted Transaction Reconciliation Tests
|
|
# ============================================================================
|
|
|
|
test "reconciles pending transaction when posted version arrives with different external_id" do
|
|
# Simulate SimpleFIN giving different IDs for pending vs posted transactions
|
|
# First, import a pending transaction
|
|
pending_entry = @adapter.import_transaction(
|
|
external_id: "simplefin_pending_abc",
|
|
amount: 99.99,
|
|
currency: "USD",
|
|
date: Date.today - 2.days,
|
|
name: "Coffee Shop",
|
|
source: "simplefin",
|
|
extra: { "simplefin" => { "pending" => true } }
|
|
)
|
|
|
|
assert pending_entry.transaction.pending?, "Entry should be marked pending"
|
|
original_id = pending_entry.id
|
|
|
|
# Now import the posted version with a DIFFERENT external_id
|
|
# This should claim the pending entry, not create a duplicate
|
|
assert_no_difference "@account.entries.count" do
|
|
posted_entry = @adapter.import_transaction(
|
|
external_id: "simplefin_posted_xyz",
|
|
amount: 99.99,
|
|
currency: "USD",
|
|
date: Date.today,
|
|
name: "Coffee Shop - Posted",
|
|
source: "simplefin",
|
|
extra: { "simplefin" => { "pending" => false } }
|
|
)
|
|
|
|
# Should be the same entry, now with updated external_id
|
|
assert_equal original_id, posted_entry.id
|
|
assert_equal "simplefin_posted_xyz", posted_entry.external_id
|
|
assert_not posted_entry.transaction.pending?, "Entry should no longer be pending"
|
|
end
|
|
end
|
|
|
|
test "does not reconcile when posted transaction has same external_id as pending" do
|
|
# When external_id matches, normal dedup should handle it
|
|
pending_entry = @adapter.import_transaction(
|
|
external_id: "simplefin_same_id",
|
|
amount: 50.00,
|
|
currency: "USD",
|
|
date: Date.today - 1.day,
|
|
name: "Gas Station",
|
|
source: "simplefin",
|
|
extra: { "simplefin" => { "pending" => true } }
|
|
)
|
|
|
|
# Import posted version with SAME external_id
|
|
assert_no_difference "@account.entries.count" do
|
|
posted_entry = @adapter.import_transaction(
|
|
external_id: "simplefin_same_id",
|
|
amount: 50.00,
|
|
currency: "USD",
|
|
date: Date.today,
|
|
name: "Gas Station - Posted",
|
|
source: "simplefin",
|
|
extra: { "simplefin" => { "pending" => false } }
|
|
)
|
|
|
|
assert_equal pending_entry.id, posted_entry.id
|
|
assert_not posted_entry.transaction.pending?
|
|
end
|
|
end
|
|
|
|
test "fuzzy amount match creates suggestion instead of auto-claiming" do
|
|
# Import pending transaction (pre-tip authorization)
|
|
pending_entry = @adapter.import_transaction(
|
|
external_id: "simplefin_pending_amount_test",
|
|
amount: 100.00,
|
|
currency: "USD",
|
|
date: Date.today - 1.day,
|
|
name: "Restaurant",
|
|
source: "simplefin",
|
|
extra: { "simplefin" => { "pending" => true } }
|
|
)
|
|
|
|
# Import posted with tip added - should NOT auto-claim, but should store suggestion
|
|
# Fuzzy matches now create suggestions for user review instead of auto-reconciling
|
|
assert_difference "@account.entries.count", 1 do
|
|
posted_entry = @adapter.import_transaction(
|
|
external_id: "simplefin_posted_amount_test",
|
|
amount: 105.00, # 5% tip added - within 25% tolerance
|
|
currency: "USD",
|
|
date: Date.today,
|
|
name: "Restaurant",
|
|
source: "simplefin",
|
|
extra: { "simplefin" => { "pending" => false } }
|
|
)
|
|
|
|
# Should be a NEW entry (not claimed)
|
|
assert_not_equal pending_entry.id, posted_entry.id
|
|
assert_equal "simplefin_posted_amount_test", posted_entry.external_id
|
|
|
|
# The PENDING entry should now have a potential_posted_match suggestion
|
|
pending_entry.reload
|
|
assert pending_entry.transaction.has_potential_duplicate?
|
|
assert_equal posted_entry.id, pending_entry.transaction.potential_duplicate_entry.id
|
|
end
|
|
end
|
|
|
|
test "does not reconcile pending when amount difference exceeds tolerance" do
|
|
# Import pending transaction
|
|
pending_entry = @adapter.import_transaction(
|
|
external_id: "simplefin_pending_big_diff",
|
|
amount: 50.00,
|
|
currency: "USD",
|
|
date: Date.today - 1.day,
|
|
name: "Store",
|
|
source: "simplefin",
|
|
extra: { "simplefin" => { "pending" => true } }
|
|
)
|
|
|
|
# Import posted with amount >25% different - should NOT match
|
|
# $100 posted / 1.25 = $80 minimum pending, but pending is only $50
|
|
assert_difference "@account.entries.count", 1 do
|
|
posted_entry = @adapter.import_transaction(
|
|
external_id: "simplefin_posted_big_diff",
|
|
amount: 100.00, # 100% increase - way outside 25% tolerance
|
|
currency: "USD",
|
|
date: Date.today,
|
|
name: "Store",
|
|
source: "simplefin",
|
|
extra: { "simplefin" => { "pending" => false } }
|
|
)
|
|
|
|
assert_not_equal pending_entry.id, posted_entry.id
|
|
end
|
|
end
|
|
|
|
test "does not reconcile pending when date is outside window" do
|
|
# Import pending transaction
|
|
pending_entry = @adapter.import_transaction(
|
|
external_id: "simplefin_pending_date_test",
|
|
amount: 25.00,
|
|
currency: "USD",
|
|
date: Date.today - 15.days, # 15 days ago
|
|
name: "Subscription",
|
|
source: "simplefin",
|
|
extra: { "simplefin" => { "pending" => true } }
|
|
)
|
|
|
|
# Import posted with date outside 7-day window - should NOT match
|
|
assert_difference "@account.entries.count", 1 do
|
|
posted_entry = @adapter.import_transaction(
|
|
external_id: "simplefin_posted_date_test",
|
|
amount: 25.00,
|
|
currency: "USD",
|
|
date: Date.today,
|
|
name: "Subscription",
|
|
source: "simplefin",
|
|
extra: { "simplefin" => { "pending" => false } }
|
|
)
|
|
|
|
assert_not_equal pending_entry.id, posted_entry.id
|
|
end
|
|
end
|
|
|
|
test "reconciles pending within 7 day window" do
|
|
# Import pending transaction
|
|
pending_entry = @adapter.import_transaction(
|
|
external_id: "simplefin_pending_window_test",
|
|
amount: 75.00,
|
|
currency: "USD",
|
|
date: Date.today - 5.days,
|
|
name: "Online Order",
|
|
source: "simplefin",
|
|
extra: { "simplefin" => { "pending" => true } }
|
|
)
|
|
|
|
# Import posted within 7-day window - should match
|
|
assert_no_difference "@account.entries.count" do
|
|
posted_entry = @adapter.import_transaction(
|
|
external_id: "simplefin_posted_window_test",
|
|
amount: 75.00,
|
|
currency: "USD",
|
|
date: Date.today,
|
|
name: "Online Order - Posted",
|
|
source: "simplefin",
|
|
extra: { "simplefin" => { "pending" => false } }
|
|
)
|
|
|
|
assert_equal pending_entry.id, posted_entry.id
|
|
end
|
|
end
|
|
|
|
test "does not reconcile pending from different source" do
|
|
# Import pending from SimpleFIN
|
|
pending_entry = @adapter.import_transaction(
|
|
external_id: "simplefin_pending_source_test",
|
|
amount: 30.00,
|
|
currency: "USD",
|
|
date: Date.today - 1.day,
|
|
name: "Pharmacy",
|
|
source: "simplefin",
|
|
extra: { "simplefin" => { "pending" => true } }
|
|
)
|
|
|
|
# Import from different source (plaid) - should NOT match SimpleFIN pending
|
|
assert_difference "@account.entries.count", 1 do
|
|
plaid_entry = @adapter.import_transaction(
|
|
external_id: "plaid_posted_source_test",
|
|
amount: 30.00,
|
|
currency: "USD",
|
|
date: Date.today,
|
|
name: "Pharmacy",
|
|
source: "plaid",
|
|
extra: { "plaid" => { "pending" => false } }
|
|
)
|
|
|
|
assert_not_equal pending_entry.id, plaid_entry.id
|
|
end
|
|
end
|
|
|
|
test "does not reconcile when incoming transaction is also pending" do
|
|
# Import first pending transaction
|
|
pending_entry1 = @adapter.import_transaction(
|
|
external_id: "simplefin_pending_1",
|
|
amount: 45.00,
|
|
currency: "USD",
|
|
date: Date.today - 1.day,
|
|
name: "Store",
|
|
source: "simplefin",
|
|
extra: { "simplefin" => { "pending" => true } }
|
|
)
|
|
|
|
# Import another pending transaction with different ID - should NOT match
|
|
assert_difference "@account.entries.count", 1 do
|
|
pending_entry2 = @adapter.import_transaction(
|
|
external_id: "simplefin_pending_2",
|
|
amount: 45.00,
|
|
currency: "USD",
|
|
date: Date.today,
|
|
name: "Store",
|
|
source: "simplefin",
|
|
extra: { "simplefin" => { "pending" => true } }
|
|
)
|
|
|
|
assert_not_equal pending_entry1.id, pending_entry2.id
|
|
end
|
|
end
|
|
|
|
test "reconciles most recent pending when multiple exist" do
|
|
# Create two pending transactions with same amount
|
|
older_pending = @adapter.import_transaction(
|
|
external_id: "simplefin_older_pending",
|
|
amount: 60.00,
|
|
currency: "USD",
|
|
date: Date.today - 5.days,
|
|
name: "Recurring Payment - Old",
|
|
source: "simplefin",
|
|
extra: { "simplefin" => { "pending" => true } }
|
|
)
|
|
|
|
newer_pending = @adapter.import_transaction(
|
|
external_id: "simplefin_newer_pending",
|
|
amount: 60.00,
|
|
currency: "USD",
|
|
date: Date.today - 1.day,
|
|
name: "Recurring Payment - New",
|
|
source: "simplefin",
|
|
extra: { "simplefin" => { "pending" => true } }
|
|
)
|
|
|
|
# Import posted - should match the most recent pending (by date)
|
|
assert_no_difference "@account.entries.count" do
|
|
posted_entry = @adapter.import_transaction(
|
|
external_id: "simplefin_posted_recurring",
|
|
amount: 60.00,
|
|
currency: "USD",
|
|
date: Date.today,
|
|
name: "Recurring Payment - Posted",
|
|
source: "simplefin",
|
|
extra: { "simplefin" => { "pending" => false } }
|
|
)
|
|
|
|
# Should match the newer pending entry
|
|
assert_equal newer_pending.id, posted_entry.id
|
|
# Older pending should remain untouched
|
|
assert_equal "simplefin_older_pending", older_pending.reload.external_id
|
|
end
|
|
end
|
|
|
|
test "find_pending_transaction returns nil when no pending transactions exist" do
|
|
# Create a non-pending transaction
|
|
@adapter.import_transaction(
|
|
external_id: "simplefin_not_pending",
|
|
amount: 40.00,
|
|
currency: "USD",
|
|
date: Date.today - 1.day,
|
|
name: "Regular Transaction",
|
|
source: "simplefin",
|
|
extra: { "simplefin" => { "pending" => false } }
|
|
)
|
|
|
|
result = @adapter.find_pending_transaction(
|
|
date: Date.today,
|
|
amount: 40.00,
|
|
currency: "USD",
|
|
source: "simplefin"
|
|
)
|
|
|
|
assert_nil result
|
|
end
|
|
|
|
# ============================================================================
|
|
# Critical Direction Fix Tests (CITGO Bug Prevention)
|
|
# ============================================================================
|
|
|
|
test "does not match pending transaction that is AFTER the posted date (direction fix)" do
|
|
# This is the CITGO bug scenario:
|
|
# - Posted transaction on Dec 31
|
|
# - Pending transaction on Jan 8 (AFTER the posted)
|
|
# - These should NOT match because pending MUST come BEFORE posted
|
|
|
|
# First, import a POSTED transaction on an earlier date
|
|
posted_entry = @adapter.import_transaction(
|
|
external_id: "simplefin_posted_dec31",
|
|
amount: 6.67,
|
|
currency: "USD",
|
|
date: Date.today - 8.days, # Dec 31 (earlier)
|
|
name: "CITGO Gas Station",
|
|
source: "simplefin",
|
|
extra: { "simplefin" => { "pending" => false } }
|
|
)
|
|
|
|
# Now import a PENDING transaction on a LATER date
|
|
# This should NOT be matched because the date direction is wrong
|
|
assert_difference "@account.entries.count", 1 do
|
|
pending_entry = @adapter.import_transaction(
|
|
external_id: "simplefin_pending_jan8",
|
|
amount: 6.65, # Similar but different amount
|
|
currency: "USD",
|
|
date: Date.today, # Jan 8 (later)
|
|
name: "CITGO Gas Station",
|
|
source: "simplefin",
|
|
extra: { "simplefin" => { "pending" => true } }
|
|
)
|
|
|
|
# Should be a DIFFERENT entry - not matched to the earlier posted one
|
|
assert_not_equal posted_entry.id, pending_entry.id
|
|
assert pending_entry.transaction.pending?
|
|
end
|
|
end
|
|
|
|
test "find_pending_transaction only searches backward in time" do
|
|
# Create a pending transaction in the FUTURE (after the posted date we'll search from)
|
|
# This should NOT be found because pending must be ON or BEFORE posted
|
|
future_pending = @adapter.import_transaction(
|
|
external_id: "simplefin_future_pending",
|
|
amount: 50.00,
|
|
currency: "USD",
|
|
date: Date.today + 3.days, # Future date
|
|
name: "Future Transaction",
|
|
source: "simplefin",
|
|
extra: { "simplefin" => { "pending" => true } }
|
|
)
|
|
|
|
# Search from today - should NOT find the future pending
|
|
result = @adapter.find_pending_transaction(
|
|
date: Date.today,
|
|
amount: 50.00,
|
|
currency: "USD",
|
|
source: "simplefin"
|
|
)
|
|
|
|
assert_nil result, "Should not find pending transactions that are in the future relative to the posted date"
|
|
end
|
|
|
|
test "find_pending_transaction finds pending transaction that is before posted date" do
|
|
# Create a pending transaction in the PAST (before the posted date)
|
|
# This SHOULD be found
|
|
past_pending = @adapter.import_transaction(
|
|
external_id: "simplefin_past_pending",
|
|
amount: 75.00,
|
|
currency: "USD",
|
|
date: Date.today - 3.days, # 3 days ago
|
|
name: "Past Transaction",
|
|
source: "simplefin",
|
|
extra: { "simplefin" => { "pending" => true } }
|
|
)
|
|
|
|
# Search from today - should find the past pending
|
|
result = @adapter.find_pending_transaction(
|
|
date: Date.today,
|
|
amount: 75.00,
|
|
currency: "USD",
|
|
source: "simplefin"
|
|
)
|
|
|
|
assert_equal past_pending.id, result.id
|
|
end
|
|
|
|
# ============================================================================
|
|
# Plaid pending_transaction_id Tests
|
|
# ============================================================================
|
|
|
|
test "reconciles pending via Plaid pending_transaction_id" do
|
|
# Import a pending transaction
|
|
pending_entry = @adapter.import_transaction(
|
|
external_id: "plaid_pending_abc",
|
|
amount: 42.00,
|
|
currency: "USD",
|
|
date: Date.today - 2.days,
|
|
name: "Coffee Shop",
|
|
source: "plaid",
|
|
extra: { "plaid" => { "pending" => true } }
|
|
)
|
|
|
|
# Import posted with pending_transaction_id linking to the pending
|
|
assert_no_difference "@account.entries.count" do
|
|
posted_entry = @adapter.import_transaction(
|
|
external_id: "plaid_posted_xyz",
|
|
amount: 42.00,
|
|
currency: "USD",
|
|
date: Date.today,
|
|
name: "Coffee Shop",
|
|
source: "plaid",
|
|
pending_transaction_id: "plaid_pending_abc", # Links to pending
|
|
extra: { "plaid" => { "pending" => false, "pending_transaction_id" => "plaid_pending_abc" } }
|
|
)
|
|
|
|
# Should claim the pending entry
|
|
assert_equal pending_entry.id, posted_entry.id
|
|
assert_equal "plaid_posted_xyz", posted_entry.external_id
|
|
assert_not posted_entry.transaction.pending?
|
|
end
|
|
end
|
|
|
|
test "Plaid pending_transaction_id takes priority over amount matching" do
|
|
# Create TWO pending transactions with same amount
|
|
pending1 = @adapter.import_transaction(
|
|
external_id: "plaid_pending_1",
|
|
amount: 25.00,
|
|
currency: "USD",
|
|
date: Date.today - 1.day,
|
|
name: "Store A",
|
|
source: "plaid",
|
|
extra: { "plaid" => { "pending" => true } }
|
|
)
|
|
|
|
pending2 = @adapter.import_transaction(
|
|
external_id: "plaid_pending_2",
|
|
amount: 25.00,
|
|
currency: "USD",
|
|
date: Date.today - 1.day,
|
|
name: "Store B",
|
|
source: "plaid",
|
|
extra: { "plaid" => { "pending" => true } }
|
|
)
|
|
|
|
# Import posted that explicitly links to pending2 via pending_transaction_id
|
|
assert_no_difference "@account.entries.count" do
|
|
posted_entry = @adapter.import_transaction(
|
|
external_id: "plaid_posted_linked",
|
|
amount: 25.00,
|
|
currency: "USD",
|
|
date: Date.today,
|
|
name: "Store B",
|
|
source: "plaid",
|
|
pending_transaction_id: "plaid_pending_2", # Explicitly links to pending2
|
|
extra: { "plaid" => { "pending" => false } }
|
|
)
|
|
|
|
# Should claim pending2 specifically (not pending1)
|
|
assert_equal pending2.id, posted_entry.id
|
|
assert_equal "plaid_posted_linked", posted_entry.external_id
|
|
end
|
|
|
|
# pending1 should still exist as pending
|
|
pending1.reload
|
|
assert_equal "plaid_pending_1", pending1.external_id
|
|
end
|
|
end
|