Skip to content

fix(enable-banking): fix pending→posted auto-claim producing badge, duplicate, and wrong date#1783

Merged
jjmata merged 2 commits into
we-promise:mainfrom
CrossDrain:eb-fix-claiming-pending-transactions
May 13, 2026
Merged

fix(enable-banking): fix pending→posted auto-claim producing badge, duplicate, and wrong date#1783
jjmata merged 2 commits into
we-promise:mainfrom
CrossDrain:eb-fix-claiming-pending-transactions

Conversation

@CrossDrain
Copy link
Copy Markdown
Contributor

@CrossDrain CrossDrain commented May 13, 2026

Problem

When Enable Banking syncs a booked transaction that matches an existing pending entry by amount/date (the auto-claim path in find_pending_transaction), three things went wrong:

1. Pending badge on the booked entry
The extra["enable_banking"]["pending"] = true flag was never cleared on the claimed entry. For simple transactions (no FX, no MCC) the incoming extra is nil, so the deep-merge block is skipped entirely — the badge persisted forever.

2. The original pending entry reappeared after the next sync
After the claim, the old pending external_id (e.g. PDNG_123) stayed in the stored raw_transactions_payload. Enable Banking issues completely different IDs for pending vs. booked transactions, so the importer's C4 filter — which only matches by transaction_id equality — never pruned it. On the next sync, find_or_initialize_by(external_id: "PDNG_123") couldn't find the claimed entry (now keyed as BOOK_456) and created a fresh pending duplicate with no category.

3. Wrong date on the claimed entry
The pending date (when the transaction was initiated) was silently overwritten with the booked/settlement date — inconsistent with how the manual merge_with_duplicate! path already handles this.

The result visible to users: a booked transaction showing a pending badge, the user-assigned category, and a later date; plus the original pending transaction reappearing alongside it with no category and the correct earlier date.

Solution

ProviderImportAdapter#import_transaction

  • Capture pending_entry_date and old_pending_external_id before claiming.
  • Use pending_entry_date || date when assigning attributes so the pending date is preserved.
  • Clear the pending key from all providers in extra in-memory (persisted by the existing save path).
  • Store old_pending_external_id in extra["auto_claimed_pending_ids"] so the Processor knows to skip it.

EnableBankingAccount::Transactions::Processor#process

  • Add a second LATERAL query alongside the existing manual_merge exclusion to also collect all auto_claimed_pending_ids, then union both into excluded_ids. This prevents the old pending external_id from being re-imported on every subsequent sync.

Testing

  • provider_import_adapter_test.rb: new test covers nil-extra auto-claim — asserts pending flag cleared, date preserved, and auto_claimed_pending_ids populated.
  • processor_test.rb: two new tests — one for auto_claimed_pending_ids exclusion alone, one for both exclusion types together in the same batch.

Summary by CodeRabbit

  • Bug Fixes
    • Improved transaction date preservation: when pending transactions are matched with posted entries during reconciliation, the original pending date is now correctly retained
    • Enhanced duplicate prevention: the system now maintains comprehensive tracking of auto-reconciled pending transactions to prevent unintended re-imports
    • Refined reconciliation logic: strengthened detection of previously auto-matched transactions to ensure they are excluded from future import cycles

Review Change Stack

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 13, 2026

📝 Walkthrough

Walkthrough

When posted transactions claim matching pending entries, the adapter now preserves the pending external ID and date, clears provider pending flags, and records the old pending ID in auto_claimed_pending_ids. The Enable Banking processor then expands re-import exclusion logic to skip transactions with IDs found in auto_claimed_pending_ids alongside existing manual_merge exclusions.

Changes

Pending Entry Auto-Claim and Re-Import Exclusion

