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 "" 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 "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 "" 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