mirror of
https://github.com/we-promise/sure.git
synced 2026-04-17 11:04:14 +00:00
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:
@@ -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
|
||||
|
||||
61
lib/tasks/cleanup_stuck_pending_lunchflow.rake
Normal file
61
lib/tasks/cleanup_stuck_pending_lunchflow.rake
Normal 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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user