fix: Lunchflow pending transaction duplicates, missing from search and filter (#859)

* fix: lunchflow parity with simplefin/plaid pending behaviour

* fix: don't suggest duplicate if both entries are pending

* refactor: reuse the same external_id for re-synced pending transactions

* chore: replace illogical duplicate collision test with multiple sync test

* fix: prevent duplicates when users edit pending lunchflow transactions

* chore: add test for preventing duplicates when users edit pending lunchflow transactions

* fix: normalise extra hash keys for pending detection
This commit is contained in:
AdamWHY2K
2026-02-01 22:48:54 +00:00
committed by GitHub
parent 81cf473862
commit ad386c6e27
6 changed files with 101 additions and 25 deletions

View File

@@ -77,10 +77,14 @@ class Account::ProviderImportAdapter
end
# If still a new entry and this is a POSTED transaction, check for matching pending transactions
incoming_pending = extra.is_a?(Hash) && (
ActiveModel::Type::Boolean.new.cast(extra.dig("simplefin", "pending")) ||
ActiveModel::Type::Boolean.new.cast(extra.dig("plaid", "pending"))
)
incoming_pending = false
if extra.is_a?(Hash)
pending_extra = extra.with_indifferent_access
incoming_pending =
ActiveModel::Type::Boolean.new.cast(pending_extra.dig("simplefin", "pending")) ||
ActiveModel::Type::Boolean.new.cast(pending_extra.dig("plaid", "pending")) ||
ActiveModel::Type::Boolean.new.cast(pending_extra.dig("lunchflow", "pending"))
end
if entry.new_record? && !incoming_pending
pending_match = nil
@@ -686,6 +690,7 @@ class Account::ProviderImportAdapter
.where(<<~SQL.squish)
(transactions.extra -> 'simplefin' ->> 'pending')::boolean = true
OR (transactions.extra -> 'plaid' ->> 'pending')::boolean = true
OR (transactions.extra -> 'lunchflow' ->> 'pending')::boolean = true
SQL
.order(date: :desc) # Prefer most recent pending transaction
@@ -731,6 +736,7 @@ class Account::ProviderImportAdapter
.where(<<~SQL.squish)
(transactions.extra -> 'simplefin' ->> 'pending')::boolean = true
OR (transactions.extra -> 'plaid' ->> 'pending')::boolean = true
OR (transactions.extra -> 'lunchflow' ->> 'pending')::boolean = true
SQL
# If merchant_id is provided, prioritize matching by merchant
@@ -799,6 +805,7 @@ class Account::ProviderImportAdapter
.where(<<~SQL.squish)
(transactions.extra -> 'simplefin' ->> 'pending')::boolean = true
OR (transactions.extra -> 'plaid' ->> 'pending')::boolean = true
OR (transactions.extra -> 'lunchflow' ->> 'pending')::boolean = true
SQL
# For low confidence, require BOTH merchant AND name match (stronger signal needed)
@@ -836,6 +843,11 @@ class Account::ProviderImportAdapter
# Don't overwrite if already has a suggestion (keep first one found)
return if existing_extra["potential_posted_match"].present?
# Don't suggest if the posted entry is also still pending (pending→pending match)
# Suggestions are only for pending→posted reconciliation
posted_transaction = posted_entry.entryable
return if posted_transaction.is_a?(Transaction) && posted_transaction.pending?
pending_transaction.update!(
extra: existing_extra.merge(
"potential_posted_match" => {

View File

@@ -42,6 +42,7 @@ class Entry < ApplicationRecord
.where(<<~SQL.squish)
(transactions.extra -> 'simplefin' ->> 'pending')::boolean = true
OR (transactions.extra -> 'plaid' ->> 'pending')::boolean = true
OR (transactions.extra -> 'lunchflow' ->> 'pending')::boolean = true
SQL
}
@@ -56,6 +57,7 @@ class Entry < ApplicationRecord
AND (
(t.extra -> 'simplefin' ->> 'pending')::boolean = true
OR (t.extra -> 'plaid' ->> 'pending')::boolean = true
OR (t.extra -> 'lunchflow' ->> 'pending')::boolean = true
)
)
SQL
@@ -118,6 +120,7 @@ class Entry < ApplicationRecord
.where(<<~SQL.squish)
(transactions.extra -> 'simplefin' ->> 'pending')::boolean IS NOT TRUE
AND (transactions.extra -> 'plaid' ->> 'pending')::boolean IS NOT TRUE
AND (transactions.extra -> 'lunchflow' ->> 'pending')::boolean IS NOT TRUE
SQL
.limit(2) # Only need to know if 0, 1, or 2+ candidates
.to_a # Load limited records to avoid COUNT(*) on .size
@@ -164,6 +167,7 @@ class Entry < ApplicationRecord
.where(<<~SQL.squish)
(transactions.extra -> 'simplefin' ->> 'pending')::boolean IS NOT TRUE
AND (transactions.extra -> 'plaid' ->> 'pending')::boolean IS NOT TRUE
AND (transactions.extra -> 'lunchflow' ->> 'pending')::boolean IS NOT TRUE
SQL
# Match by name similarity (first 3 words)

View File

@@ -70,6 +70,7 @@ class EntrySearch
AND (
(t.extra -> 'simplefin' ->> 'pending')::boolean = true
OR (t.extra -> 'plaid' ->> 'pending')::boolean = true
OR (t.extra -> 'lunchflow' ->> 'pending')::boolean = true
)
)
SQL
@@ -82,6 +83,7 @@ class EntrySearch
AND (
(t.extra -> 'simplefin' ->> 'pending')::boolean = true
OR (t.extra -> 'plaid' ->> 'pending')::boolean = true
OR (t.extra -> 'lunchflow' ->> 'pending')::boolean = true
)
)
SQL