Layer / File(s) Summary
Pending Claim Reconciliation Logic
app/models/account/provider_import_adapter.rb, test/models/account/provider_import_adapter_test.rb
When a pending entry matches a posted transaction, the adapter preserves the pending external ID and date, clears provider-specific pending flags from transaction.extra, and records the old pending external ID in auto_claimed_pending_ids (deduplicated) to prevent later re-import. The date field is assigned from pending entry date if available, falling back to the posted date. A new test validates this behavior including pending flag clearing, date preservation, and metadata tracking.
Enable Banking Re-Import Exclusion Expansion
app/models/enable_banking_account/transactions/processor.rb, test/models/enable_banking_account/transactions/processor_test.rb
The processor now pre-fetches external IDs from both transactions.extra['manual_merge'] and transactions.extra['auto_claimed_pending_ids'] via separate SQL queries with JSONB lateral expansion, unions them into a single excluded_ids Set, and skips any incoming transaction matching either exclusion source. Two new tests verify that auto-claimed pending transactions are correctly skipped and that mixed exclusion sources work together.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

  • we-promise/sure#859: Both PRs modify pending→posted reconciliation in Account::ProviderImportAdapter with changes to how provider-specific pending flags are detected and handled.
  • we-promise/sure#1709: Both PRs update EnableBankingAccount::Transactions::Processor to prefetch exclusion external IDs from transactions.extra with overlapping re-import skip logic.
  • we-promise/sure#1088: This PR clears provider-specific pending flags using Transaction::PENDING_PROVIDERS during pending claim, directly tied to that PR's refactor of pending-provider handling via the same constant.

Suggested labels

contributor:verified, pr:verified

Suggested reviewers

  • jjmata
  • sokie

Poem

🐰 A pending claim takes shape,
IDs preserved, dates escape,
Flags are cleared with gentle care,
Excluded from the sync's repair,
Duplication fades to air!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and specifically describes the three bugs being fixed in the pending-to-posted auto-claim flow: badge persistence, duplicate entries, and incorrect date handling.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

CrossDrain and others added 2 commits May 13, 2026 10:06
…fter auto-claim

When a booked transaction claims a pending entry via the amount/date heuristic
(find_pending_transaction), two bugs caused the entry to remain incorrectly pending
and the old pending transaction to reappear on subsequent syncs.

Bug 1: The extra["enable_banking"]["pending"] flag was never cleared on the claimed
entry. For simple booked transactions with nil extra the deep-merge path is skipped
entirely, so the pending badge persisted forever.

Bug 2: After the claim the old pending external_id (e.g. PDNG_123) stayed in the
stored raw_transactions_payload. The importer's C4 filter only removes pending
entries whose transaction_id matches a BOOK id — Enable Banking issues completely
different ids for pending vs booked transactions — so PDNG_123 was never pruned.
On the next sync find_or_initialize_by(PDNG_123) couldn't find the claimed entry
(now keyed as BOOK_456) and created a fresh pending duplicate with no category.

Fix: on claim, explicitly clear all providers' pending keys from extra in-memory,
and store the displaced pending external_id in extra["auto_claimed_pending_ids"].
The Processor now queries this field alongside manual_merge to build the excluded_ids
set, so the stale pending data is skipped on every future sync.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When a pending transaction is claimed by a booked transaction, the
original pending date is now preserved instead of being overwritten
by the booked transaction's date. This ensures historical accuracy
for transactions that were originally recorded on a different date.
@CrossDrain CrossDrain force-pushed the eb-fix-claiming-pending-transactions branch from cce7cf8 to 76a7e98 Compare May 13, 2026 07:08
@CrossDrain CrossDrain marked this pull request as ready for review May 13, 2026 07:20
@superagent-security superagent-security Bot added contributor:verified Contributor passed trust analysis. pr:verified PR passed security analysis. labels May 13, 2026
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
app/models/enable_banking_account/transactions/processor.rb (1)

79-83: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Log message no longer matches all exclusion sources.

excluded_ids now unions manual_merge and auto_claimed_pending_ids, but the skip log always attributes the skip to "manually merged" — making it harder to debug why an auto-claimed pending was skipped vs. one a user merged. Cheap to disambiguate by tracking the source alongside the ID set:

