From 356d9ebf3ac5a6acd792b21c0753ff2e83de5cb4 Mon Sep 17 00:00:00 2001 From: AdamWHY2K Date: Thu, 19 Feb 2026 17:58:01 +0000 Subject: [PATCH] =?UTF-8?q?fix:=20add=20logic=20to=20skip=20future=20pendi?= =?UTF-8?q?ng=20transactions=20and=20add=20cleanup=20ta=E2=80=A6=20(#1011)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * Update app/models/lunchflow_entry/processor.rb Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Signed-off-by: AdamWHY2K * 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 Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- app/models/lunchflow_entry/processor.rb | 51 ++++++- .../cleanup_stuck_pending_lunchflow.rake | 61 ++++++++ test/models/lunchflow_entry/processor_test.rb | 133 ++++++++++++++++++ 3 files changed, 242 insertions(+), 3 deletions(-) create mode 100644 lib/tasks/cleanup_stuck_pending_lunchflow.rake diff --git a/app/models/lunchflow_entry/processor.rb b/app/models/lunchflow_entry/processor.rb index 6164752d2..11e42c755 100644 --- a/app/models/lunchflow_entry/processor.rb +++ b/app/models/lunchflow_entry/processor.rb @@ -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 diff --git a/lib/tasks/cleanup_stuck_pending_lunchflow.rake b/lib/tasks/cleanup_stuck_pending_lunchflow.rake new file mode 100644 index 000000000..ecd002731 --- /dev/null +++ b/lib/tasks/cleanup_stuck_pending_lunchflow.rake @@ -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 diff --git a/test/models/lunchflow_entry/processor_test.rb b/test/models/lunchflow_entry/processor_test.rb index a597b2f58..18cd193af 100644 --- a/test/models/lunchflow_entry/processor_test.rb +++ b/test/models/lunchflow_entry/processor_test.rb @@ -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