feat(bank-statement-importer): cascade delete with confirmation

Stock delete on Bank Statement Importer hits Frappe's link-check
(registry rows + Bank Account dynamic-link) and refuses to proceed,
leaving users unable to remove an importer they no longer need without
hand-cleaning every dependent row.

New flow:
- cascade_delete.py adds two whitelisted endpoints: get_dependents
  (counts) and cascade_delete (unlink Bank Accounts → delete Customer/
  Supplier/Purpose registry rows → delete the importer)
- bank_statement_importer.js overrides the Menu → Delete action: shows
  a confirm dialog listing exactly how many records will be deleted vs
  just unlinked, with a red primary button to drive the choice home.
  After success the user is sent to the list view.

Bank Accounts are intentionally unlinked rather than deleted — they hold
business data (Bank Transactions, reconciliations) that has nothing to
do with the importer being removed.
This commit is contained in:
Ali 2026-05-18 13:50:03 +00:00
parent 5bff95501f
commit 31d93fb887
2 changed files with 201 additions and 0 deletions

View File

@ -0,0 +1,86 @@
"""Cascade-delete endpoint for Bank Statement Importer.
By default Frappe refuses to delete a Bank Statement Importer that still has
dependent registry rows (Customer/Supplier/Purpose) or Bank Accounts pointing
at it via the bank_integration Dynamic Link. This module exposes:
- `get_dependents(name)` preview counts so the UI can warn the user
- `cascade_delete(name)` unlinks Bank Accounts and deletes registry rows,
then deletes the importer itself. Caller is expected to have shown a
confirm dialog with the counts from get_dependents.
"""
import frappe
from frappe import _
DOCTYPE = "Bank Statement Importer"
_REGISTRY_DOCTYPES = (
("Bank Integration Customer", "Customers"),
("Bank Integration Supplier", "Suppliers"),
("Bank Integration Purpose", "Purposes"),
)
@frappe.whitelist()
def get_dependents(name):
"""Return counts of records that would be touched by a cascade delete."""
if not name or not frappe.db.exists(DOCTYPE, name):
frappe.throw(_("Bank Statement Importer '{0}' not found").format(name))
counts = {}
for dt, _label in _REGISTRY_DOCTYPES:
counts[dt] = frappe.db.count(dt, {"parent_bank_integration": name})
counts["Bank Account"] = frappe.db.count("Bank Account", {
"bank_integration_type": DOCTYPE,
"bank_integration": name,
})
return counts
@frappe.whitelist()
def cascade_delete(name):
"""Delete a Bank Statement Importer and everything that depends on it.
Steps (executed in this order so Frappe's link-check doesn't block the
final delete_doc on the importer):
1. Unlink Bank Accounts: clear bank_integration_type / bank_integration
2. Delete registry rows (Customer / Supplier / Purpose) by parent_bank_integration
3. Delete the importer itself (its mapping child tables go with it)
"""
if not name or not frappe.db.exists(DOCTYPE, name):
frappe.throw(_("Bank Statement Importer '{0}' not found").format(name))
frappe.only_for(("System Manager", "Accounts Manager"))
# 1. Unlink Bank Accounts
unlinked_accounts = 0
ba_names = frappe.get_all("Bank Account", filters={
"bank_integration_type": DOCTYPE,
"bank_integration": name,
}, pluck="name")
for ba in ba_names:
frappe.db.set_value("Bank Account", ba, {
"bank_integration_type": None,
"bank_integration": None,
}, update_modified=False)
unlinked_accounts += 1
# 2. Delete registries
deleted = {}
for dt, _label in _REGISTRY_DOCTYPES:
rows = frappe.get_all(dt, filters={"parent_bank_integration": name}, pluck="name")
for r in rows:
frappe.delete_doc(dt, r, ignore_permissions=True, force=True, delete_permanently=True)
deleted[dt] = len(rows)
# 3. Delete the importer (child mapping tables cascade automatically)
frappe.delete_doc(DOCTYPE, name, ignore_permissions=True, force=True, delete_permanently=True)
frappe.db.commit()
return {
"success": True,
"deleted": deleted,
"unlinked_bank_accounts": unlinked_accounts,
}

View File

