Skip to content

fix(enable_banking): clear stuck pending flag when ASPSP reuses same transaction_id#1982

Open
CrossDrain wants to merge 9 commits into
we-promise:mainfrom
CrossDrain:fix/eb-same-external-id-pending-stuck
Open

fix(enable_banking): clear stuck pending flag when ASPSP reuses same transaction_id#1982
CrossDrain wants to merge 9 commits into
we-promise:mainfrom
CrossDrain:fix/eb-same-external-id-pending-stuck

Conversation

@CrossDrain
Copy link
Copy Markdown
Contributor

@CrossDrain CrossDrain commented May 25, 2026

Problem

Some ASPSPs reuse the same transaction_id for both the pending and booked versions of a transaction. Because find_or_initialize_by finds the existing entry as already persisted, the auto-claim path is bypassed entirely — and the pending badge gets permanently stuck, even after the bank confirms the transaction.

If the user had categorised the pending entry (setting user_modified=true), the protection check returned early before any clearing could happen, making it impossible for the flag to ever clear.

Reported by @abeilprincipino in #1783.

Fix

  • Protected entries (user_modified=true): clear the pending flag via update! before the protection early-return.
  • Non-protected entries with nil extra: add a second in-memory clearing block before the final save (the deep-merge block is skipped when extra is blank).
  • Move incoming_pending computation before the protection check so both paths can use it.

Test plan

  • bin/rails test test/models/account/provider_import_adapter_test.rb — two new tests cover both paths

Summary by CodeRabbit

  • Bug Fixes

    • Transactions moving from pending to posted/booked now reliably clear stale provider-level pending flags, including when a booked import reuses the same external ID and in both user-modified and non-user-modified reconciliation paths.
  • Tests

    • Added tests covering same-external-ID pending→booked reconciliation to prevent regressions.

Review Change Stack

@superagent-security superagent-security Bot added the contributor:verified Contributor passed trust analysis. label May 25, 2026
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 25, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 4feb70b2-c7b8-4701-b270-99808e933266

📥 Commits

Reviewing files that changed from the base of the PR and between eb04162 and 807a429.

📒 Files selected for processing (1)
  • app/models/account/provider_import_adapter.rb

📝 Walkthrough

Walkthrough

Account::ProviderImportAdapter#import_transaction computes incoming_pending earlier, adds a private helper to clear provider "pending" flags from transaction.extra, and applies that helper in protection-check and persisted same-external-id reconciliation; tests cover unmodified and user-modified pending→booked transitions.

Changes

Pending-to-booked reconciliation with stale flag clearing

Layer / File(s) Summary
Early incoming_pending computation and protection-check clearing
app/models/account/provider_import_adapter.rb
incoming_pending is computed from extra before protection-check logic and the later duplicate recomputation is removed. If a protected (user_modified) persisted entry is matched by a booked payload where incoming_pending is false, stale provider "pending" flags are cleared from transaction.extra and persisted.
Pending-flag clearing helper implementation
app/models/account/provider_import_adapter.rb
Added private helper clear_pending_flags_from_extra(extra) which deep-dups and removes provider entries under Transaction::PENDING_PROVIDERS' "pending" keys, dropping empty provider hashes.
Claim-path pending-flag clearing uses helper
app/models/account/provider_import_adapter.rb
Inline mutation that removed provider "pending" flags in the claim path is replaced with a call to clear_pending_flags_from_extra(entry.transaction.extra).
Post-protection reconciliation for persisted same-external-id entries
app/models/account/provider_import_adapter.rb
For non-protected persisted entries (same external_id), when incoming_pending is false but the stored transaction.extra still indicates provider pending, stale pending flags are cleared via clear_pending_flags_from_extra before final attribute assignment and save.
Same-external-id pending-to-booked reconciliation tests
test/models/account/provider_import_adapter_test.rb
Header comment plus two tests verifying that a booked import reusing the same external_id and extra: nil clears the persisted entry's transaction.pending?, covering both unmodified and user-modified pending entries.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • we-promise/sure#1797: Both PRs modify Account::ProviderImportAdapter#import_transaction to reconcile provider-level pending state and related pending handling.
  • we-promise/sure#1783: Overlapping changes clearing provider-level "pending" flags from transaction.extra during pending→booked reconciliation.
  • we-promise/sure#602: Related edits to pending→posted reconciliation and same-external-id pending resolution in the importer.

