From e9b67ff6827d944c10b14767c77fc9ed2ff3ca8a Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Tue, 9 Jun 2026 15:57:57 +0530 Subject: [PATCH] refactor(journal_entry): extract reference validation into a service Move validate_reference_doc and its helpers, plus validate_orders and validate_invoices, out of the controller into a JournalEntryReferenceValidator service under services/. Behaviour preserved; the per-reference totals stay on the document. The order/invoice validators are split into <=15-line helpers. --- .../doctype/journal_entry/journal_entry.py | 170 +--------------- .../services/reference_validator.py | 191 ++++++++++++++++++ 2 files changed, 196 insertions(+), 165 deletions(-) create mode 100644 erpnext/accounts/doctype/journal_entry/services/reference_validator.py diff --git a/erpnext/accounts/doctype/journal_entry/journal_entry.py b/erpnext/accounts/doctype/journal_entry/journal_entry.py index 6b9b21dd0ba..6f73be40fb3 100644 --- a/erpnext/accounts/doctype/journal_entry/journal_entry.py +++ b/erpnext/accounts/doctype/journal_entry/journal_entry.py @@ -11,7 +11,6 @@ from frappe.model.document import Document from frappe.utils import comma_and, cstr, flt, fmt_money, formatdate, get_link_to_form, nowdate import erpnext -from erpnext.accounts.deferred_revenue import get_deferred_booking_accounts from erpnext.accounts.doctype.invoice_discounting.invoice_discounting import ( get_party_account_based_on_invoice_discounting, ) @@ -34,13 +33,6 @@ from erpnext.assets.doctype.asset_depreciation_schedule.asset_depreciation_sched from erpnext.controllers.accounts_controller import AccountsController from erpnext.setup.utils import get_exchange_rate as _get_exchange_rate -REFERENCE_PARTY_ACCOUNT_FIELDS = { - "Sales Invoice": ["Customer", "Debit To"], - "Purchase Invoice": ["Supplier", "Credit To"], - "Sales Order": ["Customer"], - "Purchase Order": ["Supplier"], -} - class StockAccountInvalidTransaction(frappe.ValidationError): pass @@ -132,6 +124,10 @@ class JournalEntry(AccountsController): super().__init__(*args, **kwargs) def validate(self): + from erpnext.accounts.doctype.journal_entry.services.reference_validator import ( + JournalEntryReferenceValidator, + ) + if self.voucher_type == "Opening Entry": self.is_opening = "Yes" @@ -151,7 +147,7 @@ class JournalEntry(AccountsController): self.validate_against_jv() self.validate_stock_accounts() - self.validate_reference_doc() + JournalEntryReferenceValidator(self).validate() if self.docstatus == 0: self.set_against_account() self.create_remarks() @@ -748,162 +744,6 @@ class JournalEntry(AccountsController): ) ) - def validate_reference_doc(self): - """Validates reference document""" - self.reference_totals = {} - self.reference_types = {} - self.reference_accounts = {} - for d in self.get("accounts"): - self._normalize_reference_fields(d) - if not self._has_party_reference(d): - continue - self._validate_order_direction(d) - self._register_reference(d) - self._validate_reference_party_and_account(d) - - self.validate_orders() - self.validate_invoices() - - def _normalize_reference_fields(self, row): - if not row.reference_type: - row.reference_name = None - if not row.reference_name: - row.reference_type = None - - def _has_party_reference(self, row): - return bool( - row.reference_type and row.reference_name and row.reference_type in REFERENCE_PARTY_ACCOUNT_FIELDS - ) - - def _reference_amount_field(self, row): - if row.reference_type in ("Sales Order", "Sales Invoice"): - return "credit_in_account_currency" - return "debit_in_account_currency" - - def _validate_order_direction(self, row): - if row.reference_type == "Sales Order" and flt(row.debit) > 0: - frappe.throw( - _("Row {0}: Debit entry can not be linked with a {1}").format(row.idx, row.reference_type) - ) - if row.reference_type == "Purchase Order" and flt(row.credit) > 0: - frappe.throw( - _("Row {0}: Credit entry can not be linked with a {1}").format(row.idx, row.reference_type) - ) - - def _register_reference(self, row): - if row.reference_name not in self.reference_totals: - self.reference_totals[row.reference_name] = 0.0 - if self.voucher_type not in ("Deferred Revenue", "Deferred Expense"): - self.reference_totals[row.reference_name] += flt(row.get(self._reference_amount_field(row))) - self.reference_types[row.reference_name] = row.reference_type - self.reference_accounts[row.reference_name] = row.account - - def _validate_reference_party_and_account(self, row): - party_fields = REFERENCE_PARTY_ACCOUNT_FIELDS[row.reference_type] - against_voucher = frappe.db.get_value( - row.reference_type, row.reference_name, [scrub(f) for f in party_fields] - ) - if not against_voucher: - frappe.throw(_("Row {0}: Invalid reference {1}").format(row.idx, row.reference_name)) - - if row.reference_type in ("Sales Invoice", "Purchase Invoice"): - self._validate_invoice_party_and_account(row, against_voucher, party_fields) - elif row.reference_type in ("Sales Order", "Purchase Order"): - self._validate_order_party(row, against_voucher) - - def _validate_invoice_party_and_account(self, row, against_voucher, party_fields): - party_account, against_party = self._resolve_invoice_party_account(row, against_voucher) - if self.voucher_type == "Exchange Gain Or Loss": - return - if against_party != cstr(row.party) or party_account != row.account: - frappe.throw( - _("Row {0}: Party / Account does not match with {1} / {2} in {3} {4}").format( - row.idx, party_fields[0], party_fields[1], row.reference_type, row.reference_name - ) - ) - - def _resolve_invoice_party_account(self, row, against_voucher): - if self.voucher_type in ("Deferred Revenue", "Deferred Expense") and row.reference_detail_no: - debit_or_credit = "Debit" if row.debit else "Credit" - party_account = get_deferred_booking_accounts( - row.reference_type, row.reference_detail_no, debit_or_credit - ) - return party_account, "" - if row.reference_type == "Sales Invoice": - party_account = ( - get_party_account_based_on_invoice_discounting(row.reference_name) or against_voucher[1] - ) - else: - party_account = against_voucher[1] - return party_account, against_voucher[0] - - def _validate_order_party(self, row, against_voucher): - if against_voucher != row.party: - frappe.throw( - _("Row {0}: {1} {2} does not match with {3}").format( - row.idx, row.party_type, row.party, row.reference_type - ) - ) - - def validate_orders(self): - """Validate totals, closed and docstatus for orders""" - for reference_name, total in self.reference_totals.items(): - reference_type = self.reference_types[reference_name] - account = self.reference_accounts[reference_name] - - if reference_type in ("Sales Order", "Purchase Order"): - order = frappe.get_doc(reference_type, reference_name) - - if order.docstatus != 1: - frappe.throw(_("{0} {1} is not submitted").format(reference_type, reference_name)) - - if flt(order.per_billed) >= 100: - frappe.throw(_("{0} {1} is fully billed").format(reference_type, reference_name)) - - if cstr(order.status) == "Closed": - frappe.throw(_("{0} {1} is closed").format(reference_type, reference_name)) - - account_currency = get_account_currency(account) - if account_currency == self.company_currency: - voucher_total = order.base_grand_total - formatted_voucher_total = fmt_money( - voucher_total, order.precision("base_grand_total"), currency=account_currency - ) - else: - voucher_total = order.grand_total - formatted_voucher_total = fmt_money( - voucher_total, order.precision("grand_total"), currency=account_currency - ) - - if flt(voucher_total) < (flt(order.advance_paid) + total): - frappe.throw( - _("Advance paid against {0} {1} cannot be greater than Grand Total {2}").format( - reference_type, reference_name, formatted_voucher_total - ) - ) - - def validate_invoices(self): - """Validate totals and docstatus for invoices""" - for reference_name, total in self.reference_totals.items(): - reference_type = self.reference_types[reference_name] - - if reference_type in ("Sales Invoice", "Purchase Invoice") and self.voucher_type not in [ - "Debit Note", - "Credit Note", - ]: - invoice = frappe.get_doc(reference_type, reference_name) - - if invoice.docstatus != 1: - frappe.throw(_("{0} {1} is not submitted").format(reference_type, reference_name)) - - precision = invoice.precision("outstanding_amount") - if total and flt(invoice.outstanding_amount, precision) < flt(total, precision): - frappe.throw( - _("Payment against {0} {1} cannot be greater than Outstanding Amount {2}").format( - reference_type, reference_name, invoice.outstanding_amount - ) - ) - def set_against_account(self): accounts_debited, accounts_credited = [], [] if self.voucher_type in ("Deferred Revenue", "Deferred Expense"): diff --git a/erpnext/accounts/doctype/journal_entry/services/reference_validator.py b/erpnext/accounts/doctype/journal_entry/services/reference_validator.py new file mode 100644 index 00000000000..b6b25d4f4aa --- /dev/null +++ b/erpnext/accounts/doctype/journal_entry/services/reference_validator.py @@ -0,0 +1,191 @@ +# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + +import frappe +from frappe import _, scrub +from frappe.utils import cstr, flt, fmt_money + +from erpnext.accounts.deferred_revenue import get_deferred_booking_accounts +from erpnext.accounts.doctype.invoice_discounting.invoice_discounting import ( + get_party_account_based_on_invoice_discounting, +) +from erpnext.accounts.utils import get_account_currency + +REFERENCE_PARTY_ACCOUNT_FIELDS = { + "Sales Invoice": ["Customer", "Debit To"], + "Purchase Invoice": ["Supplier", "Credit To"], + "Sales Order": ["Customer"], + "Purchase Order": ["Supplier"], +} + + +class JournalEntryReferenceValidator: + """Validates Journal Entry account rows against their referenced documents. + + For each row that links a Sales/Purchase Invoice or Order, this checks the + debit/credit direction, party and account match, and aggregates per-reference + totals (held on the document as ``reference_totals``/``reference_types``/ + ``reference_accounts``) which are then validated against the referenced + orders and invoices. + """ + + def __init__(self, doc): + self.doc = doc + + def validate(self): + self.doc.reference_totals = {} + self.doc.reference_types = {} + self.doc.reference_accounts = {} + for row in self.doc.get("accounts"): + self._normalize_reference_fields(row) + if not self._has_party_reference(row): + continue + self._validate_order_direction(row) + self._register_reference(row) + self._validate_reference_party_and_account(row) + + self._validate_orders() + self._validate_invoices() + + def _normalize_reference_fields(self, row): + if not row.reference_type: + row.reference_name = None + if not row.reference_name: + row.reference_type = None + + def _has_party_reference(self, row): + return bool( + row.reference_type and row.reference_name and row.reference_type in REFERENCE_PARTY_ACCOUNT_FIELDS + ) + + def _reference_amount_field(self, row): + if row.reference_type in ("Sales Order", "Sales Invoice"): + return "credit_in_account_currency" + return "debit_in_account_currency" + + def _validate_order_direction(self, row): + if row.reference_type == "Sales Order" and flt(row.debit) > 0: + frappe.throw( + _("Row {0}: Debit entry can not be linked with a {1}").format(row.idx, row.reference_type) + ) + if row.reference_type == "Purchase Order" and flt(row.credit) > 0: + frappe.throw( + _("Row {0}: Credit entry can not be linked with a {1}").format(row.idx, row.reference_type) + ) + + def _register_reference(self, row): + if row.reference_name not in self.doc.reference_totals: + self.doc.reference_totals[row.reference_name] = 0.0 + if self.doc.voucher_type not in ("Deferred Revenue", "Deferred Expense"): + self.doc.reference_totals[row.reference_name] += flt(row.get(self._reference_amount_field(row))) + self.doc.reference_types[row.reference_name] = row.reference_type + self.doc.reference_accounts[row.reference_name] = row.account + + def _validate_reference_party_and_account(self, row): + party_fields = REFERENCE_PARTY_ACCOUNT_FIELDS[row.reference_type] + against_voucher = frappe.db.get_value( + row.reference_type, row.reference_name, [scrub(f) for f in party_fields] + ) + if not against_voucher: + frappe.throw(_("Row {0}: Invalid reference {1}").format(row.idx, row.reference_name)) + + if row.reference_type in ("Sales Invoice", "Purchase Invoice"): + self._validate_invoice_party_and_account(row, against_voucher, party_fields) + elif row.reference_type in ("Sales Order", "Purchase Order"): + self._validate_order_party(row, against_voucher) + + def _validate_invoice_party_and_account(self, row, against_voucher, party_fields): + party_account, against_party = self._resolve_invoice_party_account(row, against_voucher) + if self.doc.voucher_type == "Exchange Gain Or Loss": + return + if against_party != cstr(row.party) or party_account != row.account: + frappe.throw( + _("Row {0}: Party / Account does not match with {1} / {2} in {3} {4}").format( + row.idx, party_fields[0], party_fields[1], row.reference_type, row.reference_name + ) + ) + + def _resolve_invoice_party_account(self, row, against_voucher): + if self.doc.voucher_type in ("Deferred Revenue", "Deferred Expense") and row.reference_detail_no: + debit_or_credit = "Debit" if row.debit else "Credit" + party_account = get_deferred_booking_accounts( + row.reference_type, row.reference_detail_no, debit_or_credit + ) + return party_account, "" + if row.reference_type == "Sales Invoice": + party_account = ( + get_party_account_based_on_invoice_discounting(row.reference_name) or against_voucher[1] + ) + else: + party_account = against_voucher[1] + return party_account, against_voucher[0] + + def _validate_order_party(self, row, against_voucher): + if against_voucher != row.party: + frappe.throw( + _("Row {0}: {1} {2} does not match with {3}").format( + row.idx, row.party_type, row.party, row.reference_type + ) + ) + + def _validate_orders(self): + """Validate totals, closed and docstatus for orders""" + for reference_name, total in self.doc.reference_totals.items(): + reference_type = self.doc.reference_types[reference_name] + account = self.doc.reference_accounts[reference_name] + if reference_type not in ("Sales Order", "Purchase Order"): + continue + + order = frappe.get_doc(reference_type, reference_name) + self._validate_order_status(order, reference_type, reference_name) + self._validate_order_advance_total(order, account, total, reference_type, reference_name) + + def _validate_order_status(self, order, reference_type, reference_name): + if order.docstatus != 1: + frappe.throw(_("{0} {1} is not submitted").format(reference_type, reference_name)) + if flt(order.per_billed) >= 100: + frappe.throw(_("{0} {1} is fully billed").format(reference_type, reference_name)) + if cstr(order.status) == "Closed": + frappe.throw(_("{0} {1} is closed").format(reference_type, reference_name)) + + def _validate_order_advance_total(self, order, account, total, reference_type, reference_name): + account_currency = get_account_currency(account) + if account_currency == self.doc.company_currency: + voucher_total = order.base_grand_total + field = "base_grand_total" + else: + voucher_total = order.grand_total + field = "grand_total" + + if flt(voucher_total) < (flt(order.advance_paid) + total): + formatted_voucher_total = fmt_money( + voucher_total, order.precision(field), currency=account_currency + ) + frappe.throw( + _("Advance paid against {0} {1} cannot be greater than Grand Total {2}").format( + reference_type, reference_name, formatted_voucher_total + ) + ) + + def _validate_invoices(self): + """Validate totals and docstatus for invoices""" + if self.doc.voucher_type in ("Debit Note", "Credit Note"): + return + for reference_name, total in self.doc.reference_totals.items(): + reference_type = self.doc.reference_types[reference_name] + if reference_type not in ("Sales Invoice", "Purchase Invoice"): + continue + invoice = frappe.get_doc(reference_type, reference_name) + self._validate_invoice_outstanding(invoice, total, reference_type, reference_name) + + def _validate_invoice_outstanding(self, invoice, total, reference_type, reference_name): + if invoice.docstatus != 1: + frappe.throw(_("{0} {1} is not submitted").format(reference_type, reference_name)) + + precision = invoice.precision("outstanding_amount") + if total and flt(invoice.outstanding_amount, precision) < flt(total, precision): + frappe.throw( + _("Payment against {0} {1} cannot be greater than Outstanding Amount {2}").format( + reference_type, reference_name, invoice.outstanding_amount + ) + )