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>
This commit is contained in:
AdamWHY2K
2026-02-19 17:58:01 +00:00
committed by GitHub
parent cb3c076f7b
commit 356d9ebf3a
3 changed files with 242 additions and 3 deletions

View File

@@ -18,6 +18,16 @@ class LunchflowEntry::Processor
return nil
end
# If this is a pending transaction with a temporary ID, check if a posted version already exists
# This prevents duplicate entries when posted transactions arrive before pending ones
if is_pending? && external_id.start_with?("lunchflow_pending_")
existing_posted = find_existing_posted_version
if existing_posted
Rails.logger.info "LunchflowEntry::Processor - Skipping pending transaction (posted version already exists): pending=#{external_id}, posted=#{existing_posted.external_id}"
return existing_posted
end
end
# Wrap import in error handling to catch validation and save errors
begin
import_adapter.import_transaction(
@@ -63,6 +73,10 @@ class LunchflowEntry::Processor
end
def external_id
@external_id ||= calculate_external_id
end
def calculate_external_id
id = data[:id].presence
# For pending transactions, Lunchflow may return blank/nil IDs
@@ -101,10 +115,10 @@ class LunchflowEntry::Processor
Rails.logger.debug "Lunchflow: Generated temporary ID #{final_id} for pending transaction: #{data[:merchant]} #{data[:amount]} #{data[:currency]}"
end
return final_id
final_id
else
"lunchflow_#{id}"
end
"lunchflow_#{id}"
end
def entry_exists_with_external_id?(external_id)
@@ -203,4 +217,35 @@ class LunchflowEntry::Processor
metadata
end
# Check if this transaction is marked as pending
def is_pending?
ActiveModel::Type::Boolean.new.cast(data[:isPending])
end
# Find an existing posted version of this pending transaction
# Matches by: exact amount, currency, merchant name (if present), and date window
# Uses same 8-day window as Account::ProviderImportAdapter reconciliation logic
# Note: Lunchflow never provides real IDs for pending transactions (they're always blank),
# so filtering by external_id NOT LIKE 'lunchflow_pending_%' is sufficient to exclude pending entries
def find_existing_posted_version
return nil unless account.present?
query = account.entries
.where(source: "lunchflow")
.where(amount: amount)
.where(currency: currency)
.where("date BETWEEN ? AND ?", date, date + 8)
.where("external_id NOT LIKE 'lunchflow_pending_%'")
.where("external_id IS NOT NULL")
.order(date: :asc) # Closest date first (prefer same-day posted, then next day, etc.)
# Add merchant name matching for better precision
# Only if merchant name is present in the transaction data
if data[:merchant].present?
query = query.where(name: name)
end
query.first
end
end

View File

@@ -0,0 +1,61 @@
namespace :lunchflow do
desc "Cleanup stuck pending Lunchflow transactions that have posted duplicates"
task cleanup_stuck_pending: :environment do
puts "Finding stuck pending Lunchflow transactions..."
stuck_pending = Transaction
.pending
.where("transactions.extra -> 'lunchflow' ->> 'pending' = 'true'")
.includes(:entry)
.where(entries: { source: "lunchflow" })
puts "Found #{stuck_pending.count} pending Lunchflow transactions"
puts ""
deleted_count = 0
kept_count = 0
stuck_pending.each do |transaction|
pending_entry = transaction.entry
# Search for a posted version with same merchant name, amount, and date window
# Note: Lunchflow never provides real IDs for pending transactions, so external_id pattern
# matching is sufficient. We still exclude self (pending_entry.id) for extra safety.
posted_match = Entry
.where(source: "lunchflow")
.where(account_id: pending_entry.account_id)
.where(name: pending_entry.name)
.where(amount: pending_entry.amount)
.where(currency: pending_entry.currency)
.where("date BETWEEN ? AND ?", pending_entry.date, pending_entry.date + 8)
.where("external_id NOT LIKE 'lunchflow_pending_%'")
.where("external_id IS NOT NULL")
.where.not(id: pending_entry.id)
.order(date: :asc) # Prefer closest date match
.first
if posted_match
puts "DELETING duplicate pending entry:"
puts " Pending: #{pending_entry.date} | #{pending_entry.name} | #{pending_entry.amount} | #{pending_entry.external_id}"
puts " Posted: #{posted_match.date} | #{posted_match.name} | #{posted_match.amount} | #{posted_match.external_id}"
# Delete the pending entry (this will also delete the transaction via cascade)
pending_entry.destroy!
deleted_count += 1
puts " ✓ Deleted"
puts ""
else
puts "KEEPING (no posted duplicate found):"
puts " #{pending_entry.date} | #{pending_entry.name} | #{pending_entry.amount} | #{pending_entry.external_id}"
puts " This may be legitimately still pending, or posted with different details"
puts ""
kept_count += 1
end
end
puts "="*80
puts "Cleanup complete!"
puts " Deleted: #{deleted_count} duplicate pending entries"
puts " Kept: #{kept_count} entries (no posted version found)"
end
end

View File

@@ -245,4 +245,137 @@ class LunchflowEntry::ProcessorTest < ActiveSupport::TestCase
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