Suggested reviewers

  • jjmata
  • alessiocappa

Poem

🐰 I found a pending flag tucked in the hay,
Same external id hopped in booked today.
I scrubbed the stale marks and nudged the save,
Ledger neat and tidy — oh what a brave!
Carrots for clean imports, hop on the way.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 66.67% 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 accurately describes the main change: fixing a stuck pending flag when ASPSPs reuse the same transaction_id, which is the core issue addressed in the PR.
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.

@superagent-security superagent-security Bot added the pr:verified PR passed security analysis. label May 25, 2026
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d4227d6b13

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread app/models/account/provider_import_adapter.rb Outdated
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.

🧹 Nitpick comments (1)
app/models/account/provider_import_adapter.rb (1)

157-173: 💤 Low value

Consider extracting the duplicate pending-flag clearing logic.

The clearing logic at lines 164-172 is nearly identical to lines 75-80. Extracting this into a private helper would improve maintainability.

♻️ Suggested refactor
# Add private helper method at the end of the class
private

def clear_pending_flags_from_extra(extra_hash)
  return nil if extra_hash.blank?
  
  ex = extra_hash.deep_dup
  Transaction::PENDING_PROVIDERS.each do |p|
    next unless ex.key?(p)
    ex[p].delete("pending")
    ex.delete(p) if ex[p].empty?
  end
  ex
end

Then replace the duplicate blocks:

 # In protection check (lines 75-80):
-              ex = (entry.transaction.extra || {}).deep_dup
-              Transaction::PENDING_PROVIDERS.each do |p|
-                next unless ex.key?(p)
-                ex[p].delete("pending")
-                ex.delete(p) if ex[p].empty?
-              end
+              ex = clear_pending_flags_from_extra(entry.transaction.extra)
               entry.transaction.update!(extra: ex)

 # In post-protection reconciliation (lines 165-170):
-          ex = (entry.transaction.extra || {}).deep_dup
-          Transaction::PENDING_PROVIDERS.each do |p|
-            next unless ex.key?(p)
-            ex[p].delete("pending")
-            ex.delete(p) if ex[p].empty?
-          end
+          ex = clear_pending_flags_from_extra(entry.transaction.extra)
           entry.transaction.extra = ex
🤖 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/account/provider_import_adapter.rb` around lines 157 - 173,
Duplicate logic that removes "pending" flags from entry.transaction.extra
(iterating Transaction::PENDING_PROVIDERS, deleting "pending" keys and removing
empty provider hashes) should be extracted into a private helper (e.g.,
clear_pending_flags_from_extra(extra_hash)). Implement that helper to deep_dup
the incoming extra, perform the same deletion loop, and return the modified hash
(or nil/unchanged value consistent with current behavior). Replace both
duplicated blocks that operate on entry.transaction.extra with a call to
clear_pending_flags_from_extra(entry.transaction.extra) and assign the result
back to entry.transaction.extra when non-nil.
🤖 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.

Nitpick comments:
In `@app/models/account/provider_import_adapter.rb`:
- Around line 157-173: Duplicate logic that removes "pending" flags from
entry.transaction.extra (iterating Transaction::PENDING_PROVIDERS, deleting
"pending" keys and removing empty provider hashes) should be extracted into a
private helper (e.g., clear_pending_flags_from_extra(extra_hash)). Implement
that helper to deep_dup the incoming extra, perform the same deletion loop, and
return the modified hash (or nil/unchanged value consistent with current
behavior). Replace both duplicated blocks that operate on
entry.transaction.extra with a call to
clear_pending_flags_from_extra(entry.transaction.extra) and assign the result
back to entry.transaction.extra when non-nil.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 00c84dd7-9523-4853-95b8-81219362b11b

📥 Commits

Reviewing files that changed from the base of the PR and between d4227d6 and 396752c.

📒 Files selected for processing (1)
  • app/models/account/provider_import_adapter.rb

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.

🧹 Nitpick comments (1)
app/models/account/provider_import_adapter.rb (1)

75-81: 💤 Low value

Consider using the new helper here for consistency.

Lines 75–80 duplicate the logic now extracted into clear_pending_flags_from_extra. Reusing the helper would reduce duplication.

Suggested change
           if entry_is_pending
-            ex = (entry.transaction.extra || {}).deep_dup
-            Transaction::PENDING_PROVIDERS.each do |p|
-              next unless ex.key?(p)
-              ex[p].delete("pending")
-              ex.delete(p) if ex[p].empty?
-            end
-            entry.transaction.update!(extra: ex)
+            entry.transaction.update!(extra: clear_pending_flags_from_extra(entry.transaction.extra))
           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/account/provider_import_adapter.rb` around lines 75 - 81, This
