mirror of
https://github.com/we-promise/sure.git
synced 2026-06-06 03:09:02 +00:00
EnableBanking: use remittance for CARD-* names and merchants (#1478)
* EnableBanking: skip CARD-* counterparty in name # Conflicts: # test/models/enable_banking_entry/processor_test.rb # Conflicts: # test/models/enable_banking_entry/processor_test.rb * Fix whitespace in remittance_information array Whitespace added before 'ACME SHOP' in remittance_information. Signed-off-by: Juan José Mata <jjmata@jjmata.com> * Fix merchant creation for Wise and prefer remittance for Entry name if counterparty is CARD-XXX * Fix review * Handle scalars * Handle empty strings * Fix review * Make truncate not use ellipsis at the end --------- Signed-off-by: Juan José Mata <jjmata@jjmata.com> Co-authored-by: quentinreytinas <quentin@reytinas.fr> Co-authored-by: Juan José Mata <jjmata@jjmata.com>
This commit is contained in:
@@ -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-<digits> 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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user