Files
sure/test/models/enable_banking_account/transactions/processor_test.rb
CrossDrain 96b1d28d5d feat(enable-banking): safe pending transaction merge with sync re-import prevention (#1709)
* feat(enable-banking): safe pending transaction merge with sync re-import prevention

* preserve all merged pending IDs across syncs

* fix(enable-banking): harden merge locking, safe logging, and non-blocking index

* fix(enable-banking): use safe external ID in invalid currency log

* refactor(models): centralize pending transaction SQL logic

Move the SQL fragment used to identify pending transactions from the `Entry` model to a constant in the `Transaction` model. This improves maintainability and ensures that the logic for determining if a transaction is pending is defined in a single location.

* fix(enable-banking): drop dead manual_merge index, use lateral join for excluded IDs

* No net schema changes

---------

Co-authored-by: Juan José Mata <jjmata@jjmata.com>
2026-05-09 11:56:16 +02:00

199 lines
6.6 KiB
Ruby

require "test_helper"
class EnableBankingAccount::Transactions::ProcessorTest < ActiveSupport::TestCase
include EntriesTestHelper
setup do
@family = families(:dylan_family)
@account = accounts(:depository)
@enable_banking_item = EnableBankingItem.create!(
family: @family,
name: "Test EB Item",
country_code: "FR",
application_id: "app_id",
client_certificate: "cert"
)
@enable_banking_account = EnableBankingAccount.create!(
enable_banking_item: @enable_banking_item,
name: "Compte courant",
uid: "uid_txn_proc_test",
currency: "EUR",
current_balance: 1000.00
)
AccountProvider.create!(account: @account, provider: @enable_banking_account)
end
# Minimal raw transaction payload hash matching the shape EnableBankingEntry::Processor expects
def raw_pending_transaction(transaction_id:)
{
transaction_id: transaction_id,
value_date: 3.days.ago.to_date.to_s,
transaction_amount: { amount: "25.00", currency: "EUR" },
credit_debit_indicator: "DBIT",
_pending: true
}
end
test "does not re-import a pending transaction whose external_id was manually merged" do
pending_ext_id = "enable_banking_PDNG_MERGED"
# Simulate a previously-merged state: a posted transaction carries the pending's external_id
# in its manual_merge metadata, which is how merge_with_duplicate! records the merge.
posted_entry = create_transaction(
account: @account,
name: "Coffee Shop",
date: 1.day.ago.to_date,
amount: 25,
currency: "EUR",
external_id: "enable_banking_BOOK_SETTLED",
source: "enable_banking"
)
posted_entry.transaction.update!(
extra: {
"manual_merge" => {
"merged_from_entry_id" => SecureRandom.uuid,
"merged_from_external_id" => pending_ext_id,
"merged_at" => Time.current.iso8601,
"source" => "enable_banking"
}
}
)
posted_entry.mark_user_modified!
# Raw payload contains the pending transaction that was already merged
@enable_banking_account.update!(
raw_transactions_payload: [
raw_pending_transaction(transaction_id: "PDNG_MERGED")
]
)
assert_no_difference "@account.entries.count" do
EnableBankingAccount::Transactions::Processor.new(@enable_banking_account).process
end
end
test "imports a pending transaction that has NOT been merged" do
@enable_banking_account.update!(
raw_transactions_payload: [
raw_pending_transaction(transaction_id: "PDNG_NEW_UNMERGED")
]
)
assert_difference "@account.entries.count", 1 do
EnableBankingAccount::Transactions::Processor.new(@enable_banking_account).process
end
end
test "imports non-excluded transactions alongside excluded ones in the same batch" do
pending_ext_id = "enable_banking_PDNG_SKIP_ME"
posted_entry = create_transaction(
account: @account,
name: "Already Merged",
date: 2.days.ago.to_date,
amount: 15,
currency: "EUR",
external_id: "enable_banking_BOOK_ALREADY",
source: "enable_banking"
)
posted_entry.transaction.update!(
extra: {
"manual_merge" => {
"merged_from_external_id" => pending_ext_id,
"merged_at" => Time.current.iso8601,
"source" => "enable_banking"
}
}
)
@enable_banking_account.update!(
raw_transactions_payload: [
raw_pending_transaction(transaction_id: "PDNG_SKIP_ME"), # excluded
raw_pending_transaction(transaction_id: "PDNG_BRAND_NEW_12345") # should be imported
]
)
assert_difference "@account.entries.count", 1 do
EnableBankingAccount::Transactions::Processor.new(@enable_banking_account).process
end
end
test "excludes all external_ids when multiple pending entries were merged into the same posted entry" do
pending_ext_id_1 = "enable_banking_PDNG_MULTI_1"
pending_ext_id_2 = "enable_banking_PDNG_MULTI_2"
posted_entry = create_transaction(
account: @account,
name: "Multi Merge",
date: 2.days.ago.to_date,
amount: 30,
currency: "EUR",
external_id: "enable_banking_BOOK_MULTI",
source: "enable_banking"
)
posted_entry.transaction.update!(
extra: {
"manual_merge" => [
{ "merged_from_external_id" => pending_ext_id_1, "merged_at" => 2.days.ago.iso8601, "source" => "enable_banking" },
{ "merged_from_external_id" => pending_ext_id_2, "merged_at" => 1.day.ago.iso8601, "source" => "enable_banking" }
]
}
)
@enable_banking_account.update!(
raw_transactions_payload: [
raw_pending_transaction(transaction_id: "PDNG_MULTI_1"), # excluded
raw_pending_transaction(transaction_id: "PDNG_MULTI_2"), # excluded
raw_pending_transaction(transaction_id: "PDNG_MULTI_NEW") # new — should import
]
)
result = nil
assert_difference "@account.entries.count", 1 do
result = EnableBankingAccount::Transactions::Processor.new(@enable_banking_account).process
end
assert_equal 2, result[:skipped]
assert_equal 1, result[:imported]
end
test "handles empty raw_transactions_payload gracefully" do
@enable_banking_account.update!(raw_transactions_payload: nil)
result = EnableBankingAccount::Transactions::Processor.new(@enable_banking_account).process
assert_equal true, result[:success]
assert_equal 0, result[:total]
end
test "reports excluded transactions as skipped, not imported or failed" do
pending_ext_id = "enable_banking_PDNG_SKIP_STATS"
posted_entry = create_transaction(
account: @account,
name: "Stats Test",
date: 2.days.ago.to_date,
amount: 50,
currency: "EUR",
external_id: "enable_banking_BOOK_STATS",
source: "enable_banking"
)
posted_entry.transaction.update!(
extra: { "manual_merge" => { "merged_from_external_id" => pending_ext_id } }
)
@enable_banking_account.update!(
raw_transactions_payload: [
raw_pending_transaction(transaction_id: "PDNG_SKIP_STATS")
]
)
result = EnableBankingAccount::Transactions::Processor.new(@enable_banking_account).process
assert_equal true, result[:success]
assert_equal 1, result[:skipped]
assert_equal 0, result[:imported]
assert_equal 0, result[:failed]
end
end