block duplicates logic now in clear_pending_flags_from_extra: remove the manual
deep_dup/each/delete/update! block and call clear_pending_flags_from_extra with
the transaction instead (e.g.
clear_pending_flags_from_extra(entry.transaction)); if the helper returns a new
extra hash, assign it and call entry.transaction.update!(extra: returned_extra),
otherwise rely on the helper to persist the change—use the
clear_pending_flags_from_extra helper to centralize the behavior and eliminate
the duplicated logic that currently iterates Transaction::PENDING_PROVIDERS and
mutates extra.
🤖 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.

Nitpick comments:
In `@app/models/account/provider_import_adapter.rb`:
- Around line 75-81: This block duplicates logic now in
clear_pending_flags_from_extra: remove the manual deep_dup/each/delete/update!
block and call clear_pending_flags_from_extra with the transaction instead (e.g.
clear_pending_flags_from_extra(entry.transaction)); if the helper returns a new
extra hash, assign it and call entry.transaction.update!(extra: returned_extra),
otherwise rely on the helper to persist the change—use the
clear_pending_flags_from_extra helper to centralize the behavior and eliminate
the duplicated logic that currently iterates Transaction::PENDING_PROVIDERS and
mutates extra.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 6c435db3-16a2-4d9c-82e1-354c9718e098

📥 Commits

Reviewing files that changed from the base of the PR and between 396752c and 79b9818.

📒 Files selected for processing (1)
  • app/models/account/provider_import_adapter.rb

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.

🧹 Nitpick comments (1)
app/models/account/provider_import_adapter.rb (1)

988-996: 💤 Low value

Consider adding a type guard for defensive safety.

If ex[provider] is ever not a Hash (e.g., nil or malformed data from a migration/manual edit), line 992 would raise NoMethodError. While the current data model makes this unlikely, a small guard adds resilience.

🛡️ Optional defensive improvement
 def clear_pending_flags_from_extra(extra)
   ex = (extra || {}).deep_dup
   Transaction::PENDING_PROVIDERS.each do |provider|
-    next unless ex.key?(provider)
+    next unless ex[provider].is_a?(Hash)
     ex[provider].delete("pending")
     ex.delete(provider) if ex[provider].empty?
   end
   ex
 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/account/provider_import_adapter.rb` around lines 988 - 996, The
method clear_pending_flags_from_extra should defensively ensure ex[provider] is
a Hash before calling Hash methods: when iterating
Transaction::PENDING_PROVIDERS inside clear_pending_flags_from_extra, add a type
guard (e.g., skip or coerce) so you only call delete("pending") and check empty?
on ex[provider] if ex[provider].is_a?(Hash); otherwise skip that provider (or
replace non-Hash values with an empty Hash) to avoid NoMethodError on malformed
data.
🤖 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.

Nitpick comments:
In `@app/models/account/provider_import_adapter.rb`:
- Around line 988-996: The method clear_pending_flags_from_extra should
defensively ensure ex[provider] is a Hash before calling Hash methods: when
iterating Transaction::PENDING_PROVIDERS inside clear_pending_flags_from_extra,
add a type guard (e.g., skip or coerce) so you only call delete("pending") and
check empty? on ex[provider] if ex[provider].is_a?(Hash); otherwise skip that
provider (or replace non-Hash values with an empty Hash) to avoid NoMethodError
on malformed data.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 00d46aae-5ea5-4a20-ac9e-60da731e5d37

📥 Commits

Reviewing files that changed from the base of the PR and between 79b9818 and 4722775.

📒 Files selected for processing (1)
  • app/models/account/provider_import_adapter.rb

…rom_extra

Add a check to ensure that the value associated with a provider key in
the `extra` hash is a Hash before attempting to call `delete` on it.
This prevents a `NoMethodError` when encountering malformed data where
the provider key exists but does not map to a Hash.
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.

🧹 Nitpick comments (1)
app/models/account/provider_import_adapter.rb (1)

988-1000: ⚡ Quick win

Make clear_pending_flags_from_extra private.

In app/models/account/provider_import_adapter.rb (around the method at ~988), the indentation is already correct; the method is only referenced within this class, so mark it private to reflect internal-only usage.

🤖 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/account/provider_import_adapter.rb` around lines 988 - 1000, Move
the clear_pending_flags_from_extra method to the class's private scope (or add a
private keyword immediately above it) so it is not part of the public API;
locate the clear_pending_flags_from_extra method in provider_import_adapter.rb
(which uses Transaction::PENDING_PROVIDERS) and ensure it remains callable by
other methods in the same class after being made private.
🤖 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.

