Files
sure/test/models/enable_banking_account/transactions/processor_test.rb
CrossDrain 33bc6b59c8 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.
2026-05-12 00:17:49 +02:00

231 lines
7.8 KiB
Ruby

require "test_helper"
class EnableBankingAccount::Transactions::ProcessorTest < ActiveSupport::TestCase
include EntriesTestHelper
setup do
@family = families(:dylan_family)
@account = accounts(:depository)
@enable_banking_item = EnableBankingItem.create!(
family: @family,
name: "Test EB Item",
country_code: "FR",
application_id: "app_id",
client_certificate: "cert"
)
@enable_banking_account = EnableBankingAccount.create!(
enable_banking_item: @enable_banking_item,
name: "Compte courant",
uid: "uid_txn_proc_test",
currency: "EUR",
current_balance: 1000.00
)
AccountProvider.create!(account: @account, provider: @enable_banking_account)
end
# Minimal raw transaction payload hash matching the shape EnableBankingEntry::Processor expects
def raw_pending_transaction(transaction_id:)
{
transaction_id: transaction_id,
value_date: 3.days.ago.to_date.to_s,
transaction_amount: { amount: "25.00", currency: "EUR" },
credit_debit_indicator: "DBIT",
_pending: true
}
end
test "does not re-import a pending transaction whose external_id was manually merged" do
pending_ext_id = "enable_banking_PDNG_MERGED"
# Simulate a previously-merged state: a posted transaction carries the pending's external_id
# in its manual_merge metadata, which is how merge_with_duplicate! records the merge.
posted_entry = create_transaction(
account: @account,
name: "Coffee Shop",
date: 1.day.ago.to_date,
amount: 25,
currency: "EUR",
external_id: "enable_banking_BOOK_SETTLED",
source: "enable_banking"
)
posted_entry.transaction.update!(
extra: {
"manual_merge" => {
"merged_from_entry_id" => SecureRandom.uuid,
"merged_from_external_id" => pending_ext_id,
"merged_at" => Time.current.iso8601,
"source" => "enable_banking"
}
}
)
posted_entry.mark_user_modified!
# Raw payload contains the pending transaction that was already merged
@enable_banking_account.update!(
raw_transactions_payload: [
raw_pending_transaction(transaction_id: "PDNG_MERGED")
]
)
assert_no_difference "@account.entries.count" do
EnableBankingAccount::Transactions::Processor.new(@enable_banking_account).process
end
end
test "imports a pending transaction that has NOT been merged" do
@enable_banking_account.update!(
raw_transactions_payload: [
raw_pending_transaction(transaction_id: "PDNG_NEW_UNMERGED")
]
)
assert_difference "@account.entries.count", 1 do
EnableBankingAccount::Transactions::Processor.new(@enable_banking_account).process
end
end
test "imports non-excluded transactions alongside excluded ones in the same batch" do
pending_ext_id = "enable_banking_PDNG_SKIP_ME"
posted_entry = create_transaction(
account: @account,
name: "Already Merged",
date: 2.days.ago.to_date,
amount: 15,
currency: "EUR",
external_id: "enable_banking_BOOK_ALREADY",
source: "enable_banking"
)
posted_entry.transaction.update!(
extra: {
"manual_merge" => {
"merged_from_external_id" => pending_ext_id,
"merged_at" => Time.current.iso8601,
"source" => "enable_banking"
}
}
)
@enable_banking_account.update!(
raw_transactions_payload: [
raw_pending_transaction(transaction_id: "PDNG_SKIP_ME"), # excluded
raw_pending_transaction(transaction_id: "PDNG_BRAND_NEW_12345") # should be imported
]
)
assert_difference "@account.entries.count", 1 do
EnableBankingAccount::Transactions::Processor.new(@enable_banking_account).process
end
end
test "excludes all external_ids when multiple pending entries were merged into the same posted entry" do
pending_ext_id_1 = "enable_banking_PDNG_MULTI_1"
pending_ext_id_2 = "enable_banking_PDNG_MULTI_2"
posted_entry = create_transaction(
account: @account,
name: "Multi Merge",
date: 2.days.ago.to_date,
amount: 30,
currency: "EUR",
external_id: "enable_banking_BOOK_MULTI",
source: "enable_banking"
)
posted_entry.transaction.update!(
extra: {
"manual_merge" => [
{ "merged_from_external_id" => pending_ext_id_1, "merged_at" => 2.days.ago.iso8601, "source" => "enable_banking" },
{ "merged_from_external_id" => pending_ext_id_2, "merged_at" => 1.day.ago.iso8601, "source" => "enable_banking" }
]
}
)
@enable_banking_account.update!(
raw_transactions_payload: [
raw_pending_transaction(transaction_id: "PDNG_MULTI_1"), # excluded
raw_pending_transaction(transaction_id: "PDNG_MULTI_2"), # excluded
raw_pending_transaction(transaction_id: "PDNG_MULTI_NEW") # new — should import
]
)
result = nil
assert_difference "@account.entries.count", 1 do
result = EnableBankingAccount::Transactions::Processor.new(@enable_banking_account).process
end
assert_equal 2, result[:skipped]
assert_equal 1, result[:imported]
end
test "imports id-less transaction using content fingerprint" do
tx = {
"booking_date" => Date.current.to_s,
"transaction_amount" => { "amount" => "19.99", "currency" => "EUR" },
"credit_debit_indicator" => "DBIT",
"creditor" => { "name" => "Spotify" }
}
@enable_banking_account.update!(raw_transactions_payload: [ tx ])
assert_difference "@account.entries.count", 1 do
EnableBankingAccount::Transactions::Processor.new(@enable_banking_account).process
end
expected_id = EnableBankingEntry::Processor.compute_external_id(tx)
assert @account.entries.exists?(external_id: expected_id, source: "enable_banking")
end
test "id-less transaction does not appear in failed count" do
tx = {
"booking_date" => Date.current.to_s,
"transaction_amount" => { "amount" => "5.00", "currency" => "EUR" },
"credit_debit_indicator" => "CRDT",
"debtor" => { "name" => "Employer" }
}
@enable_banking_account.update!(raw_transactions_payload: [ tx ])
result = EnableBankingAccount::Transactions::Processor.new(@enable_banking_account).process
assert_equal 0, result[:failed]
assert_equal 1, result[:imported]
end
test "handles empty raw_transactions_payload gracefully" do
@enable_banking_account.update!(raw_transactions_payload: nil)
result = EnableBankingAccount::Transactions::Processor.new(@enable_banking_account).process
assert_equal true, result[:success]
assert_equal 0, result[:total]
end
test "reports excluded transactions as skipped, not imported or failed" do
pending_ext_id = "enable_banking_PDNG_SKIP_STATS"
posted_entry = create_transaction(
account: @account,
name: "Stats Test",
date: 2.days.ago.to_date,
amount: 50,
currency: "EUR",
external_id: "enable_banking_BOOK_STATS",
source: "enable_banking"
)
posted_entry.transaction.update!(
extra: { "manual_merge" => { "merged_from_external_id" => pending_ext_id } }
)
@enable_banking_account.update!(
raw_transactions_payload: [
raw_pending_transaction(transaction_id: "PDNG_SKIP_STATS")
]
)
result = EnableBankingAccount::Transactions::Processor.new(@enable_banking_account).process
assert_equal true, result[:success]
assert_equal 1, result[:skipped]
assert_equal 0, result[:imported]
assert_equal 0, result[:failed]
end
end