feat(enable-banking): enhance transaction import, metadata handling, and UI (#1406)

* feat(enable-banking): enhance transaction import, metadata handling, and UI

* fix(enable-banking): address security, sync edge cases and PR feedback

* fix(enable-banking): resolve silent failures, auth overrides, and sync logic bugs

* fix(enable-banking): resolve sync logic bugs, trailing whitespaces, and apply safe_psu_headers

* test(enable-banking): mock set_current_balance to return success result

* fix(budget): properly filter pending transactions and classify synced loan payments

* style: fix trailing whitespace detected by rubocop

* refactor: address code review feedback for Enable Banking sync and reporting

---------

Signed-off-by: Louis <contact@boul2gom.com>
Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
This commit is contained in:
Louis
2026-04-10 23:19:48 +02:00
committed by GitHub
parent d6d7df12fd
commit e96fb0c23f
28 changed files with 1118 additions and 160 deletions

View File

@@ -0,0 +1,72 @@
require "test_helper"
class EnableBankingAccount::ProcessorTest < ActiveSupport::TestCase
setup do
@family = families(:dylan_family)
@account = accounts(:depository)
@enable_banking_item = EnableBankingItem.create!(
family: @family,
name: "Test EB",
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: "hash_abc",
currency: "EUR",
current_balance: 1500.00
)
AccountProvider.create!(account: @account, provider: @enable_banking_account)
end
test "calls set_current_balance instead of direct account update" do
EnableBankingAccount::Processor.new(@enable_banking_account).process
assert_equal 1500.0, @account.reload.cash_balance
end
test "updates account currency" do
@enable_banking_account.update!(currency: "USD")
EnableBankingAccount::Processor.new(@enable_banking_account).process
assert_equal "USD", @account.reload.currency
end
test "does nothing when no linked account" do
@account.account_providers.destroy_all
result = EnableBankingAccount::Processor.new(@enable_banking_account).process
assert_nil result
end
test "sets CC balance to available_credit when credit_limit is present" do
cc_account = accounts(:credit_card)
@enable_banking_account.update!(
current_balance: 450.00,
credit_limit: 1000.00
)
AccountProvider.find_by(provider: @enable_banking_account)&.destroy
AccountProvider.create!(account: cc_account, provider: @enable_banking_account)
EnableBankingAccount::Processor.new(@enable_banking_account).process
assert_equal 550.0, cc_account.reload.cash_balance
if cc_account.accountable.respond_to?(:available_credit)
assert_equal 550.0, cc_account.accountable.reload.available_credit
end
end
test "sets CC balance to raw outstanding when credit_limit is absent" do
cc_account = accounts(:credit_card)
@enable_banking_account.update!(current_balance: 300.00, credit_limit: nil)
AccountProvider.find_by(provider: @enable_banking_account)&.destroy
AccountProvider.create!(account: cc_account, provider: @enable_banking_account)
EnableBankingAccount::Processor.new(@enable_banking_account).process
assert_equal 300.0, cc_account.reload.cash_balance
end
end

View File

@@ -0,0 +1,152 @@
require "test_helper"
class EnableBankingAccountTest < ActiveSupport::TestCase
setup do
@family = families(:dylan_family)
@item = EnableBankingItem.create!(
family: @family,
name: "Test EB",
country_code: "FR",
application_id: "app_id",
client_certificate: "cert"
)
@account = EnableBankingAccount.create!(
enable_banking_item: @item,
name: "Mon compte",
uid: "hash_abc123",
currency: "EUR"
)
end
# suggested_account_type / suggested_subtype mapping
test "suggests Depository checking for CACC" do
@account.update!(account_type: "CACC")
assert_equal "Depository", @account.suggested_account_type
assert_equal "checking", @account.suggested_subtype
end
test "suggests Depository savings for SVGS" do
@account.update!(account_type: "SVGS")
assert_equal "Depository", @account.suggested_account_type
assert_equal "savings", @account.suggested_subtype
end
test "suggests CreditCard for CARD" do
@account.update!(account_type: "CARD")
assert_equal "CreditCard", @account.suggested_account_type
assert_equal "credit_card", @account.suggested_subtype
end
test "suggests Loan for LOAN" do
@account.update!(account_type: "LOAN")
assert_equal "Loan", @account.suggested_account_type
assert_nil @account.suggested_subtype
end
test "suggests Loan mortgage for MORT" do
@account.update!(account_type: "MORT")
assert_equal "Loan", @account.suggested_account_type
assert_equal "mortgage", @account.suggested_subtype
end
test "returns nil for OTHR" do
@account.update!(account_type: "OTHR")
assert_nil @account.suggested_account_type
assert_nil @account.suggested_subtype
end
test "suggests Depository savings for MOMA and ONDP" do
@account.update!(account_type: "MOMA")
assert_equal "Depository", @account.suggested_account_type
assert_equal "savings", @account.suggested_subtype
@account.update!(account_type: "ONDP")
assert_equal "Depository", @account.suggested_account_type
assert_equal "savings", @account.suggested_subtype
end
test "suggests Depository checking for NREX, TAXE, and TRAS" do
@account.update!(account_type: "NREX")
assert_equal "Depository", @account.suggested_account_type
assert_equal "checking", @account.suggested_subtype
@account.update!(account_type: "TAXE")
assert_equal "Depository", @account.suggested_account_type
assert_equal "checking", @account.suggested_subtype
@account.update!(account_type: "TRAS")
assert_equal "Depository", @account.suggested_account_type
assert_equal "checking", @account.suggested_subtype
end
test "returns nil when account_type is blank" do
@account.update!(account_type: nil)
assert_nil @account.suggested_account_type
assert_nil @account.suggested_subtype
end
test "is case insensitive for account type mapping" do
@account.update!(account_type: "svgs")
assert_equal "Depository", @account.suggested_account_type
assert_equal "savings", @account.suggested_subtype
end
# upsert_enable_banking_snapshot! stores new fields
test "stores product from snapshot" do
@account.upsert_enable_banking_snapshot!({
uid: "hash_abc123",
identification_hash: "hash_abc123",
currency: "EUR",
cash_account_type: "SVGS",
product: "Livret A"
})
assert_equal "Livret A", @account.reload.product
end
test "stores identification_hashes from snapshot" do
@account.upsert_enable_banking_snapshot!({
uid: "uid_uuid_123",
identification_hash: "hash_abc123",
identification_hashes: [ "hash_abc123", "hash_old456" ],
currency: "EUR",
cash_account_type: "CACC"
})
reloaded_account = @account.reload
assert_includes reloaded_account.identification_hashes, "hash_abc123"
assert_includes reloaded_account.identification_hashes, "hash_old456"
end
test "stores credit_limit from snapshot" do
@account.upsert_enable_banking_snapshot!({
uid: "uid_uuid_123",
identification_hash: "hash_abc123",
currency: "EUR",
cash_account_type: "CARD",
credit_limit: { amount: "2000.00", currency: "EUR" }
})
assert_equal 2000.00, @account.reload.credit_limit.to_f
end
test "stores account_servicer bic in institution_metadata" do
@account.upsert_enable_banking_snapshot!({
uid: "uid_uuid_123",
identification_hash: "hash_abc123",
currency: "EUR",
cash_account_type: "CACC",
account_servicer: { bic_fi: "BOURFRPPXXX", name: "Boursobank" }
})
metadata = @account.reload.institution_metadata
assert_equal "BOURFRPPXXX", metadata["bic"]
assert_equal "Boursobank", metadata["servicer_name"]
end
test "stores empty identification_hashes when not in snapshot" do
@account.upsert_enable_banking_snapshot!({
uid: "uid_uuid_123",
identification_hash: "hash_abc123",
currency: "EUR",
cash_account_type: "CACC"
})
assert_equal [], @account.reload.identification_hashes
end
end

View File

@@ -117,4 +117,95 @@ class EnableBankingEntry::ProcessorTest < ActiveSupport::TestCase
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 "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
end

View File

@@ -0,0 +1,146 @@
require "test_helper"
class EnableBankingItem::ImporterPdngTest < ActiveSupport::TestCase
setup do
@family = families(:dylan_family)
@account = accounts(:depository)
@enable_banking_item = EnableBankingItem.create!(
family: @family,
name: "Test EB",
country_code: "FR",
application_id: "test_app_id",
client_certificate: "test_cert",
session_id: "test_session",
session_expires_at: 1.day.from_now,
sync_start_date: Date.new(2026, 3, 1)
)
@enable_banking_account = EnableBankingAccount.create!(
enable_banking_item: @enable_banking_item,
name: "Compte courant",
uid: "hash_abc123",
account_id: "uuid-1234-5678-abcd",
currency: "EUR"
)
AccountProvider.create!(account: @account, provider: @enable_banking_account)
@mock_provider = mock()
@importer = EnableBankingItem::Importer.new(@enable_banking_item, enable_banking_provider: @mock_provider)
end
# --- Post-fetch date filtering ---
test "filters out transactions before sync_start_date" do
old_tx = {
entry_reference: "old_ref",
transaction_id: nil,
booking_date: "2024-01-15", # Before sync_start_date of 2026-03-01
transaction_amount: { amount: "50.00", currency: "EUR" },
credit_debit_indicator: "DBIT",
status: "BOOK"
}
recent_tx = {
entry_reference: "recent_ref",
transaction_id: nil,
booking_date: "2026-03-10",
transaction_amount: { amount: "30.00", currency: "EUR" },
credit_debit_indicator: "DBIT",
status: "BOOK"
}
result = @importer.send(:filter_transactions_by_date, [ old_tx, recent_tx ], Date.new(2026, 3, 1))
assert_equal 1, result.count
assert_equal "recent_ref", result.first[:entry_reference]
end
test "uses value_date when booking_date is absent for filtering" do
tx_only_value_date = {
entry_reference: "vd_ref",
transaction_id: nil,
value_date: "2024-06-01", # Before sync_start_date
transaction_amount: { amount: "10.00", currency: "EUR" },
credit_debit_indicator: "DBIT",
status: "BOOK"
}
result = @importer.send(:filter_transactions_by_date, [ tx_only_value_date ], Date.new(2026, 3, 1))
assert_equal 0, result.count
end
test "keeps transactions with no date (cannot determine, keep to avoid data loss)" do
tx_no_date = {
entry_reference: "nodate_ref",
transaction_id: nil,
transaction_amount: { amount: "10.00", currency: "EUR" },
credit_debit_indicator: "DBIT",
status: "BOOK"
}
result = @importer.send(:filter_transactions_by_date, [ tx_no_date ], Date.new(2026, 3, 1))
assert_equal 1, result.count
end
test "keeps transactions on exactly sync_start_date" do
boundary_tx = {
entry_reference: "boundary_ref",
transaction_id: nil,
booking_date: "2026-03-01", # Exactly on sync_start_date
transaction_amount: { amount: "10.00", currency: "EUR" },
credit_debit_indicator: "DBIT",
status: "BOOK"
}
result = @importer.send(:filter_transactions_by_date, [ boundary_tx ], Date.new(2026, 3, 1))
assert_equal 1, result.count
end
# --- PDNG transaction tagging ---
test "tags PDNG transactions with pending: true in extra" do
pdng_tx = {
entry_reference: "pdng_ref",
transaction_id: "pdng_txn",
booking_date: Date.current.to_s,
transaction_amount: { amount: "20.00", currency: "EUR" },
credit_debit_indicator: "DBIT",
status: "PDNG"
}
result = @importer.send(:tag_as_pending, [ pdng_tx ])
assert_equal true, result.first[:_pending]
end
test "tags all passed transactions regardless of status (caller is responsible for filtering)" do
# tag_as_pending blindly marks everything passed to it.
# The caller (fetch_and_store_transactions) is responsible for only passing PDNG transactions.
any_tx = {
entry_reference: "any_ref",
transaction_id: "any_txn",
booking_date: Date.current.to_s,
transaction_amount: { amount: "20.00", currency: "EUR" },
credit_debit_indicator: "DBIT",
status: "BOOK"
}
result = @importer.send(:tag_as_pending, [ any_tx ])
assert_equal true, result.first[:_pending]
end
# --- identification_hashes matching ---
test "find_enable_banking_account_by_hash uses identification_hashes for matching" do
# Account already exists with uid = identification_hash
@enable_banking_account.update!(identification_hashes: [ "hash_abc123", "hash_old_xyz" ])
# Lookup by a secondary hash that is in identification_hashes
found = @importer.send(:find_enable_banking_account_by_hash, "hash_old_xyz")
assert_equal @enable_banking_account.id, found.id
end
end

View File

@@ -25,6 +25,18 @@ class TransactionTest < ActiveSupport::TestCase
assert_not transaction.pending?
end
test "pending? returns true for enable_banking pending transactions" do
transaction = Transaction.new(extra: { "enable_banking" => { "pending" => true } })
assert transaction.pending?
end
test "pending? returns false for enable_banking non-pending transactions" do
transaction = Transaction.new(extra: { "enable_banking" => { "pending" => false } })
assert_not transaction.pending?
end
test "investment_contribution is a valid kind" do
transaction = Transaction.new(kind: "investment_contribution")