From a61890ec2b5dfbe77b286d5c32c6e560b9eca075 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Mon, 15 Dec 2025 23:04:16 +0100 Subject: [PATCH] feat: introduce extended bank transaction fields (backport #50021) (#51112) Co-authored-by: barredterra <14891507+barredterra@users.noreply.github.com> Co-authored-by: 0xD0M1M0 <76812428+0xD0M1M0@users.noreply.github.com> --- .../bank_transaction/bank_transaction.json | 27 +++- .../bank_transaction/bank_transaction.py | 38 +++++ .../test_bank_transaction_fees.py | 133 ++++++++++++++++++ 3 files changed, 196 insertions(+), 2 deletions(-) create mode 100644 erpnext/accounts/doctype/bank_transaction/test_bank_transaction_fees.py diff --git a/erpnext/accounts/doctype/bank_transaction/bank_transaction.json b/erpnext/accounts/doctype/bank_transaction/bank_transaction.json index 599a0604755..99622314532 100644 --- a/erpnext/accounts/doctype/bank_transaction/bank_transaction.json +++ b/erpnext/accounts/doctype/bank_transaction/bank_transaction.json @@ -38,7 +38,10 @@ "column_break_3czf", "bank_party_name", "bank_party_account_number", - "bank_party_iban" + "bank_party_iban", + "extended_bank_statement_section", + "included_fee", + "excluded_fee" ], "fields": [ { @@ -233,12 +236,32 @@ { "fieldname": "column_break_oufv", "fieldtype": "Column Break" + }, + { + "fieldname": "extended_bank_statement_section", + "fieldtype": "Section Break", + "label": "Extended Bank Statement" + }, + { + "fieldname": "included_fee", + "fieldtype": "Currency", + "label": "Included Fee", + "non_negative": 1, + "options": "currency" + }, + { + "description": "On save, the Excluded Fee will be converted to an Included Fee.", + "fieldname": "excluded_fee", + "fieldtype": "Currency", + "label": "Excluded Fee", + "non_negative": 1, + "options": "currency" } ], "grid_page_length": 50, "is_submittable": 1, "links": [], - "modified": "2025-10-23 17:32:58.514807", + "modified": "2025-12-07 20:49:18.600757", "modified_by": "Administrator", "module": "Accounts", "name": "Bank Transaction", diff --git a/erpnext/accounts/doctype/bank_transaction/bank_transaction.py b/erpnext/accounts/doctype/bank_transaction/bank_transaction.py index 16c84ac2a60..f9f1b54406b 100644 --- a/erpnext/accounts/doctype/bank_transaction/bank_transaction.py +++ b/erpnext/accounts/doctype/bank_transaction/bank_transaction.py @@ -32,6 +32,8 @@ class BankTransaction(Document): date: DF.Date | None deposit: DF.Currency description: DF.SmallText | None + excluded_fee: DF.Currency + included_fee: DF.Currency naming_series: DF.Literal["ACC-BTN-.YYYY.-"] party: DF.DynamicLink | None party_type: DF.Link | None @@ -45,9 +47,11 @@ class BankTransaction(Document): # end: auto-generated types def before_validate(self): + self.handle_excluded_fee() self.update_allocated_amount() def validate(self): + self.validate_included_fee() self.validate_duplicate_references() self.validate_currency() @@ -307,6 +311,40 @@ class BankTransaction(Document): self.party_type, self.party = result + def validate_included_fee(self): + """ + The included_fee is only handled for withdrawals. An included_fee for a deposit, is not credited to the account and is + therefore outside of the deposit value and can be larger than the deposit itself. + """ + + if self.included_fee and self.withdrawal: + if self.included_fee > self.withdrawal: + frappe.throw(_("Included fee is bigger than the withdrawal itself.")) + + def handle_excluded_fee(self): + # Include the excluded fee on validate to handle all further processing the same + excluded_fee = flt(self.excluded_fee) + if excluded_fee <= 0: + return + + # Suppress a negative deposit (aka withdrawal), likely not intendend + if flt(self.deposit) > 0 and (flt(self.deposit) - excluded_fee) < 0: + frappe.throw(_("The Excluded Fee is bigger than the Deposit it is deducted from.")) + + # Enforce directionality + if flt(self.deposit) > 0 and flt(self.withdrawal) > 0: + frappe.throw( + _("Only one of Deposit or Withdrawal should be non-zero when applying an Excluded Fee.") + ) + + if flt(self.deposit) > 0: + self.deposit = flt(self.deposit) - excluded_fee + # A fee applied to deposit and withdrawal equal 0 become a withdrawal + elif flt(self.withdrawal) >= 0: + self.withdrawal = flt(self.withdrawal) + excluded_fee + self.included_fee = flt(self.included_fee) + excluded_fee + self.excluded_fee = 0 + @frappe.whitelist() def get_doctypes_for_bank_reconciliation(): diff --git a/erpnext/accounts/doctype/bank_transaction/test_bank_transaction_fees.py b/erpnext/accounts/doctype/bank_transaction/test_bank_transaction_fees.py new file mode 100644 index 00000000000..13f4f9cfd79 --- /dev/null +++ b/erpnext/accounts/doctype/bank_transaction/test_bank_transaction_fees.py @@ -0,0 +1,133 @@ +# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt + +import frappe +from frappe.tests.utils import FrappeTestCase + + +class TestBankTransactionFees(FrappeTestCase): + def test_included_fee_throws(self): + """A fee that's part of a withdrawal cannot be bigger than the + withdrawal itself.""" + bt = frappe.new_doc("Bank Transaction") + bt.withdrawal = 100 + bt.included_fee = 101 + + self.assertRaises(frappe.ValidationError, bt.validate_included_fee) + + def test_included_fee_allows_equal(self): + """A fee that's part of a withdrawal may be equal to the withdrawal + amount (only the fee was deducted from the account).""" + bt = frappe.new_doc("Bank Transaction") + bt.withdrawal = 100 + bt.included_fee = 100 + + bt.validate_included_fee() + + def test_included_fee_allows_for_deposit(self): + """For deposits, a fee may be recorded separately without limiting the + received amount.""" + bt = frappe.new_doc("Bank Transaction") + bt.deposit = 10 + bt.included_fee = 999 + + bt.validate_included_fee() + + def test_excluded_fee_noop_when_zero(self): + """When there is no excluded fee to apply, the amounts should remain + unchanged.""" + bt = frappe.new_doc("Bank Transaction") + bt.deposit = 100 + bt.withdrawal = 0 + bt.included_fee = 5 + bt.excluded_fee = 0 + + bt.handle_excluded_fee() + + self.assertEqual(bt.deposit, 100) + self.assertEqual(bt.withdrawal, 0) + self.assertEqual(bt.included_fee, 5) + self.assertEqual(bt.excluded_fee, 0) + + def test_excluded_fee_throws_when_exceeds_deposit(self): + """A fee deducted from an incoming payment must not exceed the incoming + amount (else it would be a withdrawal, a conversion we don't support).""" + bt = frappe.new_doc("Bank Transaction") + bt.deposit = 10 + bt.excluded_fee = 11 + + self.assertRaises(frappe.ValidationError, bt.handle_excluded_fee) + + def test_excluded_fee_throws_when_both_deposit_and_withdrawal_are_set(self): + """A transaction must be either incoming or outgoing when applying a + fee, not both.""" + bt = frappe.new_doc("Bank Transaction") + bt.deposit = 10 + bt.withdrawal = 10 + bt.excluded_fee = 1 + + self.assertRaises(frappe.ValidationError, bt.handle_excluded_fee) + + def test_excluded_fee_deducts_from_deposit(self): + """When a fee is deducted from an incoming payment, the net received + amount decreases and the fee is tracked as included.""" + bt = frappe.new_doc("Bank Transaction") + bt.deposit = 100 + bt.withdrawal = 0 + bt.included_fee = 2 + bt.excluded_fee = 5 + + bt.handle_excluded_fee() + + self.assertEqual(bt.deposit, 95) + self.assertEqual(bt.withdrawal, 0) + self.assertEqual(bt.included_fee, 7) + self.assertEqual(bt.excluded_fee, 0) + + def test_excluded_fee_can_reduce_an_incoming_payment_to_zero(self): + """A separately-deducted fee may reduce an incoming payment to zero, + while still tracking the fee.""" + bt = frappe.new_doc("Bank Transaction") + bt.deposit = 5 + bt.withdrawal = 0 + bt.included_fee = 0 + bt.excluded_fee = 5 + + bt.handle_excluded_fee() + + self.assertEqual(bt.deposit, 0) + self.assertEqual(bt.withdrawal, 0) + self.assertEqual(bt.included_fee, 5) + self.assertEqual(bt.excluded_fee, 0) + + def test_excluded_fee_increases_outgoing_payment(self): + """When a separately-deducted fee is provided for an outgoing payment, + the total money leaving increases and the fee is tracked.""" + bt = frappe.new_doc("Bank Transaction") + bt.deposit = 0 + bt.withdrawal = 100 + bt.included_fee = 2 + bt.excluded_fee = 5 + + bt.handle_excluded_fee() + + self.assertEqual(bt.deposit, 0) + self.assertEqual(bt.withdrawal, 105) + self.assertEqual(bt.included_fee, 7) + self.assertEqual(bt.excluded_fee, 0) + + def test_excluded_fee_turns_zero_amount_into_withdrawal(self): + """If only an excluded fee is provided, it should be treated as an + outgoing payment and the fee is then tracked as included.""" + bt = frappe.new_doc("Bank Transaction") + bt.deposit = 0 + bt.withdrawal = 0 + bt.included_fee = 0 + bt.excluded_fee = 5 + + bt.handle_excluded_fee() + + self.assertEqual(bt.deposit, 0) + self.assertEqual(bt.withdrawal, 5) + self.assertEqual(bt.included_fee, 5) + self.assertEqual(bt.excluded_fee, 0)