View File

@@ -73,10 +73,20 @@ class LunchflowEntry::Processor
base_temp_id = content_hash_for_transaction(data)
temp_id_with_prefix = "lunchflow_pending_#{base_temp_id}"
# Handle collisions: if this external_id already exists for this account,
# append a counter to make it unique. This prevents multiple pending transactions
# with identical attributes (e.g., two same-day Uber rides) from colliding.
# We check both the account's entries and the current raw payload being processed.
# Check if entry with this external_id already exists
# If it does AND it's still pending, reuse the same ID for re-sync.
# The import adapter's skip logic will handle user edits correctly.
# We DON'T check if attributes match - user edits should not cause duplicates.
if entry_exists_with_external_id?(temp_id_with_prefix)
existing_entry = account.entries.find_by(external_id: temp_id_with_prefix, source: "lunchflow")
if existing_entry && existing_entry.entryable.is_a?(Transaction) && existing_entry.entryable.pending?
Rails.logger.debug "Lunchflow: Reusing ID #{temp_id_with_prefix} for re-synced pending transaction"
return temp_id_with_prefix
end
end
# Handle true collisions: multiple different transactions with same attributes
# (e.g., two Uber rides on the same day for the same amount within the same sync)
final_id = temp_id_with_prefix
counter = 1

View File

@@ -185,11 +185,13 @@ class Transaction::Search
pending_condition = <<~SQL.squish
(transactions.extra -> 'simplefin' ->> 'pending')::boolean = true
OR (transactions.extra -> 'plaid' ->> 'pending')::boolean = true
OR (transactions.extra -> 'lunchflow' ->> 'pending')::boolean = true
SQL
confirmed_condition = <<~SQL.squish
(transactions.extra -> 'simplefin' ->> 'pending')::boolean IS DISTINCT FROM true
AND (transactions.extra -> 'plaid' ->> 'pending')::boolean IS DISTINCT FROM true
AND (transactions.extra -> 'lunchflow' ->> 'pending')::boolean IS DISTINCT FROM true
SQL
case statuses.sort