Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@
},
{
"description": "If multiple rules match a <b>Bank Transaction</b>, only the one with the highest priority is executed.",
"allow_on_submit": 1,
"fieldname": "priority",
"fieldtype": "Int",
"in_list_view": 1,
Expand All @@ -95,7 +96,7 @@
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],
"modified": "2026-03-28 02:28:06.918430",
"modified": "2026-05-17 18:58:20.426374",
"modified_by": "Administrator",
"module": "Klarna Kosma Integration",
"name": "Bank Reconciliation Rule",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@
# For license information, please see license.txt

import json
from typing import cast

import frappe
from frappe import _
from frappe.exceptions import ValidationError
from frappe.model.document import Document
from frappe.utils import cint

from banking.exceptions import CurrencyMismatchError

Expand Down Expand Up @@ -49,3 +51,90 @@ def validate_account_currencies(self):
def validate_filters(self):
if not self.filters or not json.loads(self.filters):
frappe.throw(_("Please define at least one filter!"), NoFiltersError)


@frappe.whitelist()
def get_bank_accounts_with_rules() -> list[str]:
"""Bank accounts that have at least one non-cancelled reconciliation rule."""
if not frappe.has_permission("Bank Reconciliation Rule", "read"):
frappe.throw(_("Not permitted to read the bank reconciliation rules"), frappe.PermissionError)

return frappe.get_all(
"Bank Reconciliation Rule",
filters={"docstatus": ("!=", 2)},
pluck="bank_account",
group_by="bank_account",
order_by="bank_account asc",
)
Comment on lines +57 to +68

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
def get_bank_accounts_with_rules() -> list[str]:
"""Bank accounts that have at least one non-cancelled reconciliation rule."""
if not frappe.has_permission("Bank Reconciliation Rule", "read"):
frappe.throw(_("Not permitted to read the bank reconciliation rules"), frappe.PermissionError)
return frappe.get_all(
"Bank Reconciliation Rule",
filters={"docstatus": ("!=", 2)},
pluck="bank_account",
group_by="bank_account",
order_by="bank_account asc",
)
def get_bank_accounts_with_rules() -> list[str]:
"""Bank accounts that have at least one non-cancelled reconciliation rule."""
return frappe.get_list(
"Bank Reconciliation Rule",
filters={"docstatus": ("!=", 2)},
pluck="bank_account",
group_by="bank_account",
order_by="bank_account asc",
)

Any particular reason you chose this permission structure? get_list seems more suitable at first glance.

-> Applies to the other whitelisted methods as well.



@frappe.whitelist()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
@frappe.whitelist()
@frappe.whitelist(methods=["GET"])
@frappe.read_only()

Security: whitelisted methods should specify allowed HTTP verbs (GET for read-requests, POST for write requests).
Performance: read_only methods can read from a database replica, if configured.

-> Applies to the other whitelisted methods as well.

def get_rules_for_reorder(bank_account: str | None = None) -> list[dict]:
"""Load non-cancelled rules for a bank account (list order = current evaluation order)."""
if not bank_account:
frappe.throw(_("Please set a Bank Account"))
if not frappe.has_permission("Bank Reconciliation Rule", "read"):
frappe.throw(_("Not permitted to read the bank reconciliation rules"), frappe.PermissionError)

rules = frappe.get_all(
"Bank Reconciliation Rule",
filters={"bank_account": bank_account, "docstatus": ("!=", 2)},
fields=["name", "target_account", "priority", "docstatus", "disabled"],
order_by="priority desc, creation asc",
)
return list(rules)


@frappe.whitelist()
def reorder_bank_reconciliation_rule_priorities(
bank_account: str | None = None, ordered_names: str | list | None = None

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
bank_account: str | None = None, ordered_names: str | list | None = None
bank_account: str, ordered_names: str | list

Why are these parameters advertised as optional in the signature but required in the function body?

) -> None:
"""
Persist **priority** from a full drag-and-drop order: first row = highest priority
(must match `order_by` in **Bank Transaction** automatic rules: `priority desc`).
Values are **10, 20, 30, …** so users can set intermediate priorities (e.g. 15) without
running the dialog again.
"""
if not bank_account:
frappe.throw(_("Please set a Bank Account"))
if not frappe.has_permission("Bank Reconciliation Rule", "write"):
frappe.throw(_("Not permitted to edit the bank reconciliation rules"), frappe.PermissionError)

