diff --git a/app/models/enable_banking_entry/processor.rb b/app/models/enable_banking_entry/processor.rb index 8ffc6807a..1cc2309db 100644 --- a/app/models/enable_banking_entry/processor.rb +++ b/app/models/enable_banking_entry/processor.rb @@ -73,41 +73,32 @@ class EnableBankingEntry::Processor # Build name from available Enable Banking transaction fields # Priority: counterparty name > bank_transaction_code description > remittance_information - # Determine counterparty based on transaction direction - # For outgoing payments (DBIT), counterparty is the creditor (who we paid) - # For incoming payments (CRDT), counterparty is the debtor (who paid us) - counterparty = if credit_debit_indicator == "CRDT" - data.dig(:debtor, :name) || data[:debtor_name] - else - data.dig(:creditor, :name) || data[:creditor_name] - end + counterparty = counterparty_name + return counterparty if counterparty.present? && !technical_card_counterparty?(counterparty) - return counterparty if counterparty.present? + # Some institutions (e.g. Wise) use technical CARD-* identifiers as counterparties + # Prefer remittance_information first in that case since it contains the real merchant label for Wise + if technical_card_counterparty?(counterparty) + remittance = primary_remittance_information + return remittance.truncate(100) if remittance.present? + end # Fall back to bank_transaction_code description bank_tx_description = data.dig(:bank_transaction_code, :description) return bank_tx_description if bank_tx_description.present? # Fall back to remittance_information - remittance = data[:remittance_information] - return remittance.first.truncate(100) if remittance.is_a?(Array) && remittance.first.present? + remittance = primary_remittance_information + return remittance.truncate(100) if remittance.present? # Final fallback: use transaction type indicator credit_debit_indicator == "CRDT" ? "Incoming Transfer" : "Outgoing Transfer" end def merchant - # For outgoing payments (DBIT), merchant is the creditor (who we paid) - # For incoming payments (CRDT), merchant is the debtor (who paid us) - merchant_name = if credit_debit_indicator == "CRDT" - data.dig(:debtor, :name) || data[:debtor_name] - else - data.dig(:creditor, :name) || data[:creditor_name] - end - - return nil unless merchant_name.present? - - merchant_name = merchant_name.to_s.strip + # Use the counterparty when it is human readable; otherwise fall back to remittance + # for CARD-* transactions where the remittance often contains the actual merchant + merchant_name = merchant_name_candidate return nil if merchant_name.blank? merchant_id = Digest::MD5.hexdigest(merchant_name.downcase) @@ -183,6 +174,40 @@ class EnableBankingEntry::Processor data[:credit_debit_indicator] end + def counterparty_name + # Determine counterparty based on transaction direction + # For outgoing payments (DBIT), counterparty is the creditor (who we paid) + # For incoming payments (CRDT), counterparty is the debtor (who paid us) + if credit_debit_indicator == "CRDT" + data.dig(:debtor, :name).presence || data[:debtor_name].presence + else + data.dig(:creditor, :name).presence || data[:creditor_name].presence + end + end + + def technical_card_counterparty?(value) + # Some providers expose card transactions with CARD- placeholders instead of a real counterparty name + value.to_s.strip.match?(/\ACARD-\d+\z/i) + end + + def primary_remittance_information + remittance = data[:remittance_information] + Array.wrap(remittance) + .map { |value| value.to_s.strip.presence } + .compact + .first + end + + def merchant_name_candidate + counterparty = counterparty_name.to_s.strip + return counterparty if counterparty.present? && !technical_card_counterparty?(counterparty) + # For technical CARD-* counterparties, reuse remittance as the best merchant candidate + remittance = primary_remittance_information + return remittance.truncate(100, omission: "") if remittance.present? && technical_card_counterparty?(counterparty) + + nil + end + def amount # Sure convention: positive = outflow (debit/expense), negative = inflow (credit/income) # amount_value already applies this: DBIT → +absolute, CRDT → -absolute diff --git a/test/models/enable_banking_entry/processor_test.rb b/test/models/enable_banking_entry/processor_test.rb index f8fae7323..9a4623ee6 100644 --- a/test/models/enable_banking_entry/processor_test.rb +++ b/test/models/enable_banking_entry/processor_test.rb @@ -208,4 +208,154 @@ class EnableBankingEntry::ProcessorTest < ActiveSupport::TestCase 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