♻️ Suggested diff
-      manually_merged_ids = Transaction.joins(:entry)
+      manually_merged_ids = Transaction.joins(:entry)
                                        .where(entries: { account_id: account_id })
                                        .where("transactions.extra ? 'manual_merge'")
                                        .joins(
                                          Arel.sql(<<~SQL.squish)
                                            CROSS JOIN LATERAL jsonb_array_elements(
                                              CASE jsonb_typeof(transactions.extra->'manual_merge')
                                              WHEN 'array'  THEN transactions.extra->'manual_merge'
                                              WHEN 'object' THEN jsonb_build_array(transactions.extra->'manual_merge')
                                              ELSE '[]'::jsonb
                                              END
                                            ) AS merge_elem
                                          SQL
                                        )
                                        .pluck(Arel.sql("merge_elem->>'merged_from_external_id'"))
                                        .compact
                                        .to_set
@@
-      manually_merged_ids | auto_claimed_ids
+      {
+        manual_merge:        manually_merged_ids,
+        auto_claimed_pending: auto_claimed_ids
+      }
     else
-      Set.new
+      { manual_merge: Set.new, auto_claimed_pending: Set.new }
     end
@@
-        if ext_id && excluded_ids.include?(ext_id)
-          Rails.logger.info("EnableBankingAccount::Transactions::Processor - Skipping re-import of manually merged pending transaction: #{ext_id}")
+        skip_source = if excluded_ids[:manual_merge].include?(ext_id)
+          "manually merged"
+        elsif excluded_ids[:auto_claimed_pending].include?(ext_id)
+          "auto-claimed by booked"
+        end
+
+        if ext_id && skip_source
+          Rails.logger.info("EnableBankingAccount::Transactions::Processor - Skipping re-import of #{skip_source} pending transaction: #{ext_id}")
           skipped_count += 1
           next
         end
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/models/enable_banking_account/transactions/processor.rb` around lines 79
- 83, The skip log in EnableBankingAccount::Transactions::Processor incorrectly
always says "manually merged" even though excluded_ids is a union of
manual_merge and auto_claimed_pending_ids; change the exclusion logic to track
the source per ext_id (e.g., build a mapping/hash or tag each id when combining
manual_merge and auto_claimed_pending_ids) and then, inside the check for ext_id
inclusion, read the source for that ext_id and log it (use ext_id and its source
in the message), increment skipped_count as before, and call next; update any
references to excluded_ids, manual_merge, and auto_claimed_pending_ids
accordingly so the log accurately reflects whether the skip was due to a
manual_merge or auto_claimed_pending_ids.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@app/models/enable_banking_account/transactions/processor.rb`:
- Around line 79-83: The skip log in
EnableBankingAccount::Transactions::Processor incorrectly always says "manually
merged" even though excluded_ids is a union of manual_merge and
auto_claimed_pending_ids; change the exclusion logic to track the source per
ext_id (e.g., build a mapping/hash or tag each id when combining manual_merge
and auto_claimed_pending_ids) and then, inside the check for ext_id inclusion,
read the source for that ext_id and log it (use ext_id and its source in the
message), increment skipped_count as before, and call next; update any
references to excluded_ids, manual_merge, and auto_claimed_pending_ids
accordingly so the log accurately reflects whether the skip was due to a
manual_merge or auto_claimed_pending_ids.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: f6852ba6-823b-48fd-8a49-c1b2b63de277

📥 Commits

Reviewing files that changed from the base of the PR and between ce5d7dd and 76a7e98.

📒 Files selected for processing (4)
  • app/models/account/provider_import_adapter.rb
  • app/models/enable_banking_account/transactions/processor.rb
  • test/models/account/provider_import_adapter_test.rb
  • test/models/enable_banking_account/transactions/processor_test.rb

@jjmata jjmata added this to the v0.7.1 milestone May 13, 2026
@jjmata jjmata merged commit 406e721 into we-promise:main May 13, 2026
13 of 14 checks passed
@CrossDrain CrossDrain deleted the eb-fix-claiming-pending-transactions branch May 13, 2026 12:50
@abeilprincipino
Copy link
Copy Markdown