raw = frappe.parse_json(ordered_names) if isinstance(ordered_names, str) else ordered_names
if not raw:
frappe.throw(_("Please add at least one rule to the list"))
if not isinstance(raw, list | tuple) or not all(isinstance(n, str) for n in raw):
frappe.throw(_("Invalid rule order"))
names = cast("list[str] | tuple[str, ...]", raw)
ordered: list[str] = [str(n) for n in names]
if len(ordered) != len(set(ordered)):
frappe.throw(_("Each rule may only appear once"))

expected = set(
frappe.get_all(
"Bank Reconciliation Rule",
filters={"bank_account": bank_account, "docstatus": ("!=", 2)},
pluck="name",
)
)
if set(ordered) != expected:
frappe.throw(
_("The order must include every rule for this bank account. Reload the dialog and try again.")
)

n = len(ordered)
for name in ordered:
if not frappe.has_permission("Bank Reconciliation Rule", "write", name):
frappe.throw(_("Not permitted to update {0}.").format(name), frappe.PermissionError)
Comment thread
greptile-apps[bot] marked this conversation as resolved.

# Stagger so we do not hold duplicate priorities while renumbering submitted rules
for i, name in enumerate(ordered):
frappe.db.set_value(
"Bank Reconciliation Rule", name, "priority", 1_000_000 + i, update_modified=False
)

# 10, 20, 30, … so manually inserted rules can use intermediate priorities without renumbering
step = 10
for idx, name in enumerate(ordered):
priority = cint(n - idx) * step
frappe.db.set_value("Bank Reconciliation Rule", name, "priority", priority)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider moving the reorder popup into a dedicated JavaScript bundle that's only loaded and initialized here. Because bundles are built, you can then keep html and css in separate (template) files and import them. That makes the whole code a lot cleaner.

Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// For license information, please see license.txt

frappe.listview_settings["Bank Reconciliation Rule"] = {
add_fields: ["docstatus", "disabled"],
add_fields: ["docstatus", "disabled", "priority"],
has_indicator_for_draft: true,

get_indicator: function (doc) {
Expand All @@ -20,4 +20,224 @@ frappe.listview_settings["Bank Reconciliation Rule"] = {
return [__("Draft"), "blue", "docstatus,=,0"];
}
},

onload: function (listview) {
if (!frappe.model.can_write("Bank Reconciliation Rule")) {
return;
}
listview.page.add_inner_button(__("Reorder by Priority"), () =>
banking_brr_open_reorder_dialog(listview)
);
},
};

function banking_brr_open_reorder_dialog(listview) {
frappe.call({
method:
"banking.klarna_kosma_integration.doctype.bank_reconciliation_rule.bank_reconciliation_rule.get_bank_accounts_with_rules",
callback(r) {
const accounts = r.message || [];
if (!accounts.length) {
frappe.msgprint({
title: __("Reorder by Priority"),
message: __("No bank accounts with reconciliation rules."),
indicator: "orange",
});
return;
}
banking_brr_show_reorder_dialog(listview, accounts);
},
});
}

