diff --git a/banking/custom/bank_transaction.js b/banking/custom/bank_transaction.js index aa86d604..bae52d19 100644 --- a/banking/custom/bank_transaction.js +++ b/banking/custom/bank_transaction.js @@ -7,6 +7,8 @@ frappe.ui.form.on("Bank Transaction", { frm.trigger("set_included_fee_headline"); frm.trigger("set_foreign_currency_included_fee_headline"); } + + refresh_rule_creation_menu(frm); }, set_included_fee_headline(frm) { @@ -53,6 +55,25 @@ frappe.ui.form.on("Bank Transaction", { }, }); +function refresh_rule_creation_menu(frm) { + frappe.require("rule_creation_dialog.bundle.js", () => { + if (frm._rule_creation_menu_link) { + frm._rule_creation_menu_link.closest("li").remove(); + frm._rule_creation_menu_link = null; + } + if ( + !frm.doc.bank_account || + !frappe.model.can_create("Bank Reconciliation Rule") + ) { + return; + } + frm._rule_creation_menu_link = frm.page.add_menu_item( + __("Create Reconciliation Rule"), + () => banking.bank_reconciliation.show_rule_creation_dialog(frm.doc) + ); + }); +} + function has_deposit_with_included_fee(frm) { return ( get_rounded_amount(frm, "deposit") > 0 && 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 2d96ca91..cb4ac077 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 @@ -2,14 +2,42 @@ // 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"); + frappe.require("bank_reconciliation_rule_stats.bundle.js", () => { + if (!frm.stats_manager) { + frm.stats_manager = + new banking.bank_reconciliation.BankReconciliationRuleStatsManager( + frm + ); + } + const stats = frm.stats_manager; + + frm.trigger("set_target_account_query"); + frm.remove_custom_button(__("Open Matches")); + const filters_json = + frm.doc.filters && frm.doc.filters !== "[]" ? frm.doc.filters : "[]"; + let has_filters = false; + try { + has_filters = JSON.parse(filters_json).length > 0; + } catch (e) { + has_filters = false; + } + if (frm.doc.bank_account && has_filters) { + frm.add_custom_button(__("Open Matches"), () => { + stats.open_bank_transaction_list(); + }); + } + + if (stats.needs_filter_rerender()) { + frm.trigger("render_bt_filters"); + } else { + stats.fetch_and_show(); + } + }); }, bank_account(frm) { frm.trigger("set_target_account_query"); + frm.stats_manager?.schedule_fetch(); }, async set_target_account_query(frm) { if (!frm.doc.bank_account) { @@ -49,13 +77,21 @@ frappe.ui.form.on("Bank Reconciliation Rule", { })); }, render_bt_filters(frm) { + const stats = frm.stats_manager; + stats.teardown(); + const parent = frm.fields_dict.filter_area.$wrapper; parent.empty(); - const filters = - frm.doc.filters && frm.doc.filters !== "[]" - ? JSON.parse(frm.doc.filters) - : []; + let filters = []; + try { + if (frm.doc.filters && frm.doc.filters !== "[]") { + const parsed = JSON.parse(frm.doc.filters); + filters = Array.isArray(parsed) ? parsed : []; + } + } catch (e) { + filters = []; + } frappe.model.with_doctype("Bank Transaction", () => { const filter_group = new frappe.ui.FilterGroup({ @@ -63,16 +99,41 @@ frappe.ui.form.on("Bank Reconciliation Rule", { doctype: "Bank Transaction", on_change: () => { frm.set_value("filters", JSON.stringify(filter_group.get_filters())); + stats.schedule_fetch(); }, }); - filter_group.add_filters_to_filter_group(filters); + stats.filter_group = filter_group; + + const after_filters_ready = () => { + if (frm.doc.docstatus !== 0) { + parent.find(".filter-action-buttons").remove(); + parent.find(".divider").remove(); + parent.find(".remove-filter").remove(); + parent.find(".form-control").prop("disabled", true); + } + + stats.rendered_for = frm.docname; + stats.mount(parent); + }; - 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); + if (filters.length) { + filter_group.toggle_empty_filters(false); + frappe + .run_serially( + filters.map( + (f) => () => filter_group.add_filter(f[0], f[1], f[2], f[3]) + ) + ) + .then(() => { + filter_group.update_filters(); + after_filters_ready(); + }) + .catch(() => { + after_filters_ready(); + }); + } else { + after_filters_ready(); } }); }, 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 a2a3a6c7..37d5100f 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 @@ -2,19 +2,113 @@ # For license information, please see license.txt import json +from typing import Any import frappe from frappe import _ from frappe.exceptions import ValidationError +from frappe.model.db_query import DatabaseQuery from frappe.model.document import Document +from frappe.utils import add_days, getdate, today +from frappe.utils.data import get_filter from banking.exceptions import CurrencyMismatchError +# Same cap as List View (`count_upper_bound`): count at most N rows, show "N-1+" when hit. +COUNT_UPPER_BOUND = 1001 + class NoFiltersError(ValidationError): pass +def _normalize_filter_rows(raw_filters: list) -> list[list[Any]]: + normalized: list[list[Any]] = [] + for row in raw_filters: + if not isinstance(row, list | tuple) or len(row) < 4: + frappe.throw(_("Invalid filter")) + doctype = row[0] + if doctype != "Bank Transaction": + frappe.throw(_("Invalid filter: only Bank Transaction fields are supported")) + fd = get_filter(doctype, row) + normalized.append([fd.doctype, fd.fieldname, fd.operator, fd.value]) + return normalized + + +def _bank_transaction_stats_filters( + user_filters: list, + bank_account: str, + min_date: str, +) -> list[list[Any]]: + filters = _normalize_filter_rows(user_filters) + filters.extend( + [ + ["Bank Transaction", "bank_account", "=", bank_account], + ["Bank Transaction", "docstatus", "=", 1], + ["Bank Transaction", "date", ">=", min_date], + ] + ) + return filters + + +def _approx_bank_transaction_count(filters: list) -> int: + """Fast capped count, same approach as List View (LIMIT then count rows).""" + return len( + DatabaseQuery("Bank Transaction").execute( + filters=filters, + limit=COUNT_UPPER_BOUND, + order_by=None, + pluck="name", + ) + ) + + +@frappe.whitelist() +def get_bank_transaction_match_stats( + bank_account: str, + filters: str | None = None, + bank_reconciliation_rule: str | None = None, +) -> dict[str, Any]: + """Approximate count of submitted Bank Transactions matching the rule filters. + + Uses a capped count (like List View) so large tables stay fast. + Windows use calendar days: last 30 days and last 365 days (\"12 months\"), relative to *today*. + """ + if not bank_account: + frappe.throw(_("Bank Account is required")) + + frappe.has_permission("Bank Transaction", ptype="read", throw=True) + + if bank_reconciliation_rule and frappe.db.exists("Bank Reconciliation Rule", bank_reconciliation_rule): + rule = frappe.get_doc("Bank Reconciliation Rule", bank_reconciliation_rule) + if rule.bank_account != bank_account: + frappe.throw(_("Bank Account does not match this Bank Reconciliation Rule")) + + try: + user_filters = json.loads(filters or "[]") + except json.JSONDecodeError: + frappe.throw(_("Invalid filters")) + + if not isinstance(user_filters, list) or not user_filters: + frappe.throw(_("Please define at least one filter")) + + as_of = getdate(today()) + # Match UI wording: transactions with *date* on or after (today - 30) / (today - 365). + min_30 = add_days(as_of, -30) + min_365 = add_days(as_of, -365) + + return { + "last_30_days": _approx_bank_transaction_count( + _bank_transaction_stats_filters(user_filters, bank_account, min_30) + ), + "last_12_months": _approx_bank_transaction_count( + _bank_transaction_stats_filters(user_filters, bank_account, min_365) + ), + "as_of": as_of, + "count_upper_bound": COUNT_UPPER_BOUND, + } + + class BankReconciliationRule(Document): # begin: auto-generated types # This code is auto-generated. Do not modify anything in this block. @@ -47,5 +141,11 @@ def validate_account_currencies(self): ) def validate_filters(self): - if not self.filters or not json.loads(self.filters): + if not self.filters: + frappe.throw(_("Please define at least one filter!"), NoFiltersError) + try: + parsed = json.loads(self.filters) + except json.JSONDecodeError: + frappe.throw(_("Invalid filters"), NoFiltersError) + if not parsed: frappe.throw(_("Please define at least one filter!"), NoFiltersError) 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 8d8652a5..4b85657e 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 @@ -1,36 +1,66 @@ # Copyright (c) 2025, ALYF GmbH and Contributors # See license.txt +import json +from unittest.mock import patch + 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 ( + BankReconciliationRule, NoFiltersError, + get_bank_transaction_match_stats, ) from banking.testing_utils import TEST_COMPANY, create_bank_account, create_currency_account +def _group_account_with_parent(company: str) -> str: + name = frappe.db.get_value( + "Account", + {"company": company, "is_group": 1, "parent_account": ["!=", ""]}, + "name", + ) + if not name: + raise RuntimeError(f"No suitable group Account found for company {company!r}") + return name + + +def _resolved_test_company() -> str: + if frappe.db.exists("Company", TEST_COMPANY): + return TEST_COMPANY + name = frappe.db.get_value("Company", {}, "name", order_by="creation asc") + if not name: + raise RuntimeError("No Company found for banking tests") + return name + + class TestBankReconciliationRule(FrappeTestCase): @classmethod def setUpClass(cls): super().setUpClass() - parent_account = frappe.db.get_value("Account", {"is_group": 1, "company": TEST_COMPANY}) + cls.test_company = _resolved_test_company() + parent_account = _group_account_with_parent(cls.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 + brr_doc = BankReconciliationRule( + { + "doctype": "Bank Reconciliation Rule", + "bank_account": self.ba.name, + "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 = BankReconciliationRule({"doctype": "Bank Reconciliation Rule"}) brr_doc.filters = None with self.assertRaises(NoFiltersError): brr_doc.validate_filters() @@ -38,3 +68,64 @@ def test_validate_filters(self): brr_doc.filters = "[]" with self.assertRaises(NoFiltersError): brr_doc.validate_filters() + + @patch( + "banking.klarna_kosma_integration.doctype.bank_reconciliation_rule.bank_reconciliation_rule.today", + return_value="2025-06-15", + ) + def test_get_bank_transaction_match_stats_windows(self, _mock_today): + desc = "STAT-COUNT-BRR-359" + filters = json.dumps([["Bank Transaction", "description", "=", desc, False]]) + + def _insert_submitted_bt(posting_date: str): + bt = frappe.new_doc("Bank Transaction") + bt.company = self.test_company + bt.bank_account = self.ba.name + bt.deposit = 1.0 + bt.date = posting_date + bt.description = desc + bt.insert(ignore_permissions=True, ignore_mandatory=True, ignore_links=True) + frappe.db.set_value("Bank Transaction", bt.name, "docstatus", 1, update_modified=False) + + try: + _insert_submitted_bt("2025-06-10") + _insert_submitted_bt("2025-05-01") + _insert_submitted_bt("2024-01-01") + + stats = get_bank_transaction_match_stats(self.ba.name, filters) + self.assertEqual(stats["last_30_days"], 1) + self.assertEqual(stats["last_12_months"], 2) + self.assertEqual(str(stats["as_of"]), "2025-06-15") + finally: + frappe.db.delete("Bank Transaction", {"description": desc}) + + def test_get_bank_transaction_match_stats_bank_account_mismatch(self): + parent_account = _group_account_with_parent(self.test_company) + eur_target = create_currency_account("EUR", parent_account, "_Test_BRR_EUR_Tgt_Mis") + other_bank_acc = create_currency_account("EUR", parent_account, "_Test_Account_EUR_BrrMis") + other_ba = frappe.new_doc("Bank Account") + other_ba.account_name = "_Test_B_Other_Mismatch" + other_ba.account = other_bank_acc.name + other_ba.bank = "_Test_Bank" + other_ba.is_company_account = 1 + other_ba.insert(ignore_permissions=True, ignore_links=True) + rule = frappe.new_doc("Bank Reconciliation Rule") + rule.bank_account = self.ba.name + rule.target_account = eur_target.name + rule.filters = json.dumps([["Bank Transaction", "description", "=", "X"]]) + rule.insert(ignore_permissions=True, ignore_mandatory=True) + rule.flags.ignore_permissions = True + rule.submit() + try: + with self.assertRaises(frappe.ValidationError): + get_bank_transaction_match_stats( + other_ba.name, + rule.filters, + bank_reconciliation_rule=rule.name, + ) + finally: + rule.cancel() + frappe.delete_doc("Bank Reconciliation Rule", rule.name, force=1, ignore_permissions=True) + frappe.delete_doc("Bank Account", other_ba.name, force=1, ignore_permissions=True) + frappe.delete_doc("Account", other_bank_acc.name, force=1, ignore_permissions=True) + frappe.delete_doc("Account", eur_target.name, force=1, ignore_permissions=True) diff --git a/banking/public/js/bank_reconciliation_rule_stats.bundle.js b/banking/public/js/bank_reconciliation_rule_stats.bundle.js new file mode 100644 index 00000000..28601767 --- /dev/null +++ b/banking/public/js/bank_reconciliation_rule_stats.bundle.js @@ -0,0 +1,205 @@ +// Copyright (c) 2025, ALYF GmbH and contributors +// For license information, please see license.txt + +frappe.provide("banking.bank_reconciliation"); + +banking.bank_reconciliation.BankReconciliationRuleStatsManager = class BankReconciliationRuleStatsManager { + constructor(frm) { + this.frm = frm; + this.request_id = 0; + this.$stats_el = null; + this.filter_group = null; + this._debounced_fetch = null; + this.rendered_for = null; + this.rendered_readonly = false; + } + + teardown() { + this.filter_group = null; + this.$stats_el = null; + this.rendered_for = null; + this.rendered_readonly = false; + } + + needs_filter_rerender() { + if (!this.host_is_live() || !this.filter_group) { + return true; + } + if (this.rendered_for !== this.frm.docname) { + return true; + } + // Submitted rules disable filter controls; duplicate as draft must rebuild editable UI. + return this.rendered_readonly && this.frm.doc.docstatus === 0; + } + + static filters_to_route_options(filters) { + const by_field = {}; + for (const row of filters) { + if (!Array.isArray(row) || row.length < 4) { + continue; + } + const field = row[1]; + const op = row[2]; + const value = row[3]; + (by_field[field] ||= []).push([op, value]); + } + const opts = {}; + for (const [field, conditions] of Object.entries(by_field)) { + if (conditions.length === 1) { + const [op, value] = conditions[0]; + opts[field] = op === "=" ? value : [op, value]; + } else { + opts[field] = conditions.map(([op, value]) => + JSON.stringify([op, value]) + ); + } + } + return opts; + } + + open_bank_transaction_list() { + if (!this.frm.doc.bank_account) { + frappe.throw(__("Set a Bank Account first.")); + } + const user_filters = this.get_user_filters(); + if (!user_filters.length) { + frappe.throw(__("Please define at least one filter.")); + } + const merged = user_filters.map((row) => row.slice(0, 4)); + merged.push([ + "Bank Transaction", + "bank_account", + "=", + this.frm.doc.bank_account, + ]); + frappe.route_options = this.constructor.filters_to_route_options(merged); + frappe.set_route("List", "Bank Transaction"); + } + + host_is_live() { + const parent = this.frm.fields_dict?.filter_area?.$wrapper; + if (!parent?.length || !this.$stats_el?.length) { + return false; + } + return $.contains(parent[0], this.$stats_el[0]); + } + + get_user_filters() { + if (this.filter_group) { + try { + const from_ui = this.filter_group.get_filters() || []; + if (from_ui.length) { + return from_ui; + } + } catch (e) { + // fall through to saved filters + } + } + try { + const parsed = JSON.parse(this.frm.doc.filters || "[]"); + return Array.isArray(parsed) ? parsed : []; + } catch (e) { + return []; + } + } + + schedule_fetch() { + if (!this._debounced_fetch) { + this._debounced_fetch = frappe.utils.debounce( + () => this.fetch_and_show(), + 400 + ); + } + this._debounced_fetch(); + } + + async fetch_and_show() { + const $el = this.$stats_el; + if (!$el?.length || !this.host_is_live()) { + return; + } + + if (!this.frm.doc.bank_account) { + $el.html( + `

${__( + "Set a Bank Account to see match counts." + )}

` + ); + return; + } + + const user_filters = this.get_user_filters(); + if (!user_filters.length) { + $el.html( + `

${__( + "Add at least one filter to see match counts." + )}

` + ); + return; + } + + const request_id = ++this.request_id; + if (!$el.text().trim()) { + $el.html(`

${__("Updating...")}

`); + } + + const filters_str = JSON.stringify(user_filters); + const method = + "banking.klarna_kosma_integration.doctype.bank_reconciliation_rule.bank_reconciliation_rule.get_bank_transaction_match_stats"; + + let data; + try { + const res = await frappe.call({ + method, + args: { + bank_account: this.frm.doc.bank_account, + filters: filters_str, + ...(!this.frm.is_new() && this.frm.doc.name + ? { bank_reconciliation_rule: this.frm.doc.name } + : {}), + }, + }); + data = res.message; + } catch (e) { + if (request_id === this.request_id) { + $el.html( + `

${__( + "Could not load match counts." + )}

` + ); + } + return; + } + + if (request_id !== this.request_id) { + return; + } + + const format_count = (n) => { + const bound = data.count_upper_bound; + if (bound && n === bound) { + return `${bound - 1}+`; + } + return String(n); + }; + + const title = __("Submitted Bank Transactions matching this rule:"); + const counts = __( + "{0} in the last 30 days, {1} in the last 12 months (365 days).", + [format_count(data.last_30_days), format_count(data.last_12_months)] + ); + + $el.html( + `

${frappe.utils.escape_html( + title + )} ${frappe.utils.escape_html(counts)}

` + ); + } + + mount(parent) { + this.rendered_readonly = this.frm.doc.docstatus !== 0; + this.$stats_el = $('
'); + parent.append(this.$stats_el); + this.fetch_and_show(); + } +}; diff --git a/banking/public/js/rule_creation_dialog.bundle.js b/banking/public/js/rule_creation_dialog.bundle.js new file mode 100644 index 00000000..fdee0504 --- /dev/null +++ b/banking/public/js/rule_creation_dialog.bundle.js @@ -0,0 +1,134 @@ +// Copyright (c) 2026, ALYF GmbH and contributors +// For license information, please see license.txt + +frappe.provide("banking.bank_reconciliation"); + +const CANDIDATE_FIELDS = [ + "deposit", + "withdrawal", + "currency", + "description", + "reference_number", + "included_fee", + "excluded_fee", + "subtransaction_id", + "transaction_type", + "party_type", + "party", + "bank_party_name", + "bank_party_account_number", + "bank_party_iban", +]; + +function field_has_value(df, value) { + if (value === undefined || value === null) { + return false; + } + const ft = df.fieldtype; + if (frappe.model.numeric_fieldtypes.includes(ft)) { + return flt(value) !== 0; + } + if (ft === "Link" || ft === "Dynamic Link") { + return Boolean(String(value).trim()); + } + return String(value).trim() !== ""; +} + +function get_visible_field_rows(doc) { + const rows = []; + for (const fieldname of CANDIDATE_FIELDS) { + const df = frappe.meta.get_docfield("Bank Transaction", fieldname); + if (!df) { + continue; + } + const value = doc[fieldname]; + if (!field_has_value(df, value)) { + continue; + } + rows.push({ fieldname, df, value }); + } + return rows; +} + +function format_cell(df, value, doc) { + const formatted = frappe.format(value, df, { only_value: true }, doc); + return frappe.utils.escape_html(String(formatted ?? "")); +} + +banking.bank_reconciliation.show_rule_creation_dialog = function (doc) { + if (!doc.bank_account) { + frappe.throw(__("Set a Bank Account first.")); + } + if (!frappe.model.can_create("Bank Reconciliation Rule")) { + frappe.throw( + __("You do not have permission to create a Bank Reconciliation Rule.") + ); + } + + const field_rows = get_visible_field_rows(doc); + if (!field_rows.length) { + frappe.msgprint( + __("No filterable field values are set on this Bank Transaction.") + ); + return; + } + + const table_rows = field_rows + .map((row) => { + const label = frappe.utils.escape_html(__(row.df.label) || row.fieldname); + const display = format_cell(row.df, row.value, doc); + return ` + + + + ${label} + ${display} + `; + }) + .join(""); + + const html = `
+ + + + + + + + + ${table_rows} +
${frappe.utils.escape_html(__("Use"))}${frappe.utils.escape_html(__("Field"))}${frappe.utils.escape_html(__("Value"))}
+
`; + + const dialog = new frappe.ui.Dialog({ + title: __("Create Bank Reconciliation Rule"), + fields: [{ fieldname: "picker_html", fieldtype: "HTML", label: "" }], + primary_action_label: __("Create Rule"), + primary_action: () => { + const selected = []; + dialog.$wrapper + .find(".rule-creation-filter-cb:checked") + .each(function () { + const fieldname = $(this).attr("data-fieldname"); + if (fieldname) { + selected.push(["Bank Transaction", fieldname, "=", doc[fieldname]]); + } + }); + if (!selected.length) { + frappe.msgprint(__("Select at least one field to use as a filter.")); + return; + } + dialog.hide(); + frappe.route_options = { + bank_account: doc.bank_account, + filters: JSON.stringify(selected), + }; + frappe.set_route("Form", "Bank Reconciliation Rule", "new"); + }, + }); + + dialog.fields_dict.picker_html.$wrapper.html(html); + dialog.show(); +};