diff --git a/banking/custom/bank_account.js b/banking/custom/bank_account.js
new file mode 100644
index 00000000..001a7e02
--- /dev/null
+++ b/banking/custom/bank_account.js
@@ -0,0 +1,35 @@
+// Copyright (c) 2025, ALYF GmbH and contributors
+// For license information, please see license.txt
+
+frappe.ui.form.on("Bank Account", {
+ refresh(frm) {
+ frm.trigger("set_fee_account_query");
+ },
+
+ account(frm) {
+ frm.trigger("set_fee_account_query");
+ },
+
+ async set_fee_account_query(frm) {
+ if (!frm.doc.account) {
+ return;
+ }
+
+ const {
+ message: { account_currency: currency, company: company },
+ } = await frappe.db.get_value("Account", frm.doc.account, [
+ "account_currency",
+ "company",
+ ]);
+
+ frm.set_query("bank_fee_account", (doc) => {
+ return {
+ filters: {
+ root_type: "Expense",
+ account_currency: currency,
+ company: company,
+ },
+ };
+ });
+ },
+});
diff --git a/banking/custom_fields.py b/banking/custom_fields.py
index 841b5daa..e3c8015a 100644
--- a/banking/custom_fields.py
+++ b/banking/custom_fields.py
@@ -87,4 +87,14 @@ def get_custom_fields():
insert_after="transaction_id",
),
],
+ "Bank Account": [
+ dict(
+ fieldname="bank_fee_account",
+ fieldtype="Link",
+ label=_("Bank Fee Account"),
+ options="Account",
+ depends_on="eval:doc.is_company_account && doc.account",
+ insert_after="account_subtype",
+ ),
+ ],
}
diff --git a/banking/exceptions.py b/banking/exceptions.py
new file mode 100644
index 00000000..f73ac70f
--- /dev/null
+++ b/banking/exceptions.py
@@ -0,0 +1,10 @@
+# Copyright (c) 2025, ALYF GmbH and Contributors
+# See license.txt
+
+from frappe.exceptions import ValidationError
+
+
+class CurrencyMismatchError(ValidationError):
+ """Raised when two accounts unexpectedly have different currencies."""
+
+ pass
diff --git a/banking/hooks.py b/banking/hooks.py
index 72d299c1..a4cb81dd 100644
--- a/banking/hooks.py
+++ b/banking/hooks.py
@@ -34,6 +34,7 @@
"Employee": "custom/employee.js",
"Supplier": "custom/supplier.js",
"Bank Reconciliation Tool": "custom/bank_reconciliation_tool.js",
+ "Bank Account": "custom/bank_account.js",
}
doctype_list_js = {"Purchase Invoice": "custom/purchase_invoice_list.js"}
# doctype_tree_js = {"doctype" : "public/js/doctype_tree.js"}
@@ -108,9 +109,13 @@
doc_events = {
"Bank Transaction": {
"on_update_after_submit": "banking.overrides.bank_transaction.on_update_after_submit",
+ "before_validate": "banking.overrides.bank_transaction.before_validate",
+ "before_submit": "banking.overrides.bank_transaction.before_submit",
+ "on_cancel": "banking.overrides.bank_transaction.on_cancel",
},
"Bank Account": {
"before_validate": "banking.overrides.bank_account.before_validate",
+ "validate": "banking.overrides.bank_account.validate",
},
"Employee": {
"validate": "banking.custom.employee.validate",
diff --git a/banking/klarna_kosma_integration/doctype/bank_reconciliation_rule/__init__.py b/banking/klarna_kosma_integration/doctype/bank_reconciliation_rule/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/banking/klarna_kosma_integration/doctype/bank_reconciliation_rule/bank_reconciliation_rule.js b/banking/klarna_kosma_integration/doctype/bank_reconciliation_rule/bank_reconciliation_rule.js
new file mode 100644
index 00000000..03b4b17f
--- /dev/null
+++ b/banking/klarna_kosma_integration/doctype/bank_reconciliation_rule/bank_reconciliation_rule.js
@@ -0,0 +1,69 @@
+// Copyright (c) 2025, ALYF GmbH and contributors
+// For license information, please see license.txt
+
+frappe.ui.form.on("Bank Reconciliation Rule", {
+ onload: function (frm) {
+ frm.trigger("render_bt_filters");
+ },
+ refresh(frm) {
+ frm.trigger("set_target_account_query");
+ },
+ bank_account(frm) {
+ frm.trigger("set_target_account_query");
+ },
+ async set_target_account_query(frm) {
+ if (!frm.doc.bank_account) {
+ return;
+ }
+
+ const {
+ message: { account },
+ } = await frappe.db.get_value(
+ "Bank Account",
+ frm.doc.bank_account,
+ "account"
+ );
+
+ const {
+ message: { account_currency: currency, company: company },
+ } = await frappe.db.get_value("Account", account, [
+ "account_currency",
+ "company",
+ ]);
+
+ frm.set_query("target_account", () => ({
+ filters: {
+ account_currency: currency,
+ company: company,
+ },
+ }));
+ },
+ render_bt_filters(frm) {
+ const parent = frm.fields_dict.filter_area.$wrapper;
+ parent.empty();
+
+ const filters =
+ frm.doc.filters && frm.doc.filters !== "[]"
+ ? JSON.parse(frm.doc.filters)
+ : [];
+
+ frappe.model.with_doctype("Bank Transaction", () => {
+ const filter_group = new frappe.ui.FilterGroup({
+ parent: parent,
+ doctype: "Bank Transaction",
+ on_change: () => {
+ frm.set_value("filters", JSON.stringify(filter_group.get_filters()));
+ },
+ });
+
+ filter_group.add_filters_to_filter_group(filters);
+
+ if (frm.doc.docstatus === 1) {
+ parent.find(".filter-action-buttons").remove();
+ parent.find(".divider").remove();
+ parent.find(".remove-filter").remove();
+ parent.find(".form-control").prop("disabled", true);
+ }
+ });
+ },
+});
diff --git a/banking/klarna_kosma_integration/doctype/bank_reconciliation_rule/bank_reconciliation_rule.json b/banking/klarna_kosma_integration/doctype/bank_reconciliation_rule/bank_reconciliation_rule.json
new file mode 100644
index 00000000..1308022d
--- /dev/null
+++ b/banking/klarna_kosma_integration/doctype/bank_reconciliation_rule/bank_reconciliation_rule.json
@@ -0,0 +1,135 @@
+{
+ "actions": [],
+ "creation": "2025-10-23 13:00:49.226504",
+ "doctype": "DocType",
+ "engine": "InnoDB",
+ "field_order": [
+ "account_settings_section",
+ "bank_account",
+ "target_account",
+ "disabled",
+ "filters_section",
+ "filter_description",
+ "filters",
+ "filter_area",
+ "amended_from"
+ ],
+ "fields": [
+ {
+ "fieldname": "bank_account",
+ "fieldtype": "Link",
+ "in_list_view": 1,
+ "label": "Bank Account",
+ "link_filters": "[[\"Bank Account\",\"is_company_account\",\"=\",1],[\"Bank Account\",\"account\",\"is\",\"set\"]]",
+ "options": "Bank Account",
+ "reqd": 1
+ },
+ {
+ "fieldname": "account_settings_section",
+ "fieldtype": "Section Break",
+ "label": "Account Settings"
+ },
+ {
+ "fieldname": "target_account",
+ "fieldtype": "Link",
+ "in_list_view": 1,
+ "label": "Target Account",
+ "options": "Account",
+ "reqd": 1
+ },
+ {
+ "fieldname": "filters_section",
+ "fieldtype": "Section Break",
+ "label": "Filters"
+ },
+ {
+ "fieldname": "filters",
+ "fieldtype": "Code",
+ "hidden": 1,
+ "label": "Filters",
+ "options": "JSON",
+ "read_only": 1
+ },
+ {
+ "fieldname": "filter_area",
+ "fieldtype": "HTML"
+ },
+ {
+ "fieldname": "filter_description",
+ "fieldtype": "HTML",
+ "options": "A Journal Entry is created automatically, when a submitted Bank Transaction matches these filters.
Please define set these filters as strictly as possible to avoid accounting errors.
"
+ },
+ {
+ "fieldname": "amended_from",
+ "fieldtype": "Link",
+ "label": "Amended From",
+ "no_copy": 1,
+ "options": "Bank Reconciliation Rule",
+ "print_hide": 1,
+ "read_only": 1,
+ "search_index": 1
+ },
+ {
+ "allow_on_submit": 1,
+ "default": "0",
+ "fieldname": "disabled",
+ "fieldtype": "Check",
+ "label": "Disabled"
+ }
+ ],
+ "grid_page_length": 50,
+ "index_web_pages_for_search": 1,
+ "is_submittable": 1,
+ "links": [],
+ "modified": "2026-01-27 21:55:21.191776",
+ "modified_by": "Administrator",
+ "module": "Klarna Kosma Integration",
+ "name": "Bank Reconciliation Rule",
+ "owner": "Administrator",
+ "permissions": [
+ {
+ "amend": 1,
+ "cancel": 1,
+ "create": 1,
+ "delete": 1,
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "System Manager",
+ "share": 1,
+ "submit": 1,
+ "write": 1
+ },
+ {
+ "cancel": 1,
+ "create": 1,
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "Accounts Manager",
+ "share": 1,
+ "submit": 1,
+ "write": 1
+ },
+ {
+ "create": 1,
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "Accounts User",
+ "share": 1,
+ "write": 1
+ }
+ ],
+ "row_format": "Dynamic",
+ "rows_threshold_for_grid_search": 20,
+ "sort_field": "modified",
+ "sort_order": "DESC",
+ "states": []
+}
\ No newline at end of file
diff --git a/banking/klarna_kosma_integration/doctype/bank_reconciliation_rule/bank_reconciliation_rule.py b/banking/klarna_kosma_integration/doctype/bank_reconciliation_rule/bank_reconciliation_rule.py
new file mode 100644
index 00000000..45e3b9b9
--- /dev/null
+++ b/banking/klarna_kosma_integration/doctype/bank_reconciliation_rule/bank_reconciliation_rule.py
@@ -0,0 +1,49 @@
+# Copyright (c) 2025, ALYF GmbH and contributors
+# For license information, please see license.txt
+
+import frappe
+from frappe import _
+from frappe.exceptions import ValidationError
+from frappe.model.document import Document
+
+from banking.exceptions import CurrencyMismatchError
+
+
+class NoFiltersError(ValidationError):
+ pass
+
+
+class BankReconciliationRule(Document):
+ # begin: auto-generated types
+ # This code is auto-generated. Do not modify anything in this block.
+
+ from typing import TYPE_CHECKING
+
+ if TYPE_CHECKING:
+ from frappe.types import DF
+
+ amended_from: DF.Link | None
+ bank_account: DF.Link
+ disabled: DF.Check
+ filters: DF.Code | None
+ target_account: DF.Link
+
+ # end: auto-generated types
+ def validate(self):
+ self.validate_account_currencies()
+ self.validate_filters()
+
+ def validate_account_currencies(self):
+ bank_account = frappe.db.get_value("Bank Account", self.bank_account, "account")
+ bank_account_currency = frappe.db.get_value("Account", bank_account, "account_currency")
+ target_account_currency = frappe.db.get_value("Account", self.target_account, "account_currency")
+
+ if bank_account_currency != target_account_currency:
+ frappe.throw(
+ _("Bank Account and Target Account need to be in the same currency!"), CurrencyMismatchError
+ )
+
+ def validate_filters(self):
+ # self.filters is a code field with json, so it has "[]" when empty
+ if not self.filters or len(self.filters) <= 2:
+ frappe.throw(_("Please define at least one filter!"), NoFiltersError)
diff --git a/banking/klarna_kosma_integration/doctype/bank_reconciliation_rule/bank_reconciliation_rule_list.js b/banking/klarna_kosma_integration/doctype/bank_reconciliation_rule/bank_reconciliation_rule_list.js
new file mode 100644
index 00000000..f215c265
--- /dev/null
+++ b/banking/klarna_kosma_integration/doctype/bank_reconciliation_rule/bank_reconciliation_rule_list.js
@@ -0,0 +1,23 @@
+// Copyright (c) 2025, ALYF GmbH and contributors
+// For license information, please see license.txt
+
+frappe.listview_settings["Bank Reconciliation Rule"] = {
+ add_fields: ["docstatus", "disabled"],
+ has_indicator_for_draft: true,
+
+ get_indicator: function (doc) {
+ // Submitted documents
+ if (doc.docstatus === 1) {
+ if (doc.disabled === 1) {
+ return [__("Disabled"), "orange", "disabled,=,1"];
+ } else {
+ return [__("Active"), "green", "docstatus,=,1"];
+ }
+ }
+
+ // Draft documents
+ if (doc.docstatus === 0) {
+ return [__("Draft"), "blue", "docstatus,=,0"];
+ }
+ },
+};
diff --git a/banking/klarna_kosma_integration/doctype/bank_reconciliation_rule/test_bank_reconciliation_rule.py b/banking/klarna_kosma_integration/doctype/bank_reconciliation_rule/test_bank_reconciliation_rule.py
new file mode 100644
index 00000000..6ff45c7a
--- /dev/null
+++ b/banking/klarna_kosma_integration/doctype/bank_reconciliation_rule/test_bank_reconciliation_rule.py
@@ -0,0 +1,41 @@
+# Copyright (c) 2025, ALYF GmbH and Contributors
+# See license.txt
+
+import frappe
+from frappe.tests.utils import FrappeTestCase
+
+from banking.exceptions import CurrencyMismatchError
+from banking.klarna_kosma_integration.doctype.bank_reconciliation_rule.bank_reconciliation_rule import (
+ NoFiltersError,
+)
+from banking.utils import TEST_COMPANY, create_bank_account, create_currency_account
+
+
+class TestBankReconciliationRule(FrappeTestCase):
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+
+ # these should be cleaned up by the DB rollback of FrappeTestCase
+ parent_account = frappe.db.get_value("Account", {"is_group": 1, "company": TEST_COMPANY})
+ bank_account = create_currency_account("EUR", parent_account)
+ cls.target_account = create_currency_account("USD", parent_account)
+ cls.ba = create_bank_account(bank_account.name)
+
+ def test_validate_account_currencies(self):
+ brr_doc = frappe.new_doc("Bank Reconciliation Rule")
+ brr_doc.bank_account = self.ba.name
+ brr_doc.target_account = self.target_account.name
+
+ with self.assertRaises(CurrencyMismatchError):
+ brr_doc.validate_account_currencies()
+
+ def test_validate_filters(self):
+ brr_doc = frappe.new_doc("Bank Reconciliation Rule")
+ brr_doc.filters = None
+ with self.assertRaises(NoFiltersError):
+ brr_doc.validate_filters()
+
+ brr_doc.filters = "[]"
+ with self.assertRaises(NoFiltersError):
+ brr_doc.validate_filters()
diff --git a/banking/klarna_kosma_integration/doctype/banking_settings/banking_settings.json b/banking/klarna_kosma_integration/doctype/banking_settings/banking_settings.json
index 102ab36a..fb0c3735 100644
--- a/banking/klarna_kosma_integration/doctype/banking_settings/banking_settings.json
+++ b/banking/klarna_kosma_integration/doctype/banking_settings/banking_settings.json
@@ -19,6 +19,7 @@
"fintech_licensee_name",
"fintech_license_key",
"bank_reconciliation_tab",
+ "enable_automatic_journal_entries_for_bank_fees",
"advanced_section",
"reference_fields",
"voucher_matching_defaults"
@@ -115,13 +116,20 @@
"fieldtype": "Table MultiSelect",
"label": "Voucher Matching Defaults",
"options": "Voucher Matching Default"
+ },
+ {
+ "default": "0",
+ "description": "When a Bank Transaction with Included Fee is submitted, we'll automatically create a Journal Entry for the fee amount. Please specify the Bank Fee Account in your Bank Account.",
+ "fieldname": "enable_automatic_journal_entries_for_bank_fees",
+ "fieldtype": "Check",
+ "label": "Enable Automatic Journal Entries for Bank Fees"
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
- "modified": "2025-10-07 18:44:07.181312",
+ "modified": "2026-02-17 22:00:58.508344",
"modified_by": "Administrator",
"module": "Klarna Kosma Integration",
"name": "Banking Settings",
diff --git a/banking/klarna_kosma_integration/doctype/banking_settings/banking_settings.py b/banking/klarna_kosma_integration/doctype/banking_settings/banking_settings.py
index 2ed7c120..6ae16fbc 100644
--- a/banking/klarna_kosma_integration/doctype/banking_settings/banking_settings.py
+++ b/banking/klarna_kosma_integration/doctype/banking_settings/banking_settings.py
@@ -33,6 +33,7 @@ class BankingSettings(Document):
admin_endpoint: DF.Data | None
api_token: DF.Password | None
customer_id: DF.Data | None
+ enable_automatic_journal_entries_for_bank_fees: DF.Check
enable_ebics: DF.Check
enabled: DF.Check
fintech_license_key: DF.Password | None
diff --git a/banking/klarna_kosma_integration/workspace/alyf_banking/alyf_banking.json b/banking/klarna_kosma_integration/workspace/alyf_banking/alyf_banking.json
index b595bc42..ed97d353 100644
--- a/banking/klarna_kosma_integration/workspace/alyf_banking/alyf_banking.json
+++ b/banking/klarna_kosma_integration/workspace/alyf_banking/alyf_banking.json
@@ -45,7 +45,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Setup",
- "link_count": 2,
+ "link_count": 3,
"link_type": "DocType",
"onboard": 0,
"type": "Card Break"
@@ -69,9 +69,19 @@
"link_type": "DocType",
"onboard": 0,
"type": "Link"
+ },
+ {
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Bank Reconciliation Rule",
+ "link_count": 0,
+ "link_to": "Bank Reconciliation Rule",
+ "link_type": "DocType",
+ "onboard": 0,
+ "type": "Link"
}
],
- "modified": "2025-08-05 19:58:14.464908",
+ "modified": "2026-01-14 01:28:31.573172",
"modified_by": "Administrator",
"module": "Klarna Kosma Integration",
"name": "ALYF Banking",
diff --git a/banking/locale/de.po b/banking/locale/de.po
index 69a9a8c9..4559a6eb 100644
--- a/banking/locale/de.po
+++ b/banking/locale/de.po
@@ -1,4 +1,4 @@
-# Translations template for ALYF Banking.
+# German translations for ALYF Banking.
# Copyright (C) 2025 ALYF GmbH
# This file is distributed under the same license as the ALYF Banking project.
# FIRST AUTHOR , 2025.
@@ -7,10 +7,12 @@ msgid ""
msgstr ""
"Project-Id-Version: ALYF Banking VERSION\n"
"Report-Msgid-Bugs-To: hallo@alyf.de\n"
-"POT-Creation-Date: 2026-01-27 18:03+0053\n"
+"POT-Creation-Date: 2026-01-27 21:56+0053\n"
"PO-Revision-Date: 2025-01-31 19:26+0053\n"
"Last-Translator: hallo@alyf.de\n"
+"Language: de\n"
"Language-Team: hallo@alyf.de\n"
+"Plural-Forms: nplurals=2; plural=(n != 1);\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
@@ -31,6 +33,12 @@ msgstr "Banking"
msgid "- It stores DocType-wise mapping of fields that should be considered as 'reference number' fields in Bank Reconciliation Tool Beta
- Link and Data fields are supported
- The field must have a singular reference number for matching with Bank Transactions
"
msgstr "- DocType-weise Zuordnung von Feldern, die im Bankabgleich Beta als zusätzliche Referenznummer verwendet werden sollen
- Es werden Link- und Datenfelder unterstützt
- Das Feld sollte genau eine Referenznummer enthalten, die auch in der Banktransaktionen vorkommen kann
"
+#. Content of the 'filter_description' (HTML) field in DocType 'Bank
+#. Reconciliation Rule'
+#: banking/klarna_kosma_integration/doctype/bank_reconciliation_rule/bank_reconciliation_rule.json
+msgid "A Journal Entry is created automatically, when a submitted Bank Transaction matches these filters.
Please define set these filters as strictly as possible to avoid accounting errors.
"
+msgstr "Ein Buchungssatz wird automatisch erstellt, wenn eine gebuchte Banktransaktion diesen Filtern entspricht.
Bitte definieren Sie diese Filter so streng wie möglich, um Buchungsfehler zu vermeiden.
"
+
#: banking/klarna_kosma_integration/doctype/banking_settings/banking_settings.py:214
msgid "A new version of the Banking app is available ({0}). Please update your instance."
msgstr "Eine neue Version der Banking-App ist verfügbar ({0}). Bitte aktualisieren Sie Ihre Instanz."
@@ -44,6 +52,11 @@ msgstr "ALYF Banking"
msgid "Account Holder"
msgstr "Kontoinhaber"
+#. Label of a Section Break field in DocType 'Bank Reconciliation Rule'
+#: banking/klarna_kosma_integration/doctype/bank_reconciliation_rule/bank_reconciliation_rule.json
+msgid "Account Settings"
+msgstr "Konteneinstellungen"
+
#. Label of a Data field in DocType 'Banking Settings'
#: banking/klarna_kosma_integration/doctype/banking_settings/banking_settings.json
msgid "Admin URL"
@@ -69,7 +82,7 @@ msgstr "Ein Fehler ist aufgetreten"
#: banking/ebics/doctype/sepa_payment_order/sepa_payment_order_list.js:19
msgid "Approved"
-msgstr ""
+msgstr "Genehmigt"
#: banking/public/js/bank_reconciliation_beta/actions_panel/match_tab.js:355
msgid "Are you trying to reconcile vouchers of different parties? This action will reconcile vouchers using a Journal Entry."
@@ -91,6 +104,18 @@ msgstr "Automatischer Abgleich ..."
msgid "Auto reconcile bank transactions based on matching reference numbers?"
msgstr "Möchten Sie Banktransaktionen automatisch abgleichen, basierend auf übereinstimmenden Referenznummern?"
+#: banking/overrides/bank_transaction.py:239
+msgid "Auto-created from Bank Transaction {0}"
+msgstr "Automatisch erstellt aus Banktransaktion {0}"
+
+#: banking/overrides/bank_transaction.py:237
+msgid "Auto-created from Bank Transaction {0} by Bank Reconciliation Rule {1}"
+msgstr "Automatisch erstellt aus Banktransaktion {0} durch Bankabgleich-Regel {1}"
+
+#: banking/klarna_kosma_integration/doctype/bank_reconciliation_rule/bank_reconciliation_rule.py:42
+msgid "Bank Account and Target Account need to be in the same currency!"
+msgstr "Bankkonto und Zielkonto müssen die gleiche Währung haben!"
+
#: banking/ebics/utils.py:216
msgid "Bank Account not found for IBAN {0}"
msgstr "Bankkonto nicht gefunden für IBAN {0}"
@@ -107,6 +132,13 @@ msgstr "Bankenschlüssel aktiviert"
msgid "Bank Reconciliation"
msgstr "Bankabgleich"
+#. Name of a DocType
+#. Label of a Link in the ALYF Banking Workspace
+#: banking/klarna_kosma_integration/doctype/bank_reconciliation_rule/bank_reconciliation_rule.json
+#: banking/klarna_kosma_integration/workspace/alyf_banking/alyf_banking.json
+msgid "Bank Reconciliation Rule"
+msgstr "Bankabgleich-Regel"
+
#. Name of a DocType
#: banking/klarna_kosma_integration/doctype/bank_reconciliation_tool_beta/bank_reconciliation_tool_beta.json
msgid "Bank Reconciliation Tool Beta"
@@ -220,6 +252,10 @@ msgstr "Protokollversion ändern"
msgid "Charges"
msgstr "Gebühren"
+#: banking/overrides/bank_account.py:23
+msgid "Company Account and Bank Fee Account must be in the same currency!"
+msgstr "Firmenkonto und Bankgebührenkonto müssen die gleiche Währung haben!"
+
#: banking/custom_fields.py:63
msgid "Create Employee Bank Account"
msgstr "Mitarbeiter-Bankkonto erstellen"
@@ -289,7 +325,7 @@ msgstr "Antwortdateien herunterladen"
msgid "Downloaded"
msgstr "Heruntergeladen"
-#: banking/overrides/bank_transaction.py:33
+#: banking/overrides/bank_transaction.py:38
msgid "Due to Period Closing, you cannot reconcile unpaid vouchers with a Bank Transaction before {0}"
msgstr "Aufgrund des Periodenschlusses können Sie keine unbezahlten Belege mit einer Banktransaktion vor dem {0} abgleichen"
@@ -355,11 +391,11 @@ msgstr ""
#: banking/ebics/doctype/ebics_user/ebics_user.js:114
msgid "Ebics 2.5 (H004)"
-msgstr ""
+msgstr "Ebics 2.5 (H004)"
#: banking/ebics/doctype/ebics_user/ebics_user.js:118
msgid "Ebics 3.0 (H005)"
-msgstr ""
+msgstr "Ebics 3.0 (H005)"
#: banking/klarna_kosma_integration/doctype/banking_settings/banking_settings.js:70
msgid "Ebics Users"
@@ -428,6 +464,10 @@ msgstr "Fehler beim Abrufen von Releases"
msgid "Execution Date"
msgstr "Ausführungsdatum"
+#: banking/overrides/bank_transaction.py:139
+msgid "Failed to cancel {0}: {1}"
+msgstr "Stornieren von {0} ist fehlgeschlagen: {1}"
+
#: banking/public/js/bank_reconciliation_beta/actions_panel/create_tab.js:91
msgid "Failed to create {0} against {1}"
msgstr "Fehler beim Erstellen von {0} gegen {1}"
@@ -486,12 +526,12 @@ msgstr "Banktransaktionen abrufen"
#. Option for the 'Protocol Version' (Select) field in DocType 'EBICS User'
#: banking/ebics/doctype/ebics_user/ebics_user.json
msgid "H004"
-msgstr ""
+msgstr "H004"
#. Option for the 'Protocol Version' (Select) field in DocType 'EBICS User'
#: banking/ebics/doctype/ebics_user/ebics_user.json
msgid "H005"
-msgstr ""
+msgstr "H005"
#: banking/public/js/bank_reconciliation_beta/actions_panel/create_tab.js:288
#: banking/public/js/bank_reconciliation_beta/actions_panel/details_tab.js:190
@@ -641,8 +681,8 @@ msgstr "Nur unbezahlte Belege"
msgid "Open Billing Portal"
msgstr "Abrechnungsportal öffnen"
-#: banking/overrides/bank_transaction.py:73
-msgid "Over Allocation"
+#: banking/overrides/bank_transaction.py:120
+msgid "Over-allocation"
msgstr "Überbuchung"
#: banking/klarna_kosma_integration/doctype/bank_reconciliation_tool_beta/unpaid_vouchers.py:279
@@ -704,6 +744,10 @@ msgstr "Bitte fügen Sie dem Land {0} einen zweistelligen Ländercode hinzu"
msgid "Please confirm that the following keys are identical to the ones mentioned on your bank's letter:"
msgstr "Bitte bestätigen Sie, dass die folgenden Schlüssel mit denen auf dem Schreiben Ihrer Bank übereinstimmen:"
+#: banking/klarna_kosma_integration/doctype/bank_reconciliation_rule/bank_reconciliation_rule.py:48
+msgid "Please define at least one filter!"
+msgstr "Bitte definieren Sie mindestens einen Filter!"
+
#. Description of the 'section_break_qjlc' (Section Break) field in DocType
#. 'EBICS User'
#: banking/ebics/doctype/ebics_user/ebics_user.json
@@ -731,6 +775,10 @@ msgstr "Bitte wählen Sie Belege des gleichen Typs aus, um sie abzugleichen"
msgid "Please set the 'Bank Account' filter"
msgstr "Bitte setzen Sie den 'Bankkonto'-Filter"
+#: banking/overrides/bank_transaction.py:153
+msgid "Please specify a Bank Fee Account for {0}."
+msgstr "Bitte geben Sie ein Bankgebührenkonto für {0} an."
+
#. Label of a Password field in DocType 'Banking Settings'
#: banking/klarna_kosma_integration/doctype/banking_settings/banking_settings.json
msgid "Portal API Token"
@@ -760,7 +808,7 @@ msgstr "Erneut importieren"
#: banking/ebics/doctype/ebics_request/ebics_request.js:28
msgid "Re-import cancelled."
-msgstr ""
+msgstr "Erneuter Import abgebrochen."
#: banking/ebics/doctype/ebics_request/ebics_request.js:39
msgid "Re-importing EBICS transactions ..."
@@ -899,9 +947,10 @@ msgstr "Lieferanten-Bankkonto wurde erstellt."
msgid "Supplier Bank Account was not created."
msgstr "Lieferanten-Bankkonto wurde nicht erstellt."
-#: banking/overrides/bank_transaction.py:70
-msgid "The Bank Transaction is over-allocated by {0} at row {1}."
-msgstr "Die Banktransaktion ist um {0} bei Zeile {1} überbucht."
+#. Label of a Link field in DocType 'Bank Reconciliation Rule'
+#: banking/klarna_kosma_integration/doctype/bank_reconciliation_rule/bank_reconciliation_rule.json
+msgid "Target Account"
+msgstr "Zielkonto"
#: banking/klarna_kosma_integration/doctype/bank_reconciliation_tool_beta/unpaid_vouchers.py:280
msgid "The allocated amount cannot be negative. Please adjust the selected return vouchers."
@@ -915,6 +964,14 @@ msgstr "Die Währung des zweiten Kontos ({0} : {1}) muss mit der des Bankkontos
msgid "The currency of the second account ({0}) must be the same as of the bank account ({1})"
msgstr "Die Währung des zweiten Kontos ({0}) muss mit der des Bankkontos ({1}) übereinstimmen."
+#: banking/overrides/bank_transaction.py:86
+msgid "The field {0} is negative. Please verify the input data."
+msgstr "Das Feld {0} ist negativ. Bitte verifizieren Sie die Eingabedaten."
+
+#: banking/overrides/bank_transaction.py:76
+msgid "The field {0} is required. Please verify the input data."
+msgstr "Das Feld {0} ist erforderlich. Bitte verifizieren Sie die Eingabedaten."
+
#: banking/klarna_kosma_integration/doctype/banking_settings/banking_settings.py:219
msgid "The scheduler is inactive. Please activate it to continue auto-syncing bank transactions."
msgstr "Der Scheduler ist inaktiv. Bitte aktivieren Sie ihn, um den automatischen Abruf von Banktransaktionen fortzusetzen."
@@ -1015,6 +1072,14 @@ msgstr "Sie haben Protokollversion {0} ausgewählt, aber die Bank {1} unterstüt
msgid "to import bank transactions for {0}."
msgstr "um Banktransaktionen für {0} zu importieren."
+#: banking/overrides/bank_transaction.py:16
+msgid "{0} is already fully reconciled"
+msgstr "{0} ist bereits vollständig abgeglichen"
+
+#: banking/overrides/bank_transaction.py:115
+msgid "{0} is over-allocated by {1} at row {2}."
+msgstr "{0} ist um {1} bei Zeile {2} überbucht."
+
#: banking/klarna_kosma_integration/doctype/bank_reconciliation_tool_beta/bank_reconciliation_tool_beta.py:418
#: banking/klarna_kosma_integration/doctype/bank_reconciliation_tool_beta/bank_reconciliation_tool_beta.py:427
msgid "{0} {1} {2}"
diff --git a/banking/locale/main.pot b/banking/locale/main.pot
index 8d5a155d..2cd9e35e 100644
--- a/banking/locale/main.pot
+++ b/banking/locale/main.pot
@@ -7,8 +7,8 @@ msgid ""
msgstr ""
"Project-Id-Version: ALYF Banking VERSION\n"
"Report-Msgid-Bugs-To: hallo@alyf.de\n"
-"POT-Creation-Date: 2026-01-27 18:03+0053\n"
-"PO-Revision-Date: 2026-01-27 18:03+0053\n"
+"POT-Creation-Date: 2026-01-27 21:56+0053\n"
+"PO-Revision-Date: 2026-01-27 21:56+0053\n"
"Last-Translator: hallo@alyf.de\n"
"Language-Team: hallo@alyf.de\n"
"MIME-Version: 1.0\n"
@@ -31,6 +31,12 @@ msgstr ""
msgid "- It stores DocType-wise mapping of fields that should be considered as 'reference number' fields in Bank Reconciliation Tool Beta
- Link and Data fields are supported
- The field must have a singular reference number for matching with Bank Transactions
"
msgstr ""
+#. Content of the 'filter_description' (HTML) field in DocType 'Bank
+#. Reconciliation Rule'
+#: banking/klarna_kosma_integration/doctype/bank_reconciliation_rule/bank_reconciliation_rule.json
+msgid "A Journal Entry is created automatically, when a submitted Bank Transaction matches these filters.
Please define set these filters as strictly as possible to avoid accounting errors.
"
+msgstr ""
+
#: banking/klarna_kosma_integration/doctype/banking_settings/banking_settings.py:214
msgid "A new version of the Banking app is available ({0}). Please update your instance."
msgstr ""
@@ -44,6 +50,11 @@ msgstr ""
msgid "Account Holder"
msgstr ""
+#. Label of a Section Break field in DocType 'Bank Reconciliation Rule'
+#: banking/klarna_kosma_integration/doctype/bank_reconciliation_rule/bank_reconciliation_rule.json
+msgid "Account Settings"
+msgstr ""
+
#. Label of a Data field in DocType 'Banking Settings'
#: banking/klarna_kosma_integration/doctype/banking_settings/banking_settings.json
msgid "Admin URL"
@@ -91,6 +102,18 @@ msgstr ""
msgid "Auto reconcile bank transactions based on matching reference numbers?"
msgstr ""
+#: banking/overrides/bank_transaction.py:239
+msgid "Auto-created from Bank Transaction {0}"
+msgstr ""
+
+#: banking/overrides/bank_transaction.py:237
+msgid "Auto-created from Bank Transaction {0} by Bank Reconciliation Rule {1}"
+msgstr ""
+
+#: banking/klarna_kosma_integration/doctype/bank_reconciliation_rule/bank_reconciliation_rule.py:42
+msgid "Bank Account and Target Account need to be in the same currency!"
+msgstr ""
+
#: banking/ebics/utils.py:216
msgid "Bank Account not found for IBAN {0}"
msgstr ""
@@ -107,6 +130,13 @@ msgstr ""
msgid "Bank Reconciliation"
msgstr ""
+#. Name of a DocType
+#. Label of a Link in the ALYF Banking Workspace
+#: banking/klarna_kosma_integration/doctype/bank_reconciliation_rule/bank_reconciliation_rule.json
+#: banking/klarna_kosma_integration/workspace/alyf_banking/alyf_banking.json
+msgid "Bank Reconciliation Rule"
+msgstr ""
+
#. Name of a DocType
#: banking/klarna_kosma_integration/doctype/bank_reconciliation_tool_beta/bank_reconciliation_tool_beta.json
msgid "Bank Reconciliation Tool Beta"
@@ -220,6 +250,10 @@ msgstr ""
msgid "Charges"
msgstr ""
+#: banking/overrides/bank_account.py:23
+msgid "Company Account and Bank Fee Account must be in the same currency!"
+msgstr ""
+
#: banking/custom_fields.py:63
msgid "Create Employee Bank Account"
msgstr ""
@@ -289,7 +323,7 @@ msgstr ""
msgid "Downloaded"
msgstr ""
-#: banking/overrides/bank_transaction.py:33
+#: banking/overrides/bank_transaction.py:38
msgid "Due to Period Closing, you cannot reconcile unpaid vouchers with a Bank Transaction before {0}"
msgstr ""
@@ -428,6 +462,10 @@ msgstr ""
msgid "Execution Date"
msgstr ""
+#: banking/overrides/bank_transaction.py:139
+msgid "Failed to cancel {0}: {1}"
+msgstr ""
+
#: banking/public/js/bank_reconciliation_beta/actions_panel/create_tab.js:91
msgid "Failed to create {0} against {1}"
msgstr ""
@@ -641,8 +679,8 @@ msgstr ""
msgid "Open Billing Portal"
msgstr ""
-#: banking/overrides/bank_transaction.py:73
-msgid "Over Allocation"
+#: banking/overrides/bank_transaction.py:120
+msgid "Over-allocation"
msgstr ""
#: banking/klarna_kosma_integration/doctype/bank_reconciliation_tool_beta/unpaid_vouchers.py:279
@@ -704,6 +742,10 @@ msgstr ""
msgid "Please confirm that the following keys are identical to the ones mentioned on your bank's letter:"
msgstr ""
+#: banking/klarna_kosma_integration/doctype/bank_reconciliation_rule/bank_reconciliation_rule.py:48
+msgid "Please define at least one filter!"
+msgstr ""
+
#. Description of the 'section_break_qjlc' (Section Break) field in DocType
#. 'EBICS User'
#: banking/ebics/doctype/ebics_user/ebics_user.json
@@ -731,6 +773,10 @@ msgstr ""
msgid "Please set the 'Bank Account' filter"
msgstr ""
+#: banking/overrides/bank_transaction.py:153
+msgid "Please specify a Bank Fee Account for {0}."
+msgstr ""
+
#. Label of a Password field in DocType 'Banking Settings'
#: banking/klarna_kosma_integration/doctype/banking_settings/banking_settings.json
msgid "Portal API Token"
@@ -899,8 +945,9 @@ msgstr ""
msgid "Supplier Bank Account was not created."
msgstr ""
-#: banking/overrides/bank_transaction.py:70
-msgid "The Bank Transaction is over-allocated by {0} at row {1}."
+#. Label of a Link field in DocType 'Bank Reconciliation Rule'
+#: banking/klarna_kosma_integration/doctype/bank_reconciliation_rule/bank_reconciliation_rule.json
+msgid "Target Account"
msgstr ""
#: banking/klarna_kosma_integration/doctype/bank_reconciliation_tool_beta/unpaid_vouchers.py:280
@@ -915,6 +962,14 @@ msgstr ""
msgid "The currency of the second account ({0}) must be the same as of the bank account ({1})"
msgstr ""
+#: banking/overrides/bank_transaction.py:86
+msgid "The field {0} is negative. Please verify the input data."
+msgstr ""
+
+#: banking/overrides/bank_transaction.py:76
+msgid "The field {0} is required. Please verify the input data."
+msgstr ""
+
#: banking/klarna_kosma_integration/doctype/banking_settings/banking_settings.py:219
msgid "The scheduler is inactive. Please activate it to continue auto-syncing bank transactions."
msgstr ""
@@ -1015,6 +1070,14 @@ msgstr ""
msgid "to import bank transactions for {0}."
msgstr ""
+#: banking/overrides/bank_transaction.py:16
+msgid "{0} is already fully reconciled"
+msgstr ""
+
+#: banking/overrides/bank_transaction.py:115
+msgid "{0} is over-allocated by {1} at row {2}."
+msgstr ""
+
#: banking/klarna_kosma_integration/doctype/bank_reconciliation_tool_beta/bank_reconciliation_tool_beta.py:418
#: banking/klarna_kosma_integration/doctype/bank_reconciliation_tool_beta/bank_reconciliation_tool_beta.py:427
msgid "{0} {1} {2}"
diff --git a/banking/overrides/bank_account.py b/banking/overrides/bank_account.py
index 6b856435..21000595 100644
--- a/banking/overrides/bank_account.py
+++ b/banking/overrides/bank_account.py
@@ -1,4 +1,24 @@
+import frappe
+from frappe import _
+
+from banking.exceptions import CurrencyMismatchError
+
+
def before_validate(doc, method):
"""Remove spaces from IBAN"""
if doc.iban:
doc.iban = doc.iban.replace(" ", "")
+
+
+def validate(doc, method):
+ validate_account_currencies(doc)
+
+
+def validate_account_currencies(doc):
+ if doc.account and doc.bank_fee_account:
+ bank_account_currency = frappe.db.get_value("Account", doc.account, "account_currency")
+ bank_fee_currency = frappe.db.get_value("Account", doc.bank_fee_account, "account_currency")
+ if bank_account_currency != bank_fee_currency:
+ frappe.throw(
+ _("Company Account and Bank Fee Account must be in the same currency!"), CurrencyMismatchError
+ )
diff --git a/banking/overrides/bank_transaction.py b/banking/overrides/bank_transaction.py
index 38fc3544..852cd457 100644
--- a/banking/overrides/bank_transaction.py
+++ b/banking/overrides/bank_transaction.py
@@ -1,15 +1,20 @@
+import json
+
import frappe
from erpnext.accounts.doctype.bank_transaction.bank_transaction import BankTransaction
from frappe import _
from frappe.core.utils import find
from frappe.utils import flt, getdate
+from frappe.utils.data import evaluate_filters, get_link_to_form
class CustomBankTransaction(BankTransaction):
def add_payment_entries(self, vouchers: list, reconcile_multi_party: bool = False):
"Add the vouchers with zero allocation. Save() will perform the allocations and clearance"
if self.unallocated_amount <= 0.0:
- frappe.throw(frappe._("Bank Transaction {0} is already fully reconciled").format(self.name))
+ frappe.throw(
+ _("{0} is already fully reconciled").format(get_link_to_form("Bank Transaction", self.name))
+ )
pe_length_before = len(self.payment_entries)
self.reconcile_paid_vouchers(vouchers)
@@ -58,6 +63,55 @@ def is_duplicate_reference(self, voucher_type, voucher_name):
lambda x: x.payment_document == voucher_type and x.payment_entry == voucher_name,
)
+ def convert_to_positive_value(self, fieldname: str):
+ cur_value = self.get(fieldname)
+ if cur_value is not None and flt(cur_value) < 0:
+ self.set(fieldname, abs(flt(cur_value)))
+
+
+def before_validate(doc: "CustomBankTransaction", method):
+ enforce_positive_values(doc)
+
+
+def before_submit(doc: "CustomBankTransaction", method):
+ date = doc.date or frappe.utils.nowdate()
+
+ if not doc.bank_account:
+ frappe.throw(
+ _("The field {0} is required. Please verify the input data.").format(
+ _(doc.meta.get_label("bank_account"))
+ )
+ )
+
+ if doc.deposit == 0 and doc.withdrawal == 0:
+ return
+
+ for fieldname in ["deposit", "withdrawal", "included_fee"]:
+ value = doc.get(fieldname)
+ if value is None:
+ continue
+
+ if value < 0:
+ frappe.throw(
+ _("The field {0} is negative. Please verify the input data.").format(
+ _(doc.meta.get_label(fieldname))
+ )
+ )
+
+ cost_center = frappe.get_cached_value("Company", doc.company, "cost_center")
+ account = frappe.get_cached_value("Bank Account", doc.bank_account, "account")
+ debit, credit = (doc.deposit, 0) if doc.deposit else (0, doc.withdrawal)
+ included_fee = doc.included_fee or 0
+
+ if frappe.db.get_single_value("Banking Settings", "enable_automatic_journal_entries_for_bank_fees"):
+ create_je_bank_fees(doc, cost_center, date, account, debit, credit)
+
+ # For withdrawals, `included_fee` must be posted separately (auto or manual).
+ # So rules should only reconcile the net amount.
+ credit_no_fee = max(0, credit - included_fee)
+
+ create_je_automatic_rules(doc, cost_center, date, account, debit, credit_no_fee)
+
def on_update_after_submit(doc, event):
"""Validate if the Bank Transaction is over-allocated."""
@@ -65,10 +119,210 @@ def on_update_after_submit(doc, event):
for entry in doc.payment_entries:
to_allocate -= flt(entry.allocated_amount)
if round(to_allocate, 2) < 0.0:
- symbol = frappe.db.get_value("Currency", doc.currency, "symbol")
frappe.throw(
- msg=_("The Bank Transaction is over-allocated by {0} at row {1}.").format(
- frappe.bold(f"{symbol} {abs(to_allocate)!s}"), frappe.bold(entry.idx)
+ msg=_("{0} is over-allocated by {1} at row {2}.").format(
+ get_link_to_form("Bank Transaction", doc.name),
+ frappe.bold(frappe.format(abs(to_allocate), "Currency", currency=doc.currency)),
+ frappe.bold(entry.idx),
),
- title=_("Over Allocation"),
+ title=_("Over-allocation"),
)
+
+
+def on_cancel(doc, method):
+ # Cancel the journal entries created by this Bank Transaction
+ auto_created_journal_entries = frappe.get_all(
+ "Journal Entry",
+ filters={"cheque_no": doc.name},
+ pluck="name",
+ )
+
+ for journal_entry in auto_created_journal_entries:
+ try:
+ doc = frappe.get_doc("Journal Entry", journal_entry)
+ if doc.docstatus == 1:
+ doc.cancel()
+ except Exception as e:
+ frappe.msgprint(
+ _("Failed to cancel {0}: {1}").format(get_link_to_form("Journal Entry", journal_entry), e)
+ )
+
+
+def create_je_bank_fees(doc, cost_center, date, account, debit, credit):
+ # First step: Create a journal entry for included bank fees
+ included_fee = doc.included_fee
+
+ if included_fee is None or included_fee <= 0:
+ return
+
+ bank_fee_account = frappe.db.get_value("Bank Account", doc.bank_account, "bank_fee_account")
+ if not bank_fee_account:
+ frappe.throw(
+ _("Please specify a Bank Fee Account for {0}.").format(
+ get_link_to_form("Bank Account", doc.bank_account)
+ )
+ )
+
+ je_fee_name = create_automatic_journal_entry(
+ company=doc.company,
+ bank_account=doc.bank_account,
+ bank_transaction=doc.name,
+ cost_center=cost_center,
+ date=date,
+ account=account,
+ target_account=bank_fee_account,
+ debit=0,
+ credit=included_fee,
+ )
+
+ if credit > 0:
+ # only correct the credit value if set, as the debit value (deposit) is never including the fee.
+ credit_no_fee = credit - included_fee
+ # Set manually the un-/allocated amounts, as this value is already set and needs to be updated
+ doc.allocated_amount = included_fee
+ doc.unallocated_amount = debit + (credit_no_fee or 0)
+ allocated_amount = included_fee
+ else:
+ allocated_amount = 0
+
+ # Add the journal entry for a deposit fee with an allocated_amount of 0, as the fee is not included in the deposit itself.
+ # Otherwise this would cause the un-/allocated amounts to fail.
+ doc.append(
+ "payment_entries",
+ {
+ "payment_document": "Journal Entry",
+ "payment_entry": je_fee_name,
+ "allocated_amount": allocated_amount,
+ },
+ )
+
+ if doc.unallocated_amount == 0:
+ doc.status = "Reconciled"
+
+
+def create_je_automatic_rules(doc, cost_center, date, account, debit, credit):
+ # Second step: Automatic reconcilation based on the Bank Reconciliation Rules
+ bank_reconciliation_rules = frappe.get_all(
+ "Bank Reconciliation Rule",
+ filters={
+ "disabled": 0,
+ "bank_account": doc.bank_account,
+ "docstatus": 1,
+ "filters": ("is", "set"),
+ },
+ fields=["name", "target_account", "filters"],
+ as_list=True,
+ )
+
+ for br_rule_name, target_account, filters in bank_reconciliation_rules:
+ try:
+ filters = json.loads(filters)
+ except json.JSONDecodeError:
+ frappe.log_error(
+ title="Invalid Filters in Bank Reconciliation Rule",
+ message=f"The filters for the Bank Reconciliation Rule {br_rule_name} are not valid JSON: {filters}",
+ reference_doctype="Bank Reconciliation Rule",
+ reference_name=br_rule_name,
+ )
+ continue
+
+ if not evaluate_filters(doc, filters):
+ continue
+
+ je_auto_name = create_automatic_journal_entry(
+ company=doc.company,
+ bank_account=doc.bank_account,
+ bank_transaction=doc.name,
+ cost_center=cost_center,
+ date=date,
+ account=account,
+ target_account=target_account,
+ debit=debit,
+ credit=credit,
+ rule=br_rule_name,
+ )
+ doc.append(
+ "payment_entries",
+ {
+ "payment_document": "Journal Entry",
+ "payment_entry": je_auto_name,
+ "allocated_amount": debit + credit,
+ },
+ )
+ # Set manually the un-/allocated amounts, as this value is already set and needs to be updated
+ doc.allocated_amount = flt(doc.allocated_amount) + debit + credit
+ total_amount = abs(flt(doc.withdrawal) - flt(doc.deposit))
+ doc.unallocated_amount = max(0, total_amount - flt(doc.allocated_amount))
+ doc.status = "Reconciled" if doc.unallocated_amount == 0 else "Pending"
+ break
+
+
+def create_automatic_journal_entry(
+ company: str,
+ bank_account: str,
+ bank_transaction: str,
+ cost_center: str,
+ date: str,
+ account: str,
+ target_account: str,
+ debit: float = 0,
+ credit: float = 0,
+ rule: str | None = None,
+):
+ journal_entry = frappe.new_doc("Journal Entry")
+ journal_entry.voucher_type = "Journal Entry"
+ journal_entry.posting_date = date
+ journal_entry.company = company
+ journal_entry.user_remark = (
+ _("Auto-created from Bank Transaction {0} by Bank Reconciliation Rule {1}").format(
+ bank_transaction, rule
+ )
+ if rule
+ else _("Auto-created from Bank Transaction {0}").format(bank_transaction)
+ )
+ journal_entry.cheque_no = bank_transaction
+ journal_entry.cheque_date = date
+ journal_entry.multi_currency = 1
+
+ # Bank account entry
+ journal_entry.append(
+ "accounts",
+ {
+ "account": account,
+ "bank_account": bank_account,
+ "debit_in_account_currency": debit,
+ "credit_in_account_currency": credit,
+ "cost_center": cost_center,
+ },
+ )
+
+ # Target account entry
+ journal_entry.append(
+ "accounts",
+ {
+ "account": target_account,
+ "bank_account": "",
+ "debit_in_account_currency": credit,
+ "credit_in_account_currency": debit,
+ "cost_center": cost_center,
+ },
+ )
+
+ journal_entry.submit()
+ frappe.db.set_value("Journal Entry", journal_entry.name, "clearance_date", frappe.utils.today())
+
+ return journal_entry.name
+
+
+def enforce_positive_values(doc: "CustomBankTransaction"):
+ """Convert any negative values to positive values.
+
+ Bank Statements often contain negative values (mostly for withdrawals and
+ fees) while ERPNext expects positive values only. To avoid errors during
+ Data Import, we accept negative values but convert them to positive values.
+ """
+ for fieldname in ["deposit", "withdrawal", "included_fee", "excluded_fee"]:
+ doc.convert_to_positive_value(fieldname)
+
+ # Re-call this function as the original function runs before this one and values are not converted
+ doc.handle_excluded_fee()
diff --git a/banking/overrides/test_bank_account.py b/banking/overrides/test_bank_account.py
new file mode 100644
index 00000000..715f74ba
--- /dev/null
+++ b/banking/overrides/test_bank_account.py
@@ -0,0 +1,23 @@
+# Copyright (c) 2025, ALYF GmbH and Contributors
+# See license.txt
+
+import frappe
+from frappe.tests.utils import FrappeTestCase
+
+from banking.exceptions import CurrencyMismatchError
+from banking.utils import TEST_COMPANY, create_bank_account, create_currency_account
+
+
+class TestBankReconciliationRule(FrappeTestCase):
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+
+ # these should be cleaned up by the DB rollback of FrappeTestCase
+ parent_account = frappe.db.get_value("Account", {"is_group": 1, "company": TEST_COMPANY})
+ cls.account_1 = create_currency_account("EUR", parent_account)
+ cls.account_2 = create_currency_account("USD", parent_account)
+
+ def test_validate_account_currencies(self):
+ with self.assertRaises(CurrencyMismatchError):
+ create_bank_account(self.account_1.name, bank_fee_account=self.account_2.name)
diff --git a/banking/overrides/test_bank_transaction.py b/banking/overrides/test_bank_transaction.py
new file mode 100644
index 00000000..30d49d01
--- /dev/null
+++ b/banking/overrides/test_bank_transaction.py
@@ -0,0 +1,419 @@
+# Copyright (c) 2025, ALYF GmbH and Contributors
+# See license.txt
+
+from unittest.mock import patch
+
+import frappe
+from frappe.tests.utils import FrappeTestCase
+
+from banking.overrides.bank_transaction import enforce_positive_values
+from banking.utils import TEST_COMPANY, create_bank_account, create_currency_account
+
+
+def create_bank_reconciliation_rule(
+ bank_account,
+ target_account,
+ filters,
+ disabled=0,
+ submit=True,
+):
+ rule = frappe.new_doc("Bank Reconciliation Rule")
+ rule.disabled = disabled
+ rule.bank_account = bank_account
+ rule.target_account = target_account
+ rule.filters = filters
+ rule.insert(ignore_permissions=True, ignore_mandatory=True)
+ if submit:
+ rule.flags.ignore_permissions = True
+ rule.flags.ignore_mandatory = True
+ rule.submit()
+ return rule
+
+
+def create_bank_transaction(insert=True, **values):
+ doc = frappe.new_doc("Bank Transaction")
+ doc.company = TEST_COMPANY
+ doc.update(values)
+ if insert:
+ doc.insert(ignore_permissions=True, ignore_mandatory=True, ignore_links=True)
+ return doc
+
+
+class TestEnforcePositiveValues(FrappeTestCase):
+ def test_deposit(self):
+ doc = frappe.new_doc("Bank Transaction")
+
+ doc.deposit = -2.0
+ doc.withdrawal = 0.0
+ doc.included_fee = -1.0
+ doc.excluded_fee = -1.0
+
+ enforce_positive_values(doc)
+
+ self.assertEqual(doc.deposit, 1.0)
+ self.assertEqual(doc.withdrawal, 0.0)
+ self.assertEqual(doc.included_fee, 2.0)
+ self.assertEqual(doc.excluded_fee, 0.0)
+
+ def test_withdrawal(self):
+ doc = frappe.new_doc("Bank Transaction")
+
+ doc.deposit = 0.0
+ doc.withdrawal = -1.0
+ doc.included_fee = -1.0
+ doc.excluded_fee = -1.0
+
+ enforce_positive_values(doc)
+
+ self.assertEqual(doc.deposit, 0.0)
+ self.assertEqual(doc.withdrawal, 2.0)
+ self.assertEqual(doc.included_fee, 2.0)
+ self.assertEqual(doc.excluded_fee, 0.0)
+
+ def test_none(self):
+ doc = frappe.new_doc("Bank Transaction")
+
+ doc.deposit = None
+ doc.withdrawal = None
+ doc.included_fee = None
+ doc.excluded_fee = None
+
+ enforce_positive_values(doc)
+
+ self.assertEqual(doc.deposit, None)
+ self.assertEqual(doc.withdrawal, None)
+ self.assertEqual(doc.included_fee, None)
+ self.assertEqual(doc.excluded_fee, None)
+
+
+class TestBankReconciliationRule(FrappeTestCase):
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+
+ # these should be cleaned up by the DB rollback of FrappeTestCase
+ parent_account = frappe.db.get_value("Account", {"is_group": 1, "company": TEST_COMPANY})
+ cls.account_1 = create_currency_account("EUR", parent_account)
+ cls.account_2 = create_currency_account("EUR", parent_account, account_name="_Test_Account_EUR_Fee")
+ cls.ba = create_bank_account(cls.account_1.name, bank_fee_account=cls.account_2.name)
+
+ @patch("banking.overrides.bank_transaction.create_je_automatic_rules")
+ @patch("banking.overrides.bank_transaction.create_je_bank_fees")
+ def test_before_submit(self, mock_create_bank_fees, mock_create_auto_rules):
+ from banking.overrides.bank_transaction import before_submit
+
+ bt = create_bank_transaction(insert=False)
+
+ with self.assertRaises(
+ frappe.ValidationError,
+ msg="Expected ValidationError when bank account is missing.",
+ ):
+ before_submit(bt, None)
+
+ bt.bank_account = self.ba.name
+ bt.deposit = -1.0
+
+ with self.assertRaises(
+ frappe.ValidationError,
+ msg="Expected ValidationError for negative deposit.",
+ ):
+ before_submit(bt, None)
+
+ bt.deposit = 0.0
+ bt.withdrawal = -1.0
+
+ with self.assertRaises(
+ frappe.ValidationError,
+ msg="Expected ValidationError for negative withdrawal.",
+ ):
+ before_submit(bt, None)
+
+ bt.withdrawal = 1.0
+ frappe.db.set_single_value("Banking Settings", "enable_automatic_journal_entries_for_bank_fees", 1)
+
+ before_submit(bt, None)
+ mock_create_bank_fees.assert_called_once()
+ mock_create_auto_rules.assert_called_once()
+
+ @patch("banking.overrides.bank_transaction.create_je_automatic_rules")
+ @patch("banking.overrides.bank_transaction.create_je_bank_fees")
+ def test_before_submit_without_automatic_bank_fee_entries(
+ self, mock_create_bank_fees, mock_create_auto_rules
+ ):
+ from banking.overrides.bank_transaction import before_submit
+
+ frappe.db.set_single_value("Banking Settings", "enable_automatic_journal_entries_for_bank_fees", 0)
+ bt = create_bank_transaction(
+ insert=False,
+ bank_account=self.ba.name,
+ withdrawal=10.0,
+ included_fee=1.0,
+ date="2025-01-01",
+ )
+
+ before_submit(bt, None)
+
+ mock_create_bank_fees.assert_not_called()
+ mock_create_auto_rules.assert_called_once()
+ self.assertEqual(mock_create_auto_rules.call_args.args[0], bt)
+ self.assertEqual(mock_create_auto_rules.call_args.args[4], 0)
+ self.assertEqual(mock_create_auto_rules.call_args.args[5], 9.0)
+
+ @patch("banking.overrides.bank_transaction.create_automatic_journal_entry")
+ def test_create_je_bank_fees_withdrawal(self, mock_create_je):
+ from banking.overrides.bank_transaction import create_je_bank_fees
+
+ mock_create_je.return_value = "JE-TEST-0001"
+ date = "2025-01-01"
+
+ bt = create_bank_transaction(withdrawal=5.0, included_fee=1.0, bank_account=self.ba.name)
+
+ company_doc = frappe.get_cached_doc("Company", bt.company)
+
+ # Assert regular entry
+ create_je_bank_fees(bt, company_doc.cost_center, date, self.account_1, 0, bt.withdrawal)
+
+ mock_create_je.assert_called_once_with(
+ company=bt.company,
+ bank_account=bt.bank_account,
+ bank_transaction=bt.name,
+ cost_center=company_doc.cost_center,
+ date=date,
+ account=self.account_1,
+ target_account=self.account_2.name,
+ debit=0,
+ credit=1.0,
+ )
+
+ self.assertEqual(len(bt.payment_entries), 1)
+ self.assertEqual(bt.payment_entries[0].payment_entry, "JE-TEST-0001")
+ self.assertEqual(bt.payment_entries[0].allocated_amount, 1.0)
+ self.assertEqual(bt.allocated_amount, 1.0)
+ self.assertEqual(bt.unallocated_amount, 4.0)
+ self.assertEqual(bt.status, "Pending")
+
+ # Assert fee = full amount entry
+ bt.withdrawal = 1.0
+ create_je_bank_fees(bt, company_doc.cost_center, date, self.account_1, 0, bt.withdrawal)
+
+ self.assertEqual(bt.allocated_amount, 1.0)
+ self.assertEqual(bt.unallocated_amount, 0.0)
+ self.assertEqual(bt.status, "Reconciled")
+
+ @patch("banking.overrides.bank_transaction.create_automatic_journal_entry")
+ def test_create_je_bank_fees_deposit(self, mock_create_je):
+ from banking.overrides.bank_transaction import create_je_bank_fees
+
+ mock_create_je.return_value = "JE-TEST-0001"
+ date = "2025-01-01"
+
+ bt = create_bank_transaction(deposit=5.0, included_fee=1.0, bank_account=self.ba.name)
+
+ company_doc = frappe.get_cached_doc("Company", bt.company)
+
+ # Assert regular entry
+ create_je_bank_fees(bt, company_doc.cost_center, date, self.account_1, bt.deposit, 0)
+
+ mock_create_je.assert_called_once_with(
+ company=bt.company,
+ bank_account=bt.bank_account,
+ bank_transaction=bt.name,
+ cost_center=company_doc.cost_center,
+ date=date,
+ account=self.account_1,
+ target_account=self.account_2.name,
+ debit=0,
+ credit=1.0,
+ )
+
+ self.assertEqual(len(bt.payment_entries), 1)
+ self.assertEqual(bt.payment_entries[0].payment_entry, "JE-TEST-0001")
+ self.assertEqual(bt.payment_entries[0].allocated_amount, 0.0)
+ self.assertEqual(bt.allocated_amount, 0.0)
+ self.assertEqual(bt.unallocated_amount, 5.0)
+ self.assertEqual(bt.status, "Pending")
+
+ @patch("banking.overrides.bank_transaction.create_automatic_journal_entry")
+ def test_create_je_automatic_rules_withdrawal(self, mock_create_je):
+ from banking.overrides.bank_transaction import create_je_automatic_rules
+
+ frappe.db.delete("Bank Reconciliation Rule")
+
+ mock_create_je.return_value = "JE-TEST-0001"
+ date = "2025-01-01"
+
+ # brr_1 => correct
+ brr_1 = create_bank_reconciliation_rule(
+ self.ba.name,
+ self.account_2.name,
+ '[["Bank Transaction","description","=","FLAG-TRUE",false]]',
+ )
+
+ # brr_2 => fail
+ create_bank_reconciliation_rule(
+ self.ba.name,
+ self.account_1.name,
+ '[["Bank Transaction","description","=","FLAG-TRUE",false]]',
+ disabled=1,
+ )
+
+ # brr_3 => fail
+ create_bank_reconciliation_rule(
+ self.ba.name,
+ self.account_1.name,
+ '[["Bank Transaction","description","=","FLAG-TRUE",false]]',
+ submit=False,
+ )
+
+ bt = create_bank_transaction(
+ withdrawal=5.0,
+ included_fee=1.0,
+ bank_account=self.ba.name,
+ description="FLAG-TRUE",
+ )
+
+ company_doc = frappe.get_cached_doc("Company", bt.company)
+
+ # Assert regular entry
+ create_je_automatic_rules(
+ bt,
+ company_doc.cost_center,
+ date,
+ self.account_1,
+ 0,
+ bt.withdrawal - bt.included_fee,
+ )
+
+ mock_create_je.assert_called_once_with(
+ company=bt.company,
+ bank_account=bt.bank_account,
+ bank_transaction=bt.name,
+ cost_center=company_doc.cost_center,
+ date=date,
+ account=self.account_1,
+ target_account=self.account_2.name,
+ debit=0.0,
+ credit=4.0,
+ rule=brr_1.name,
+ )
+
+ self.assertEqual(len(bt.payment_entries), 1)
+ self.assertEqual(bt.payment_entries[0].payment_entry, "JE-TEST-0001")
+ self.assertEqual(bt.payment_entries[0].allocated_amount, 4.0)
+ self.assertEqual(bt.allocated_amount, 4.0)
+ self.assertEqual(bt.unallocated_amount, 1.0)
+ self.assertEqual(bt.status, "Pending")
+
+ @patch("banking.overrides.bank_transaction.create_automatic_journal_entry")
+ def test_create_je_automatic_rules_withdrawal_with_preallocated_fee(self, mock_create_je):
+ from banking.overrides.bank_transaction import create_je_automatic_rules
+
+ frappe.db.delete("Bank Reconciliation Rule")
+
+ mock_create_je.return_value = "JE-TEST-0001"
+ date = "2025-01-01"
+
+ brr_1 = create_bank_reconciliation_rule(
+ self.ba.name,
+ self.account_2.name,
+ '[["Bank Transaction","description","=","FLAG-TRUE",false]]',
+ )
+
+ bt = create_bank_transaction(
+ withdrawal=5.0,
+ included_fee=1.0,
+ bank_account=self.ba.name,
+ description="FLAG-TRUE",
+ )
+ bt.allocated_amount = 1.0
+ bt.unallocated_amount = 4.0
+
+ company_doc = frappe.get_cached_doc("Company", bt.company)
+
+ create_je_automatic_rules(
+ bt,
+ company_doc.cost_center,
+ date,
+ self.account_1,
+ 0,
+ bt.withdrawal - bt.included_fee,
+ )
+
+ mock_create_je.assert_called_once_with(
+ company=bt.company,
+ bank_account=bt.bank_account,
+ bank_transaction=bt.name,
+ cost_center=company_doc.cost_center,
+ date=date,
+ account=self.account_1,
+ target_account=self.account_2.name,
+ debit=0.0,
+ credit=4.0,
+ rule=brr_1.name,
+ )
+ self.assertEqual(bt.allocated_amount, 5.0)
+ self.assertEqual(bt.unallocated_amount, 0.0)
+ self.assertEqual(bt.status, "Reconciled")
+
+ @patch("banking.overrides.bank_transaction.create_automatic_journal_entry")
+ def test_create_je_automatic_rules_deposit(self, mock_create_je):
+ from banking.overrides.bank_transaction import create_je_automatic_rules
+
+ frappe.db.delete("Bank Reconciliation Rule")
+
+ mock_create_je.return_value = "JE-TEST-0001"
+ date = "2025-01-01"
+
+ # brr_1 => correct
+ brr_1 = create_bank_reconciliation_rule(
+ self.ba.name,
+ self.account_2.name,
+ '[["Bank Transaction","description","=","FLAG-TRUE",false]]',
+ )
+
+ # brr_2 => fail
+ create_bank_reconciliation_rule(
+ self.ba.name,
+ self.account_1.name,
+ '[["Bank Transaction","description","=","FLAG-TRUE",false]]',
+ disabled=1,
+ )
+
+ # brr_3 => fail
+ create_bank_reconciliation_rule(
+ self.ba.name,
+ self.account_1.name,
+ '[["Bank Transaction","description","=","FLAG-TRUE",false]]',
+ submit=False,
+ )
+
+ bt = create_bank_transaction(
+ deposit=5.0,
+ included_fee=1.0,
+ bank_account=self.ba.name,
+ description="FLAG-TRUE",
+ )
+
+ company_doc = frappe.get_cached_doc("Company", bt.company)
+
+ # Assert regular entry
+ create_je_automatic_rules(bt, company_doc.cost_center, date, self.account_1, bt.deposit, 0)
+
+ mock_create_je.assert_called_once_with(
+ company=bt.company,
+ bank_account=bt.bank_account,
+ bank_transaction=bt.name,
+ cost_center=company_doc.cost_center,
+ date=date,
+ account=self.account_1,
+ target_account=self.account_2.name,
+ debit=5.0,
+ credit=0.0,
+ rule=brr_1.name,
+ )
+
+ self.assertEqual(len(bt.payment_entries), 1)
+ self.assertEqual(bt.payment_entries[0].payment_entry, "JE-TEST-0001")
+ self.assertEqual(bt.payment_entries[0].allocated_amount, 5.0)
+ self.assertEqual(bt.allocated_amount, 5.0)
+ self.assertEqual(bt.status, "Reconciled")
diff --git a/banking/patches.txt b/banking/patches.txt
index 15a2faee..bf0a6a01 100644
--- a/banking/patches.txt
+++ b/banking/patches.txt
@@ -1,5 +1,5 @@
[pre_model_sync]
-banking.patches.recreate_custom_fields # 2025-10-23
+banking.patches.recreate_custom_fields # 2025-12-16
banking.patches.remove_spaces_from_iban
[post_model_sync]
diff --git a/banking/utils.py b/banking/utils.py
index 439ad4d8..e7afcc2d 100644
--- a/banking/utils.py
+++ b/banking/utils.py
@@ -10,6 +10,9 @@
from erpnext.accounts.doctype.bank_account.bank_account import BankAccount
+TEST_COMPANY = "Bolt Trades"
+
+
def before_tests():
# complete setup if missing
year = now_datetime().year
@@ -90,3 +93,26 @@ def create_bank(iban: str) -> str:
doc.insert()
return doc.name
+
+
+def create_currency_account(currency: str, parent_account: str, account_name: str | None = None):
+ """Used in tests that ensure accounts have matching currencies."""
+ acc = frappe.new_doc("Account")
+ acc.account_name = account_name or f"_Test_Account_{currency}"
+ acc.account_currency = currency
+ acc.parent_account = parent_account
+ acc.insert(ignore_permissions=True, ignore_mandatory=True, ignore_links=True)
+ return acc
+
+
+def create_bank_account(account: str, bank_fee_account: str | None = None):
+ """Used in tests that ensure bank accounts have matching currencies."""
+ ba = frappe.new_doc("Bank Account")
+ ba.account_name = "_Test_B_Account"
+ ba.account = account
+ ba.bank = "_Test_Bank"
+ ba.is_company_account = 1
+ if bank_fee_account:
+ ba.bank_fee_account = bank_fee_account
+ ba.insert(ignore_permissions=True, ignore_links=True)
+ return ba