@ -20,6 +20,11 @@ frappe.ui.form.on('Bank Statement Importer', {
if (frm.is_new()) return;
// Replace the standard Menu → Delete with a cascading version. The
// stock action would just error out on the link-check (registry rows
// + Bank Account dynamic-link) and leave the user stuck.
BSICascadeDelete.installMenuOverride(frm);
// === Load Data (file-based, registry-only) ===
frm.add_custom_button(__('Load Data'), () => {
_show_load_data_dialog(frm);
@ -629,3 +634,113 @@ function _bi_list_header(label, total, refresh_id) {
<i class="octicon octicon-sync"></i> ${__('Refresh')}</button></div>
</div>`;
}
// ═══════════════════════════════════════════════════════════════════════════════
// CASCADE DELETE — replace Menu → Delete with a confirm dialog that explains
// what registry rows and Bank Account links will be cleaned up along with
// this importer.
// ═══════════════════════════════════════════════════════════════════════════════
const BSICascadeDelete = {
installMenuOverride(frm) {
// frappe.PageMenu renders the items right after refresh; defer slightly.
setTimeout(() => this._wireMenuItem(frm), 50);
},
_wireMenuItem(frm) {
// Each Standard menu item is an <a class="dropdown-item"> in the
// page menu. We replace the "Delete" one's click handler.
const $menu = frm.page.menu;
if (!$menu || !$menu.length) return;
const $delete = $menu.find('a.grey-link').filter(function () {
const label = ($(this).find('.menu-item-label').text() || $(this).text() || '').trim();
return label === __('Delete');
});
if (!$delete.length) return;
if ($delete.data('bsiCascadeWired')) return;
$delete.data('bsiCascadeWired', true);
$delete.off('click.bsiCascade').on('click.bsiCascade', (e) => {
e.preventDefault();
e.stopImmediatePropagation();
this.run(frm);
});
},
run(frm) {
if (frm.is_new()) return;
frappe.call({
method: 'jey_erp.bank_integration.cascade_delete.get_dependents',
args: { name: frm.doc.name },
callback: (r) => {
if (!r.message) return;
this._showConfirm(frm, r.message);
},
});
},
_showConfirm(frm, counts) {
const items = [
[__('Customers (registry)'), counts['Bank Integration Customer'] || 0],
[__('Suppliers (registry)'), counts['Bank Integration Supplier'] || 0],
[__('Purposes (registry)'), counts['Bank Integration Purpose'] || 0],
[__('Bank Accounts (will be unlinked, not deleted)'), counts['Bank Account'] || 0],
];
const total = items.reduce((s, [, n]) => s + n, 0);
const rows = items
.filter(([, n]) => n > 0)
.map(([label, n]) => `<li>${frappe.utils.escape_html(label)}: <b>${n}</b></li>`)
.join('');
const body = total === 0
? `<p>${__('No dependent records — deleting this importer is safe.')}</p>`
: `<p>${__('Deleting <b>{0}</b> will also affect:', [frappe.utils.escape_html(frm.doc.name)])}</p>
<ul style="margin-left:18px;">${rows}</ul>
<p>${__('Registry rows will be <b>deleted</b>. Bank Accounts will only have their Bank Statement Importer link cleared — the accounts themselves stay.')}</p>
<p class="text-danger"><b>${__('This cannot be undone.')}</b></p>`;
const d = new frappe.ui.Dialog({
title: __('Delete Bank Statement Importer?'),
fields: [{ fieldname: 'body', fieldtype: 'HTML', options: body }],
primary_action_label: __('Delete Everything'),
primary_action: () => {
d.disable_primary_action();
this._doDelete(frm.doc.name).then(() => {
d.hide();
frappe.set_route('List', 'Bank Statement Importer');
}).catch(() => {
d.enable_primary_action();
});
},
secondary_action_label: __('Cancel'),
secondary_action: () => d.hide(),
});
// Primary button red since this is destructive.
d.show();
d.get_primary_btn().removeClass('btn-primary').addClass('btn-danger');
},
_doDelete(name) {
return new Promise((resolve, reject) => {
frappe.call({
method: 'jey_erp.bank_integration.cascade_delete.cascade_delete',
args: { name },
freeze: true,
freeze_message: __('Deleting…'),
callback: (r) => {
if (!r.message || r.message.success === false) {
frappe.msgprint({
title: __('Delete Failed'),
indicator: 'red',
message: (r.message && r.message.message) || __('Unknown error'),
});
reject();
return;
}
frappe.show_alert({ message: __('Deleted.'), indicator: 'green' }, 4);
resolve();
},
error: reject,
});
});
},
};