Files
sure/test/models/enable_banking_entry/processor_test.rb
T
GeraldandClaude Sonnet 5 2c3027b97a fix(enable-banking): use real merchant instead of POS terminal line for name
Some ASPSPs (e.g. BankDirekt/Raiffeisen in Austria) return
remittance_information as a multi-element array where the first line is a
generic card terminal descriptor (\"POS   45,13 AT  D6   31.07. 10:27\")
and a later line holds the real merchant. EnableBankingEntry::Processor
always used the first array element, so transaction names showed the
terminal string instead of the merchant.

primary_remittance_information now skips lines that look like a technical
terminal booking (POS/ATM + amount, or a trailing date+time stamp) and
prefers the first descriptive line, falling back to the original element
when nothing better is available. It also strips known small-merchant
payment-processor prefixes (SumUp, Square, iZettle, PayPal) from the
selected line.

Fixes #2935

Disclosure: this fix was written by Claude Code, verified against the
reporter's real (decrypted) Enable Banking payload and against test-stack
Rails test / RuboCop / Brakeman runs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-08 10:21:45 +02:00

558 lines
19 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 "imports transaction with real merchant name instead of generic POS terminal line (issue #2935)" do
tx = {
entry_reference: "ref_pos_2935",
transaction_id: nil,
booking_date: Date.current.to_s,
transaction_amount: { amount: "45.13", currency: "EUR" },
creditor: { name: "" },
debtor: nil,
bank_transaction_code: nil,
credit_debit_indicator: "DBIT",
remittance_information: [
"POS 45,13 AT D6 31.07. 10:27",
"BILLA DANKT 0007114 SIEGENDORF 7011"
],
status: "BOOK"
}
EnableBankingEntry::Processor.new(tx, enable_banking_account: @enable_banking_account).process
entry = @account.entries.find_by!(external_id: "enable_banking_ref_pos_2935")
assert_equal "BILLA DANKT 0007114 SIEGENDORF 7011", entry.name
assert_includes entry.notes, "POS 45,13 AT D6 31.07. 10:27"
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
# --- technical remittance line skip (issue #2935) ---
test "skips generic POS terminal line and uses real merchant from remittance_information" do
name = build_name(
credit_debit_indicator: "DBIT",
creditor: { name: "" },
bank_transaction_code: nil,
remittance_information: [
"POS 45,13 AT D6 31.07. 10:27",
"BILLA DANKT 0007114 SIEGENDORF 7011"
]
)
assert_equal "BILLA DANKT 0007114 SIEGENDORF 7011", name
end
test "skips generic ATM terminal line and uses real merchant from remittance_information" do
name = build_name(
credit_debit_indicator: "DBIT",
creditor: { name: "" },
bank_transaction_code: nil,
remittance_information: [
"ATM 50,00 AT D6 31.07. 10:27",
"SPARKASSE EISENSTADT 7000"
]
)
assert_equal "SPARKASSE EISENSTADT 7000", name
end
test "recognizes technical remittance line by trailing date+time even without POS/ATM keyword" do
name = build_name(
credit_debit_indicator: "DBIT",
creditor: { name: "" },
bank_transaction_code: nil,
remittance_information: [
"KAUF 12,00 AT D6 31.07. 10:27",
"STADTKIND EISENSTADT 7000"
]
)
assert_equal "STADTKIND EISENSTADT 7000", name
end
test "strips SumUp payment processor prefix from the merchant line" do
name = build_name(
credit_debit_indicator: "DBIT",
creditor: { name: "" },
bank_transaction_code: nil,
remittance_information: [
"POS 130,00 AT D6 21.07. 14:20",
"SUMUP *HERR DR. EISENSTADT 7000"
]
)
assert_equal "HERR DR. EISENSTADT 7000", name
end
test "falls back to the technical line when remittance_information has no descriptive line" do
name = build_name(
credit_debit_indicator: "DBIT",
creditor: { name: "" },
bank_transaction_code: nil,
remittance_information: [ "POS 45,13 AT D6 31.07. 10:27" ]
)
assert_equal "POS 45,13 AT D6 31.07. 10:27", name
end
test "does not treat a merchant-like line starting with POS/ATM as technical" do
name = build_name(
credit_debit_indicator: "DBIT",
creditor: { name: "" },
bank_transaction_code: nil,
remittance_information: [ "POS Café Wien" ]
)
assert_equal "POS Café Wien", name
end
test "converts unix timestamp date using family timezone not UTC" do
# 2025-07-14 23:30:00 UTC == 2025-07-15 01:30:00 CEST
@family.update!(timezone: "Europe/Berlin")
tx = {
entry_reference: "tz_ref",
transaction_id: nil,
booking_date: 1752535800, # 2025-07-14 23:30:00 UTC
transaction_amount: { amount: "10.00", currency: "EUR" },
creditor: { name: "Late Night Shop" },
credit_debit_indicator: "DBIT",
remittance_information: [ "After midnight" ],
status: "BOOK"
}
result = EnableBankingEntry::Processor.new(tx, enable_banking_account: @enable_banking_account).process
assert_not_nil result
assert_equal Date.new(2025, 7, 15), result.date
end
end