mirror of
https://github.com/we-promise/sure.git
synced 2026-04-19 03:54:08 +00:00
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:
72
test/models/enable_banking_account/processor_test.rb
Normal file
72
test/models/enable_banking_account/processor_test.rb
Normal 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
|
||||
152
test/models/enable_banking_account_test.rb
Normal file
152
test/models/enable_banking_account_test.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
146
test/models/enable_banking_item/importer_pdng_test.rb
Normal file
146
test/models/enable_banking_item/importer_pdng_test.rb
Normal 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
|
||||
@@ -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")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user