require "test_helper" require "ostruct" 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" ], 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", entry.name assert_includes entry.notes, "POS 45,13 AT D6 31.07. 10:27" end test "uses the family's known merchant name instead of the raw remittance line when it matches, and assigns the merchant" do @family.merchants.create!(name: "Billa") tx = { entry_reference: "ref_known_merchant", transaction_id: nil, booking_date: Date.current.to_s, transaction_amount: { amount: "45.13", currency: "EUR" }, creditor: { name: "" }, bank_transaction_code: nil, credit_debit_indicator: "DBIT", remittance_information: [ "POS 45,13 AT D6 31.07. 10:27", "BILLA DANKT 0007114" ], status: "BOOK" } EnableBankingEntry::Processor.new(tx, enable_banking_account: @enable_banking_account).process entry = @account.entries.find_by!(external_id: "enable_banking_ref_known_merchant") assert_equal "Billa", entry.name assert_equal "Billa", entry.transaction.merchant&.name end test "matches known merchants regardless of retailer chain" do %w[Bipa Spar Hofer Lidl Penny].each do |chain_name| @family.merchants.create!(name: chain_name) end [ [ "bipa", "BIPA DANKT 0001234", "Bipa" ], [ "spar", "SPAR DANKT 0005678", "Spar" ], [ "hofer", "HOFER DANKT 0009876", "Hofer" ], [ "lidl", "LIDL DANKT 0004321", "Lidl" ], [ "penny", "DANKE 0009999 PENNY", "Penny" ] ].each do |ref_suffix, raw_line, expected_name| tx = { entry_reference: "ref_known_#{ref_suffix}", transaction_id: nil, booking_date: Date.current.to_s, transaction_amount: { amount: "12.34", currency: "EUR" }, creditor: { name: "" }, bank_transaction_code: nil, credit_debit_indicator: "DBIT", remittance_information: [ "POS 12,34 AT D6 01.01. 09:00", raw_line ], status: "BOOK" } EnableBankingEntry::Processor.new(tx, enable_banking_account: @enable_banking_account).process entry = @account.entries.find_by!(external_id: "enable_banking_ref_known_#{ref_suffix}") assert_equal expected_name, entry.name, "expected #{raw_line.inspect} to resolve to #{expected_name.inspect}" end end test "does not match a known merchant name shorter than the minimum match length" do @family.merchants.create!(name: "IT") name = build_name_with_family( credit_debit_indicator: "DBIT", creditor: { name: "" }, bank_transaction_code: nil, remittance_information: [ "NAME IT 1234" ] ) # No known merchant match attempted ("IT" is below MIN_KNOWN_MERCHANT_MATCH_LENGTH), # so the line passes through unchanged. assert_equal "NAME IT 1234", name end test "prefers the longest matching known merchant name when multiple match" do @family.merchants.create!(name: "Billa") @family.merchants.create!(name: "Billa Corso") name = build_name_with_family( credit_debit_indicator: "DBIT", creditor: { name: "" }, bank_transaction_code: nil, remittance_information: [ "Billa Corso 0001234" ] ) assert_equal "Billa Corso", name end test "escapes regex metacharacters in a known merchant name so matching never raises" do @family.merchants.create!(name: "A+B (Café)") name = build_name_with_family( credit_debit_indicator: "DBIT", creditor: { name: "" }, bank_transaction_code: nil, remittance_information: [ "A+B (Café) 0001234" ] ) assert_equal "A+B (Café)", name 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) # A minimal stand-in that responds to current_account (real EnableBankingAccount # always does) so `account`/`known_merchant_names` resolve safely to nil/[] instead # of raising -- these unit-level tests aren't wired to a real family, so they always # exercise the "no known merchant" fallback path. EnableBankingEntry::Processor.new(data, enable_banking_account: OpenStruct.new(current_account: nil)) end def build_name(data) build_processor(data).send(:name) end # Unlike build_name/build_processor, wires up the real @enable_banking_account # (and thus the real @family) so known-merchant matching has something to match # against -- used by tests that create FamilyMerchant records via @family first. def build_name_with_family(data) EnableBankingEntry::Processor.new(data, enable_banking_account: @enable_banking_account).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" ] ) assert_equal "BILLA DANKT 0007114", 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 "does not treat a POS/ATM-prefixed line as technical when it lacks a trailing date+time (merchant embedded in the same line)" do name = build_name( credit_debit_indicator: "DBIT", creditor: { name: "" }, bank_transaction_code: nil, remittance_information: [ "POS 45,13 BILLA DANKT 0007114", "Reference 0394676" ] ) assert_equal "POS 45,13 BILLA DANKT 0007114", name end test "does not treat a legitimate line as technical just because it ends with a date+time stamp" do name = build_name( credit_debit_indicator: "DBIT", creditor: { name: "" }, bank_transaction_code: nil, remittance_information: [ "Invoice paid 31.07. 10:27", "Reference 0394676" ] ) assert_equal "Invoice paid 31.07. 10:27", 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