refactor(accounts): extract deferred-accounting and document-schedule out of AccountsController

Continues the AccountsController service decomposition (Phase 5).

- Add accounts/services/deferred_accounting.py with DeferredAccountingService
  owning the deferred revenue/expense validations (income/expense account
  defaulting and service start/end date checks).
- Move the document-schedule orchestration (validate_all_documents_schedule
  and the invoice/non-invoice variants) into PaymentScheduleService, where
  they already delegated, removing the controller-to-service round trip.
- Update the three validate() call sites; keep
  validate_auto_repeat_subscription_dates on the controller (still called by
  buying/selling controllers).

No behavior change. accounts_controller.py 1818 -> 1745 lines.
This commit is contained in:
Nabin Hait
2026-07-09 17:55:19 +05:30
parent d418dd9e70
commit 174027bd57
3 changed files with 99 additions and 82 deletions

View File

@@ -0,0 +1,57 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
"""Deferred revenue/expense accounting validations."""
import frappe
from frappe import _
from frappe.utils import getdate
DEFERRED_ACCOUNT_FIELD = {
"Sales Invoice": "deferred_revenue_account",
"Purchase Invoice": "deferred_expense_account",
}
class DeferredAccountingService:
def __init__(self, doc):
self.doc = doc
def validate_income_expense_account(self) -> None:
account_field = DEFERRED_ACCOUNT_FIELD.get(self.doc.doctype)
for item in self.doc.get("items"):
if not self._is_deferred(item) or item.get(account_field):
continue
default_account = frappe.get_cached_value("Company", self.doc.company, "default_" + account_field)
if not default_account:
frappe.throw(
_(
"Row #{0}: Please update deferred revenue/expense account in item row or default account in company master"
).format(item.idx)
)
item.set(account_field, default_account)
def validate_start_and_end_date(self) -> None:
for item in self.doc.items:
if not self._is_deferred(item):
continue
if not (item.service_start_date and item.service_end_date):
frappe.throw(
_("Row #{0}: Service Start and End Date is required for deferred accounting").format(
item.idx
)
)
elif getdate(item.service_start_date) > getdate(item.service_end_date):
frappe.throw(
_("Row #{0}: Service Start Date cannot be greater than Service End Date").format(item.idx)
)
elif getdate(self.doc.posting_date) > getdate(item.service_end_date):
frappe.throw(
_("Row #{0}: Service End Date cannot be before Invoice Posting Date").format(item.idx)
)
def _is_deferred(self, item) -> bool:
return bool(item.get("enable_deferred_revenue") or item.get("enable_deferred_expense"))

View File

@@ -293,6 +293,39 @@ class PaymentScheduleService:
_("Total Payment Amount in Payment Schedule must be equal to Grand / Rounded Total")
)
def validate_all_documents_schedule(self) -> None:
if self.doc.doctype in ("Sales Invoice", "Purchase Invoice"):
self.validate_invoice_documents_schedule()
elif self.doc.doctype in ("Quotation", "Purchase Order", "Sales Order"):
self.validate_non_invoice_documents_schedule()
def validate_invoice_documents_schedule(self) -> None:
doc = self.doc
if (
doc.is_return
or (doc.doctype == "Purchase Invoice" and doc.is_paid)
or (doc.doctype == "Sales Invoice" and doc.is_pos)
or doc.get("is_opening") == "Yes"
):
doc.payment_terms_template = ""
doc.payment_schedule = []
if doc.is_return:
return
self.validate_payment_schedule_dates()
self.set_due_date()
self.set_payment_schedule()
if not doc.get("ignore_default_payment_terms_template"):
self.validate_payment_schedule_amount()
doc.validate_due_date()
doc.validate_advance_entries()
def validate_non_invoice_documents_schedule(self) -> None:
self.set_payment_schedule()
self.validate_payment_schedule_dates()
self.validate_payment_schedule_amount()
def linked_order_has_payment_terms_template(po_or_so, doctype) -> str | None:
return frappe.get_value(doctype, po_or_so, "payment_terms_template")

View File

