fix(enable-banking): import transactions missing transaction_id and entry_reference (#1767)

* fix(enable-banking): handle transactions missing transaction_id and entry_reference

Some ASPSPs omit both transaction_id and entry_reference from their transaction payloads, which is valid per the PSD2/Berlin Group spec. Previously, every such transaction raised an ArgumentError and was silently dropped during sync.

compute_external_id now falls back to a deterministic MD5 fingerprint (prefixed enable_banking_content_) derived from date, amount, currency, direction, counterparty, and remittance info. This fingerprint is stable across re-syncs, so duplicate imports are still correctly prevented. An ArgumentError is only raised for truly empty/unidentifiable payloads.

The importer is updated in three places to use compute_external_id
consistently: the pending pre-filter (before combining with booked),
the C4 stored-pending cleanup, and the new_transactions dedup. This means ID-less pending entries are now also removed when their settled booked counterpart arrives.

Tests cover compute_external_id directly (all 5 cases), end-to-end
fingerprint import, idempotency, and importer storage/dedup behaviour for ID-less transactions including the pending→booked settlement path.

* fix(enable-banking): implement dual-strategy matching for transaction settlement

When a stored pending row had only entry_reference (no transaction_id) and
the settled BOOK row arrived with a new transaction_id, compute_external_id
produced different fingerprints for each side (enable_banking_<ref> vs
enable_banking_<txn_id>). The fingerprint-only comparison introduced in the
previous commit never matched, leaving the stale pending entry in
raw_transactions_payload. Both rows were then imported as separate visible
transactions.

Restore a book_entry_refs set alongside book_fingerprints in both the
pending pre-filter and the C4 stored-pending cleanup. A pending entry is
now removed when either its fingerprint or its entry_reference matches a
booked counterpart — covering same-ID settlement, content-fingerprint
settlement, and the entry_reference cross-match settlement path.

Also updates the ArgumentError message in external_id to accurately
reflect that transaction_id, entry_reference, and content fingerprint
are all accepted identifiers, and aligns build_transaction_content_key
to use transaction_date as a fallback (matching compute_external_id).

Adds a regression test that stores a pending-only row and asserts it is removed when the booked counterpart arrives with a new transaction_id.
This commit is contained in:
CrossDrain
2026-05-11 22:17:49 +00:00
committed by GitHub
parent 325084e342
commit 33bc6b59c8
5 changed files with 297 additions and 23 deletions

View File

@@ -13,7 +13,25 @@ class EnableBankingEntry::Processor
def self.compute_external_id(raw_transaction_data)
data = raw_transaction_data.with_indifferent_access
id = data[:transaction_id].presence || data[:entry_reference].presence
id ? "enable_banking_#{id}" : nil
return "enable_banking_#{id}" if id
# Some ASPSPs omit both transaction_id and entry_reference (both are optional
# in PSD2). Generate a deterministic content-based ID so these transactions
# can still be imported idempotently. Uses the same fields as the importer's
# dedup key so the two strategies stay in sync.
date = data[:booking_date].presence || data[:value_date].presence || data[:transaction_date]
amount = data.dig(:transaction_amount, :amount).presence || data[:amount]
currency = data.dig(:transaction_amount, :currency).presence || data[:currency]
direction = data[:credit_debit_indicator]
creditor = data.dig(:creditor, :name).presence || data[:creditor_name]
debtor = data.dig(:debtor, :name).presence || data[:debtor_name]
remittance = data[:remittance_information]
remittance_key = remittance.is_a?(Array) ? remittance.compact.map(&:to_s).sort.join("|") : remittance.to_s
content = [ date, amount, currency, direction, creditor, debtor, remittance_key ].map(&:to_s).join("\x1F")
return nil if content.gsub("\x1F", "").blank?
"enable_banking_content_#{Digest::MD5.hexdigest(content)}"
end
def initialize(enable_banking_transaction, enable_banking_account:, import_adapter: nil)
@@ -75,7 +93,7 @@ class EnableBankingEntry::Processor
def external_id
id = self.class.compute_external_id(data)
raise ArgumentError, "Enable Banking transaction missing required field 'transaction_id'" unless id
raise ArgumentError, "Enable Banking transaction missing required identifier (transaction_id, entry_reference, or identifiable content)" unless id
id
end