Files
sure/test/models/lunchflow_entry/processor_test.rb
AdamWHY2K 356d9ebf3a fix: add logic to skip future pending transactions and add cleanup ta… (#1011)
* fix: add logic to skip future pending transactions and add cleanup task for stuck entries

* Update lib/tasks/cleanup_stuck_pending_lunchflow.rake

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Signed-off-by: AdamWHY2K <adamgm.email@gmail.com>

* Update app/models/lunchflow_entry/processor.rb

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Signed-off-by: AdamWHY2K <adamgm.email@gmail.com>

* fix(coderabbit): assertions use entryable instead of transaction for pending state checks

* chore(codex): add comments to clarify handling of pending transactions, exclude self in cleanup task

* fix(coderabbit): memoize external_id

---------

Signed-off-by: AdamWHY2K <adamgm.email@gmail.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-02-19 18:58:01 +01:00

382 lines
11 KiB
Ruby

require "test_helper"
class LunchflowEntry::ProcessorTest < ActiveSupport::TestCase
setup do
@family = families(:empty)
@lunchflow_item = LunchflowItem.create!(
name: "Test Lunchflow Connection",
api_key: "test_key",
family: @family
)
@lunchflow_account = LunchflowAccount.create!(
lunchflow_item: @lunchflow_item,
name: "Test Account",
currency: "USD",
account_id: "lf_acc_123"
)
# Create a real account and link it
@account = Account.create!(
family: @family,
name: "Test Checking",
accountable: Depository.new(subtype: "checking"),
balance: 1000,
currency: "USD"
)
AccountProvider.create!(
account: @account,
provider: @lunchflow_account
)
@lunchflow_account.reload
end
test "stores pending metadata when isPending is true" do
transaction_data = {
id: "lf_txn_123",
accountId: 456,
amount: -50.00,
currency: "USD",
date: "2025-01-15",
merchant: "Test Merchant",
description: "Test transaction",
isPending: true
}
result = LunchflowEntry::Processor.new(
transaction_data,
lunchflow_account: @lunchflow_account
).process
assert_not_nil result
transaction = result.entryable
assert_kind_of Transaction, transaction
assert_equal true, transaction.pending?
assert_equal true, transaction.extra.dig("lunchflow", "pending")
end
test "stores pending false when isPending is false" do
transaction_data = {
id: "lf_txn_124",
accountId: 456,
amount: -50.00,
currency: "USD",
date: "2025-01-15",
merchant: "Test Merchant",
description: "Test transaction",
isPending: false
}
result = LunchflowEntry::Processor.new(
transaction_data,
lunchflow_account: @lunchflow_account
).process
assert_not_nil result
transaction = result.entryable
assert_equal false, transaction.pending?
assert_equal false, transaction.extra.dig("lunchflow", "pending")
end
test "does not store pending metadata when isPending is absent" do
transaction_data = {
id: "lf_txn_125",
accountId: 456,
amount: -50.00,
currency: "USD",
date: "2025-01-15",
merchant: "Test Merchant",
description: "Test transaction"
}
result = LunchflowEntry::Processor.new(
transaction_data,
lunchflow_account: @lunchflow_account
).process
assert_not_nil result
transaction = result.entryable
assert_not transaction.pending?
assert_nil transaction.extra.dig("lunchflow", "pending")
assert_nil transaction.extra.dig("lunchflow")
end
test "handles string true value for isPending" do
transaction_data = {
id: "lf_txn_126",
accountId: 456,
amount: -50.00,
currency: "USD",
date: "2025-01-15",
merchant: "Test Merchant",
description: "Test transaction",
isPending: "true"
}
result = LunchflowEntry::Processor.new(
transaction_data,
lunchflow_account: @lunchflow_account
).process
assert_not_nil result
transaction = result.entryable
assert_equal true, transaction.pending?
assert_equal true, transaction.extra.dig("lunchflow", "pending")
end
test "generates temporary ID for pending transactions with blank ID" do
transaction_data = {
id: "",
accountId: 456,
amount: -50.00,
currency: "USD",
date: "2025-01-15",
merchant: "Test Merchant",
description: "Pending transaction",
isPending: true
}
# Process transaction with blank ID
result = LunchflowEntry::Processor.new(
transaction_data,
lunchflow_account: @lunchflow_account
).process
assert_not_nil result
transaction = result.entryable
assert_kind_of Transaction, transaction
assert transaction.pending?
# Verify the entry has a generated external_id (since we can't have blank IDs)
assert result.external_id.present?
assert_match /^lunchflow_pending_[a-f0-9]{32}$/, result.external_id
end
test "does not duplicate pending transaction when synced multiple times" do
# Create a pending transaction
transaction_data = {
id: "",
accountId: 456,
amount: -15.00,
currency: "USD",
date: "2025-01-15",
merchant: "UBER",
description: "Ride",
isPending: true
}
# Process first transaction
result1 = LunchflowEntry::Processor.new(
transaction_data,
lunchflow_account: @lunchflow_account
).process
assert_not_nil result1
transaction1 = result1.entryable
assert transaction1.pending?
assert_equal true, transaction1.extra.dig("lunchflow", "pending")
# Count entries before second sync
entries_before = @account.entries.where(source: "lunchflow").count
# Second sync - same pending transaction (still hasn't posted)
result2 = LunchflowEntry::Processor.new(
transaction_data,
lunchflow_account: @lunchflow_account
).process
assert_not_nil result2
# Should return the SAME entry, not create a duplicate
assert_equal result1.id, result2.id, "Should update existing pending transaction, not create duplicate"
# Verify no new entries were created
entries_after = @account.entries.where(source: "lunchflow").count
assert_equal entries_before, entries_after, "Should not create duplicate entry on re-sync"
end
test "does not duplicate pending transaction when user has edited it" do
# User imports a pending transaction, then edits it (name, amount, date)
# Next sync should update the same entry, not create a duplicate
transaction_data = {
id: "",
accountId: 456,
amount: -25.50,
currency: "USD",
date: "2025-01-20",
merchant: "Coffee Shop",
description: "Morning coffee",
isPending: true
}
# First sync - import the pending transaction
result1 = LunchflowEntry::Processor.new(
transaction_data,
lunchflow_account: @lunchflow_account
).process
assert_not_nil result1
original_external_id = result1.external_id
# User edits the transaction (common scenario)
result1.update!(name: "Coffee Shop Downtown", amount: 26.00)
result1.reload
# Verify the edits were applied
assert_equal "Coffee Shop Downtown", result1.name
assert_equal 26.00, result1.amount
entries_before = @account.entries.where(source: "lunchflow").count
# Second sync - same pending transaction data from provider (unchanged)
result2 = LunchflowEntry::Processor.new(
transaction_data,
lunchflow_account: @lunchflow_account
).process
assert_not_nil result2
# Should return the SAME entry (same external_id, not a _1 suffix)
assert_equal result1.id, result2.id, "Should reuse existing entry even when user edited it"
assert_equal original_external_id, result2.external_id, "Should not create new external_id for user-edited entry"
# Verify no duplicate was created
entries_after = @account.entries.where(source: "lunchflow").count
assert_equal entries_before, entries_after, "Should not create duplicate when user has edited pending transaction"
end
test "skips creating pending entry when posted version already exists" do
# First sync: posted transaction arrives with real ID
posted_transaction_data = {
id: "lf_txn_real_123",
accountId: 456,
amount: -75.50,
currency: "USD",
date: "2025-01-20",
merchant: "Coffee Shop",
description: "Morning coffee",
isPending: false
}
result1 = LunchflowEntry::Processor.new(
posted_transaction_data,
lunchflow_account: @lunchflow_account
).process
assert_not_nil result1
assert_equal "lunchflow_lf_txn_real_123", result1.external_id
assert_not result1.entryable.pending?
entries_before = @account.entries.where(source: "lunchflow").count
# Second sync: pending version arrives later (without ID, so would create temp ID)
# This should skip creation since posted version exists
pending_transaction_data = {
id: "", # No ID = would generate lunchflow_pending_xxx
accountId: 456,
amount: -75.50,
currency: "USD",
date: "2025-01-20", # Same date
merchant: "Coffee Shop",
description: "Morning coffee",
isPending: true
}
result2 = LunchflowEntry::Processor.new(
pending_transaction_data,
lunchflow_account: @lunchflow_account
).process
# Should return the existing posted entry, not create a duplicate
assert_not_nil result2
assert_equal result1.id, result2.id, "Should return existing posted entry"
assert_equal "lunchflow_lf_txn_real_123", result2.external_id, "Should keep posted external_id"
# Verify no duplicate was created
entries_after = @account.entries.where(source: "lunchflow").count
assert_equal entries_before, entries_after, "Should not create duplicate pending entry"
end
test "skips creating pending entry when posted version exists with nearby date" do
# Posted transaction on Jan 20
posted_transaction_data = {
id: "lf_txn_456",
accountId: 456,
amount: -55.33,
currency: "USD",
date: "2025-01-20",
merchant: "MORRISONS",
description: "Groceries",
isPending: false
}
result1 = LunchflowEntry::Processor.new(
posted_transaction_data,
lunchflow_account: @lunchflow_account
).process
assert_not_nil result1
# Pending transaction on Jan 19 (1 day earlier, within 8-day forward window)
pending_transaction_data = {
id: "",
accountId: 456,
amount: -55.33,
currency: "USD",
date: "2025-01-19",
merchant: "MORRISONS",
description: "Groceries",
isPending: true
}
result2 = LunchflowEntry::Processor.new(
pending_transaction_data,
lunchflow_account: @lunchflow_account
).process
# Should return existing posted entry (posted date is within pending date + 8 days)
assert_equal result1.id, result2.id, "Should match posted entry with nearby date"
end
test "creates pending entry when merchant name doesn't match" do
# Posted transaction at Coffee Shop
posted_transaction_data = {
id: "lf_txn_coffee",
accountId: 456,
amount: -5.00,
currency: "USD",
date: "2025-01-20",
merchant: "Coffee Shop",
description: "Latte",
isPending: false
}
LunchflowEntry::Processor.new(
posted_transaction_data,
lunchflow_account: @lunchflow_account
).process
# Pending transaction at different merchant but same amount and date
pending_transaction_data = {
id: "",
accountId: 456,
amount: -5.00, # Same amount
currency: "USD",
date: "2025-01-20",
merchant: "Tea House", # Different merchant
description: "Tea",
isPending: true
}
result = LunchflowEntry::Processor.new(
pending_transaction_data,
lunchflow_account: @lunchflow_account
).process
assert_not_nil result
assert result.entryable.pending?, "Should create new pending entry when merchant doesn't match"
assert result.external_id.start_with?("lunchflow_pending_"), "Should have temporary ID"
end
end