@@ -234,7 +234,9 @@ class AccountsController(TransactionBase):
if self.is_return:
self.validate_qty()
else:
self.validate_deferred_start_and_end_date()
from erpnext.accounts.services.deferred_accounting import DeferredAccountingService
DeferredAccountingService(self).validate_start_and_end_date()
from erpnext.accounts.services.internal_transfer import InternalTransferService
@@ -262,7 +264,9 @@ class AccountsController(TransactionBase):
validate_return(self)
self.validate_all_documents_schedule()
from erpnext.accounts.services.payment_schedule import PaymentScheduleService
PaymentScheduleService(self).validate_all_documents_schedule()
from erpnext.accounts.services.party_validation import PartyValidator
@@ -286,7 +290,9 @@ class AccountsController(TransactionBase):
self.set_advance_gain_or_loss()
self.validate_deferred_income_expense_account()
from erpnext.accounts.services.deferred_accounting import DeferredAccountingService
DeferredAccountingService(self).validate_income_expense_account()
InternalTransferService(self).set_account()
if self.doctype == "Purchase Invoice":
@@ -504,89 +510,10 @@ class AccountsController(TransactionBase):
)
)
def validate_deferred_income_expense_account(self):
field_map = {
"Sales Invoice": "deferred_revenue_account",
"Purchase Invoice": "deferred_expense_account",
}
for item in self.get("items"):
if item.get("enable_deferred_revenue") or item.get("enable_deferred_expense"):
if not item.get(field_map.get(self.doctype)):
default_deferred_account = frappe.get_cached_value(
"Company", self.company, "default_" + field_map.get(self.doctype)
)
if not default_deferred_account:
frappe.throw(
_(
"Row #{0}: Please update deferred revenue/expense account in item row or default account in company master"
).format(item.idx)
)
else:
item.set(field_map.get(self.doctype), default_deferred_account)
def validate_auto_repeat_subscription_dates(self):
if self.get("from_date") and self.get("to_date") and getdate(self.from_date) > getdate(self.to_date):
frappe.throw(_("To Date cannot be before From Date"), title=_("Invalid Auto Repeat Date"))
def validate_deferred_start_and_end_date(self):
for d in self.items:
if d.get("enable_deferred_revenue") or d.get("enable_deferred_expense"):
if not (d.service_start_date and d.service_end_date):
frappe.throw(
_("Row #{0}: Service Start and End Date is required for deferred accounting").format(
d.idx
)
)
elif getdate(d.service_start_date) > getdate(d.service_end_date):
frappe.throw(
_("Row #{0}: Service Start Date cannot be greater than Service End Date").format(
d.idx
)
)
elif getdate(self.posting_date) > getdate(d.service_end_date):
frappe.throw(
_("Row #{0}: Service End Date cannot be before Invoice Posting Date").format(d.idx)
)
def validate_invoice_documents_schedule(self):
if (
self.is_return
or (self.doctype == "Purchase Invoice" and self.is_paid)
or (self.doctype == "Sales Invoice" and self.is_pos)
or self.get("is_opening") == "Yes"
):
self.payment_terms_template = ""
self.payment_schedule = []
if self.is_return:
return
from erpnext.accounts.services.payment_schedule import PaymentScheduleService
ps = PaymentScheduleService(self)
ps.validate_payment_schedule_dates()
ps.set_due_date()
ps.set_payment_schedule()
if not self.get("ignore_default_payment_terms_template"):
ps.validate_payment_schedule_amount()
self.validate_due_date()
self.validate_advance_entries()
def validate_non_invoice_documents_schedule(self):
from erpnext.accounts.services.payment_schedule import PaymentScheduleService
ps = PaymentScheduleService(self)
ps.set_payment_schedule()
ps.validate_payment_schedule_dates()
ps.validate_payment_schedule_amount()
def validate_all_documents_schedule(self):
if self.doctype in ("Sales Invoice", "Purchase Invoice"):
self.validate_invoice_documents_schedule()
elif self.doctype in ("Quotation", "Purchase Order", "Sales Order"):
self.validate_non_invoice_documents_schedule()
def before_print(self, settings=None):
if self.doctype in [
"Purchase Order",