diff --git a/kapital_bank/client/bank_reconciliation_tool.js b/kapital_bank/client/bank_reconciliation_tool.js
new file mode 100644
index 0000000..dda7a40
--- /dev/null
+++ b/kapital_bank/client/bank_reconciliation_tool.js
@@ -0,0 +1,274 @@
+// Kapital Bank — Bank Reconciliation Tool integration
+// Adds checkbox column + "Create Purpose Mapping" toolbar
+
+frappe.ui.form.on("Bank Reconciliation Tool", {
+ render: function (frm) {
+ // DTM is created synchronously but its datatable is built after an async API call.
+ // Poll until datatable is ready, then enhance once.
+ if (frm._kb_check_interval) clearInterval(frm._kb_check_interval);
+
+ frm._kb_check_interval = setInterval(function () {
+ const dtm = frm.bank_reconciliation_data_table_manager;
+ if (dtm && dtm.datatable && !dtm._kb_enhanced) {
+ clearInterval(frm._kb_check_interval);
+ KBBRTMapping.enhance(frm, dtm);
+ }
+ }, 200);
+
+ setTimeout(function () { clearInterval(frm._kb_check_interval); }, 15000);
+ },
+});
+
+const KBBRTMapping = {
+ enhance: function (frm, dtm) {
+ dtm._kb_enhanced = true;
+
+ // Rebuild DataTable with checkboxColumn: true
+ dtm.datatable.destroy();
+ dtm.datatable = new frappe.DataTable(dtm.$reconciliation_tool_dt.get(0), {
+ columns: dtm.columns,
+ data: dtm.transactions,
+ dynamicRowHeight: true,
+ checkboxColumn: true,
+ inlineFilters: true,
+ });
+ $(`.${dtm.datatable.style.scopeClass} .dt-scrollable`).css("max-height", "calc(100vh - 400px)");
+
+ // Restore ERPNext's Action-button listeners for the new datatable
+ dtm.set_listeners();
+
+ dtm._kb_scopeClass = dtm.datatable.style.scopeClass;
+
+ KBBRTMapping.injectToolbar(dtm);
+ },
+
+ injectToolbar: function (dtm) {
+ $("#kb-brt-mapping-toolbar").remove();
+
+ const $toolbar = $(`
+
").html(row[2] || "").text().trim();
+ const withdrawal = parseFloat(row[5]) || 0;
+ txns.push({
+ purpose: description,
+ drcr: withdrawal > 0 ? "D" : "C",
+ party_type: partyType,
+ party: partyName,
+ });
+ });
+
+ // Deduplicate for preview
+ const purposeMap = new Map();
+ txns.forEach(function (txn) {
+ const key = txn.purpose + "|" + txn.drcr;
+ if (purposeMap.has(key)) {
+ purposeMap.get(key).count++;
+ } else {
+ purposeMap.set(key, {
+ purpose: txn.purpose,
+ payment_type: txn.drcr === "D" ? "Pay" : "Receive",
+ party: txn.party,
+ count: 1,
+ });
+ }
+ });
+
+ if (purposeMap.size === 0) {
+ frappe.msgprint({ title: __("No Purposes"), indicator: "orange",
+ message: __("All selected rows have no description text.") });
+ return;
+ }
+
+ // Preview table
+ let previewHtml = '
' +
+ '
' +
+ "" +
+ "| " + __("Description") + " | " +
+ '' + __("Type") + " | " +
+ '' + __("Party") + " | " +
+ '' + __("Rows") + " | " +
+ "
";
+ purposeMap.forEach(function (e) {
+ const badge = e.payment_type === "Pay"
+ ? 'Pay'
+ : 'Receive';
+ previewHtml += "" +
+ '| ' + frappe.utils.escape_html(e.purpose) + " | " +
+ "" + badge + " | " +
+ "" + (e.party ? frappe.utils.escape_html(e.party) : '—') + " | " +
+ '' + e.count + " |
";
+ });
+ previewHtml += "
";
+ if (skippedCount > 0) {
+ previewHtml += '
' +
+ __("{0} row(s) skipped — no description text.", [skippedCount]) + "
";
+ }
+
+ const d = new frappe.ui.Dialog({
+ title: __("Create Purpose Mappings") + " (" + purposeMap.size + ")",
+ size: "large",
+ fields: [
+ { fieldname: "preview_html", fieldtype: "HTML", options: previewHtml },
+ { fieldname: "sec_options", fieldtype: "Section Break", label: __("Options") },
+ {
+ fieldname: "document_type",
+ fieldtype: "Select",
+ label: __("Document Type"),
+ options: "Payment Entry\nJournal Entry",
+ default: "Payment Entry",
+ reqd: 1,
+ },
+ {
+ fieldname: "use_custom_purpose",
+ fieldtype: "Check",
+ label: __("Use custom purpose keyword"),
+ onchange: function () {
+ const on = d.get_value("use_custom_purpose");
+ d.set_df_property("custom_purpose", "hidden", on ? 0 : 1);
+ d.set_df_property("custom_purpose", "reqd", on ? 1 : 0);
+ d.get_field("custom_purpose").refresh();
+ },
+ },
+ {
+ fieldname: "custom_purpose",
+ fieldtype: "Data",
+ label: __("Custom Purpose Keyword"),
+ hidden: 1,
+ description: __("All selected rows will be mapped with this purpose instead of their own description"),
+ },
+ { fieldname: "sec_accounts", fieldtype: "Section Break", label: __("GL Accounts") },
+ {
+ fieldname: "paid_from", fieldtype: "Link", options: "Account",
+ label: __("Paid From (Account)"), reqd: 1,
+ },
+ { fieldname: "col_break", fieldtype: "Column Break" },
+ {
+ fieldname: "paid_to", fieldtype: "Link", options: "Account",
+ label: __("Paid To (Account)"), reqd: 1,
+ },
+ ],
+ primary_action_label: __("Create Mappings"),
+ primary_action: function () {
+ const values = d.get_values();
+ if (!values) return;
+
+ let finalTxns = txns;
+ if (values.use_custom_purpose && (values.custom_purpose || "").trim()) {
+ const cp = values.custom_purpose.trim();
+ const seen = new Set();
+ finalTxns = [];
+ txns.forEach(function (t) {
+ const key = cp + "|" + t.drcr;
+ if (!seen.has(key)) {
+ seen.add(key);
+ finalTxns.push(Object.assign({}, t, { purpose: cp }));
+ }
+ });
+ }
+
+ KBBRTMapping.submitMappings(d, finalTxns, values.paid_from, values.paid_to, values.document_type);
+ },
+ });
+
+ d.show();
+
+ // Pre-fill custom purpose from the Description inline filter if active
+ const $fi = $(`.${dtm._kb_scopeClass} .dt-row-filter input.dt-filter[data-name="Description"]`);
+ const filterVal = ($fi.val() || "").trim();
+ if (filterVal) {
+ d.set_df_property("custom_purpose", "hidden", 0);
+ d.set_df_property("custom_purpose", "reqd", 1);
+ d.set_value("use_custom_purpose", 1);
+ d.set_value("custom_purpose", filterVal);
+ d.get_field("custom_purpose").refresh();
+ }
+ },
+
+ submitMappings: function (dialog, txns, paid_from, paid_to, document_type) {
+ dialog.disable_primary_action();
+ frappe.call({
+ method: "kapital_bank.mapping.create_purpose_mappings",
+ args: {
+ transactions: JSON.stringify(txns),
+ paid_from: paid_from,
+ paid_to: paid_to,
+ document_type: document_type || "Payment Entry",
+ },
+ callback: function (r) {
+ dialog.enable_primary_action();
+ if (!r.message || !r.message.success) {
+ frappe.msgprint({ title: __("Error"), indicator: "red",
+ message: r.message ? r.message.message : __("Unknown error") });
+ return;
+ }
+ dialog.hide();
+ const res = r.message;
+ const parts = [];
+ if (res.created) parts.push(__("Created: {0}", [res.created]));
+ if (res.already_mapped) parts.push(__("Already mapped: {0}", [res.already_mapped]));
+ if (res.skipped_no_data) parts.push(__("Skipped (no purpose): {0}", [res.skipped_no_data]));
+ frappe.show_alert({
+ message: parts.join(" • ") || __("No changes"),
+ indicator: res.created > 0 ? "green" : "blue",
+ }, 5);
+ },
+ error: function () {
+ dialog.enable_primary_action();
+ frappe.msgprint({ title: __("Error"), indicator: "red",
+ message: __("Network error creating mappings") });
+ },
+ });
+ },
+};
diff --git a/kapital_bank/client/mapping.js b/kapital_bank/client/mapping.js
new file mode 100644
index 0000000..65911c2
--- /dev/null
+++ b/kapital_bank/client/mapping.js
@@ -0,0 +1,196 @@
+const KBMapping = {};
+
+KBMapping.init = function(dialog, txns) {
+ const $container = dialog.$wrapper.find('.kb-bt-txn-table').closest('div');
+ if ($container.length === 0) return;
+
+ // Search input + button row
+ const $toolbar = $(`
+
+
+
+
+ `);
+ $container.prepend($toolbar);
+
+ const $searchInput = $toolbar.find('.kb-mapping-search');
+ const $mappingBtn = $toolbar.find('.kb-mapping-btn');
+
+ // Search: filter rows by purpose column (8th td, index 7)
+ $searchInput.on('input', function() {
+ const query = $(this).val().toLowerCase();
+ dialog.$wrapper.find('.kb-bt-txn-table tbody tr').each(function() {
+ const purposeText = $(this).find('td').eq(7).text().toLowerCase();
+ $(this).toggle(purposeText.indexOf(query) !== -1);
+ });
+ });
+
+ // Toggle mapping button visibility based on checked rows
+ function toggleMappingBtn() {
+ const anyChecked = dialog.$wrapper.find('.kb-bt-select-txn:checked').length > 0;
+ $mappingBtn.toggle(anyChecked);
+ }
+
+ dialog.$wrapper.on('change', '.kb-bt-select-txn, .kb-bt-select-all-txns', toggleMappingBtn);
+
+ // Button click -> show mapping dialog
+ $mappingBtn.on('click', function() {
+ KBMapping.showMappingDialog(dialog, txns);
+ });
+};
+
+KBMapping.showMappingDialog = function(parentDialog, txns) {
+ // Collect checked transactions
+ const selectedTxns = [];
+ parentDialog.$wrapper.find('.kb-bt-select-txn:checked').each(function() {
+ const idx = $(this).data('idx');
+ selectedTxns.push(txns[idx]);
+ });
+
+ if (selectedTxns.length === 0) {
+ frappe.msgprint({ title: __('Warning'), indicator: 'orange', message: __('No transactions selected') });
+ return;
+ }
+
+ // Build unique purposes: key = "purpose|payment_type"
+ const purposeMap = new Map();
+ let skippedCount = 0;
+
+ selectedTxns.forEach(function(txn) {
+ const purpose = (txn.purpose || '').trim();
+ if (!purpose) {
+ skippedCount++;
+ return;
+ }
+ const paymentType = txn.drcr === 'D' ? 'Pay' : 'Receive';
+ const key = purpose + '|' + paymentType;
+ if (purposeMap.has(key)) {
+ purposeMap.get(key).count++;
+ } else {
+ purposeMap.set(key, { purpose: purpose, payment_type: paymentType, count: 1 });
+ }
+ });
+
+ if (purposeMap.size === 0 && skippedCount > 0) {
+ frappe.msgprint({
+ title: __('No Purposes'),
+ indicator: 'orange',
+ message: __('All selected transactions have no purpose text.'),
+ });
+ return;
+ }
+
+ // Preview table
+ let previewHtml = '
';
+ previewHtml += '
';
+ previewHtml += '' +
+ '| ' + __('Purpose') + ' | ' +
+ '' + __('Type') + ' | ' +
+ '' + __('Txns') + ' | ' +
+ '
';
+
+ purposeMap.forEach(function(entry) {
+ const typeLabel = entry.payment_type === 'Pay'
+ ? 'Pay'
+ : 'Receive';
+ previewHtml += '' +
+ '| ' + entry.purpose + ' | ' +
+ '' + typeLabel + ' | ' +
+ '' + entry.count + ' | ' +
+ '
';
+ });
+
+ previewHtml += '
';
+
+ if (skippedCount > 0) {
+ previewHtml += '
' +
+ __('{0} transaction(s) skipped — no purpose text.', [skippedCount]) +
+ '
';
+ }
+
+ const d = new frappe.ui.Dialog({
+ title: __('Create Purpose Mappings') + ' (' + purposeMap.size + ')',
+ size: 'large',
+ fields: [
+ {
+ fieldname: 'preview_html',
+ fieldtype: 'HTML',
+ options: previewHtml,
+ },
+ { fieldname: 'sec_accounts', fieldtype: 'Section Break', label: __('GL Accounts') },
+ {
+ fieldname: 'paid_from',
+ fieldtype: 'Link',
+ options: 'Account',
+ label: __('Paid From (Account)'),
+ reqd: 1,
+ },
+ { fieldname: 'col_break', fieldtype: 'Column Break' },
+ {
+ fieldname: 'paid_to',
+ fieldtype: 'Link',
+ options: 'Account',
+ label: __('Paid To (Account)'),
+ reqd: 1,
+ },
+ ],
+ primary_action_label: __('Create Mappings'),
+ primary_action: function() {
+ const values = d.get_values();
+ if (!values) return;
+ KBMapping._submitMappings(d, selectedTxns, values.paid_from, values.paid_to);
+ },
+ });
+
+ d.show();
+};
+
+KBMapping._submitMappings = function(dialog, txns, paid_from, paid_to) {
+ dialog.disable_primary_action();
+
+ frappe.call({
+ method: 'kapital_bank.mapping.create_purpose_mappings',
+ args: {
+ transactions: JSON.stringify(txns),
+ paid_from: paid_from,
+ paid_to: paid_to,
+ },
+ callback: function(r) {
+ dialog.enable_primary_action();
+
+ if (!r.message || !r.message.success) {
+ frappe.msgprint({
+ title: __('Error'),
+ indicator: 'red',
+ message: r.message ? r.message.message : __('Unknown error'),
+ });
+ return;
+ }
+
+ dialog.hide();
+
+ const res = r.message;
+ let parts = [];
+ if (res.created) parts.push(__('Created: {0}', [res.created]));
+ if (res.already_mapped) parts.push(__('Already mapped: {0}', [res.already_mapped]));
+ if (res.skipped_no_data) parts.push(__('Skipped (no purpose): {0}', [res.skipped_no_data]));
+
+ frappe.show_alert({
+ message: parts.join(' • ') || __('No changes'),
+ indicator: res.created > 0 ? 'green' : 'blue',
+ }, 5);
+ },
+ error: function() {
+ dialog.enable_primary_action();
+ frappe.msgprint({
+ title: __('Error'),
+ indicator: 'red',
+ message: __('Network error creating mappings'),
+ });
+ },
+ });
+};
diff --git a/kapital_bank/hooks.py b/kapital_bank/hooks.py
index 1b0b9ca..e0946f2 100644
--- a/kapital_bank/hooks.py
+++ b/kapital_bank/hooks.py
@@ -13,6 +13,7 @@ after_migrate = "kapital_bank.setup.after_migrate"
doctype_js = {
"Payment Entry": "client/payment_entry.js",
"Bank Transaction": "client/bank_transaction.js",
+ "Bank Reconciliation Tool": "client/bank_reconciliation_tool.js",
}
doctype_list_js = {
diff --git a/kapital_bank/kapital_bank/doctype/kapital_bank_transaction_mapping/kapital_bank_transaction_mapping.json b/kapital_bank/kapital_bank/doctype/kapital_bank_transaction_mapping/kapital_bank_transaction_mapping.json
index b14480b..d32916a 100644
--- a/kapital_bank/kapital_bank/doctype/kapital_bank_transaction_mapping/kapital_bank_transaction_mapping.json
+++ b/kapital_bank/kapital_bank/doctype/kapital_bank_transaction_mapping/kapital_bank_transaction_mapping.json
@@ -1,7 +1,8 @@
{
"actions": [],
- "creation": "2026-02-26 00:00:00.000000",
+ "creation": "2026-02-26 00:00:00",
"doctype": "DocType",
+ "editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"document_type",
@@ -43,7 +44,7 @@
"fieldtype": "Select",
"in_list_view": 1,
"label": "Counterparty Type",
- "options": "\nKapital Bank Customer\nKapital Bank Supplier"
+ "options": "\nCustomer\nSupplier"
},
{
"fieldname": "counterparty",
@@ -83,17 +84,15 @@
"label": "Notes"
}
],
- "index_web_pages_for_search": 0,
- "allow_bulk_edit": 1,
- "editable_grid": 1,
"istable": 1,
"links": [],
- "modified": "2026-03-05 00:00:00.000000",
+ "modified": "2026-03-10 17:54:47.738883",
"modified_by": "Administrator",
"module": "Kapital Bank",
"name": "Kapital Bank Transaction Mapping",
"owner": "Administrator",
"permissions": [],
+ "row_format": "Dynamic",
"sort_field": "modified",
"sort_order": "DESC",
"states": []
diff --git a/kapital_bank/mapping.py b/kapital_bank/mapping.py
new file mode 100644
index 0000000..6496619
--- /dev/null
+++ b/kapital_bank/mapping.py
@@ -0,0 +1,112 @@
+import json
+
+import frappe
+
+
+@frappe.whitelist()
+def create_purpose_mappings(transactions, paid_from, paid_to, document_type="Payment Entry"):
+ """Bulk-create transaction mappings from selected BRT transactions."""
+ try:
+ txn_list = json.loads(transactions) if isinstance(transactions, str) else transactions
+
+ settings = frappe.get_single("Kapital Bank Settings")
+ existing = {
+ (row.purpose_keyword, row.payment_type)
+ for row in settings.transaction_mappings
+ if row.purpose_keyword and row.payment_type
+ }
+
+ # Deduplicate within batch; first occurrence wins for party info
+ unique_purposes = {}
+ skipped_no_data = 0
+
+ for txn in txn_list:
+ purpose = (txn.get("purpose") or "").strip()
+ if not purpose:
+ skipped_no_data += 1
+ continue
+ payment_type = "Pay" if txn.get("drcr") == "D" else "Receive"
+ key = (purpose, payment_type)
+ if key not in unique_purposes:
+ unique_purposes[key] = {
+ "count": 0,
+ # party_type is already "Customer"/"Supplier" from ERPNext BRT
+ "counterparty_type": (txn.get("party_type") or "").strip(),
+ "counterparty": (txn.get("party") or "").strip(),
+ }
+ unique_purposes[key]["count"] += 1
+
+ created = 0
+ already_mapped = 0
+ for (purpose_text, payment_type), data in unique_purposes.items():
+ purpose_name = _ensure_purpose(purpose_text)
+
+ if (purpose_name, payment_type) in existing:
+ already_mapped += 1
+ continue
+
+ settings.append("transaction_mappings", {
+ "purpose_keyword": purpose_name,
+ "payment_type": payment_type,
+ "paid_from": paid_from,
+ "paid_to": paid_to,
+ "document_type": document_type,
+ "counterparty_type": data["counterparty_type"] or None,
+ "counterparty": data["counterparty"] or None,
+ })
+ existing.add((purpose_name, payment_type))
+ created += 1
+
+ if created > 0:
+ settings.save(ignore_permissions=True)
+ for (purpose_text, _payment_type) in unique_purposes:
+ purpose_name = frappe.db.get_value(
+ "Kapital Bank Purpose", {"purpose_keyword": purpose_text}, "name"
+ )
+ if purpose_name:
+ frappe.db.set_value(
+ "Kapital Bank Purpose", purpose_name, "status", "Mapped",
+ update_modified=False,
+ )
+ frappe.db.commit()
+
+ return {
+ "success": True,
+ "created": created,
+ "skipped_no_data": skipped_no_data,
+ "already_mapped": already_mapped,
+ }
+
+ except Exception as e:
+ frappe.log_error(
+ f"create_purpose_mappings: {e}\n{frappe.get_traceback()}",
+ "Kapital Bank Mapping",
+ )
+ return {"success": False, "message": str(e)}
+
+
+def _ensure_purpose(purpose_text):
+ """Return the name of an existing or newly created Kapital Bank Purpose."""
+ existing = frappe.db.get_value(
+ "Kapital Bank Purpose", {"purpose_keyword": purpose_text}, "name"
+ )
+ if existing:
+ return existing
+
+ try:
+ doc = frappe.new_doc("Kapital Bank Purpose")
+ doc.purpose_keyword = purpose_text
+ doc.direction = "Both"
+ doc.status = "New"
+ doc.insert(ignore_permissions=True)
+ return doc.name
+ except frappe.DuplicateEntryError:
+ return frappe.db.get_value(
+ "Kapital Bank Purpose", {"purpose_keyword": purpose_text}, "name"
+ )
+ except Exception as e:
+ frappe.log_error(
+ f"Insert purpose failed for {purpose_text}: {e}",
+ "Kapital Bank Purpose Loading",
+ )
+ raise
diff --git a/kapital_bank/patches.txt b/kapital_bank/patches.txt
index 9a71ca0..753f784 100644
--- a/kapital_bank/patches.txt
+++ b/kapital_bank/patches.txt
@@ -4,4 +4,5 @@
kapital_bank.patches.rename_purpose_mapping_to_transaction_mapping
[post_model_sync]
-# Patches added in this section will be executed after doctypes are migrated
\ No newline at end of file
+# Patches added in this section will be executed after doctypes are migrated
+kapital_bank.patches.migrate_counterparty_type
\ No newline at end of file
diff --git a/kapital_bank/patches/migrate_counterparty_type.py b/kapital_bank/patches/migrate_counterparty_type.py
new file mode 100644
index 0000000..393faca
--- /dev/null
+++ b/kapital_bank/patches/migrate_counterparty_type.py
@@ -0,0 +1,18 @@
+import frappe
+
+
+def execute():
+ """Rename counterparty_type values from 'Kapital Bank Customer/Supplier' to 'Customer/Supplier'.
+ The counterparty values (KB registry names) are no longer valid for the new Dynamic Link target,
+ so they are cleared."""
+ frappe.db.sql("""
+ UPDATE `tabKapital Bank Transaction Mapping`
+ SET counterparty_type = 'Customer', counterparty = NULL
+ WHERE counterparty_type = 'Kapital Bank Customer'
+ """)
+ frappe.db.sql("""
+ UPDATE `tabKapital Bank Transaction Mapping`
+ SET counterparty_type = 'Supplier', counterparty = NULL
+ WHERE counterparty_type = 'Kapital Bank Supplier'
+ """)
+ frappe.db.commit()