From 95144eaf63557ba52d474443cbe44fbb0adb0b09 Mon Sep 17 00:00:00 2001 From: pandiyan Date: Wed, 12 Aug 2026 15:59:16 +0530 Subject: [PATCH] fix(accounts): disallow reversing a reverse journal entry check read permission on the source entry before the guards run, so the reversal relationship is not disclosed to a user who cannot read it. (cherry picked from commit 9dd37d5f32b739be66123d81f4cd592746ca3803) # Conflicts: # erpnext/accounts/doctype/journal_entry/journal_entry.js # erpnext/accounts/doctype/journal_entry/mapper.py --- .../doctype/journal_entry/journal_entry.js | 352 ++++++++++++++++++ .../accounts/doctype/journal_entry/mapper.py | 275 ++++++++++++++ 2 files changed, 627 insertions(+) create mode 100644 erpnext/accounts/doctype/journal_entry/mapper.py diff --git a/erpnext/accounts/doctype/journal_entry/journal_entry.js b/erpnext/accounts/doctype/journal_entry/journal_entry.js index 47ba392802e..ab04f209523 100644 --- a/erpnext/accounts/doctype/journal_entry/journal_entry.js +++ b/erpnext/accounts/doctype/journal_entry/journal_entry.js @@ -498,8 +498,360 @@ $.extend(erpnext.journal_entry, { var grid = frm.get_field("accounts").grid; if (grid) grid.set_column_disp(fields, frm.doc.multi_currency); +<<<<<<< HEAD // dynamic label var field_label_map = { +======= + lock_reversal_entry(frm) { + frm.fields + .filter((field) => field.has_input) + .filter((field) => !["posting_date", "custom_remark", "remark"].includes(field.df.fieldname)) + .forEach((field) => frm.set_df_property(field.df.fieldname, "read_only", 1)); + frm.set_df_property("accounts", "read_only", 1); + }, + + add_custom_buttons(frm) { + if (frm.doc.docstatus > 0) { + frm.add_custom_button( + __("Ledger"), + () => erpnext.journal_entry.show_general_ledger(frm), + __("View") + ); + } + + if (frm.doc.docstatus == 1 && !frm.doc.reversal_of) { + frm.add_custom_button( + __("Reverse Journal Entry"), + () => erpnext.journal_entry.reverse_journal_entry(frm), + __("Actions") + ); + } + + if (frm.doc.__islocal) { + frm.add_custom_button(__("Quick Entry"), () => erpnext.journal_entry.quick_entry(frm)); + } + + if ( + frm.doc.voucher_type == "Inter Company Journal Entry" && + frm.doc.docstatus == 1 && + !frm.doc.inter_company_journal_entry_reference + ) { + frm.add_custom_button( + __("Create Inter Company Journal Entry"), + () => erpnext.journal_entry.make_inter_company_journal_entry(frm), + __("Make") + ); + } + }, + + show_general_ledger(frm) { + frappe.route_options = { + voucher_no: frm.doc.name, + from_date: frm.doc.posting_date, + to_date: moment(frm.doc.modified).format("YYYY-MM-DD"), + company: frm.doc.company, + finance_book: frm.doc.finance_book, + categorize_by: "", + show_cancelled_entries: frm.doc.docstatus === 2, + }; + frappe.set_route("query-report", "General Ledger"); + }, + + make_inter_company_journal_entry(frm) { + const dialog = new frappe.ui.Dialog({ + title: __("Select Company"), + fields: [ + { + fieldname: "company", + fieldtype: "Link", + label: __("Company"), + options: "Company", + reqd: 1, + get_query: () => { + return { filters: [["Company", "name", "!=", frm.doc.company]] }; + }, + }, + ], + }); + + dialog.set_primary_action(__("Create"), () => { + dialog.hide(); + frappe.call({ + method: "erpnext.accounts.doctype.journal_entry.mapper.make_inter_company_journal_entry", + args: { + name: frm.doc.name, + voucher_type: frm.doc.voucher_type, + company: dialog.get_value("company"), + }, + callback: ({ message }) => { + if (message) { + const doc = frappe.model.sync(message)[0]; + frappe.set_route("Form", doc.doctype, doc.name); + } + }, + }); + }); + dialog.show(); + }, + + reverse_journal_entry(frm) { + frappe.model.open_mapped_doc({ + method: "erpnext.accounts.doctype.journal_entry.mapper.make_reverse_journal_entry", + frm: frm, + }); + }, + + quick_entry(frm) { + const naming_series_options = frm.fields_dict.naming_series.df.options; + const naming_series_default = + frm.fields_dict.naming_series.df.default || naming_series_options.split("\n")[0]; + + const dialog = new frappe.ui.Dialog({ + title: __("Quick Journal Entry"), + fields: [ + { fieldtype: "Currency", fieldname: "debit", label: __("Amount"), reqd: 1 }, + { + fieldtype: "Link", + fieldname: "debit_account", + label: __("Debit Account"), + reqd: 1, + options: "Account", + get_query: () => erpnext.journal_entry.account_query(frm), + }, + { + fieldtype: "Link", + fieldname: "credit_account", + label: __("Credit Account"), + reqd: 1, + options: "Account", + get_query: () => erpnext.journal_entry.account_query(frm), + }, + { + fieldtype: "Date", + fieldname: "posting_date", + label: __("Date"), + reqd: 1, + default: frm.doc.posting_date, + }, + { fieldtype: "Small Text", fieldname: "remark", label: __("Remark") }, + { + fieldtype: "Select", + fieldname: "naming_series", + label: __("Series"), + reqd: 1, + options: naming_series_options, + default: naming_series_default, + }, + ], + }); + + dialog.set_primary_action(__("Save"), () => { + erpnext.journal_entry.save_quick_entry(frm, dialog.get_values()); + dialog.hide(); + }); + dialog.show(); + }, + + save_quick_entry(frm, values) { + frm.set_value("posting_date", values.posting_date); + frm.set_value("naming_series", values.naming_series); + frm.set_value("custom_remark", values.remark ? 1 : 0); + frm.set_value("remark", values.remark || ""); + + // clear table in case a previous add left a partially populated row behind + frm.clear_table("accounts"); + + // grid.add_new_row() adds the row in the UI as well as locals, which the triggers need + erpnext.journal_entry.add_quick_entry_row( + frm, + values.debit_account, + "debit_in_account_currency", + values.debit + ); + erpnext.journal_entry.add_quick_entry_row( + frm, + values.credit_account, + "credit_in_account_currency", + values.debit + ); + + frm.save(); + }, + + add_quick_entry_row(frm, account, amount_field, amount) { + const row = frm.fields_dict.accounts.grid.add_new_row(); + frappe.model.set_value(row.doctype, row.name, "account", account); + frappe.model.set_value(row.doctype, row.name, amount_field, amount); + }, + + get_outstanding(frm, reference_type, reference_name, child) { + return frappe.call({ + method: "erpnext.accounts.doctype.journal_entry.journal_entry.get_outstanding", + args: { + doctype: reference_type, + docname: reference_name, + company: frm.doc.company, + account: child.account, + party: child.party, + account_currency: child.account_currency, + }, + callback: ({ message }) => { + if (!message) return; + Object.entries(message).forEach(([field, value]) => + frappe.model.set_value(child.doctype, child.name, field, value) + ); + }, + }); + }, + + set_account_details(frm, cdt, cdn) { + const row = frappe.get_doc(cdt, cdn); + if (!row.account) { + erpnext.journal_entry.clear_fields(frm, cdt, cdn); + return; + } + if (!frm.doc.company) frappe.throw(__("Please select Company first")); + if (!frm.doc.posting_date) frappe.throw(__("Please select Posting Date first")); + + return frappe.call({ + method: "erpnext.accounts.doctype.journal_entry.journal_entry.get_account_details_and_party_type", + args: { + account: row.account, + date: frm.doc.posting_date, + company: frm.doc.company, + debit: flt(row.debit_in_account_currency), + credit: flt(row.credit_in_account_currency), + exchange_rate: row.exchange_rate, + }, + callback: ({ message }) => { + if (!message) return; + $.extend(row, message); + erpnext.journal_entry.set_amount_on_last_row(frm, cdt, cdn); + erpnext.journal_entry.set_debit_credit_in_company_currency(frm, cdt, cdn); + frm.refresh_field("accounts"); + }, + }); + }, + + set_amount_on_last_row(frm, cdt, cdn) { + const row = frappe.get_doc(cdt, cdn); + if (row.idx != frm.doc.accounts.length) return; + + const difference = frm.doc.accounts.reduce((total, account) => { + return account.idx == row.idx ? total : total + account.debit - account.credit; + }, 0); + erpnext.journal_entry.set_balancing_amount(row, difference); + }, + + set_balancing_amount(row, difference) { + if (!difference) return; + + const exchange_rate = row.exchange_rate || 1; + if (difference > 0) { + row.credit_in_account_currency = difference / exchange_rate; + row.credit = difference; + } else { + row.debit_in_account_currency = -difference / exchange_rate; + row.debit = -difference; + } + }, + + clear_fields(frm, cdt, cdn) { + const row = frappe.get_doc(cdt, cdn); + row.party_type = null; + row.party = null; + row.bank_account = null; + frm.refresh_field("accounts"); + }, + + setup_queries(frm) { + frm.set_query("periodic_entry_difference_account", () => { + return { filters: { is_group: 0, company: frm.doc.company } }; + }); + + frm.set_query("stock_asset_account", () => { + return { filters: { is_group: 0, account_type: "Stock", company: frm.doc.company } }; + }); + + frm.set_query("project", "accounts", (doc, cdt, cdn) => { + const row = frappe.get_doc(cdt, cdn); + const filters = { company: doc.company }; + if (row.party_type == "Customer") filters.customer = row.party; + return { query: "erpnext.controllers.queries.get_project_name", filters }; + }); + + frm.set_query("account", "accounts", () => erpnext.journal_entry.account_query(frm)); + + frm.set_query("party_type", "accounts", (doc, cdt, cdn) => { + return { + query: "erpnext.setup.doctype.party_type.party_type.get_party_type", + filters: { account: frappe.get_doc(cdt, cdn).account }, + }; + }); + + frm.set_query("reference_name", "accounts", (doc, cdt, cdn) => { + return erpnext.journal_entry.reference_name_query(frappe.get_doc(cdt, cdn)); + }); + }, + + reference_name_query(row) { + if (row.reference_type === "Journal Entry") { + frappe.model.validate_missing(row, "account"); + return { + query: "erpnext.accounts.doctype.journal_entry.journal_entry.get_against_jv", + filters: { account: row.account, party: row.party }, + }; + } + + const out = { filters: [[row.reference_type, "docstatus", "=", 1]] }; + + if (["Sales Invoice", "Purchase Invoice"].includes(row.reference_type)) { + out.filters.push([row.reference_type, "outstanding_amount", "!=", 0]); + if (row.cost_center) { + out.filters.push([row.reference_type, "cost_center", "in", ["", row.cost_center]]); + } + frappe.model.validate_missing(row, "account"); + const party_account_field = row.reference_type === "Sales Invoice" ? "debit_to" : "credit_to"; + out.filters.push([row.reference_type, party_account_field, "=", row.account]); + } + + if (["Sales Order", "Purchase Order"].includes(row.reference_type)) { + frappe.model.validate_missing(row, "party_type"); + frappe.model.validate_missing(row, "party"); + out.filters.push([row.reference_type, "per_billed", "<", 100]); + } + + if (row.party_type && row.party) { + let party_field = ""; + if (row.reference_type.indexOf("Sales") === 0) { + party_field = "customer"; + } else if (row.reference_type.indexOf("Purchase") === 0) { + party_field = "supplier"; + } + if (party_field) out.filters.push([row.reference_type, party_field, "=", row.party]); + } + + return out; + }, + + account_query(frm) { + const filters = { company: frm.doc.company, is_group: 0 }; + if (!frm.doc.multi_currency) { + const company_currency = erpnext.get_currency(frm.doc.company); + filters.account_currency = ["in", [company_currency, null]]; + } + return { filters }; + }, + + toggle_fields_based_on_currency(frm) { + const fields = ["currency_section", "account_currency", "exchange_rate", "debit", "credit"]; + const grid = frm.get_field("accounts").grid; + if (!grid) return; + + grid.set_column_disp(fields, frm.doc.multi_currency); + + const field_label_map = { +>>>>>>> 9dd37d5f32 (fix(accounts): disallow reversing a reverse journal entry) debit_in_account_currency: "Debit", credit_in_account_currency: "Credit", }; diff --git a/erpnext/accounts/doctype/journal_entry/mapper.py b/erpnext/accounts/doctype/journal_entry/mapper.py new file mode 100644 index 00000000000..715eecbd398 --- /dev/null +++ b/erpnext/accounts/doctype/journal_entry/mapper.py @@ -0,0 +1,275 @@ +# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + +"""Document builders that map a source document to a Journal Entry or to a +Payment Entry raised against it.""" + +import frappe +from frappe import _ +from frappe.model.document import Document +from frappe.utils import flt, get_link_to_form, nowdate + +from erpnext.accounts.doctype.invoice_discounting.invoice_discounting import ( + get_party_account_based_on_invoice_discounting, +) +from erpnext.accounts.party import get_party_account +from erpnext.accounts.utils import get_account_currency + + +@frappe.whitelist() +def get_payment_entry_against_order( + dt: str, + dn: str, + amount: float | None = None, + debit_in_account_currency: str | float | None = None, + journal_entry: bool = False, + bank_account: str | None = None, +) -> dict | Document: + """Build an advance-payment Journal Entry against an unbilled Sales/Purchase Order.""" + ref_doc = frappe.get_doc(dt, dn) + + if flt(ref_doc.per_billed, 2) > 0: + frappe.throw(_("Can only make payment against unbilled {0}").format(dt)) + + if dt == "Sales Order": + party_type = "Customer" + amount_field_party = "credit_in_account_currency" + amount_field_bank = "debit_in_account_currency" + else: + party_type = "Supplier" + amount_field_party = "debit_in_account_currency" + amount_field_bank = "credit_in_account_currency" + + party_account = get_party_account(party_type, ref_doc.get(party_type.lower()), ref_doc.company) + party_account_currency = get_account_currency(party_account) + + if not amount: + if party_account_currency == ref_doc.company_currency: + amount = flt(ref_doc.base_grand_total) - flt(ref_doc.advance_paid) + else: + amount = flt(ref_doc.grand_total) - flt(ref_doc.advance_paid) + + return get_payment_entry( + ref_doc, + { + "party_type": party_type, + "party_account": party_account, + "party_account_currency": party_account_currency, + "amount_field_party": amount_field_party, + "amount_field_bank": amount_field_bank, + "amount": amount, + "debit_in_account_currency": debit_in_account_currency, + "remarks": f"Advance Payment received against {dt} {dn}", + "is_advance": "Yes", + "bank_account": bank_account, + "journal_entry": journal_entry, + }, + ) + + +@frappe.whitelist() +def get_payment_entry_against_invoice( + dt: str, + dn: str, + amount: float | None = None, + debit_in_account_currency: str | None = None, + journal_entry: bool = False, + bank_account: str | None = None, +) -> dict | Document: + """Build a payment Journal Entry against a Sales/Purchase Invoice's outstanding amount.""" + ref_doc = frappe.get_doc(dt, dn) + if dt == "Sales Invoice": + party_type = "Customer" + party_account = get_party_account_based_on_invoice_discounting(dn) or ref_doc.debit_to + else: + party_type = "Supplier" + party_account = ref_doc.credit_to + + if (dt == "Sales Invoice" and ref_doc.outstanding_amount > 0) or ( + dt == "Purchase Invoice" and ref_doc.outstanding_amount < 0 + ): + amount_field_party = "credit_in_account_currency" + amount_field_bank = "debit_in_account_currency" + else: + amount_field_party = "debit_in_account_currency" + amount_field_bank = "credit_in_account_currency" + + return get_payment_entry( + ref_doc, + { + "party_type": party_type, + "party_account": party_account, + "party_account_currency": ref_doc.party_account_currency, + "amount_field_party": amount_field_party, + "amount_field_bank": amount_field_bank, + "amount": amount if amount else abs(ref_doc.outstanding_amount), + "debit_in_account_currency": debit_in_account_currency, + "remarks": f"Payment received against {dt} {dn}. {ref_doc.remarks}", + "is_advance": "No", + "bank_account": bank_account, + "journal_entry": journal_entry, + }, + ) + + +def get_payment_entry(ref_doc, args: dict) -> dict | Document: + """Build a Bank Entry Journal Entry paying `ref_doc`, with a party row and a bank row. + + Returns the Journal Entry document when `args["journal_entry"]` is truthy, otherwise its + dict (for client calls). + """ + je = frappe.new_doc("Journal Entry") + je.update({"voucher_type": "Bank Entry", "company": ref_doc.company, "remark": args.get("remarks")}) + + cost_center = ref_doc.get("cost_center") or frappe.get_cached_value( + "Company", ref_doc.company, "cost_center" + ) + exchange_rate = _reference_exchange_rate(ref_doc, args) + + party_row = _append_party_row(je, ref_doc, args, cost_center, exchange_rate) + bank_row = _append_bank_row(je, ref_doc, args, cost_center, exchange_rate) + + if party_row.account_currency != ref_doc.company_currency or ( + bank_row.account_currency and bank_row.account_currency != ref_doc.company_currency + ): + je.multi_currency = 1 + + je.set_amounts_in_company_currency() + je.set_total_debit_credit() + + return je if args.get("journal_entry") else je.as_dict() + + +def _reference_exchange_rate(ref_doc, args: dict) -> float: + """Exchange rate of the party account on the reference document's posting date.""" + if not args.get("party_account"): + return 1 + + from erpnext.accounts.doctype.journal_entry.journal_entry import get_exchange_rate + + return get_exchange_rate( + ref_doc.get("posting_date") or ref_doc.get("transaction_date"), + args.get("party_account"), + args.get("party_account_currency"), + ref_doc.company, + ref_doc.doctype, + ref_doc.name, + ) + + +def _append_party_row(je, ref_doc, args: dict, cost_center, exchange_rate: float): + """Append the party (debtor/creditor) row that records the advance/payment.""" + return je.append( + "accounts", + { + "account": args.get("party_account"), + "party_type": args.get("party_type"), + "party": ref_doc.get(args.get("party_type").lower()), + "cost_center": cost_center, + "account_type": frappe.get_cached_value("Account", args.get("party_account"), "account_type"), + "account_currency": args.get("party_account_currency") + or get_account_currency(args.get("party_account")), + "exchange_rate": exchange_rate, + args.get("amount_field_party"): args.get("amount"), + "is_advance": args.get("is_advance"), + "reference_type": ref_doc.doctype, + "reference_name": ref_doc.name, + }, + ) + + +def _append_bank_row(je, ref_doc, args: dict, cost_center, exchange_rate: float): + """Append the bank/cash row, defaulting the account and converting the amount to it.""" + from erpnext.accounts.doctype.journal_entry.journal_entry import ( + get_default_bank_cash_account, + get_exchange_rate, + ) + + bank_row = je.append("accounts") + bank_account = get_default_bank_cash_account(ref_doc.company, "Bank", account=args.get("bank_account")) + if bank_account: + bank_row.update(bank_account) + # posting date assumed to be the reference document's posting/transaction date + bank_row.exchange_rate = get_exchange_rate( + ref_doc.get("posting_date") or ref_doc.get("transaction_date"), + bank_account["account"], + bank_account["account_currency"], + ref_doc.company, + ) + + bank_row.cost_center = cost_center + + amount = args.get("debit_in_account_currency") or args.get("amount") + if bank_row.account_currency == args.get("party_account_currency"): + bank_row.set(args.get("amount_field_bank"), amount) + else: + bank_row.set(args.get("amount_field_bank"), amount * exchange_rate) + + return bank_row + + +@frappe.whitelist() +def make_inter_company_journal_entry(name: str, voucher_type: str, company: str) -> dict: + """Build the counterpart Journal Entry in another company, linked back to `name`.""" + journal_entry = frappe.new_doc("Journal Entry") + journal_entry.voucher_type = voucher_type + journal_entry.company = company + journal_entry.posting_date = nowdate() + journal_entry.inter_company_journal_entry_reference = name + return journal_entry.as_dict() + + +@frappe.whitelist() +def make_reverse_journal_entry(source_name: str, target_doc: str | dict | Document | None = None) -> Document: + """Map a submitted Journal Entry to a reversing one (debits and credits swapped).""" + # `get_mapped_doc` checks this as well, but the guards below disclose which entry + # reverses which, so read access has to be settled before they run + if not frappe.has_permission("Journal Entry", doc=source_name): + frappe.throw(_("Not permitted"), frappe.PermissionError) + + reversal_of = frappe.db.get_value("Journal Entry", source_name, "reversal_of") + if reversal_of: + frappe.throw( + _("{0} is already a Reverse Journal Entry of {1}. Cancel it instead of reversing it.").format( + get_link_to_form("Journal Entry", source_name), + get_link_to_form("Journal Entry", reversal_of), + ) + ) + + existing_reverse = frappe.db.exists("Journal Entry", {"reversal_of": source_name, "docstatus": 1}) + if existing_reverse: + frappe.throw( + _("A Reverse Journal Entry {0} already exists for this Journal Entry.").format( + get_link_to_form("Journal Entry", existing_reverse) + ) + ) + + from frappe.model.mapper import get_mapped_doc + + def post_process(source, target) -> None: + target.reversal_of = source.name + + doclist = get_mapped_doc( + "Journal Entry", + source_name, + { + "Journal Entry": {"doctype": "Journal Entry", "validation": {"docstatus": ["=", 1]}}, + "Journal Entry Account": { + "doctype": "Journal Entry Account", + "field_map": { + "account_currency": "account_currency", + "exchange_rate": "exchange_rate", + "debit_in_account_currency": "credit_in_account_currency", + "debit": "credit", + "credit_in_account_currency": "debit_in_account_currency", + "credit": "debit", + "reference_type": "reference_type", + "reference_name": "reference_name", + }, + }, + }, + target_doc, + post_process, + ) + + return doclist