From 6da2c30b9147f59a7b7d0f56f11b6556fb437b52 Mon Sep 17 00:00:00 2001 From: barredterra <14891507+barredterra@users.noreply.github.com> Date: Tue, 17 Feb 2026 22:32:40 +0100 Subject: [PATCH 01/10] feat(Bank Reconciliation): add rule-based auto booking Introduce a Bank Reconciliation Rule doctype and apply the first matching submitted rule on Bank Transaction submission to auto-create and reconcile Journal Entries. Co-authored-by: Cursor --- banking/exceptions.py | 10 ++ banking/hooks.py | 2 + .../bank_reconciliation_rule/__init__.py | 0 .../bank_reconciliation_rule.js | 69 +++++++ .../bank_reconciliation_rule.json | 135 ++++++++++++++ .../bank_reconciliation_rule.py | 49 +++++ .../bank_reconciliation_rule_list.js | 23 +++ .../test_bank_reconciliation_rule.py | 61 +++++++ .../workspace/alyf_banking/alyf_banking.json | 14 +- banking/overrides/bank_transaction.py | 162 +++++++++++++++++ ...st_bank_transaction_reconciliation_rule.py | 170 ++++++++++++++++++ 11 files changed, 693 insertions(+), 2 deletions(-) create mode 100644 banking/exceptions.py create mode 100644 banking/klarna_kosma_integration/doctype/bank_reconciliation_rule/__init__.py create mode 100644 banking/klarna_kosma_integration/doctype/bank_reconciliation_rule/bank_reconciliation_rule.js create mode 100644 banking/klarna_kosma_integration/doctype/bank_reconciliation_rule/bank_reconciliation_rule.json create mode 100644 banking/klarna_kosma_integration/doctype/bank_reconciliation_rule/bank_reconciliation_rule.py create mode 100644 banking/klarna_kosma_integration/doctype/bank_reconciliation_rule/bank_reconciliation_rule_list.js create mode 100644 banking/klarna_kosma_integration/doctype/bank_reconciliation_rule/test_bank_reconciliation_rule.py create mode 100644 banking/overrides/test_bank_transaction_reconciliation_rule.py 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..7a2addad 100644 --- a/banking/hooks.py +++ b/banking/hooks.py @@ -108,6 +108,8 @@ doc_events = { "Bank Transaction": { "on_update_after_submit": "banking.overrides.bank_transaction.on_update_after_submit", + "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", 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..7683b15e --- /dev/null +++ b/banking/klarna_kosma_integration/doctype/bank_reconciliation_rule/test_bank_reconciliation_rule.py @@ -0,0 +1,61 @@ +# 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, +) + +TEST_COMPANY = "Bolt Trades" + + +def create_currency_account(currency: str, parent_account: str, account_name: str): + acc = frappe.new_doc("Account") + acc.account_name = account_name + 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): + ba = frappe.new_doc("Bank Account") + ba.account_name = "_Test_B_Account" + ba.account = account + ba.bank = "_Test_Bank" + ba.is_company_account = 1 + ba.insert(ignore_permissions=True, ignore_links=True) + return ba + + +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, "_Test_Account_EUR") + cls.target_account = create_currency_account("USD", parent_account, "_Test_Account_USD") + 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/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/overrides/bank_transaction.py b/banking/overrides/bank_transaction.py index 38fc3544..c6e52e46 100644 --- a/banking/overrides/bank_transaction.py +++ b/banking/overrides/bank_transaction.py @@ -1,8 +1,11 @@ +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): @@ -72,3 +75,162 @@ def on_update_after_submit(doc, event): ), title=_("Over Allocation"), ) + + +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"]: + 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) + + create_je_automatic_rules(doc, cost_center, date, account, debit, credit) + + +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: + je_doc = frappe.get_doc("Journal Entry", journal_entry) + if je_doc.docstatus == 1: + je_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_automatic_rules(doc, cost_center, date, account, debit, credit): + 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, + }, + ) + doc.allocated_amount = (doc.allocated_amount or 0) + debit + credit + doc.unallocated_amount = 0 + doc.status = "Reconciled" + 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 + + journal_entry.append( + "accounts", + { + "account": account, + "bank_account": bank_account, + "debit_in_account_currency": debit, + "credit_in_account_currency": credit, + "cost_center": cost_center, + }, + ) + + 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 diff --git a/banking/overrides/test_bank_transaction_reconciliation_rule.py b/banking/overrides/test_bank_transaction_reconciliation_rule.py new file mode 100644 index 00000000..cce3942e --- /dev/null +++ b/banking/overrides/test_bank_transaction_reconciliation_rule.py @@ -0,0 +1,170 @@ +# Copyright (c) 2025, ALYF GmbH and Contributors +# See license.txt + +from unittest.mock import patch + +import frappe +from frappe.tests.utils import FrappeTestCase + +TEST_COMPANY = "Bolt Trades" + + +def create_currency_account(currency: str, parent_account: str, account_name: str): + acc = frappe.new_doc("Account") + acc.account_name = account_name + 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): + ba = frappe.new_doc("Bank Account") + ba.account_name = "_Test_B_Account" + ba.account = account + ba.bank = "_Test_Bank" + ba.is_company_account = 1 + ba.insert(ignore_permissions=True, ignore_links=True) + return ba + + +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 TestBankTransactionReconciliationRule(FrappeTestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + + parent_account = frappe.db.get_value("Account", {"is_group": 1, "company": TEST_COMPANY}) + cls.account_main = create_currency_account("EUR", parent_account, "_Test_Account_EUR") + cls.account_target = create_currency_account("EUR", parent_account, "_Test_Account_EUR_Target") + cls.bank_account = create_bank_account(cls.account_main.name) + + @patch("banking.overrides.bank_transaction.create_je_automatic_rules") + def test_before_submit_invokes_rule_engine(self, mock_create_auto_rules): + from banking.overrides.bank_transaction import before_submit + + bt = create_bank_transaction(insert=False) + + with self.assertRaises(frappe.ValidationError): + before_submit(bt, None) + + bt.bank_account = self.bank_account.name + bt.deposit = 1.0 + before_submit(bt, None) + + mock_create_auto_rules.assert_called_once() + + @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 = create_bank_reconciliation_rule( + self.bank_account.name, + self.account_target.name, + '[["Bank Transaction","description","=","FLAG-TRUE",false]]', + ) + + create_bank_reconciliation_rule( + self.bank_account.name, + self.account_main.name, + '[["Bank Transaction","description","=","FLAG-TRUE",false]]', + disabled=1, + ) + create_bank_reconciliation_rule( + self.bank_account.name, + self.account_main.name, + '[["Bank Transaction","description","=","FLAG-TRUE",false]]', + submit=False, + ) + + bt = create_bank_transaction( + withdrawal=5.0, + bank_account=self.bank_account.name, + description="FLAG-TRUE", + ) + cost_center = frappe.get_cached_value("Company", bt.company, "cost_center") + + create_je_automatic_rules(bt, cost_center, date, self.account_main.name, 0, bt.withdrawal) + + mock_create_je.assert_called_once_with( + company=bt.company, + bank_account=bt.bank_account, + bank_transaction=bt.name, + cost_center=cost_center, + date=date, + account=self.account_main.name, + target_account=self.account_target.name, + debit=0.0, + credit=5.0, + rule=brr_1.name, + ) + 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") + + @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 = create_bank_reconciliation_rule( + self.bank_account.name, + self.account_target.name, + '[["Bank Transaction","description","=","FLAG-TRUE",false]]', + ) + + bt = create_bank_transaction( + deposit=5.0, + bank_account=self.bank_account.name, + description="FLAG-TRUE", + ) + cost_center = frappe.get_cached_value("Company", bt.company, "cost_center") + + create_je_automatic_rules(bt, cost_center, date, self.account_main.name, bt.deposit, 0) + + mock_create_je.assert_called_once_with( + company=bt.company, + bank_account=bt.bank_account, + bank_transaction=bt.name, + cost_center=cost_center, + date=date, + account=self.account_main.name, + target_account=self.account_target.name, + debit=5.0, + credit=0.0, + rule=brr_1.name, + ) + 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") From bc74d14b28daa05b0996e7e7108050882debd30a Mon Sep 17 00:00:00 2001 From: barredterra <14891507+barredterra@users.noreply.github.com> Date: Tue, 17 Feb 2026 23:57:05 +0100 Subject: [PATCH 02/10] chore(Bank Reconciliation): bump model timestamps for sync Update modified timestamps in changed JSON model files so site model sync picks up the latest controller updates. Co-authored-by: Cursor --- .../bank_reconciliation_rule/bank_reconciliation_rule.json | 2 +- .../workspace/alyf_banking/alyf_banking.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 index 1308022d..7eb34322 100644 --- 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 @@ -81,7 +81,7 @@ "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2026-01-27 21:55:21.191776", + "modified": "2026-02-17 23:56:35.890446", "modified_by": "Administrator", "module": "Klarna Kosma Integration", "name": "Bank Reconciliation Rule", 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 ed97d353..056310fa 100644 --- a/banking/klarna_kosma_integration/workspace/alyf_banking/alyf_banking.json +++ b/banking/klarna_kosma_integration/workspace/alyf_banking/alyf_banking.json @@ -81,7 +81,7 @@ "type": "Link" } ], - "modified": "2026-01-14 01:28:31.573172", + "modified": "2026-02-17 23:56:35.890446", "modified_by": "Administrator", "module": "Klarna Kosma Integration", "name": "ALYF Banking", From 6da5a2e70b2a35c9099bfa48e90d484e67f51f0d Mon Sep 17 00:00:00 2001 From: barredterra <14891507+barredterra@users.noreply.github.com> Date: Sat, 28 Mar 2026 01:58:53 +0100 Subject: [PATCH 03/10] fix: mark Journal Entry as system generated and reference Bank Transaction --- banking/overrides/bank_transaction.py | 30 +++++++++++++-------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/banking/overrides/bank_transaction.py b/banking/overrides/bank_transaction.py index c6e52e46..070ab081 100644 --- a/banking/overrides/bank_transaction.py +++ b/banking/overrides/bank_transaction.py @@ -5,7 +5,7 @@ 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 +from frappe.utils.data import evaluate_filters class CustomBankTransaction(BankTransaction): @@ -109,22 +109,19 @@ def before_submit(doc: "CustomBankTransaction", method): def on_cancel(doc, method): - # Cancel the journal entries created by this Bank Transaction. - auto_created_journal_entries = frappe.get_all( + """Cancel the automatically created Journal Entries for this Bank Transaction.""" + for journal_entry in frappe.get_all( "Journal Entry", - filters={"cheque_no": doc.name}, + filters=[ + ["Journal Entry", "docstatus", "=", 1], + ["Journal Entry", "is_system_generated", "=", 1], + ["Journal Entry Account", "reference_type", "=", "Bank Transaction"], + ["Journal Entry Account", "reference_name", "=", doc.name], + ], pluck="name", - ) - - for journal_entry in auto_created_journal_entries: - try: - je_doc = frappe.get_doc("Journal Entry", journal_entry) - if je_doc.docstatus == 1: - je_doc.cancel() - except Exception as e: - frappe.msgprint( - _("Failed to cancel {0}: {1}").format(get_link_to_form("Journal Entry", journal_entry), e) - ) + distinct=True, + ): + frappe.get_doc("Journal Entry", journal_entry).cancel() def create_je_automatic_rules(doc, cost_center, date, account, debit, credit): @@ -207,6 +204,7 @@ def create_automatic_journal_entry( journal_entry.cheque_no = bank_transaction journal_entry.cheque_date = date journal_entry.multi_currency = 1 + journal_entry.is_system_generated = 1 journal_entry.append( "accounts", @@ -216,6 +214,8 @@ def create_automatic_journal_entry( "debit_in_account_currency": debit, "credit_in_account_currency": credit, "cost_center": cost_center, + "reference_type": "Bank Transaction", + "reference_name": bank_transaction, }, ) From 1f49dfbea0fab773961fa1fcee909f3ab5d90b6d Mon Sep 17 00:00:00 2001 From: barredterra <14891507+barredterra@users.noreply.github.com> Date: Sat, 28 Mar 2026 01:59:44 +0100 Subject: [PATCH 04/10] fix: set Journal Entry clearance date --- banking/overrides/bank_transaction.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/banking/overrides/bank_transaction.py b/banking/overrides/bank_transaction.py index 070ab081..85efdb5d 100644 --- a/banking/overrides/bank_transaction.py +++ b/banking/overrides/bank_transaction.py @@ -231,6 +231,6 @@ def create_automatic_journal_entry( ) journal_entry.submit() - frappe.db.set_value("Journal Entry", journal_entry.name, "clearance_date", frappe.utils.today()) + frappe.db.set_value("Journal Entry", journal_entry.name, "clearance_date", date) return journal_entry.name From 4ef363e01855c04e38145cf402ddfbdd925e5e09 Mon Sep 17 00:00:00 2001 From: barredterra <14891507+barredterra@users.noreply.github.com> Date: Sat, 28 Mar 2026 02:03:18 +0100 Subject: [PATCH 05/10] fix: typo in description --- .../bank_reconciliation_rule/bank_reconciliation_rule.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 index 7eb34322..7b839269 100644 --- 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 @@ -57,7 +57,7 @@ { "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.

" + "options": "A Journal Entry is created automatically, when a submitted Bank Transaction matches these filters.
Please define these filters as strictly as possible to avoid accounting errors.

" }, { "fieldname": "amended_from", @@ -81,7 +81,7 @@ "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2026-02-17 23:56:35.890446", + "modified": "2026-03-28 02:02:55.846298", "modified_by": "Administrator", "module": "Klarna Kosma Integration", "name": "Bank Reconciliation Rule", From e2b6edb45af286f1c9623eaca716752f634c50be Mon Sep 17 00:00:00 2001 From: barredterra <14891507+barredterra@users.noreply.github.com> Date: Sat, 28 Mar 2026 02:30:39 +0100 Subject: [PATCH 06/10] fix: order rules by priority and creation --- .../bank_reconciliation_rule.json | 16 +++++++++++++++- .../bank_reconciliation_rule.py | 1 + banking/overrides/bank_transaction.py | 1 + 3 files changed, 17 insertions(+), 1 deletion(-) 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 index 7b839269..aa6e6b69 100644 --- 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 @@ -7,6 +7,8 @@ "account_settings_section", "bank_account", "target_account", + "column_break_dvyd", + "priority", "disabled", "filters_section", "filter_description", @@ -75,13 +77,25 @@ "fieldname": "disabled", "fieldtype": "Check", "label": "Disabled" + }, + { + "fieldname": "column_break_dvyd", + "fieldtype": "Column Break" + }, + { + "description": "If multiple rules match a Bank Transaction, only the one with the highest priority is executed.", + "fieldname": "priority", + "fieldtype": "Int", + "in_list_view": 1, + "label": "Priority", + "non_negative": 1 } ], "grid_page_length": 50, "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2026-03-28 02:02:55.846298", + "modified": "2026-03-28 02:28:06.918430", "modified_by": "Administrator", "module": "Klarna Kosma Integration", "name": "Bank Reconciliation Rule", 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 index 45e3b9b9..6ee53fa9 100644 --- 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 @@ -26,6 +26,7 @@ class BankReconciliationRule(Document): bank_account: DF.Link disabled: DF.Check filters: DF.Code | None + priority: DF.Int target_account: DF.Link # end: auto-generated types diff --git a/banking/overrides/bank_transaction.py b/banking/overrides/bank_transaction.py index 85efdb5d..9ca722ec 100644 --- a/banking/overrides/bank_transaction.py +++ b/banking/overrides/bank_transaction.py @@ -135,6 +135,7 @@ def create_je_automatic_rules(doc, cost_center, date, account, debit, credit): }, fields=["name", "target_account", "filters"], as_list=True, + order_by="priority DESC, creation ASC", ) for br_rule_name, target_account, filters in bank_reconciliation_rules: From 0f60cf32d47bfd3202ee7e7935ef5e613dc43b9a Mon Sep 17 00:00:00 2001 From: barredterra <14891507+barredterra@users.noreply.github.com> Date: Sat, 28 Mar 2026 02:41:35 +0100 Subject: [PATCH 07/10] fix: guard against missing company account --- .../bank_reconciliation_rule.js | 10 ++++++++++ 1 file changed, 10 insertions(+) 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 index 03b4b17f..2d96ca91 100644 --- 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 @@ -24,6 +24,16 @@ frappe.ui.form.on("Bank Reconciliation Rule", { "account" ); + if (!account) { + frappe.throw( + __("In {0} {1} the field {2} is not set.", [ + frappe.bold(__("Bank Account")), + frm.doc.bank_account, + __("Company Account"), + ]) + ); + } + const { message: { account_currency: currency, company: company }, } = await frappe.db.get_value("Account", account, [ From 0d9abbdc64f45e02ae69dc2c856b3c04cdfe21ad Mon Sep 17 00:00:00 2001 From: barredterra <14891507+barredterra@users.noreply.github.com> Date: Sat, 28 Mar 2026 02:58:18 +0100 Subject: [PATCH 08/10] refactor: consolidate testing utils --- .../test_bank_reconciliation_rule.py | 23 +---------------- ...st_bank_transaction_reconciliation_rule.py | 21 +--------------- banking/testing_utils.py | 25 +++++++++++++++++++ 3 files changed, 27 insertions(+), 42 deletions(-) create mode 100644 banking/testing_utils.py 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 index 7683b15e..8d8652a5 100644 --- 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 @@ -8,27 +8,7 @@ from banking.klarna_kosma_integration.doctype.bank_reconciliation_rule.bank_reconciliation_rule import ( NoFiltersError, ) - -TEST_COMPANY = "Bolt Trades" - - -def create_currency_account(currency: str, parent_account: str, account_name: str): - acc = frappe.new_doc("Account") - acc.account_name = account_name - 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): - ba = frappe.new_doc("Bank Account") - ba.account_name = "_Test_B_Account" - ba.account = account - ba.bank = "_Test_Bank" - ba.is_company_account = 1 - ba.insert(ignore_permissions=True, ignore_links=True) - return ba +from banking.testing_utils import TEST_COMPANY, create_bank_account, create_currency_account class TestBankReconciliationRule(FrappeTestCase): @@ -36,7 +16,6 @@ class TestBankReconciliationRule(FrappeTestCase): 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, "_Test_Account_EUR") cls.target_account = create_currency_account("USD", parent_account, "_Test_Account_USD") diff --git a/banking/overrides/test_bank_transaction_reconciliation_rule.py b/banking/overrides/test_bank_transaction_reconciliation_rule.py index cce3942e..d009cc15 100644 --- a/banking/overrides/test_bank_transaction_reconciliation_rule.py +++ b/banking/overrides/test_bank_transaction_reconciliation_rule.py @@ -6,26 +6,7 @@ import frappe from frappe.tests.utils import FrappeTestCase -TEST_COMPANY = "Bolt Trades" - - -def create_currency_account(currency: str, parent_account: str, account_name: str): - acc = frappe.new_doc("Account") - acc.account_name = account_name - 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): - ba = frappe.new_doc("Bank Account") - ba.account_name = "_Test_B_Account" - ba.account = account - ba.bank = "_Test_Bank" - ba.is_company_account = 1 - ba.insert(ignore_permissions=True, ignore_links=True) - return ba +from banking.testing_utils import TEST_COMPANY, create_bank_account, create_currency_account def create_bank_reconciliation_rule(bank_account, target_account, filters, disabled=0, submit=True): diff --git a/banking/testing_utils.py b/banking/testing_utils.py new file mode 100644 index 00000000..d3a4cef2 --- /dev/null +++ b/banking/testing_utils.py @@ -0,0 +1,25 @@ +# Copyright (c) 2025, ALYF GmbH and Contributors +# See license.txt + +import frappe + +TEST_COMPANY = "Bolt Trades" + + +def create_currency_account(currency: str, parent_account: str, account_name: str): + acc = frappe.new_doc("Account") + acc.account_name = account_name + 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): + ba = frappe.new_doc("Bank Account") + ba.account_name = "_Test_B_Account" + ba.account = account + ba.bank = "_Test_Bank" + ba.is_company_account = 1 + ba.insert(ignore_permissions=True, ignore_links=True) + return ba From ab9ec8c95fa34c60355c58016dcbe27c3f2df93b Mon Sep 17 00:00:00 2001 From: barredterra <14891507+barredterra@users.noreply.github.com> Date: Sat, 28 Mar 2026 03:10:32 +0100 Subject: [PATCH 09/10] test: scope delete to bank account --- .../overrides/test_bank_transaction_reconciliation_rule.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/banking/overrides/test_bank_transaction_reconciliation_rule.py b/banking/overrides/test_bank_transaction_reconciliation_rule.py index d009cc15..4d8a1473 100644 --- a/banking/overrides/test_bank_transaction_reconciliation_rule.py +++ b/banking/overrides/test_bank_transaction_reconciliation_rule.py @@ -61,7 +61,7 @@ def test_before_submit_invokes_rule_engine(self, mock_create_auto_rules): 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") + frappe.db.delete("Bank Reconciliation Rule", {"bank_account": self.bank_account.name}) mock_create_je.return_value = "JE-TEST-0001" date = "2025-01-01" @@ -114,7 +114,7 @@ def test_create_je_automatic_rules_withdrawal(self, mock_create_je): 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") + frappe.db.delete("Bank Reconciliation Rule", {"bank_account": self.bank_account.name}) mock_create_je.return_value = "JE-TEST-0001" date = "2025-01-01" From 693767cd9e7ad7cab591d2b3bbf87e573dd60273 Mon Sep 17 00:00:00 2001 From: barredterra <14891507+barredterra@users.noreply.github.com> Date: Sat, 28 Mar 2026 03:19:38 +0100 Subject: [PATCH 10/10] fix: parse JSON to validate empty rule --- .../bank_reconciliation_rule/bank_reconciliation_rule.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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 index 6ee53fa9..a2a3a6c7 100644 --- 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 @@ -1,6 +1,8 @@ # Copyright (c) 2025, ALYF GmbH and contributors # For license information, please see license.txt +import json + import frappe from frappe import _ from frappe.exceptions import ValidationError @@ -45,6 +47,5 @@ def validate_account_currencies(self): ) 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: + if not self.filters or not json.loads(self.filters): frappe.throw(_("Please define at least one filter!"), NoFiltersError)