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 9dd37d5f32)

# Conflicts:
#	erpnext/accounts/doctype/journal_entry/journal_entry.js
#	erpnext/accounts/doctype/journal_entry/mapper.py
This commit is contained in:
pandiyan
2026-08-12 15:59:16 +05:30
committed by Mergify
parent b41df049e6
commit 95144eaf63
2 changed files with 627 additions and 0 deletions

View File

@@ -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",
};

View File

@@ -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