Nitpick comments:
In `@app/models/account/provider_import_adapter.rb`:
- Around line 988-1000: Move the clear_pending_flags_from_extra method to the
class's private scope (or add a private keyword immediately above it) so it is
not part of the public API; locate the clear_pending_flags_from_extra method in
provider_import_adapter.rb (which uses Transaction::PENDING_PROVIDERS) and
ensure it remains callable by other methods in the same class after being made
private.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: f22e9105-39cb-47e4-939f-463b6bc853bc

📥 Commits

Reviewing files that changed from the base of the PR and between 4722775 and b17b29d.

📒 Files selected for processing (1)
  • app/models/account/provider_import_adapter.rb

@CrossDrain
Copy link
Copy Markdown
Contributor Author

@coderabbitai resume

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 25, 2026

✅ Actions performed

Reviews resumed.

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.

Actionable comments posted: 1

🤖 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.

Inline comments:
In `@app/models/account/provider_import_adapter.rb`:
- Around line 991-994: The code assumes extra is a Hash when building ex and
calling ex.key?, which will raise if extra is an Array/scalar; guard the
top-level type by ensuring ex is a Hash before using key?/indexing (e.g., after
ex = (extra || {}).deep_dup, coerce to an empty Hash unless ex.is_a?(Hash) or
respond_to?(:key?) so the Transaction::PENDING_PROVIDERS loop (and checks
ex.key?(provider) / ex[provider].is_a?(Hash)) always operate on a Hash and the
helper reliably returns a Hash.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 4feac145-b6f6-4607-8aa5-0e3e3f0b2fa4

📥 Commits

Reviewing files that changed from the base of the PR and between 4722775 and eb04162.

📒 Files selected for processing (1)
  • app/models/account/provider_import_adapter.rb

Comment thread app/models/account/provider_import_adapter.rb
Copy link
Copy Markdown
Collaborator

jjmata commented May 26, 2026

Good fix for a subtle edge case. A few observations:

incoming_pending hoist: Moving this computation before the protection check is the right structural change. It's now available to both the protection bypass block and the later auto-claim path, without being computed twice.

Narrow bypass: The skip_reason == "user_modified" && !incoming_pending guard is appropriately scoped — excluded? and import_locked? entries are intentionally left completely alone. Only user-modified entries get the pending-flag cleared and nothing else is changed.

clear_pending_flags_from_extra extraction: Eliminating the duplicated pending-clear logic from the auto-claim path is clean. The ex = {} unless ex.is_a?(Hash) guard handles the nil-extra case and the next unless ex[provider].is_a?(Hash) guard prevents a crash if a provider stored a non-hash value. Both defensive checks are warranted given that extra is a freeform JSON column.

Second clearing block (non-protected same-external-id path): The comment explains why this block is needed: when extra is nil, the deep-merge block is skipped, so without this second clear the pending flag would survive. The entry.persisted? + extra.blank? combination is exactly the scenario Revolut Italy triggers.

private at bottom of file: The new private keyword at line ~984 is valid Ruby and correctly scopes clear_pending_flags_from_extra as private. If the file already has a private section elsewhere, this adds a second one — harmless in Ruby but worth a quick check during code review that the existing private methods above aren't inadvertently affected.


Generated by Claude Code

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. not-gittensor pr:verified PR passed security analysis.

Development

Successfully merging this pull request may close these issues.

2 participants