Files
sure/test/models/account/provider_import_adapter_test.rb
LPW 61eb611529 Simplefin enhancements v2 (#267)
* SimpleFin: metadata + merge fixes; holdings (incl. crypto) + Day Change; Sync Summary; ops rakes; lint

# Conflicts:
#	db/schema.rb

# Conflicts:
#	app/controllers/simplefin_items_controller.rb

* fix testing

* fix linting

* xfix linting x2

* Review PR #267 on we-promise/sure (SimpleFin enhancements v2). Address all 15 actionable CodeRabbit comments: Add UUID validations in rakes (e.g., simplefin_unlink), swap Ruby pattern matching/loops for efficient DB queries (e.g., where LOWER(name) LIKE ?), generate docstrings for low-coverage areas (31%), consolidate routes for simplefin_items, move view logic to helpers (e.g., format_transaction_extra), strengthen tests with exact assertions/fixtures for dedup/relink failures. Also, check for overlaps with merged #262 (merchants fix): Ensure merchant creation in simplefin_entry/processor.rb aligns with new payee-based flow and MD5 IDs; add tests for edge cases like empty payees or over-merging pendings. Prioritize security (PII redaction in logs, no hardcoded secrets).

* SimpleFin: address CodeRabbit comments (batch 1)

- Consolidate simplefin_items routes under a single resources block; keep URLs stable
- Replace inline JS with Stimulus auto-relink controller; auto-load relink modal via global modal frame
- Improve a11y in relink modal by wrapping rows in labels
- Harden unlink rake: default dry_run=true, UUID validation, redact PII in outputs, clearer errors
- Backfill rake: default dry_run=true, UUID validation; groundwork for per-SFA counters
- Fix-was-merged rake: default dry_run=true, UUID validation; clearer outputs
- Idempotent transfer auto-match (find_or_create_by! + RecordNotUnique rescue)
- Extract SimpleFin error tooltip assembly into helper and use it in view

RuboCop: maintain 2-space indentation, spaces inside array brackets, spaces after commas, and no redundant returns

* Linter noise

* removed filed commited by mistake.

* manual relink flow and tighten composite matching

* enforce manual relink UI; fix adapter keywords; guarantee extra.simplefin hash

* refactor(simplefin): extract relink service; enforce manual relink UI; tighten composite match; migration 7.2

* add provider date parser; refactor rake; move view queries; partial resilience

* run balances-only import in background job. make update flow enqueue balances-only job

* persists across all update redirects and initialize
used_manual_ids to prevent NameError in relink candidate computation.

* SimpleFin: metadata + merge fixes; holdings (incl. crypto) + Day Change; Sync Summary; ops rakes; lint

* Fixed failed test after rebase.

* scan_ruby fix

* Calming the rabbit:
Fix AccountProvider linking when accounts change
Drop the legacy unique index instead of duplicating it
Fix dynamic constant assignment
Use fixtures consistently; avoid rescue for control flow.
Replace bare rescue with explicit exception class.
Move business logic out of the view.
Critical: Transaction boundary excludes recompute phase, risking data loss.
Inconsistency between documentation and implementation for zero-error case.
Refactor to use the compute_unlinked_count helper for consistency.
Fix cleanup task default: it deletes by default.
Move sync stats computation to controller to avoid N+1 queries.
Consolidate duplicate sync query.
Clarify the intent of setting flash notice on the error path.
Fix Date/Time comparison in should_be_inactive?.
Move stats retrieval logic to controller.
Remove duplicate Sync summary section.
Remove the unnecessary sleep statement; use Capybara's built-in waiting.
Add label wrappers for accessibility and consistency.

* FIX SimpleFIN new account modal

Now new account properly loads as a Modal, instead of new page.
Fixes also form showing dashboard instead of settings page.

* Remove SimpleFin legacy UI components, migrate schema, and refine linking behavior.

# Conflicts:
#	app/helpers/settings_helper.rb

* Extract SimpleFin-related logic to `prepare_show_context` helper and refactor for consistency. Adjust conditional checks and ensure controller variables are properly initialized.

* Remove unused SimpleFin maps from prepare_show_context; select IDs to avoid N+1
Replace Tailwind bg-green-500 with semantic bg-success in _simplefin_panel/_provider_form
Add f.label :setup_token in simplefin_items/new for a11y
Remove duplicate require in AccountsControllerSimplefinCtaTest

* Remove unnecessary blank lines

* Reduce unnecessary changes

This reduces the diff against main

* Simplefin Account Setup: Display in modal

This fixes an issue with the `X` dismiss button in the top right corner

* Removed unnecessary comment.

* removed unnecessary function.

* fixed broken links

* Removed unnecessary file

* changed to database query

* set to use UTC and gaurd against null

* set dry_run=true

* Fixed comment

* Changed to use a database-level query

* matched test name to test behavior.

* Eliminate code duplication and Time.zone dependency

* make final summary surface failures

* lint fix

* Revised timezone comment. better handle missing selectors.

* sanitized LIKE wildcards

* Fixed SimpleFin import to avoid “Currency can’t be blank” validation failures when providers return an empty currency string.

* Added helper methods for admin and self-hosted checks

* Specify exception types in rescue clauses.

* Refined logic to determine transaction dates for credit accounts.

* Refined stats calculation for `total_accounts` to track the maximum unique accounts per run instead of accumulating totals.

* Moved `unlink_all!` logic to `SimplefinItem::Unlinking` concern and deprecated `SimplefinItem::Unlinker`. Updated related references.

* Refined legacy unlinking logic, improved `current_holdings` formatting, and added ENV-based overrides for self-hosted checks.

* Enhanced `unlink_all!` with explicit error handling, improved transaction safety, and refined ENV-based self-hosted checks. Adjusted exception types and cleaned up private method handling.

* Improved currency assignment logic by adding fallback to `current_account` and `family` currencies.

* Enhanced error tracking during SimpleFin account imports by adding categorized error buckets, limiting stored errors to the last 5, and improving `stats` calculations.

* typo fix

* Didn't realize rabbit was still mad...
Refactored SimpleFin error handling and CTA logic: centralized duplicate detection and relink visibility into controller, improved task counters, adjusted redirect notices, and fixed form indexing.

* Dang rabbit never stops... Centralized SimpleFin maps logic into `MapsHelper` concern and integrated it into relevant controllers and rake tasks. Optimized queries, reduced redundancy, and improved unlinked counts and manual account checks with batch processing. Adjusted task arguments for clarity.

* Persistent rabbit. Optimized SimpleFin maps logic by implementing batch queries for manual account and unlinked count checks, reducing N+1 issues. Improved clarity of rake task argument descriptions and error messages for better usability.

* Lost a commit somehow, resolved here. Refactored transaction extra details logic by introducing `build_transaction_extra_details` helper to improve clarity, reusability, and reduce view complexity. Enhanced rake tasks with strict dry-run validation and better error handling. Updated schema to allow nullable `merchant_id` and added conditional unique indexes for recurring transactions.

* Refactored sensitive data redaction in `simplefin_unlink` task for recursive handling, optimized SQL sanitization in `simplefin_holdings_backfill`, improved error handling in `transactions_helper`, and streamlined day change calculation logic in `Holding` model.

* Lint fix

* Removed per PR comments.

* Also removing per PR comment.

* git commit -m "SimpleFIN polish: preserve #manual-accounts wrapper, unify \"manual\" scope, and correct unlinked counts
- Preserve #manual-accounts wrapper: switch non-empty updates to turbo_stream.update and background broadcast_update_to; keep empty-path replace to render <div id=\"manual-accounts\"></div>
- Unify definition of manual accounts via Account.visible_manual (visible + legacy-nil + no AccountProvider); reuse in controllers, jobs, and helper
- Correct setup/unlinked counts: SimplefinItem::Syncer#finalize_setup_counts and maps now consider AccountProvider links (legacy account AND provider must be absent)
Deleted:
- app/models/simplefin_item/relink_service.rb
- app/controllers/concerns/simplefin_items/relink_helpers.rb
- app/javascript/controllers/auto_relink_controller.js
- app/views/simplefin_items/_relink_modal.html.erb
- app/views/simplefin_items/manual_relink.html.erb
- app/views/simplefin_items/relink.html.erb
- test/services/simplefin_item/relink_service_test.rb
Refs: PR #318 unified link/unlink; PR #267 SimpleFIN; follow-up to fix wrapper ID loss and counting drift."

* Extend unlinked account check to include "Investment" type

* set SimpleFIN item for `balances`, remove redundant unpacking, and improve holdings task error

* SimpleFIN: add `errors` action + modal; do not reintroduce legacy relink actions; removed dead helper

* FIX simpleFIN linking

* Add delay back, tests benefit from it

* Put cache back in

* Remove empty `rake` task

* Small spelling fixes.

---------

Signed-off-by: soky srm <sokysrm@gmail.com>
Co-authored-by: Josh Waldrep <joshua.waldrep5+github@gmail.com>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: sokie <sokysrm@gmail.com>
Co-authored-by: Dylan Corrales <deathcamel58@gmail.com>
2025-11-17 21:51:37 +01:00

789 lines
23 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
end