Files
erpnext/erpnext/accounts/general_ledger.py
Nihantra C. Patel cab1b129c0 fix: validate reverse GL entries on current date under immutable ledger (#56709)
* fix: validate reverse GL entries on current date under immutable ledger

When Immutable Ledger is enabled, the reverse GL entry is posted on the
current date, but the closed-period checks in make_reverse_gl_entries still
validate against the original (backdated) posting date. This blocks cancelling
a backdated voucher, such as a suspense Journal Entry for a migrated NPA loan,
with a books-closed error even though the reverse entry lands in an open period.

Validate both check_freezing_date and validate_against_pcv against the current
date when Immutable Ledger is enabled. When it is disabled, behaviour is
unchanged.

Follow-up to #55268.

* test: reset frozen till date after reverse entry test

The freeze date set on the company was not reset, so it leaked into the next
test which posts entries in that period. Reset it in a finally block.

* fix: prefer explicit posting_date under immutable ledger

Prefer the posting_date argument before frappe.form_dict and getdate, at both
the validation and the GL entry site, so an explicit date passed by the caller
is honoured and validation still matches the posted date.
2026-07-02 10:16:57 +05:30

740 lines
22 KiB
Python

# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
import copy
import frappe
from frappe import _
from frappe.model.meta import get_field_precision
from frappe.utils import cint, flt, get_link_to_form, getdate, now
from frappe.utils.caching import request_cache
import erpnext
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
get_accounting_dimensions,
get_checks_for_pl_and_bs_accounts,
)
from erpnext.accounts.doctype.accounting_dimension_filter.accounting_dimension_filter import (
get_dimension_filter_map,
)
from erpnext.accounts.doctype.budget.budget import validate_expense_against_budget
from erpnext.accounts.services.gl_validator import (
check_freezing_date,
validate_accounting_period,
validate_against_pcv,
validate_allowed_dimensions,
validate_cwip_accounts,
validate_disabled_accounts,
)
from erpnext.accounts.utils import create_payment_ledger_entry, is_immutable_ledger_enabled
from erpnext.controllers.budget_controller import BudgetValidation
def make_gl_entries(
gl_map,
cancel=False,
adv_adj=False,
merge_entries=True,
update_outstanding="Yes",
from_repost=False,
):
if gl_map:
if (
not cancel
and not cint(frappe.get_single_value("Accounts Settings", "use_legacy_budget_controller"))
and gl_map[0].voucher_type != "Period Closing Voucher"
):
bud_val = BudgetValidation(gl_map=gl_map)
bud_val.validate()
if not cancel:
make_acc_dimensions_offsetting_entry(gl_map)
validate_accounting_period(gl_map)
validate_disabled_accounts(gl_map)
gl_map = process_gl_map(gl_map, merge_entries, from_repost=from_repost)
if gl_map and len(gl_map) > 1:
if gl_map[0].voucher_type != "Period Closing Voucher":
create_payment_ledger_entry(
gl_map,
cancel=0,
adv_adj=adv_adj,
update_outstanding=update_outstanding,
from_repost=from_repost,
)
save_entries(gl_map, adv_adj, update_outstanding, from_repost)
# Post GL Map process there may no be any GL Entries
elif gl_map:
frappe.throw(
_(
"Incorrect number of General Ledger Entries found. You might have selected a wrong Account in the transaction."
)
)
else:
make_reverse_gl_entries(gl_map, adv_adj=adv_adj, update_outstanding=update_outstanding)
def make_acc_dimensions_offsetting_entry(gl_map):
accounting_dimensions_to_offset = get_accounting_dimensions_for_offsetting_entry(
gl_map, gl_map[0].company
)
no_of_dimensions = len(accounting_dimensions_to_offset)
if no_of_dimensions == 0:
return
offsetting_entries = []
for gle in gl_map:
for dimension in accounting_dimensions_to_offset:
offsetting_entry = gle.copy()
debit = flt(gle.credit) / no_of_dimensions if gle.credit != 0 else 0
credit = flt(gle.debit) / no_of_dimensions if gle.debit != 0 else 0
offsetting_entry.update(
{
"account": dimension.offsetting_account,
"debit": debit,
"credit": credit,
"debit_in_account_currency": debit,
"credit_in_account_currency": credit,
"remarks": _("Offsetting for Accounting Dimension") + f" - {dimension.name}",
"against_voucher": None,
"account_currency": dimension.account_currency,
# Party Type and Party are restricted to Receivable and Payable accounts
"party_type": None,
"party": None,
}
)
offsetting_entry["against_voucher_type"] = None
offsetting_entries.append(offsetting_entry)
gl_map += offsetting_entries
def get_accounting_dimensions_for_offsetting_entry(gl_map, company):
acc_dimension = frappe.qb.DocType("Accounting Dimension")
dimension_detail = frappe.qb.DocType("Accounting Dimension Detail")
acc_dimensions = (
frappe.qb.from_(acc_dimension)
.inner_join(dimension_detail)
.on(acc_dimension.name == dimension_detail.parent)
.select(acc_dimension.fieldname, acc_dimension.name, dimension_detail.offsetting_account)
.where(
(acc_dimension.disabled == 0)
& (dimension_detail.company == company)
& (dimension_detail.automatically_post_balancing_accounting_entry == 1)
)
).run(as_dict=True)
accounting_dimensions_to_offset = []
for acc_dimension in acc_dimensions:
values = set([entry.get(acc_dimension.fieldname) for entry in gl_map])
acc_dimension.account_currency = frappe.get_cached_value(
"Account", acc_dimension.offsetting_account, "account_currency"
)
if len(values) > 1:
accounting_dimensions_to_offset.append(acc_dimension)
return accounting_dimensions_to_offset
def process_gl_map(gl_map, merge_entries=True, precision=None, from_repost=False):
if not gl_map:
return []
if gl_map[0].voucher_type != "Period Closing Voucher":
gl_map = distribute_gl_based_on_cost_center_allocation(gl_map, precision, from_repost)
if merge_entries:
gl_map = merge_similar_entries(gl_map, precision)
gl_map = toggle_debit_credit_if_negative(gl_map)
return gl_map
def distribute_gl_based_on_cost_center_allocation(gl_map, precision=None, from_repost=False):
round_off_account, default_currency = frappe.get_cached_value(
"Company", gl_map[0].company, ["round_off_account", "default_currency"]
)
if not precision:
precision = get_field_precision(
frappe.get_meta("GL Entry").get_field("debit"),
currency=default_currency,
)
new_gl_map = []
for d in gl_map:
cost_center = d.get("cost_center")
cost_center_allocation = get_cost_center_allocation_data(
gl_map[0]["company"], gl_map[0]["posting_date"], cost_center
)
if not cost_center_allocation:
new_gl_map.append(d)
continue
# Validate budget against main cost center
if not from_repost:
validate_expense_against_budget(
d, expense_amount=flt(d.debit, precision) - flt(d.credit, precision)
)
if d.account == round_off_account:
d.cost_center = cost_center_allocation[0][0]
new_gl_map.append(d)
continue
for sub_cost_center, percentage in cost_center_allocation:
gle = copy.deepcopy(d)
gle.cost_center = sub_cost_center
for field in ("debit", "credit", "debit_in_account_currency", "credit_in_account_currency"):
gle[field] = flt(flt(d.get(field)) * percentage / 100, precision)
new_gl_map.append(gle)
return new_gl_map
@request_cache
def get_cost_center_allocation_data(company, posting_date, cost_center):
cost_center_allocation = frappe.db.get_value(
"Cost Center Allocation",
{
"docstatus": 1,
"company": company,
"valid_from": ("<=", posting_date),
"main_cost_center": cost_center,
},
pluck=True,
order_by="valid_from desc",
)
if not cost_center_allocation:
return []
records = frappe.db.get_all(
"Cost Center Allocation Percentage",
{"parent": cost_center_allocation},
["cost_center", "percentage"],
as_list=True,
)
return records
def merge_similar_entries(gl_map, precision=None):
merged_gl_map = []
accounting_dimensions = get_accounting_dimensions()
merge_properties = get_merge_properties(accounting_dimensions)
for entry in gl_map:
if entry._skip_merge:
merged_gl_map.append(entry)
continue
entry.merge_key = get_merge_key(entry, merge_properties)
# if there is already an entry in this account then just add it
# to that entry
same_head = check_if_in_list(entry, merged_gl_map)
if same_head:
same_head.debit = flt(same_head.debit) + flt(entry.debit)
same_head.debit_in_account_currency = flt(same_head.debit_in_account_currency) + flt(
entry.debit_in_account_currency
)
same_head.debit_in_transaction_currency = flt(same_head.debit_in_transaction_currency) + flt(
entry.debit_in_transaction_currency
)
same_head.credit = flt(same_head.credit) + flt(entry.credit)
same_head.credit_in_account_currency = flt(same_head.credit_in_account_currency) + flt(
entry.credit_in_account_currency
)
same_head.credit_in_transaction_currency = flt(same_head.credit_in_transaction_currency) + flt(
entry.credit_in_transaction_currency
)
else:
merged_gl_map.append(entry)
company = gl_map[0].company if gl_map else erpnext.get_default_company()
company_currency = erpnext.get_company_currency(company)
if not precision:
precision = get_field_precision(
frappe.get_meta("GL Entry").get_field("debit"), currency=company_currency
)
# filter zero debit and credit entries
merged_gl_map = filter(
lambda x: flt(x.debit, precision) != 0
or flt(x.credit, precision) != 0
or (
x.voucher_type == "Journal Entry"
and frappe.get_cached_value("Journal Entry", x.voucher_no, "voucher_type")
== "Exchange Gain Or Loss"
),
merged_gl_map,
)
merged_gl_map = list(merged_gl_map)
return merged_gl_map
def get_merge_properties(dimensions=None):
merge_properties = [
"account",
"cost_center",
"party",
"party_type",
"voucher_detail_no",
"against_voucher",
"against_voucher_type",
"project",
"finance_book",
"voucher_no",
"advance_voucher_type",
"advance_voucher_no",
]
if dimensions:
merge_properties.extend(dimensions)
return merge_properties
def get_merge_key(entry, merge_properties):
merge_key = []
for fieldname in merge_properties:
merge_key.append(entry.get(fieldname, ""))
return tuple(merge_key)
def check_if_in_list(gle, gl_map):
for e in gl_map:
if e.merge_key == gle.merge_key:
return e
def toggle_debit_credit_if_negative(gl_map):
debit_credit_field_map = {
"debit": "credit",
"debit_in_account_currency": "credit_in_account_currency",
"debit_in_transaction_currency": "credit_in_transaction_currency",
}
for entry in gl_map:
# toggle debit, credit if negative entry
for debit_field, credit_field in debit_credit_field_map.items():
debit = flt(entry.get(debit_field))
credit = flt(entry.get(credit_field))
if debit < 0 and credit < 0 and debit == credit:
debit *= -1
credit *= -1
if debit < 0:
credit = credit - debit
debit = 0.0
if credit < 0:
debit = debit - credit
credit = 0.0
# update net values
# In some scenarios net value needs to be shown in the ledger
# This method updates net values as debit or credit
if entry.post_net_value and debit and credit:
if debit > credit:
debit = debit - credit
credit = 0.0
else:
credit = credit - debit
debit = 0.0
entry[debit_field] = debit
entry[credit_field] = credit
return gl_map
def save_entries(gl_map, adv_adj, update_outstanding, from_repost=False):
if not from_repost:
validate_cwip_accounts(gl_map)
process_debit_credit_difference(gl_map)
dimension_filter_map = get_dimension_filter_map()
if gl_map:
check_freezing_date(gl_map[0]["posting_date"], gl_map[0]["company"], adv_adj)
is_opening = any(d.get("is_opening") == "Yes" for d in gl_map)
if gl_map[0]["voucher_type"] != "Period Closing Voucher":
validate_against_pcv(is_opening, gl_map[0]["posting_date"], gl_map[0]["company"])
for entry in gl_map:
validate_allowed_dimensions(entry, dimension_filter_map)
make_entry(entry, adv_adj, update_outstanding, from_repost)
def make_entry(args, adv_adj, update_outstanding, from_repost=False):
gle = frappe.new_doc("GL Entry")
gle.update(args)
gle.flags.ignore_permissions = 1
gle.flags.from_repost = from_repost
gle.flags.adv_adj = adv_adj
gle.flags.update_outstanding = update_outstanding or "Yes"
gle.flags.notify_update = False
if gle.is_cancelled or is_immutable_ledger_enabled():
gle.flags.ignore_links = True
gle.submit()
if (
not from_repost
and gle.voucher_type != "Period Closing Voucher"
and (gle.is_cancelled == 0 or gle.voucher_type == "Journal Entry")
):
validate_expense_against_budget(args)
def process_debit_credit_difference(gl_map):
precision = get_field_precision(
frappe.get_meta("GL Entry").get_field("debit"),
currency=frappe.get_cached_value("Company", gl_map[0].company, "default_currency"),
)
voucher_type = gl_map[0].voucher_type
voucher_no = gl_map[0].voucher_no
allowance = get_debit_credit_allowance(voucher_type, precision)
debit_credit_diff, trx_cur_debit_credit_diff = get_debit_credit_difference(gl_map, precision)
if abs(debit_credit_diff) > allowance:
if not (
voucher_type == "Journal Entry"
and frappe.get_cached_value("Journal Entry", voucher_no, "voucher_type")
== "Exchange Gain Or Loss"
):
raise_debit_credit_not_equal_error(debit_credit_diff, voucher_type, voucher_no)
elif abs(debit_credit_diff) >= (1.0 / (10**precision)):
make_round_off_gle(gl_map, debit_credit_diff, trx_cur_debit_credit_diff, precision)
debit_credit_diff, trx_cur_debit_credit_diff = get_debit_credit_difference(gl_map, precision)
if abs(debit_credit_diff) > allowance:
if not (
voucher_type == "Journal Entry"
and frappe.get_cached_value("Journal Entry", voucher_no, "voucher_type")
== "Exchange Gain Or Loss"
):
raise_debit_credit_not_equal_error(debit_credit_diff, voucher_type, voucher_no)
def get_debit_credit_difference(gl_map, precision):
debit_credit_diff = 0.0
trx_cur_debit_credit_diff = 0
for entry in gl_map:
entry.debit = flt(entry.debit, precision)
entry.credit = flt(entry.credit, precision)
debit_credit_diff += entry.debit - entry.credit
entry.debit_in_transaction_currency = flt(entry.debit_in_transaction_currency, precision)
entry.credit_in_transaction_currency = flt(entry.credit_in_transaction_currency, precision)
trx_cur_debit_credit_diff += (
entry.debit_in_transaction_currency - entry.credit_in_transaction_currency
)
debit_credit_diff = flt(debit_credit_diff, precision)
trx_cur_debit_credit_diff = flt(trx_cur_debit_credit_diff, precision)
return debit_credit_diff, trx_cur_debit_credit_diff
def get_debit_credit_allowance(voucher_type, precision):
if voucher_type in ("Journal Entry", "Payment Entry"):
allowance = 5.0 / (10**precision)
else:
allowance = 0.5
return allowance
def raise_debit_credit_not_equal_error(debit_credit_diff, voucher_type, voucher_no):
frappe.throw(
_("Debit and Credit not equal for {0} #{1}. Difference is {2}.").format(
voucher_type, voucher_no, debit_credit_diff
)
)
def has_opening_entries(gl_map: list) -> bool:
for x in gl_map:
if x.is_opening == "Yes":
return True
return False
def make_round_off_gle(gl_map, debit_credit_diff, trx_cur_debit_credit_diff, precision):
round_off_account, round_off_cost_center, round_off_for_opening = get_round_off_account_and_cost_center(
gl_map[0].company, gl_map[0].voucher_type, gl_map[0].voucher_no
)
round_off_gle = frappe._dict()
round_off_account_exists = False
has_opening_entry = has_opening_entries(gl_map)
if has_opening_entry:
if not round_off_for_opening:
frappe.throw(
_("Please set '{0}' in Company: {1}").format(
frappe.bold("Round Off for Opening"), get_link_to_form("Company", gl_map[0].company)
)
)
account = round_off_for_opening
else:
account = round_off_account
if gl_map[0].voucher_type != "Period Closing Voucher":
for d in gl_map:
if d.account == account:
round_off_gle = d
if d.debit:
debit_credit_diff -= flt(d.debit) - flt(d.credit)
else:
debit_credit_diff += flt(d.credit)
round_off_account_exists = True
if round_off_account_exists and abs(debit_credit_diff) < (1.0 / (10**precision)):
gl_map.remove(round_off_gle)
return
if not round_off_gle:
for k in ["voucher_type", "voucher_no", "company", "posting_date", "remarks"]:
round_off_gle[k] = gl_map[0][k]
round_off_gle.update(
{
"account": account,
"debit_in_account_currency": abs(debit_credit_diff) if debit_credit_diff < 0 else 0,
"credit_in_account_currency": debit_credit_diff if debit_credit_diff > 0 else 0,
"debit": abs(debit_credit_diff) if debit_credit_diff < 0 else 0,
"credit": debit_credit_diff if debit_credit_diff > 0 else 0,
"debit_in_transaction_currency": abs(trx_cur_debit_credit_diff)
if trx_cur_debit_credit_diff < 0
else 0,
"credit_in_transaction_currency": trx_cur_debit_credit_diff
if trx_cur_debit_credit_diff > 0
else 0,
"cost_center": round_off_cost_center,
"party_type": None,
"party": None,
"is_opening": "No",
"against_voucher_type": None,
"against_voucher": None,
}
)
if has_opening_entry:
round_off_gle.update({"is_opening": "Yes"})
update_accounting_dimensions(round_off_gle)
if not round_off_account_exists:
gl_map.append(round_off_gle)
def update_accounting_dimensions(round_off_gle):
dimensions = get_accounting_dimensions()
meta = frappe.get_meta(round_off_gle["voucher_type"])
has_all_dimensions = True
for dimension in dimensions:
if not meta.has_field(dimension):
has_all_dimensions = False
if dimensions and has_all_dimensions:
dimension_values = frappe.db.get_value(
round_off_gle["voucher_type"], round_off_gle["voucher_no"], dimensions, as_dict=1
)
for dimension in dimensions:
round_off_gle[dimension] = dimension_values.get(dimension)
else:
report_type = frappe.get_cached_value("Account", round_off_gle.account, "report_type")
for dimension in get_checks_for_pl_and_bs_accounts():
if (
round_off_gle.company == dimension.company
and (
(report_type == "Profit and Loss" and dimension.mandatory_for_pl)
or (report_type == "Balance Sheet" and dimension.mandatory_for_bs)
)
and dimension.default_dimension
):
round_off_gle[dimension.fieldname] = dimension.default_dimension
def get_round_off_account_and_cost_center(company, voucher_type, voucher_no, use_company_default=False):
round_off_account, round_off_cost_center, round_off_for_opening = frappe.get_cached_value(
"Company", company, ["round_off_account", "round_off_cost_center", "round_off_for_opening"]
) or [None, None, None]
# Use expense account as fallback
if not round_off_account:
round_off_account = frappe.get_cached_value("Company", company, "default_expense_account")
meta = frappe.get_meta(voucher_type)
# Give first preference to parent cost center for round off GLE
if not use_company_default and meta.has_field("cost_center"):
parent_cost_center = frappe.db.get_value(voucher_type, voucher_no, "cost_center")
if parent_cost_center:
round_off_cost_center = parent_cost_center
if not round_off_account:
frappe.throw(
_("Please mention '{0}' in Company: {1}").format(
frappe.bold("Round Off Account"), get_link_to_form("Company", company)
)
)
if not round_off_cost_center:
frappe.throw(
_("Please mention '{0}' in Company: {1}").format(
frappe.bold("Round Off Cost Center"), get_link_to_form("Company", company)
)
)
return round_off_account, round_off_cost_center, round_off_for_opening
def make_reverse_gl_entries(
gl_entries=None,
voucher_type=None,
voucher_no=None,
adv_adj=False,
update_outstanding="Yes",
partial_cancel=False,
posting_date=None,
):
"""
Get original gl entries of the voucher
and make reverse gl entries by swapping debit and credit
"""
immutable_ledger_enabled = is_immutable_ledger_enabled()
if not gl_entries:
gl_entry = frappe.qb.DocType("GL Entry")
gl_entries = (
frappe.qb.from_(gl_entry)
.select("*")
.where(gl_entry.voucher_type == voucher_type)
.where(gl_entry.voucher_no == voucher_no)
.where(gl_entry.is_cancelled == 0)
.for_update()
).run(as_dict=1)
if gl_entries:
create_payment_ledger_entry(
gl_entries,
cancel=1,
adv_adj=adv_adj,
update_outstanding=update_outstanding,
partial_cancel=partial_cancel,
)
validate_accounting_period(gl_entries)
is_opening = any(d.get("is_opening") == "Yes" for d in gl_entries)
if immutable_ledger_enabled:
validation_date = posting_date or frappe.form_dict.get("posting_date") or getdate()
else:
validation_date = posting_date if posting_date else gl_entries[0]["posting_date"]
check_freezing_date(validation_date, gl_entries[0]["company"], adv_adj)
validate_against_pcv(is_opening, validation_date, gl_entries[0]["company"])
if partial_cancel:
# Partial cancel is only used by `Advance` in separate account feature.
# Only cancel GL entries for unlinked reference using `voucher_detail_no`
gle = frappe.qb.DocType("GL Entry")
for x in gl_entries:
query = (
frappe.qb.update(gle)
.set(gle.modified, now())
.set(gle.modified_by, frappe.session.user)
.where(
(gle.company == x.company)
& (gle.account == x.account)
& (gle.party_type == x.party_type)
& (gle.party == x.party)
& (gle.voucher_type == x.voucher_type)
& (gle.voucher_no == x.voucher_no)
& (gle.against_voucher_type == x.against_voucher_type)
& (gle.against_voucher == x.against_voucher)
& (gle.voucher_detail_no == x.voucher_detail_no)
)
)
if not immutable_ledger_enabled:
query = query.set(gle.is_cancelled, 1) # smallint column; postgres rejects boolean true
query.run()
else:
if not immutable_ledger_enabled:
gle_names = [x.get("name") for x in gl_entries]
# if names are available, cancel only that set of entries
if not all(gle_names):
set_as_cancel(gl_entries[0]["voucher_type"], gl_entries[0]["voucher_no"])
else:
gle = frappe.qb.DocType("GL Entry")
(
frappe.qb.update(gle)
.set(gle.is_cancelled, 1)
.set(gle.modified, now())
.set(gle.modified_by, frappe.session.user)
.where(gle.name.isin(gle_names) & (gle.is_cancelled == 0))
).run()
for entry in gl_entries:
new_gle = copy.deepcopy(entry)
new_gle["name"] = None
debit = new_gle.get("debit", 0)
credit = new_gle.get("credit", 0)
debit_in_account_currency = new_gle.get("debit_in_account_currency", 0)
credit_in_account_currency = new_gle.get("credit_in_account_currency", 0)
debit_in_transaction_currency = new_gle.get("debit_in_transaction_currency", 0)
credit_in_transaction_currency = new_gle.get("credit_in_transaction_currency", 0)
new_gle["debit"] = credit
new_gle["credit"] = debit
new_gle["debit_in_account_currency"] = credit_in_account_currency
new_gle["credit_in_account_currency"] = debit_in_account_currency
new_gle["debit_in_transaction_currency"] = credit_in_transaction_currency
new_gle["credit_in_transaction_currency"] = debit_in_transaction_currency
new_gle["remarks"] = "On cancellation of " + new_gle["voucher_no"]
new_gle["is_cancelled"] = 1
if immutable_ledger_enabled:
new_gle["is_cancelled"] = 0
new_gle["posting_date"] = posting_date or frappe.form_dict.get("posting_date") or getdate()
elif posting_date:
new_gle["posting_date"] = posting_date
if new_gle["debit"] or new_gle["credit"]:
make_entry(new_gle, adv_adj, "Yes")
def set_as_cancel(voucher_type, voucher_no):
"""
Set is_cancelled=1 in all original gl entries for the voucher
"""
gle = frappe.qb.DocType("GL Entry")
(
frappe.qb.update(gle)
.set(gle.is_cancelled, 1)
.set(gle.modified, now())
.set(gle.modified_by, frappe.session.user)
.where((gle.voucher_type == voucher_type) & (gle.voucher_no == voucher_no) & (gle.is_cancelled == 0))
).run()