mirror of
https://github.com/we-promise/sure.git
synced 2026-09-02 05:11:05 +00:00
* 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>
* fix(enable-banking): require both technical-line signals together
Address CodeRabbit/Codex review feedback on #2968: the POS/ATM+amount
prefix and the trailing date+time suffix were OR'd, so either alone
could misclassify a legitimate line as technical. A line embedding the
merchant right after the amount (e.g. "POS 45,13 BILLA DANKT ...") or
a legitimate descriptor that happens to end in a timestamp (e.g.
"Invoice paid 31.07. 10:27") would have been wrongly skipped.
Both signals are now required together in a single anchored pattern,
matching every real technical line observed in production while no
longer misclassifying either of the scenarios above.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix(enable-banking): tighten date-stamp regex and clean up the merchant line
Addresses two review threads on this PR:
1. jjmata (PR review): technical_remittance_line?'s date-stamp check required
exactly 2-digit day/month (\d{2}[./]\d{2}), so an un-padded date ("1.07."
instead of "01.07.") wasn't recognized as technical and the line would
resurface as the transaction name -- reproducing the original #2935 bug for
that date shape. Now accepts 1-2 digits for both.
2. john-frandsen (issue #2935 comment): suggested cleaning up the merchant line
further (e.g. "BILLA DANKT 0007114 SIEGENDORF 7011" -> "Billa"). Checked
point 1 (structured remittance fields) against Enable Banking's own API
docs -- no such field exists there, not applicable. Points 2/3/5 already
match current behavior. Point 4 (loyalty-marker cleanup) implemented as two
layers:
- Primary: match the line against merchants the family already knows
(Family#known_merchant_names) -- self-maintaining, no pattern-guessing,
and now also assigns the transaction's merchant when matched (previously
out of scope for blank-counterparty EB transactions). Case-insensitive,
regex-escaped, longest-match-wins, with a minimum length guard against
spurious short-name matches.
- Fallback (no known merchant yet): remove only the "DANKT"/"DANKE"
thank-you marker word itself, not a directional truncation -- the marker
can precede or follow the merchant name depending on phrasing ("X DANKT"
vs. "DANKE ... bei X"), so truncating at it risked deleting the real
merchant name in one of the two phrasings.
Verified against the full test suite, RuboCop, and Brakeman on a test-stack
Rails instance (0 RuboCop offenses, 0 Brakeman warnings; full-suite failures
present on that instance are pre-existing/environmental and unrelated to
these files).
Disclosure: this fix (investigation, implementation, and tests) was written
by Claude Code.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix(enable-banking): address CodeRabbit/Codex review on merchant-line cleanup
Follow-up to 5e39ee6b, in response to automated review on that push:
- CodeRabbit (functional correctness): remittance cleanup was order-dependent
-- strip_payment_processor_prefix ran before strip_loyalty_marker, so a
marker preceding the (start-anchored) processor-prefix pattern would leave
the prefix unstripped. Swapped the order (marker removal first) so the
result no longer depends on which came first in the input. Also switched
strip_loyalty_marker from sub to gsub so it removes every marker
occurrence, not just the first.
- CodeRabbit + Codex (performance, independently flagged by both): Family#
known_merchant_names was re-queried for every transaction in a sync batch,
since EnableBankingAccount::Transactions::Processor creates a new
EnableBankingEntry::Processor per row. Added an optional
known_merchant_names: keyword to the processor's constructor (same pattern
already used for the shared import_adapter) and compute it once per batch
in the caller instead of once per row.
- CodeRabbit (test quality, nitpick): the known_merchant_names test only used
distinct names, so `assert_equal names.uniq, names` couldn't actually catch
a deduplication bug, and never exercised the documented
recently-unlinked-merchants exclusion. Rewrote it to create a genuine
duplicate name across two different merchant records and to assign-then-
unlink a merchant, asserting it's excluded from known_merchant_names while
still present in available_merchants (the deliberate difference between the
two methods).
Re-verified test/models/enable_banking_entry/processor_test.rb +
test/models/family_test.rb (67 runs, 170 assertions, 0 failures) and RuboCop
on all changed files on a test-stack instance.
Disclosure: this fix was written by Claude Code.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix(enable-banking): drop DANKT/DANKE loyalty-marker stripping
Country-specific fallback heuristic flagged in review (only recognized
German "thank you" markers). The core fix (skipping the technical POS/
ATM line and matching against the family's known merchant list) is
already market-independent; this text heuristic only helped cosmetically
for the first transaction from a not-yet-known German/Austrian merchant.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
685 lines
23 KiB
Ruby
685 lines
23 KiB
Ruby
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
|