@CrossDrain I still see the "pending" badge after my last reimport, is this fix retroactive?

@CrossDrain
Copy link
Copy Markdown
Contributor Author

Hey @abeilprincipino! First of all, thank you so much for testing this.

This is expected behaviour for transactions that were synced before this fix landed, here's why:

The fix only clears the pending flag during the auto-claim step, which runs when a new booked transaction is first imported and matched against a pending one. For transactions that were already claimed before this PR merged, the entry already exists in the database — the importer finds it by external_id, skips the auto-claim path entirely, and never clears the stale extra["enable_banking"]["pending"] flag. A reimport doesn't help either, for the same reason: the entry is found, not created, so the clearing code is never reached.

New pending→posted claims going forward will be handled correctly. Hopefully yours too after the steps below.

To fix your existing transactions, you can run the following in the Rails console:

1. Open the console

From the directory where your compose.yml lives, run:

docker compose exec web bin/rails console

Wait for the irb(main) prompt to appear.

2. Dry run — see what will be changed

Paste this first to inspect affected transactions without changing anything:

# Dry run — inspect before applying
stale = Transaction
  .joins(:entry)
  .where("entries.source = 'enable_banking'")
  .where("(transactions.extra -> 'enable_banking' ->> 'pending')::boolean = true")

puts "Found #{stale.count} transaction(s) with stale pending flag"
stale.limit(10).each do |t|
  e = t.entry
  puts "  entry=#{e.id}  date=#{e.date}  name=#{e.name}  amount=#{e.amount}"
end

You should see a count and a list of entries with their date, name, and amount. Verify these look like the transactions with the stale badge.

3. Apply the fix

If the output looks right, paste this to clear the flag:

fixed = 0
stale.find_each do |t|
  ex = t.extra.deep_dup
  ex["enable_banking"]&.delete("pending")
  ex.delete("enable_banking") if ex["enable_banking"]&.empty?
  t.update!(extra: ex)
  fixed += 1
end
puts "Done — cleared pending flag on #{fixed} transaction(s)"

It will print Done — cleared pending flag on X transaction(s). The badge will disappear immediately on the next page load and no reimport or redeploy needed.

Once done, you can exit the console with exit.

If you do run this, please share your results and let us know whether it helped and whether the overall pending→posted flow is working correctly for you now as it'll be really useful to know!

@abeilprincipino
Copy link
Copy Markdown

It worked!
Thank you so much for the help!!!!
I will keep the situation monitored and report any further bug with Enable Banking.

Thx a lot again

@CrossDrain
Copy link
Copy Markdown
Contributor Author

It worked! Thank you so much for the help!!!! I will keep the situation monitored and report any further bug with Enable Banking.

Thx a lot again

You're welcome, glad it's working!

Out of curiosity, does the pending → booked transaction claim workflow work for you as well? (i.e. when a pending transaction gets confirmed/posted by the bank, does Sure correctly match it to the existing pending entry instead of creating a duplicate?)

@abeilprincipino
Copy link
Copy Markdown

Honestly it doesn’t look like it’s working, I’ve pending transaction since 5 days ago. On the other hand I haven’t experienced the problem you underlined in first place: I never got duplicated transactions, simply they are never becoming “not pending”.
Any test you want me to try?

@CrossDrain
Copy link
Copy Markdown
Contributor Author

@abeilprincipino

Thanks for testing! "Stuck pending, no duplicates" usually means the bank hasn't returned a booked version yet. The auto-claim only kicks in once a BOOK row actually arrives. A few users in #1470 reported the same thing and it eventually resolved when the bank booked them (sometimes a week+ for EU banks).

Two quick things that'd help:

  1. Which bank/ASPSP are you on?
  2. In your bank's own app, are those transactions already booked, or still pending there too?

If they're booked on the bank's side but still pending in Sure after the next sync, that's a real bug and I'll dig in.

