Transaction Deduplication#
Sure prevents duplicate transaction imports using two complementary strategies: a database-level unique constraint (primary) and an MD5 content fingerprint (fallback for providers that omit identifiers).
1. Primary: Composite Unique Index on entries#
Every synced transaction is stored as an Entry row tagged with an external_id (the provider's transaction identifier) and a source string (e.g., "enable_banking", "plaid").
A partial unique index enforces that no two entries on the same account can share the same (account_id, source, external_id) triple :
index_entries_on_account_source_and_external_id
ON entries (account_id, source, external_id)
WHERE external_id IS NOT NULL AND source IS NOT NULL
This index replaced an older (external_id, source) index to allow multiple providers to sync the same account without colliding . The WHERE clause means rows without either column are exempt — safe for manual/CSV-imported entries that have no external identifier.
The model-level validation mirrors the index :
validates :external_id, uniqueness: { scope: [:account_id, :source] },
if: -> { external_id.present? && source.present? }
At import time, Account::ProviderImportAdapter#import_transaction resolves the entry via find_or_initialize_by(external_id:, source:) , making every sync idempotent: existing entries are updated in place, new ones are created.
2. Fallback: MD5 Content Fingerprint (Enable Banking)#
PSD2 / Open Banking permits ASPSPs (banks) to omit both transaction_id and entry_reference. When Enable Banking delivers such transactions, EnableBankingEntry::Processor.compute_external_id generates a deterministic fingerprint instead :
-
Prefer a provider ID — uses
transaction_id, thenentry_reference, prefixed asenable_banking_<id>. -
Fall back to content hash — joins the following fields with a Unit Separator (
\x1F) and MD5-hashes the result:booking_date/value_date/transaction_date(first present)transaction_amount.amountand.currencycredit_debit_indicator(DBIT / CRDT)creditor.name/debtor.nameremittance_information(array elements sorted and joined with|)
The resulting
external_idis prefixedenable_banking_content_<md5hex>. -
Reject unidentifiable transactions — if all fields are blank after stripping separators,
compute_external_idreturnsnil, andprocessraisesArgumentError, preventing silent data loss.
The batch processor calls compute_external_id once per transaction before delegating to EnableBankingEntry::Processor#process so that already-excluded/claimed IDs can be skipped cheaply without touching the adapter .
3. Manual / CSV Import Deduplication#
When a new provider transaction arrives and no entry exists for its (external_id, source), import_transaction additionally searches for an unlinked entry (no external_id) with the same (date, amount, currency) — see find_duplicate_transaction. If found, the adapter "claims" it by writing external_id and source onto the existing row, preventing a second entry from being created.
4. Excluded-ID Allowlist (Enable Banking)#
Before iterating raw transactions, EnableBankingAccount::Transactions::Processor pre-fetches two sets of external_id values to skip :
| Set | Source |
|---|---|
manually_merged_ids | Entries whose user explicitly merged a pending into a posted transaction (extra["manual_merge"]) |
auto_claimed_ids | Entries auto-claimed by the pending→posted reconciler (extra["auto_claimed_pending_ids"]) |
These are unioned into an excluded_ids set and checked with a single Set#include? per transaction, avoiding N+1 queries .
Key Files#
| File | Purpose |
|---|---|
db/migrate/20251028104916_update_entries_external_id_index.rb | Migration that created the (account_id, source, external_id) unique index |
app/models/entry.rb | Model-level uniqueness validation |
app/models/account/provider_import_adapter.rb | import_transaction — main upsert path with duplicate/pending logic |
app/models/enable_banking_entry/processor.rb | compute_external_id — MD5 fingerprint generator |
app/models/enable_banking_account/transactions/processor.rb | Batch processor with pre-fetched excluded-ID allowlist |