diff --git a/erpnext/accounts/doctype/accounts_settings/accounts_settings.json b/erpnext/accounts/doctype/accounts_settings/accounts_settings.json index 7910dc5a30a..498b8e6393d 100644 --- a/erpnext/accounts/doctype/accounts_settings/accounts_settings.json +++ b/erpnext/accounts/doctype/accounts_settings/accounts_settings.json @@ -78,6 +78,8 @@ "over_billing_allowance", "credit_controller", "role_allowed_to_over_bill", + "enable_overdue_billing_threshold", + "role_allowed_to_bypass_overdue_billing", "column_break_11", "assets_tab", "asset_settings_section", @@ -274,6 +276,21 @@ "label": "Role Allowed to over bill ", "options": "Role" }, + { + "default": "0", + "description": "Block submitting a new Sales Invoice when the customer's overdue amount exceeds the Overdue Billing Threshold set on the customer.", + "fieldname": "enable_overdue_billing_threshold", + "fieldtype": "Check", + "label": "Enable Overdue Billing Threshold" + }, + { + "depends_on": "eval:doc.enable_overdue_billing_threshold", + "description": "Users with this role can still submit invoices for customers over their overdue billing threshold.", + "fieldname": "role_allowed_to_bypass_overdue_billing", + "fieldtype": "Link", + "label": "Role allowed to bypass overdue billing limit", + "options": "Role" + }, { "fieldname": "period_closing_settings_section", "fieldtype": "Section Break" diff --git a/erpnext/accounts/doctype/accounts_settings/accounts_settings.py b/erpnext/accounts/doctype/accounts_settings/accounts_settings.py index 59eb671b33b..b46f01152aa 100644 --- a/erpnext/accounts/doctype/accounts_settings/accounts_settings.py +++ b/erpnext/accounts/doctype/accounts_settings/accounts_settings.py @@ -78,6 +78,7 @@ class AccountsSettings(Document): enable_fuzzy_matching: DF.Check enable_immutable_ledger: DF.Check enable_loyalty_point_program: DF.Check + enable_overdue_billing_threshold: DF.Check enable_party_matching: DF.Check enable_subscription: DF.Check exchange_gain_loss_posting_date: DF.Literal["Invoice", "Payment", "Reconciliation Date"] @@ -97,6 +98,7 @@ class AccountsSettings(Document): receivable_payable_remarks_length: DF.Int reconciliation_queue_size: DF.Int repost_allowed_types: DF.Table[RepostAllowedTypes] + role_allowed_to_bypass_overdue_billing: DF.Link | None role_allowed_to_over_bill: DF.Link | None role_to_notify_on_depreciation_failure: DF.Link | None role_to_override_stop_action: DF.Link | None @@ -152,6 +154,10 @@ class AccountsSettings(Document): toggle_subscription_sections(not self.enable_subscription) clear_cache = True + if old_doc.enable_overdue_billing_threshold != self.enable_overdue_billing_threshold: + toggle_overdue_billing_threshold_field(not self.enable_overdue_billing_threshold) + clear_cache = True + if clear_cache: frappe.clear_cache() @@ -243,6 +249,10 @@ def toggle_subscription_sections(hide): create_property_setter_for_hiding_field(doctype, "subscription_section", hide) +def toggle_overdue_billing_threshold_field(hide): + create_property_setter_for_hiding_field("Customer Credit Limit", "overdue_billing_threshold", hide) + + def create_property_setter_for_hiding_field(doctype, field_name, hide): make_property_setter( doctype, diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py index b754a4d0f35..e2969ec23ce 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py @@ -465,6 +465,7 @@ class SalesInvoice(SellingController): self.update_billing_status_for_zero_amount_refdoc("Delivery Note") self.update_billing_status_for_zero_amount_refdoc("Sales Order") self.check_credit_limit() + self.check_overdue_billing_threshold() if cint(self.is_pos) != 1 and not self.is_return: self.update_against_document_in_jv() @@ -669,6 +670,11 @@ class SalesInvoice(SellingController): if validate_against_credit_limit: check_credit_limit(self.customer, self.company, bypass_credit_limit_check_at_sales_order) + def check_overdue_billing_threshold(self): + from erpnext.selling.doctype.customer.customer import check_overdue_billing_threshold + + check_overdue_billing_threshold(self.customer, self.company) + @frappe.whitelist() def set_missing_values(self, for_validate: bool = False): pos = POSService(self).set_pos_fields(for_validate) diff --git a/erpnext/selling/doctype/customer/customer.json b/erpnext/selling/doctype/customer/customer.json index 848d99e6a8f..786c834a06f 100644 --- a/erpnext/selling/doctype/customer/customer.json +++ b/erpnext/selling/doctype/customer/customer.json @@ -470,10 +470,10 @@ "report_hide": 1 }, { - "description": "Transactions are blocked or warned when outstanding balance exceeds this amount.", + "description": "Transactions are blocked when the outstanding balance exceeds the credit limit. When the overdue billing setting is enabled, new invoices are also blocked when the customer's overdue amount exceeds the overdue billing threshold.", "fieldname": "credit_limits", "fieldtype": "Table", - "label": "Credit Limit", + "label": "Credit & Overdue Limits", "options": "Customer Credit Limit", "show_description_on_click": 1 }, diff --git a/erpnext/selling/doctype/customer/customer.py b/erpnext/selling/doctype/customer/customer.py index bafb7bb2ce0..1bdae5b2944 100644 --- a/erpnext/selling/doctype/customer/customer.py +++ b/erpnext/selling/doctype/customer/customer.py @@ -16,7 +16,7 @@ from frappe.model.naming import set_name_by_naming_series, set_name_from_naming_ from frappe.model.utils.rename_doc import update_linked_doctypes from frappe.query_builder import CustomFunction, Field, functions from frappe.query_builder.functions import Cast, Coalesce, Max -from frappe.utils import cint, cstr, flt, get_formatted_email, today +from frappe.utils import cint, cstr, flt, fmt_money, get_formatted_email, getdate, today from frappe.utils.user import get_users_with_role from erpnext.accounts.party import ( @@ -210,17 +210,21 @@ class Customer(TransactionBase): self.credit_limits = [] self.payment_terms = self.default_price_list = "" - tables = [["accounts", "account"], ["credit_limits", "credit_limit"]] + tables = [ + ["accounts", ["account"]], + ["credit_limits", ["credit_limit", "overdue_billing_threshold"]], + ] fields = ["payment_terms", "default_price_list"] for row in tables: - table, field = row[0], row[1] + table, table_fields = row[0], row[1] if not doc.get(table): continue for entry in doc.get(table): child = self.append(table) - child.update({"company": entry.company, field: entry.get(field)}) + child.update({"company": entry.company}) + child.update({field: entry.get(field) for field in table_fields}) for field in fields: if not doc.get(field): @@ -409,6 +413,9 @@ class Customer(TransactionBase): else: company_record.append(limit.company) + if not flt(limit.credit_limit): + continue + outstanding_amt = get_customer_outstanding( self.name, limit.company, ignore_outstanding_sales_order=limit.bypass_credit_limit_check ) @@ -576,6 +583,126 @@ def send_emails( frappe.sendmail(recipients=credit_controller_users_list, subject=subject, message=message) +def check_overdue_billing_threshold(customer: str, company: str) -> None: + if not frappe.get_single_value("Accounts Settings", "enable_overdue_billing_threshold"): + return + + threshold = get_overdue_billing_threshold(customer, company) + if not threshold: + return + + overdue_amount = get_customer_overdue_amount(customer, company) + if overdue_amount <= threshold: + return + + bypass_role = frappe.get_single_value("Accounts Settings", "role_allowed_to_bypass_overdue_billing") + if bypass_role and bypass_role in frappe.get_roles(): + return + + company_currency = frappe.get_cached_value("Company", company, "default_currency") + frappe.throw( + _( + "Customer {0} has an overdue billing limit. Overdue amount {1} exceeds the allowed threshold {2}." + ).format( + customer, + fmt_money(overdue_amount, currency=company_currency), + fmt_money(threshold, currency=company_currency), + ), + title=_("Overdue Billing Limit Crossed"), + ) + + +def get_overdue_billing_threshold(customer: str, company: str) -> float: + """Threshold set on the customer, falling back to its customer group.""" + threshold = frappe.db.get_value( + "Customer Credit Limit", + {"parent": customer, "parenttype": "Customer", "company": company}, + "overdue_billing_threshold", + ) + + if not threshold: + customer_group = frappe.get_cached_value("Customer", customer, "customer_group") + threshold = frappe.db.get_value( + "Customer Credit Limit", + {"parent": customer_group, "parenttype": "Customer Group", "company": company}, + "overdue_billing_threshold", + ) + + return flt(threshold) + + +def get_customer_overdue_amount(customer: str, company: str) -> float: + """Amount the customer owes past its due date, in company currency. + + Follows the same rule as the Overdue invoice status, so a customer is only + blocked for what the invoice list already shows as overdue. + """ + invoices = get_outstanding_invoices_for_customer(customer, company) + if not invoices: + return 0.0 + + payable_amounts = get_past_due_payable_amounts([d.name for d in invoices]) + return flt(sum(get_overdue_portion(d, payable_amounts.get(d.name)) for d in invoices)) + + +def get_outstanding_invoices_for_customer(customer: str, company: str) -> list[frappe._dict]: + from frappe.query_builder.functions import Sum + + gl_entry = frappe.qb.DocType("GL Entry") + sales_invoice = frappe.qb.DocType("Sales Invoice") + + # debit - credit is always booked in company currency, so this is comparable to the threshold + outstanding = Sum(gl_entry.debit) - Sum(gl_entry.credit) + + return ( + frappe.qb.from_(gl_entry) + .inner_join(sales_invoice) + .on(sales_invoice.name == gl_entry.against_voucher) + .select( + sales_invoice.name, + sales_invoice.due_date, + sales_invoice.base_grand_total, + outstanding.as_("outstanding"), + ) + .where(gl_entry.party_type == "Customer") + .where(gl_entry.party == customer) + .where(gl_entry.company == company) + .where(gl_entry.is_cancelled == 0) + .where(gl_entry.against_voucher_type == "Sales Invoice") + .groupby(sales_invoice.name, sales_invoice.due_date, sales_invoice.base_grand_total) + .having(outstanding > 0) + ).run(as_dict=True) + + +def get_past_due_payable_amounts(invoices: list[str]) -> dict[str, float]: + from frappe.query_builder.functions import Sum + + payment_schedule = frappe.qb.DocType("Payment Schedule") + + rows = ( + frappe.qb.from_(payment_schedule) + .select(payment_schedule.parent, Sum(payment_schedule.base_payment_amount).as_("payable")) + .where(payment_schedule.parenttype == "Sales Invoice") + .where(payment_schedule.parent.isin(invoices)) + .where(payment_schedule.due_date < getdate()) + .groupby(payment_schedule.parent) + ).run(as_dict=True) + + return {d.parent: flt(d.payable) for d in rows} + + +def get_overdue_portion(invoice: frappe._dict, payable_amount: float | None) -> float: + outstanding = flt(invoice.outstanding) + + # No payable amount means either a schedule-less invoice (POS, opening) or one whose terms are + # all still in the future. Both are answered by the invoice due date, which is the last term. + if payable_amount is None: + return outstanding if invoice.due_date and getdate(invoice.due_date) < getdate() else 0.0 + + paid = flt(invoice.base_grand_total) - outstanding + return min(max(payable_amount - paid, 0.0), outstanding) + + def get_customer_outstanding(customer, company, ignore_outstanding_sales_order=False, cost_center=None): from frappe.query_builder import Criterion from frappe.query_builder.functions import Coalesce, IfNull, Sum diff --git a/erpnext/selling/doctype/customer/test_customer.py b/erpnext/selling/doctype/customer/test_customer.py index 164d0760dca..721ea466938 100644 --- a/erpnext/selling/doctype/customer/test_customer.py +++ b/erpnext/selling/doctype/customer/test_customer.py @@ -5,13 +5,15 @@ import json import frappe -from frappe.utils import flt, nowdate +from frappe.utils import add_days, flt, getdate, nowdate from erpnext.accounts.party import get_due_date from erpnext.exceptions import PartyDisabled, PartyFrozen from erpnext.selling.doctype.customer.customer import ( get_credit_limit, get_customer_outstanding, + get_customer_overdue_amount, + get_overdue_billing_threshold, ) from erpnext.selling.doctype.customer.mapper import ( make_quotation, @@ -93,7 +95,11 @@ class TestCustomer(ERPNextTestSuite): "company": "_Test Company", "account": "Creditors - _TC", } - test_credit_limits = {"company": "_Test Company", "credit_limit": 350000} + test_credit_limits = { + "company": "_Test Company", + "credit_limit": 350000, + "overdue_billing_threshold": 5000, + } doc.append("accounts", test_account_details) doc.append("credit_limits", test_credit_limits) doc.insert() @@ -113,6 +119,7 @@ class TestCustomer(ERPNextTestSuite): self.assertEqual(c_doc.credit_limits[0].company, "_Test Company") self.assertEqual(c_doc.credit_limits[0].credit_limit, 350000) + self.assertEqual(c_doc.credit_limits[0].overdue_billing_threshold, 5000) c_doc.delete() doc.delete() @@ -368,6 +375,128 @@ class TestCustomer(ERPNextTestSuite): ) self.assertRaises(frappe.ValidationError, customer.save) + def test_get_customer_overdue_amount(self): + from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice + + baseline = get_customer_overdue_amount("_Test Customer", "_Test Company") + + # a past-due, unpaid invoice adds its outstanding to the overdue amount + create_sales_invoice(qty=1, rate=500, posting_date=add_days(nowdate(), -30)) + self.assertEqual(get_customer_overdue_amount("_Test Customer", "_Test Company"), baseline + 500) + + # an invoice due today (not yet past due) does not + create_sales_invoice(qty=1, rate=700, posting_date=nowdate()) + self.assertEqual(get_customer_overdue_amount("_Test Customer", "_Test Company"), baseline + 500) + + def test_get_customer_overdue_amount_is_in_company_currency(self): + from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice + + baseline = get_customer_overdue_amount("_Test Customer USD", "_Test Company") + + # 100 USD at a conversion rate of 50 must be counted as 5000 in company currency + create_sales_invoice( + customer="_Test Customer USD", + debit_to="_Test Receivable USD - _TC", + currency="USD", + conversion_rate=50, + qty=1, + rate=100, + posting_date=add_days(nowdate(), -30), + ) + + self.assertEqual(get_customer_overdue_amount("_Test Customer USD", "_Test Company"), baseline + 5000) + + def test_get_customer_overdue_amount_follows_payment_terms(self): + from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry + from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice + + def make_invoice_with_terms(): + si = create_sales_invoice( + qty=1, rate=1200, posting_date=add_days(nowdate(), -60), do_not_save=True + ) + si.append("payment_schedule", {"due_date": add_days(nowdate(), -60), "invoice_portion": 50}) + si.append("payment_schedule", {"due_date": add_days(nowdate(), 30), "invoice_portion": 50}) + si.insert() + si.submit() + return si + + baseline = get_customer_overdue_amount("_Test Customer", "_Test Company") + + # only the term that has fallen due counts, not the whole 1200 balance. The invoice due_date + # is the last term (in 30 days), so this is only caught by reading the payment schedule. + si = make_invoice_with_terms() + self.assertEqual(getdate(si.due_date), getdate(add_days(nowdate(), 30))) + self.assertEqual(get_customer_overdue_amount("_Test Customer", "_Test Company"), baseline + 600) + + # paying off the past-due term clears the overdue amount + pe = get_payment_entry("Sales Invoice", si.name, bank_account="_Test Bank - _TC") + pe.reference_no = "_Test Overdue Payment" + pe.reference_date = nowdate() + pe.paid_amount = pe.received_amount = 600 + pe.references[0].allocated_amount = 600 + pe.insert() + pe.submit() + self.assertEqual(get_customer_overdue_amount("_Test Customer", "_Test Company"), baseline) + + def test_overdue_billing_threshold_on_submit(self): + from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice + + create_sales_invoice(qty=1, rate=1000, posting_date=add_days(nowdate(), -30)) + overdue = get_customer_overdue_amount("_Test Customer", "_Test Company") + + settings = frappe.get_single("Accounts Settings") + settings.enable_overdue_billing_threshold = 1 + settings.role_allowed_to_bypass_overdue_billing = None + settings.save() + set_overdue_billing_threshold("_Test Customer", "_Test Company", overdue - 100) + + # overdue is over the threshold and the user has no bypass role -> blocked + si = create_sales_invoice(do_not_submit=True) + self.assertRaises(frappe.ValidationError, si.submit) + + # a user holding the bypass role can still submit + settings.role_allowed_to_bypass_overdue_billing = "Accounts Manager" + settings.save() + si = create_sales_invoice(do_not_submit=True) + si.submit() + self.assertEqual(si.docstatus, 1) + + # threshold still crossed, but the feature is off -> never blocked + settings.enable_overdue_billing_threshold = 0 + settings.role_allowed_to_bypass_overdue_billing = None + settings.save() + si = create_sales_invoice(do_not_submit=True) + si.submit() + self.assertEqual(si.docstatus, 1) + + def test_overdue_billing_threshold_falls_back_to_customer_group(self): + customer_group = frappe.get_cached_value("Customer", "_Test Customer", "customer_group") + group = frappe.get_doc("Customer Group", customer_group) + group.credit_limits = [] + group.append("credit_limits", {"company": "_Test Company", "overdue_billing_threshold": 5000}) + group.save() + + # the customer has no threshold of its own, so the group's applies + self.assertEqual(get_overdue_billing_threshold("_Test Customer", "_Test Company"), 5000) + + # a threshold on the customer wins over the group + set_overdue_billing_threshold("_Test Customer", "_Test Company", 2000) + self.assertEqual(get_overdue_billing_threshold("_Test Customer", "_Test Company"), 2000) + + def test_overdue_threshold_row_without_credit_limit(self): + from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice + + # outstanding must be > 0 so a 0 credit_limit would previously trip the check + create_sales_invoice(qty=1, rate=500) + + customer = frappe.get_doc("Customer", "_Test Customer") + customer.credit_limits = [] + customer.append("credit_limits", {"company": "_Test Company", "overdue_billing_threshold": 1000}) + customer.save() + + self.assertEqual(customer.credit_limits[0].overdue_billing_threshold, 1000) + self.assertEqual(flt(customer.credit_limits[0].credit_limit), 0.0) + def test_customer_payment_terms(self): frappe.db.set_value( "Customer", "_Test Customer With Template", "payment_terms", "_Test Payment Term Template 3" @@ -476,6 +605,18 @@ def set_credit_limit(customer, company, credit_limit): customer.credit_limits[-1].db_insert() +def set_overdue_billing_threshold(customer, company, threshold): + customer = frappe.get_doc("Customer", customer) + for d in customer.credit_limits: + if d.company == company: + d.overdue_billing_threshold = threshold + d.db_update() + return + + customer.append("credit_limits", {"company": company, "overdue_billing_threshold": threshold}) + customer.credit_limits[-1].db_insert() + + def create_internal_customer(customer_name=None, represents_company=None, allowed_to_interact_with=None): if not customer_name: customer_name = represents_company diff --git a/erpnext/selling/doctype/customer_credit_limit/customer_credit_limit.json b/erpnext/selling/doctype/customer_credit_limit/customer_credit_limit.json index f738b3629fa..26ac31cb98d 100644 --- a/erpnext/selling/doctype/customer_credit_limit/customer_credit_limit.json +++ b/erpnext/selling/doctype/customer_credit_limit/customer_credit_limit.json @@ -8,6 +8,7 @@ "company", "column_break_2", "credit_limit", + "overdue_billing_threshold", "bypass_credit_limit_check" ], "fields": [ @@ -18,6 +19,15 @@ "in_list_view": 1, "label": "Credit Limit" }, + { + "columns": 3, + "description": "New Sales Invoices are blocked when the customer's overdue amount exceeds this. Requires 'Enable Overdue Billing Threshold' in Accounts Settings.", + "fieldname": "overdue_billing_threshold", + "fieldtype": "Currency", + "hidden": 1, + "in_list_view": 1, + "label": "Overdue Billing Threshold" + }, { "fieldname": "column_break_2", "fieldtype": "Column Break" diff --git a/erpnext/selling/doctype/customer_credit_limit/customer_credit_limit.py b/erpnext/selling/doctype/customer_credit_limit/customer_credit_limit.py index fcc6c6e6db6..e0e21d71c91 100644 --- a/erpnext/selling/doctype/customer_credit_limit/customer_credit_limit.py +++ b/erpnext/selling/doctype/customer_credit_limit/customer_credit_limit.py @@ -18,6 +18,7 @@ class CustomerCreditLimit(Document): bypass_credit_limit_check: DF.Check company: DF.Link | None credit_limit: DF.Currency + overdue_billing_threshold: DF.Currency parent: DF.Data parentfield: DF.Data parenttype: DF.Data diff --git a/erpnext/setup/doctype/customer_group/customer_group.json b/erpnext/setup/doctype/customer_group/customer_group.json index 40317c2f8f7..5461155e409 100644 --- a/erpnext/setup/doctype/customer_group/customer_group.json +++ b/erpnext/setup/doctype/customer_group/customer_group.json @@ -132,7 +132,7 @@ { "fieldname": "credit_limits", "fieldtype": "Table", - "label": "Credit Limit", + "label": "Credit & Overdue Limits", "options": "Customer Credit Limit" } ],