mirror of
https://github.com/we-promise/sure.git
synced 2026-05-24 13:04:56 +00:00
* 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.
434 lines
15 KiB
Ruby
434 lines
15 KiB
Ruby
require "test_helper"
|
|
|
|
class EnableBankingEntry::ProcessorTest < ActiveSupport::TestCase
|
|
setup do
|
|
@family = families(:dylan_family)
|
|
@account = accounts(:depository)
|
|
@enable_banking_item = EnableBankingItem.create!(
|
|
family: @family,
|
|
name: "Test Enable Banking",
|
|
country_code: "DE",
|
|
application_id: "test_app_id",
|
|
client_certificate: "test_cert"
|
|
)
|
|
@enable_banking_account = EnableBankingAccount.create!(
|
|
enable_banking_item: @enable_banking_item,
|
|
name: "N26 Hauptkonto",
|
|
uid: "eb_uid_1",
|
|
currency: "EUR"
|
|
)
|
|
AccountProvider.create!(
|
|
account: @account,
|
|
provider: @enable_banking_account
|
|
)
|
|
end
|
|
|
|
test "uses entry_reference as external_id when transaction_id is nil" do
|
|
tx = {
|
|
entry_reference: "31e13269-03fc-11f1-89d2-cd465703551c",
|
|
transaction_id: nil,
|
|
booking_date: Date.current.to_s,
|
|
transaction_amount: { amount: "11.65", currency: "EUR" },
|
|
creditor: { name: "Spar Dankt 3418" },
|
|
credit_debit_indicator: "DBIT",
|
|
status: "BOOK"
|
|
}
|
|
|
|
assert_difference "@account.entries.count", 1 do
|
|
EnableBankingEntry::Processor.new(tx, enable_banking_account: @enable_banking_account).process
|
|
end
|
|
|
|
entry = @account.entries.find_by!(
|
|
external_id: "enable_banking_31e13269-03fc-11f1-89d2-cd465703551c",
|
|
source: "enable_banking"
|
|
)
|
|
assert_equal 11.65, entry.amount.to_f
|
|
assert_equal "EUR", entry.currency
|
|
end
|
|
|
|
test "uses transaction_id as external_id when present" do
|
|
tx = {
|
|
entry_reference: "ref_123",
|
|
transaction_id: "txn_456",
|
|
booking_date: Date.current.to_s,
|
|
transaction_amount: { amount: "25.00", currency: "EUR" },
|
|
creditor: { name: "Amazon" },
|
|
credit_debit_indicator: "DBIT",
|
|
status: "BOOK"
|
|
}
|
|
|
|
EnableBankingEntry::Processor.new(tx, enable_banking_account: @enable_banking_account).process
|
|
|
|
entry = @account.entries.find_by!(external_id: "enable_banking_txn_456", source: "enable_banking")
|
|
assert_equal 25.0, entry.amount.to_f
|
|
end
|
|
|
|
test "does not create duplicate when same entry_reference is processed twice" do
|
|
tx = {
|
|
entry_reference: "unique_ref_abc",
|
|
transaction_id: nil,
|
|
booking_date: Date.current.to_s,
|
|
transaction_amount: { amount: "50.00", currency: "EUR" },
|
|
creditor: { name: "Rewe" },
|
|
credit_debit_indicator: "DBIT",
|
|
status: "BOOK"
|
|
}
|
|
|
|
assert_difference "@account.entries.count", 1 do
|
|
EnableBankingEntry::Processor.new(tx, enable_banking_account: @enable_banking_account).process
|
|
end
|
|
|
|
assert_no_difference "@account.entries.count" do
|
|
EnableBankingEntry::Processor.new(tx, enable_banking_account: @enable_banking_account).process
|
|
end
|
|
end
|
|
|
|
# --- compute_external_id unit tests ---
|
|
|
|
test "compute_external_id returns transaction_id-based id when present" do
|
|
assert_equal "enable_banking_txn_abc",
|
|
EnableBankingEntry::Processor.compute_external_id(transaction_id: "txn_abc", entry_reference: "ref_xyz")
|
|
end
|
|
|
|
test "compute_external_id falls back to entry_reference when transaction_id is blank" do
|
|
assert_equal "enable_banking_ref_xyz",
|
|
EnableBankingEntry::Processor.compute_external_id(transaction_id: nil, entry_reference: "ref_xyz")
|
|
end
|
|
|
|
test "compute_external_id returns content fingerprint when both id fields are absent" do
|
|
tx = {
|
|
booking_date: "2026-03-15",
|
|
transaction_amount: { amount: "42.00", currency: "EUR" },
|
|
credit_debit_indicator: "DBIT",
|
|
creditor: { name: "Spar" }
|
|
}
|
|
result = EnableBankingEntry::Processor.compute_external_id(tx)
|
|
assert result.start_with?("enable_banking_content_"), "Expected content fingerprint, got: #{result}"
|
|
end
|
|
|
|
test "compute_external_id fingerprint is stable across calls" do
|
|
tx = {
|
|
booking_date: "2026-03-15",
|
|
transaction_amount: { amount: "42.00", currency: "EUR" },
|
|
credit_debit_indicator: "DBIT",
|
|
creditor: { name: "Spar" }
|
|
}
|
|
assert_equal EnableBankingEntry::Processor.compute_external_id(tx),
|
|
EnableBankingEntry::Processor.compute_external_id(tx)
|
|
end
|
|
|
|
test "compute_external_id returns nil for transaction with no identifiable content" do
|
|
assert_nil EnableBankingEntry::Processor.compute_external_id({})
|
|
assert_nil EnableBankingEntry::Processor.compute_external_id(transaction_id: nil, entry_reference: nil)
|
|
end
|
|
|
|
# --- ID-less transaction processing ---
|
|
|
|
test "imports transaction using content fingerprint when transaction_id and entry_reference are absent" do
|
|
tx = {
|
|
transaction_id: nil,
|
|
entry_reference: nil,
|
|
booking_date: Date.current.to_s,
|
|
transaction_amount: { amount: "10.00", currency: "EUR" },
|
|
creditor: { name: "Lidl" },
|
|
credit_debit_indicator: "DBIT",
|
|
status: "BOOK"
|
|
}
|
|
|
|
assert_difference "@account.entries.count", 1 do
|
|
EnableBankingEntry::Processor.new(tx, enable_banking_account: @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 "does not create duplicate when same id-less transaction is processed twice" do
|
|
tx = {
|
|
transaction_id: nil,
|
|
entry_reference: nil,
|
|
booking_date: Date.current.to_s,
|
|
transaction_amount: { amount: "10.00", currency: "EUR" },
|
|
creditor: { name: "Lidl" },
|
|
credit_debit_indicator: "DBIT",
|
|
status: "BOOK"
|
|
}
|
|
|
|
assert_difference "@account.entries.count", 1 do
|
|
EnableBankingEntry::Processor.new(tx, enable_banking_account: @enable_banking_account).process
|
|
end
|
|
|
|
assert_no_difference "@account.entries.count" do
|
|
EnableBankingEntry::Processor.new(tx, enable_banking_account: @enable_banking_account).process
|
|
end
|
|
end
|
|
|
|
test "raises ArgumentError for transaction with no identifiable content at all" do
|
|
tx = { transaction_id: nil, entry_reference: nil }
|
|
|
|
assert_raises(ArgumentError) do
|
|
EnableBankingEntry::Processor.new(tx, enable_banking_account: @enable_banking_account).process
|
|
end
|
|
end
|
|
|
|
test "handles string keys in transaction data" do
|
|
tx = {
|
|
"entry_reference" => "string_key_ref",
|
|
"transaction_id" => nil,
|
|
"booking_date" => Date.current.to_s,
|
|
"transaction_amount" => { "amount" => "15.00", "currency" => "EUR" },
|
|
"creditor" => { "name" => "Lidl" },
|
|
"credit_debit_indicator" => "DBIT",
|
|
"status" => "BOOK"
|
|
}
|
|
|
|
assert_difference "@account.entries.count", 1 do
|
|
EnableBankingEntry::Processor.new(tx, enable_banking_account: @enable_banking_account).process
|
|
end
|
|
|
|
entry = @account.entries.find_by!(external_id: "enable_banking_string_key_ref", source: "enable_banking")
|
|
assert_equal 15.0, entry.amount.to_f
|
|
end
|
|
|
|
test "includes note field in transaction notes alongside remittance_information" do
|
|
tx = {
|
|
entry_reference: "ref_note",
|
|
transaction_id: nil,
|
|
booking_date: Date.current.to_s,
|
|
transaction_amount: { amount: "10.00", currency: "EUR" },
|
|
credit_debit_indicator: "DBIT",
|
|
remittance_information: [ "Facture 2026-001" ],
|
|
note: "Détail comptable interne",
|
|
status: "BOOK"
|
|
}
|
|
|
|
EnableBankingEntry::Processor.new(tx, enable_banking_account: @enable_banking_account).process
|
|
entry = @account.entries.find_by!(external_id: "enable_banking_ref_note")
|
|
assert_includes entry.notes, "Facture 2026-001"
|
|
assert_includes entry.notes, "Détail comptable interne"
|
|
end
|
|
|
|
test "stores exchange_rate in extra when present" do
|
|
tx = {
|
|
entry_reference: "ref_fx",
|
|
transaction_id: nil,
|
|
booking_date: Date.current.to_s,
|
|
transaction_amount: { amount: "100.00", currency: "EUR" },
|
|
credit_debit_indicator: "DBIT",
|
|
exchange_rate: {
|
|
unit_currency: "USD",
|
|
exchange_rate: "1.0821",
|
|
rate_type: "SPOT",
|
|
instructed_amount: { amount: "108.21", currency: "USD" }
|
|
},
|
|
status: "BOOK"
|
|
}
|
|
|
|
EnableBankingEntry::Processor.new(tx, enable_banking_account: @enable_banking_account).process
|
|
entry = @account.entries.find_by!(external_id: "enable_banking_ref_fx")
|
|
eb_extra = entry.transaction&.extra&.dig("enable_banking")
|
|
assert_equal "1.0821", eb_extra["fx_rate"]
|
|
assert_equal "USD", eb_extra["fx_unit_currency"]
|
|
assert_equal "108.21", eb_extra["fx_instructed_amount"]
|
|
end
|
|
|
|
test "stores merchant_category_code in extra when present" do
|
|
tx = {
|
|
entry_reference: "ref_mcc",
|
|
transaction_id: nil,
|
|
booking_date: Date.current.to_s,
|
|
transaction_amount: { amount: "25.00", currency: "EUR" },
|
|
credit_debit_indicator: "DBIT",
|
|
merchant_category_code: "5411",
|
|
status: "BOOK"
|
|
}
|
|
|
|
EnableBankingEntry::Processor.new(tx, enable_banking_account: @enable_banking_account).process
|
|
entry = @account.entries.find_by!(external_id: "enable_banking_ref_mcc")
|
|
eb_extra = entry.transaction&.extra&.dig("enable_banking")
|
|
assert_equal "5411", eb_extra["merchant_category_code"]
|
|
end
|
|
|
|
test "stores pending true in extra for PDNG-tagged transactions" do
|
|
tx = {
|
|
entry_reference: "ref_pdng",
|
|
transaction_id: nil,
|
|
booking_date: Date.current.to_s,
|
|
transaction_amount: { amount: "15.00", currency: "EUR" },
|
|
credit_debit_indicator: "DBIT",
|
|
status: "PDNG",
|
|
_pending: true
|
|
}
|
|
|
|
EnableBankingEntry::Processor.new(tx, enable_banking_account: @enable_banking_account).process
|
|
entry = @account.entries.find_by!(external_id: "enable_banking_ref_pdng")
|
|
eb_extra = entry.transaction&.extra&.dig("enable_banking")
|
|
assert_equal true, eb_extra["pending"]
|
|
end
|
|
|
|
test "does not add enable_banking extra key when no extra data present" do
|
|
tx = {
|
|
entry_reference: "ref_noextra",
|
|
transaction_id: nil,
|
|
booking_date: Date.current.to_s,
|
|
transaction_amount: { amount: "5.00", currency: "EUR" },
|
|
credit_debit_indicator: "DBIT",
|
|
status: "BOOK"
|
|
}
|
|
|
|
EnableBankingEntry::Processor.new(tx, enable_banking_account: @enable_banking_account).process
|
|
entry = @account.entries.find_by!(external_id: "enable_banking_ref_noextra")
|
|
assert_nil entry.transaction&.extra&.dig("enable_banking")
|
|
end
|
|
|
|
def build_processor(data)
|
|
EnableBankingEntry::Processor.new(data, enable_banking_account: Object.new)
|
|
end
|
|
|
|
def build_name(data)
|
|
build_processor(data).send(:name)
|
|
end
|
|
|
|
test "skips technical card counterparty and falls back to remittance_information" do
|
|
name = build_name(
|
|
credit_debit_indicator: "CRDT",
|
|
debtor_name: "CARD-1234",
|
|
remittance_information: [ "ACME SHOP" ],
|
|
bank_transaction_code: { description: "Card Purchase" }
|
|
)
|
|
|
|
assert_equal "ACME SHOP", name
|
|
end
|
|
|
|
test "uses counterparty when it is human readable" do
|
|
name = build_name(
|
|
credit_debit_indicator: "CRDT",
|
|
debtor_name: "ACME SHOP",
|
|
remittance_information: [ "Receipt #42" ],
|
|
bank_transaction_code: { description: "Transfer" }
|
|
)
|
|
|
|
assert_equal "ACME SHOP", name
|
|
end
|
|
|
|
test "falls back to top-level counterparty name when nested name is blank" do
|
|
processor = build_processor(
|
|
credit_debit_indicator: "CRDT",
|
|
debtor: { name: "" },
|
|
debtor_name: "ACME SHOP"
|
|
)
|
|
|
|
assert_equal "ACME SHOP", processor.send(:name)
|
|
|
|
merchant = stub(id: 789)
|
|
import_adapter = mock("import_adapter")
|
|
import_adapter.expects(:find_or_create_merchant).with(
|
|
provider_merchant_id: "enable_banking_merchant_c0b09f27a4375bb8d8d477ed552a9aa1",
|
|
name: "ACME SHOP",
|
|
source: "enable_banking"
|
|
).returns(merchant)
|
|
|
|
processor.stubs(:import_adapter).returns(import_adapter)
|
|
|
|
assert_equal merchant, processor.send(:merchant)
|
|
end
|
|
|
|
test "builds merchant from remittance when counterparty is technical card id" do
|
|
processor = build_processor(
|
|
credit_debit_indicator: "CRDT",
|
|
debtor_name: "CARD-1234",
|
|
remittance_information: [ "ACME SHOP" ],
|
|
bank_transaction_code: { description: "Card Purchase" }
|
|
)
|
|
|
|
merchant = stub(id: 123)
|
|
import_adapter = mock("import_adapter")
|
|
import_adapter.expects(:find_or_create_merchant).with(
|
|
provider_merchant_id: "enable_banking_merchant_c0b09f27a4375bb8d8d477ed552a9aa1",
|
|
name: "ACME SHOP",
|
|
source: "enable_banking"
|
|
).returns(merchant)
|
|
|
|
processor.stubs(:import_adapter).returns(import_adapter)
|
|
|
|
assert_equal merchant, processor.send(:merchant)
|
|
end
|
|
|
|
test "uses remittance fallback for debit technical card counterparty" do
|
|
processor = build_processor(
|
|
credit_debit_indicator: "DBIT",
|
|
creditor_name: "CARD-1234",
|
|
remittance_information: [ "ACME SHOP" ],
|
|
bank_transaction_code: { description: "Card Purchase" }
|
|
)
|
|
|
|
assert_equal "ACME SHOP", processor.send(:name)
|
|
|
|
merchant = stub(id: 321)
|
|
import_adapter = mock("import_adapter")
|
|
import_adapter.expects(:find_or_create_merchant).with(
|
|
provider_merchant_id: "enable_banking_merchant_c0b09f27a4375bb8d8d477ed552a9aa1",
|
|
name: "ACME SHOP",
|
|
source: "enable_banking"
|
|
).returns(merchant)
|
|
|
|
processor.stubs(:import_adapter).returns(import_adapter)
|
|
|
|
assert_equal merchant, processor.send(:merchant)
|
|
end
|
|
|
|
test "truncates remittance-derived merchant names before persisting" do
|
|
long_name = "A" * 150
|
|
truncated_name = "A" * 100
|
|
processor = build_processor(
|
|
credit_debit_indicator: "CRDT",
|
|
debtor_name: "CARD-1234",
|
|
remittance_information: [ long_name ]
|
|
)
|
|
|
|
merchant = stub(id: 654)
|
|
import_adapter = mock("import_adapter")
|
|
import_adapter.expects(:find_or_create_merchant).with(
|
|
provider_merchant_id: "enable_banking_merchant_#{Digest::MD5.hexdigest(truncated_name.downcase)}",
|
|
name: truncated_name,
|
|
source: "enable_banking"
|
|
).returns(merchant)
|
|
|
|
processor.stubs(:import_adapter).returns(import_adapter)
|
|
|
|
assert_equal merchant, processor.send(:merchant)
|
|
end
|
|
|
|
test "uses string remittance fallback for technical card counterparty" do
|
|
processor = build_processor(
|
|
credit_debit_indicator: "CRDT",
|
|
debtor_name: "CARD-1234",
|
|
remittance_information: "ACME SHOP"
|
|
)
|
|
|
|
assert_equal "ACME SHOP", processor.send(:name)
|
|
|
|
merchant = stub(id: 456)
|
|
import_adapter = mock("import_adapter")
|
|
import_adapter.expects(:find_or_create_merchant).with(
|
|
provider_merchant_id: "enable_banking_merchant_c0b09f27a4375bb8d8d477ed552a9aa1",
|
|
name: "ACME SHOP",
|
|
source: "enable_banking"
|
|
).returns(merchant)
|
|
|
|
processor.stubs(:import_adapter).returns(import_adapter)
|
|
|
|
assert_equal merchant, processor.send(:merchant)
|
|
end
|
|
|
|
test "does not build merchant from remittance when counterparty is blank" do
|
|
processor = build_processor(
|
|
credit_debit_indicator: "CRDT",
|
|
debtor_name: nil,
|
|
remittance_information: [ "Invoice 12345" ]
|
|
)
|
|
|
|
assert_nil processor.send(:merchant)
|
|
end
|
|
end
|