@abeilprincipino
Copy link
Copy Markdown

I really think there’s a bug, usually it takes 3-4 days to get transactions booked from my bank (Revolut in this case).
Tomorrow I’ll try to do once again the procedure to cleanup stuck pending transactions, if they then come as booked transactions and are not updated again to pending status, I think we can confirm 100% there’s a bug.
Do you think it’s a viable test?

@CrossDrain
Copy link
Copy Markdown
Contributor Author

@abeilprincipino Yes, the test is sensible. If you delete the stuck pendings and the very next sync brings them straight back as booked, that's a strong signal that EB was returning a booked version all along and something in the claim path isn't matching for your setup.

But I want to flag upfront: this would be genuinely weird behaviour. I'm also on Enable Banking with Revolut and the pending → booked flow has always worked correctly for me, even before this PR landed (this PR fixed the artefacts of an auto-claim that did happen, not whether the claim happened at all).

Two things that would really sharpen the test:

  1. Which Revolut ASPSP are you connected to? Revolut Bank UAB (EU), Revolut Ltd (UK), or one of the country-specific ones? Looking at the code, they route through different EB integrations and the payload shapes aren't identical.
  2. In the Revolut app itself, are the 5-day-old transactions shown as posted/completed, or still pending there? If Revolut hasn't booked them yet, EB has nothing booked to return and there's nothing for our matcher to claim. No bug, just slow bank.

And one extra thing that'd help us narrow it down! Before you run the cleanup, grab the external_id of one of the stuck pending entries:

Transaction.joins(:entry)
  .where("entries.source = 'enable_banking'")
  .where("(transactions.extra -> 'enable_banking' ->> 'pending')::boolean = true")
  .limit(5).each do |t|
    puts "entry=#{t.entry.id} date=#{t.entry.date} name=#{t.entry.name} amount=#{t.entry.amount} external_id=#{t.entry.external_id}"
  end

Then after the cleanup + next sync, we can compare.

@abeilprincipino
Copy link
Copy Markdown

@CrossDrain

The ASPS is Revolut (Italy).

I've tried what you mentioned, you can find the log before the fix attached to this message.
After the cleanup and the next sync, the transactions remained as "Booked" they did NOT went back to "pending" as I thought.
I guess we can say there's a bug. All these transactions from the app are showing as Booked.

The only thing I can think off is that I've categorized the expenses when they were still "Pending", I know that there's a kind of "override mode" if you manually setup the category that prevents the next sync to override again your choice... but maybe it's unrelated.

pre-fix.txt

Btw, thanks in advance for the support and let me know if I can help narrowing down the problem.

@sure-admin sure-admin removed this from the v0.7.1 milestone May 24, 2026
@CrossDrain
Copy link
Copy Markdown
Contributor Author

@abeilprincipino Thanks for the detailed report and for your patience, this was really helpful to track down! 🙏

This turned out to be outside the scope of this PR, but clearly worth fixing on its own, so we've addressed it separately.

What happened: Revolut Italy (unlike most other banks) reuses the exact same transaction ID for both the pending and the booked version of a transaction. Our sync logic normally handles pending→booked by matching on that ID and updating the entry. However, because the ID was already in the database, the entry was found as an existing record and the update path was skipped entirely. The pending badge was stuck with no way to clear itself, even after the bank confirmed the transaction. Categorizing the entry made it even worse, since the protection check (which prevents sync from overwriting user edits) would return early before the flag could ever be cleared.

The fix: We added a small bypass so that even when an entry is protected or already persisted, if the bank delivers a booked version, the pending flag gets cleared. We've opened #1982 as a proposed fix. Since we can't reproduce the Revolut Italy behaviour directly, it would mean a lot if you could test it on your end and let us know whether it resolves the issue for you!

Thanks again for taking the time to share your setup and reproduce steps!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

contributor:verified Contributor passed trust analysis. pr:verified PR passed security analysis.

Development

Successfully merging this pull request may close these issues.

4 participants