function banking_brr_show_reorder_dialog(listview, bank_accounts_with_rules) {
let sortable = null;

const dialog = new frappe.ui.Dialog({
title: __("Reorder bank reconciliation rules"),
fields: [
{
fieldname: "info",
fieldtype: "HTML",
options: `<p class="text-muted small">${__(
"The first row is handled first when several rules match. Drag to change the order (priority)."
)}</p>`,
},
{
fieldname: "bank_account",
fieldtype: "Link",
label: __("Bank Account"),
options: "Bank Account",
reqd: 1,
},
{
fieldname: "rules_list_html",
fieldtype: "HTML",
},
],
primary_action_label: __("Save order"),
primary_action: function () {
const account = dialog.get_value("bank_account");
if (!account) {
return;
}
const ul = dialog.$wrapper.find(".brr-reorder__list").get(0);
if (!ul) {
frappe.show_alert({
indicator: "orange",
message: __("No rules to save."),
});
return;
}
const ordered = Array.from(ul.querySelectorAll("li[data-name]")).map(
(li) => li.getAttribute("data-name")
);
if (!ordered.length) {
frappe.show_alert({
indicator: "orange",
message: __("No rules to save."),
});
return;
}
frappe.call({
type: "POST",
freeze: true,
freeze_message: __("Saving..."),
method:
"banking.klarna_kosma_integration.doctype.bank_reconciliation_rule.bank_reconciliation_rule.reorder_bank_reconciliation_rule_priorities",
args: {
bank_account: account,
ordered_names: ordered,
},
callback: (r) => {
if (!r.exc) {
banking_brr_reset_sortable();
dialog.hide();
listview.refresh();
}
},
});
},
});

function banking_brr_reset_sortable() {
if (sortable) {
try {
sortable.destroy();
} catch (e) {
// ignore
}
}
sortable = null;
}

function banking_brr_render(rules) {
const $wrap = dialog.fields_dict.rules_list_html.$wrapper;
$wrap.empty();
if (!rules || !rules.length) {
$wrap.html(
`<p class="text-muted">${__("No rules for this bank account.")}</p>`
);
return;
}
const items = rules
.map((r) => {
const name = frappe.utils.escape_html(r.name);
const ta = r.target_account
? " — " + frappe.utils.escape_html(r.target_account)
: "";
const p = Number(r.priority) || 0;
const st =
Number(r.docstatus) === 0
? "draft"
: Number(r.disabled)
? "disabled"
: "submitted";
return `<li class="brr-reorder__item" data-name="${name}" data-priority-st="${st}">
<span class="brr-reorder__handle">${frappe.utils.icon(
"drag",
"xs",
"",
"",
"sortable-handle"
)}</span>
<span class="brr-reorder__label"><strong>${name}</strong>${ta}</span>
<span class="text-muted small brr-reorder__prio">P ${p}</span>
</li>`;
})
.join("");

$wrap.html(
`<ul class="brr-reorder__list unstyled list-unstyled">${items}</ul><style>
.brr-reorder-dialog .brr-reorder__item { display: flex; align-items: center; gap: 8px; padding: 6px 8px; border: 1px solid var(--border-color, #d1d8dd); border-radius: var(--border-radius, 4px); margin-bottom: 4px; background: var(--control-bg, #fff); font-size: var(--text-md); }
.brr-reorder__handle { cursor: grab; color: var(--text-muted); }
.brr-reorder__label { flex: 1; }
.brr-reorder__item[data-priority-st="disabled"] { opacity: 0.7; }
</style>`
);
}

function banking_brr_init_sortable() {
if (typeof Sortable === "undefined") {
frappe.msgprint(
__(
"Sortable is not available. Please open the dialog from the Bank Reconciliation Rule list and try again."
)
);
return;
}
const el = dialog.$wrapper.find(".brr-reorder__list").get(0);
if (!el) {
return;
}
banking_brr_reset_sortable();
sortable = new Sortable(el, {
handle: ".sortable-handle",
animation: 150,
});
}

function banking_brr_load_rules() {
const account = dialog.get_value("bank_account");
if (!account) {
return;
}
banking_brr_reset_sortable();
dialog.fields_dict.rules_list_html.$wrapper.html(
`<p class="text-muted">${__("Loading...")}</p>`
);
frappe.call({
type: "GET",
method:
"banking.klarna_kosma_integration.doctype.bank_reconciliation_rule.bank_reconciliation_rule.get_rules_for_reorder",
args: { bank_account: account },
callback: function (r) {
if (r.exc) {
dialog.fields_dict.rules_list_html.$wrapper.html(
`<p class="text-danger">${__(
"Failed to load rules. Please try again."
)}</p>`
);
return;
}
if (r.message) {
banking_brr_render(r.message);
banking_brr_init_sortable();
}
},
Comment thread
greptile-apps[bot] marked this conversation as resolved.
});
}

dialog.$wrapper.addClass("brr-reorder-dialog");
dialog.fields_dict.bank_account.get_query = () => ({
filters: [["Bank Account", "name", "in", bank_accounts_with_rules]],
});
dialog.show();
const $ba = dialog.get_field("bank_account").$input;
$ba.on("change", () => banking_brr_load_rules());
$ba.on("awesomplete-selectcomplete", () => banking_brr_load_rules());
if (bank_accounts_with_rules.length === 1) {
dialog.set_value("bank_account", bank_accounts_with_rules[0]);
banking_brr_load_rules();
}
}
Loading
Loading