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 index aa6e6b69..3860033a 100644 --- 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 @@ -95,7 +95,7 @@ "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2026-03-28 02:28:06.918430", + "modified": "2026-05-15 14:33:05.591103", "modified_by": "Administrator", "module": "Klarna Kosma Integration", "name": "Bank Reconciliation Rule", @@ -146,4 +146,4 @@ "sort_field": "modified", "sort_order": "DESC", "states": [] -} \ No newline at end of file +} diff --git a/banking/klarna_kosma_integration/doctype/bank_reconciliation_tool_beta/bank_reconciliation_tool_beta.py b/banking/klarna_kosma_integration/doctype/bank_reconciliation_tool_beta/bank_reconciliation_tool_beta.py index d69aca1e..97609806 100644 --- a/banking/klarna_kosma_integration/doctype/bank_reconciliation_tool_beta/bank_reconciliation_tool_beta.py +++ b/banking/klarna_kosma_integration/doctype/bank_reconciliation_tool_beta/bank_reconciliation_tool_beta.py @@ -6,6 +6,9 @@ import frappe from erpnext import get_company_currency, get_default_cost_center +from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import ( + get_accounting_dimensions, +) from erpnext.accounts.doctype.bank_transaction.bank_transaction import ( get_total_allocated_amount, ) @@ -34,6 +37,56 @@ MAX_QUERY_RESULTS = 150 +STANDARD_ACCOUNTING_DIMENSION_FIELDS = frozenset({"project", "cost_center"}) + + +def _get_allowed_accounting_dimension_fields() -> set[str]: + """Return whitelisted dimension fieldnames (custom + project + cost_center).""" + return set(get_accounting_dimensions(as_list=True)) | STANDARD_ACCOUNTING_DIMENSION_FIELDS + + +def _parse_accounting_dimensions_json(accounting_dimensions: str | None) -> dict: + """Parse accounting_dimensions JSON from the reco UI; return {} on invalid input.""" + if not accounting_dimensions: + return {} + + try: + dimensions = json.loads(accounting_dimensions) + except (TypeError, json.JSONDecodeError): + return {} + + if not isinstance(dimensions, dict): + return {} + + return dimensions + + +def _merge_accounting_dimensions_into_payment_entry( + payment_entry: Document, accounting_dimensions: str | None +) -> None: + """Stamp selected accounting dimensions onto Payment Entry header fields.""" + allowed = _get_allowed_accounting_dimension_fields() + for key, value in _parse_accounting_dimensions_json(accounting_dimensions).items(): + if key not in allowed or not value: + continue + if payment_entry.meta.get_field(key): + payment_entry.set(key, value) + + +def _merge_accounting_dimensions_into_je_accounts( + account_rows: list[dict], accounting_dimensions: str | None +) -> None: + """Stamp selected accounting dimensions onto non-bank Journal Entry Account rows.""" + allowed = _get_allowed_accounting_dimension_fields() + for key, value in _parse_accounting_dimensions_json(accounting_dimensions).items(): + if key not in allowed or not value: + continue + for row in account_rows: + # Do not tag the bank GL line (like Payment Entry): dimensions belong on the other leg only. + if row.get("bank_account"): + continue + row[key] = value + class BankReconciliationToolBeta(Document): # begin: auto-generated types @@ -128,8 +181,13 @@ def create_journal_entry_bts( party_type: str | None = None, party: str | None = None, allow_edit: bool | str = False, + accounting_dimensions: str | None = None, ): - """Create a new Journal Entry for Reconciling the Bank Transaction""" + """Create a new Journal Entry for reconciling the Bank Transaction. + + :param accounting_dimensions: JSON object mapping dimension fieldnames to values + (applied to non-bank account rows only). + """ if isinstance(allow_edit, str): allow_edit = sbool(allow_edit) @@ -167,26 +225,25 @@ def create_journal_entry_bts( "user_remark": bank_transaction.description, } ) - journal_entry.set( - "accounts", - [ - { - "account": second_account, - "credit_in_account_currency": bank_debit_amount, - "debit_in_account_currency": bank_credit_amount, - "party_type": party_type, - "party": party, - "cost_center": get_default_cost_center(company), - }, - { - "account": bank_gl_account, - "bank_account": bank_transaction.bank_account, - "credit_in_account_currency": bank_credit_amount, - "debit_in_account_currency": bank_debit_amount, - "cost_center": get_default_cost_center(company), - }, - ], - ) + account_rows = [ + { + "account": second_account, + "credit_in_account_currency": bank_debit_amount, + "debit_in_account_currency": bank_credit_amount, + "party_type": party_type, + "party": party, + "cost_center": get_default_cost_center(company), + }, + { + "account": bank_gl_account, + "bank_account": bank_transaction.bank_account, + "credit_in_account_currency": bank_credit_amount, + "debit_in_account_currency": bank_debit_amount, + "cost_center": get_default_cost_center(company), + }, + ] + _merge_accounting_dimensions_into_je_accounts(account_rows, accounting_dimensions) + journal_entry.set("accounts", account_rows) company_currency = get_company_currency(company) journal_entry.multi_currency = ( @@ -228,8 +285,14 @@ def create_payment_entry_bts( mode_of_payment: str | None = None, project: str | None = None, cost_center: str | None = None, + accounting_dimensions: str | None = None, allow_edit: bool = False, ): + """Create a new Payment Entry for reconciling the Bank Transaction. + + :param accounting_dimensions: JSON object mapping dimension fieldnames to values + (applied to Payment Entry header fields). + """ if isinstance(allow_edit, str): allow_edit = sbool(allow_edit) @@ -262,10 +325,13 @@ def create_payment_entry_bts( if mode_of_payment: payment_entry.mode_of_payment = mode_of_payment + + _merge_accounting_dimensions_into_payment_entry(payment_entry, accounting_dimensions) if project: payment_entry.project = project if cost_center: payment_entry.cost_center = cost_center + if payment_type == "Receive": payment_entry.paid_to = company_account else: diff --git a/banking/klarna_kosma_integration/doctype/bank_reconciliation_tool_beta/test_bank_reconciliation_tool_beta.py b/banking/klarna_kosma_integration/doctype/bank_reconciliation_tool_beta/test_bank_reconciliation_tool_beta.py index f178a3ff..e06286f1 100644 --- a/banking/klarna_kosma_integration/doctype/bank_reconciliation_tool_beta/test_bank_reconciliation_tool_beta.py +++ b/banking/klarna_kosma_integration/doctype/bank_reconciliation_tool_beta/test_bank_reconciliation_tool_beta.py @@ -20,6 +20,8 @@ from banking.exceptions import CurrencyMismatchError, FullReconciliationRequiredError from banking.klarna_kosma_integration.doctype.bank_reconciliation_tool_beta.bank_reconciliation_tool_beta import ( + _merge_accounting_dimensions_into_je_accounts, + _merge_accounting_dimensions_into_payment_entry, auto_reconcile_vouchers, bulk_reconcile_vouchers, create_journal_entry_bts, @@ -266,6 +268,44 @@ def test_pe_against_transaction(self): self.assertEqual(len(bt.payment_entries), 1) self.assertEqual(bt.status, "Reconciled") + def test_merge_accounting_dimensions_into_payment_entry(self): + payment_entry = frappe.new_doc("Payment Entry") + payment_entry.company = "_Test Company" + _merge_accounting_dimensions_into_payment_entry( + payment_entry, + json.dumps( + { + "project": "PROJ-TEST", + "cost_center": "Main - _TC", + "not_a_real_dimension": "ignored", + } + ), + ) + + self.assertEqual(payment_entry.project, "PROJ-TEST") + self.assertEqual(payment_entry.cost_center, "Main - _TC") + + def test_merge_accounting_dimensions_into_je_accounts(self): + account_rows = [ + {"account": "Debtors - _TC"}, + {"account": "Bank - _TC", "bank_account": self.bank_account}, + ] + _merge_accounting_dimensions_into_je_accounts( + account_rows, + json.dumps( + { + "project": "PROJ-TEST", + "cost_center": "Main - _TC", + "not_a_real_dimension": "ignored", + } + ), + ) + + self.assertEqual(account_rows[0]["project"], "PROJ-TEST") + self.assertEqual(account_rows[0]["cost_center"], "Main - _TC") + self.assertNotIn("project", account_rows[1]) + self.assertNotIn("not_a_real_dimension", account_rows[0]) + def test_jv_against_transaction(self): bt = create_bank_transaction(deposit=200, reference_no="abcdef123", bank_account=self.bank_account) create_journal_entry_bts( diff --git a/banking/public/js/bank_reconciliation_beta/actions_panel/actions_panel_manager.js b/banking/public/js/bank_reconciliation_beta/actions_panel/actions_panel_manager.js index 324f147e..cbe05a4b 100644 --- a/banking/public/js/bank_reconciliation_beta/actions_panel/actions_panel_manager.js +++ b/banking/public/js/bank_reconciliation_beta/actions_panel/actions_panel_manager.js @@ -83,6 +83,10 @@ erpnext.accounts.bank_reconciliation.ActionsPanelManager = class ActionsPanelMan transaction: this.transaction, panel_manager: this.panel_manager, company: this.frm.doc.company, + accounting_dimensions: + this.panel_manager.accounting_dimensions || [], + accounting_dimension_defaults: + this.panel_manager.accounting_dimension_defaults || {}, }); }, }, diff --git a/banking/public/js/bank_reconciliation_beta/actions_panel/create_tab.js b/banking/public/js/bank_reconciliation_beta/actions_panel/create_tab.js index 48ed0159..be931653 100644 --- a/banking/public/js/bank_reconciliation_beta/actions_panel/create_tab.js +++ b/banking/public/js/bank_reconciliation_beta/actions_panel/create_tab.js @@ -1,14 +1,41 @@ frappe.provide("erpnext.accounts.bank_reconciliation"); +const STANDARD_ACCOUNTING_DIMENSION_FIELDS = [ + { fieldname: "project", label: __("Project"), document_type: "Project" }, + { + fieldname: "cost_center", + label: __("Cost Center"), + document_type: "Cost Center", + }, +]; + erpnext.accounts.bank_reconciliation.CreateTab = class CreateTab { constructor(opts) { Object.assign(this, opts); + this.dimension_fieldnames = []; this.make(); } + /** Build the create form and append cached accounting dimension fields. */ make() { this.panel_manager.actions_tab = "create_voucher-tab"; + const custom_dimensions = this.accounting_dimensions || []; + const company_defaults_map = this.accounting_dimension_defaults || {}; + this.custom_dimension_fieldnames = custom_dimensions.map( + (d) => d.fieldname + ); + this.dimension_fieldnames = [ + ...STANDARD_ACCOUNTING_DIMENSION_FIELDS.map((d) => d.fieldname), + ...this.custom_dimension_fieldnames, + ]; + + this.build_field_group(); + this.append_accounting_dimensions(custom_dimensions, company_defaults_map); + } + + /** Render the main create-voucher fields (without accounting dimensions). */ + build_field_group() { this.create_field_group = new frappe.ui.FieldGroup({ fields: this.get_create_tab_fields(), body: this.actions_panel.$tab_content, @@ -17,14 +44,136 @@ erpnext.accounts.bank_reconciliation.CreateTab = class CreateTab { this.create_field_group.make(); } + /** + * Add the collapsible accounting dimensions section after the base form; + * placed above the footer so Create stays at the bottom. + */ + append_accounting_dimensions(dimensions, company_defaults_map) { + if (!this.create_field_group) { + return; + } + const dimension_section = this.build_accounting_dimension_section_fields( + dimensions, + company_defaults_map + ); + if (!dimension_section.length) { + return; + } + + this.create_field_group.add_fields(dimension_section); + + const hidden = this.create_field_group.get_field("hidden_field"); + const $footer = + hidden && hidden.$wrapper && hidden.$wrapper.closest(".form-section"); + const $dim = this.create_field_group.wrapper + .find(".bank-br-create-accounting-dimensions") + .closest(".form-section"); + if ($footer && $footer.length && $dim && $dim.length) { + $dim.insertBefore($footer); + } + + this.create_field_group.refresh_dependency(); + + const dim_section_closed_key = + "bank-br-create-accounting-dimensions-closed"; + const collapse_default = () => { + if (localStorage.getItem(dim_section_closed_key) !== null) { + return; + } + const sec = + this.create_field_group.sections_dict?.accounting_dimensions_section || + this.create_field_group.sections?.find( + (s) => s.df?.fieldname === "accounting_dimensions_section" + ); + if (sec && typeof sec.collapse === "function") { + sec.collapse(true); + } + }; + collapse_default(); + setTimeout(collapse_default, 0); + } + + /** Field definitions for the accounting dimensions section break and link fields. */ + build_accounting_dimension_section_fields( + custom_dimensions, + company_defaults_map + ) { + const standard_fields = this.get_accounting_dimension_fields( + STANDARD_ACCOUNTING_DIMENSION_FIELDS, + company_defaults_map + ); + const custom_fields = this.get_accounting_dimension_fields( + custom_dimensions, + company_defaults_map + ); + const left_column = standard_fields[0] ? [standard_fields[0]] : []; + const right_column = standard_fields[1] ? [standard_fields[1]] : []; + + custom_fields.forEach((field, index) => { + if (index % 2 === 0) { + left_column.push(field); + } else { + right_column.push(field); + } + }); + + if (!left_column.length && !right_column.length) { + return []; + } + + const fields = [...left_column]; + if (right_column.length) { + fields.push({ + fieldname: "column_break_accounting_dimensions", + fieldtype: "Column Break", + }); + fields.push(...right_column); + } + + return [ + { + fieldtype: "Section Break", + fieldname: "accounting_dimensions_section", + label: __("Accounting Dimensions"), + collapsible: 1, + css_class: "bank-br-create-accounting-dimensions", + }, + ...fields, + ]; + } + + /** Map ERPNext dimension metadata to Link field definitions with company defaults. */ + get_accounting_dimension_fields(dimensions, company_defaults_map) { + const defaults_for_company = + company_defaults_map && this.company && company_defaults_map[this.company] + ? company_defaults_map[this.company] + : {}; + return (dimensions || []).map((dimension) => { + const df = { + fieldname: dimension.fieldname, + fieldtype: "Link", + label: __(dimension.label || frappe.model.unscrub(dimension.fieldname)), + options: dimension.document_type, + }; + const default_dim = defaults_for_company[dimension.fieldname]; + if (default_dim) { + df.default = default_dim; + } + return df; + }); + } + create_voucher() { - var me = this; let values = this.create_field_group.get_values(); let document_type = values.document_type; // Create new voucher and delete or refresh current BT row depending on reconciliation this.create_voucher_bts(false, (message) => - me.actions_panel.after_transaction_reconcile(message, true, document_type) + this.actions_panel.after_transaction_reconcile( + message, + true, + document_type + ) ); } @@ -49,8 +198,29 @@ erpnext.accounts.bank_reconciliation.CreateTab = class CreateTab { }); } + /** Collect non-empty dimension values for the given fieldnames. */ + get_selected_accounting_dimensions(values, fieldnames) { + const dim_payload = {}; + for (const fn of fieldnames || []) { + if (values[fn]) { + dim_payload[fn] = values[fn]; + } + } + return dim_payload; + } + + serialize_accounting_dimensions(dim_payload) { + return Object.keys(dim_payload).length > 0 + ? JSON.stringify(dim_payload) + : null; + } + + /** + * Create Payment Entry or Journal Entry and run `success_callback`. + * Payment Entry: project/cost_center as explicit args; custom dims as JSON. + * Journal Entry: all dimensions (standard + custom) as JSON. + */ create_voucher_bts(allow_edit = false, success_callback) { - // Create PE or JV and run `success_callback` let values = this.create_field_group.get_values(); let document_type = values.document_type; let method = @@ -68,17 +238,29 @@ erpnext.accounts.bank_reconciliation.CreateTab = class CreateTab { if (document_type === "Payment Entry") { method = method + ".create_payment_entry_bts"; + const custom_payload = this.get_selected_accounting_dimensions( + values, + this.custom_dimension_fieldnames + ); args = { ...args, project: values.project, cost_center: values.cost_center, + accounting_dimensions: + this.serialize_accounting_dimensions(custom_payload), }; } else { method = method + ".create_journal_entry_bts"; + const dim_payload = this.get_selected_accounting_dimensions( + values, + this.dimension_fieldnames + ); args = { ...args, entry_type: values.journal_entry_type, second_account: values.second_account, + accounting_dimensions: + this.serialize_accounting_dimensions(dim_payload), }; } @@ -106,7 +288,6 @@ erpnext.accounts.bank_reconciliation.CreateTab = class CreateTab { // If no response, newly created doc is in draft state // If deleted in response, newly created doc is deleted // If doc object in response, newly created doc is submitted (can be reconciled) - var me = this; frappe.call({ method: "banking.klarna_kosma_integration.doctype.bank_reconciliation_tool_beta.bank_reconciliation_tool_beta.reconcile_voucher", @@ -121,7 +302,7 @@ erpnext.accounts.bank_reconciliation.CreateTab = class CreateTab { frappe.show_alert({ message: __("Failed to reconcile new {0} against {1}", [ doctype, - me.transaction.name, + this.transaction.name, ]), indicator: "red", }); @@ -135,7 +316,7 @@ erpnext.accounts.bank_reconciliation.CreateTab = class CreateTab { return; } - me.actions_panel.after_transaction_reconcile( + this.actions_panel.after_transaction_reconcile( response.message, true, doctype @@ -267,20 +448,6 @@ erpnext.accounts.bank_reconciliation.CreateTab = class CreateTab { options: party_type, reqd: 1, }, - { - fieldname: "project", - fieldtype: "Link", - label: "Project", - options: "Project", - depends_on: "eval: doc.document_type == 'Payment Entry'", - }, - { - fieldname: "cost_center", - fieldtype: "Link", - label: "Cost Center", - options: "Cost Center", - depends_on: "eval: doc.document_type == 'Payment Entry'", - }, { fieldtype: "Section Break", }, diff --git a/banking/public/js/bank_reconciliation_beta/panel_manager.js b/banking/public/js/bank_reconciliation_beta/panel_manager.js index 38ae1cdd..88989f07 100644 --- a/banking/public/js/bank_reconciliation_beta/panel_manager.js +++ b/banking/public/js/bank_reconciliation_beta/panel_manager.js @@ -11,15 +11,33 @@ erpnext.accounts.bank_reconciliation.PanelManager = class PanelManager { } async init_panels() { - const [transactions, document_types] = await Promise.all([ - this.get_bank_transactions(), - frappe.xcall( - "banking.klarna_kosma_integration.doctype.banking_settings.banking_settings.get_doctypes_for_bank_reconciliation" - ), - ]); + const dimensions_cached = this.accounting_dimensions != null; + const document_types_cached = this.document_types != null; + + const [transactions, document_types, dimensions_message] = + await Promise.all([ + this.get_bank_transactions(), + document_types_cached + ? Promise.resolve(this.document_types) + : frappe.xcall( + "banking.klarna_kosma_integration.doctype.banking_settings.banking_settings.get_doctypes_for_bank_reconciliation" + ), + dimensions_cached + ? Promise.resolve([ + this.accounting_dimensions, + this.accounting_dimension_defaults, + ]) + : this.get_accounting_dimensions(), + ]); this.transactions = transactions; - this.document_types = document_types; + if (!document_types_cached) { + this.document_types = document_types; + } + if (!dimensions_cached) { + this.accounting_dimensions = dimensions_message?.[0] || []; + this.accounting_dimension_defaults = dimensions_message?.[1] || {}; + } this.$wrapper.empty(); this.$panel_wrapper = this.$wrapper @@ -33,6 +51,43 @@ erpnext.accounts.bank_reconciliation.PanelManager = class PanelManager { this.render_panels(); } + /** Re-fetch and re-render the transaction list (e.g. after sort change). */ + async reload_transactions() { + this.transactions = await this.get_bank_transactions(); + const active_name = this.active_transaction?.name; + + if (!this.transactions?.length) { + this.active_transaction = null; + this.$panel_wrapper.empty(); + this.render_no_transactions(); + return; + } + + this.$list_container.empty(); + this.render_transactions_list(); + + const $row = active_name + ? this.$list_container.find("#" + active_name) + : $(); + if ($row.length) { + $row.click(); + } else { + this.$list_container.find(".transaction-row").first().click(); + } + } + + /** Load custom accounting dimension metadata once per reco session. */ + async get_accounting_dimensions() { + const response = await frappe.call({ + method: + "erpnext.accounts.doctype.accounting_dimension.accounting_dimension.get_dimensions", + args: { + with_cost_center_and_project: false, + }, + }); + return response.message; + } + async get_bank_transactions() { let transactions = await frappe .call({ @@ -140,8 +195,7 @@ erpnext.accounts.bank_reconciliation.PanelManager = class PanelManager { me.order_direction = sort_order || me.order_direction || "asc"; me.order = me.order_by + " " + me.order_direction; - // Re-render the list - me.init_panels(); + me.reload_transactions(); }, }); }