mirror of
https://github.com/frappe/erpnext.git
synced 2026-07-30 15:54:35 +00:00
feat: block sales invoice submit when customer overdue exceeds threshold (backport #57230, #57298) (#57438)
* feat: block sales invoice submit when customer overdue exceeds threshold (#57230) * feat: block sales invoice submit when customer overdue exceeds threshold Adds an opt-in, per-customer Overdue Billing Threshold. When enabled in Accounts Settings, submitting a Sales Invoice is blocked if the customer's overdue amount exceeds their threshold, unless the current user holds a configured bypass role. Modeled on the existing credit limit feature. - Accounts Settings (Credit Limits tab): enable toggle + bypass role. - Per-customer threshold on the Customer Credit Limit table, shown only when the feature is enabled via a property setter (same mechanism as subscription / accounting dimension sections). Table relabeled to "Credit & Overdue Limits". - Overdue is read live from the ledger via get_outstanding_invoices (payments already netted), summing Sales Invoices past their due date. - Enforced in Sales Invoice on_submit, after the credit-limit check; returns are exempt. - validate_credit_limit_on_change no longer trips when a row sets only the overdue threshold (credit_limit = 0). Fixes #52960 * fix: compute overdue amount in company currency and format with fmt_money get_customer_overdue_amount now sums GL Entry debit - credit grouped per invoice, which is always booked in company currency, instead of using get_outstanding_invoices which returns the receivable-account currency. The threshold is in company currency, so the previous comparison could mix currencies for customers with a foreign-currency receivable account. This mirrors how get_customer_outstanding computes the figure for the existing credit-limit check. The blocking message now formats both amounts with fmt_money using the company currency. Adds a test asserting a 100 USD invoice at a conversion rate of 50 is counted as 5000 in company currency. * refactor: drop redundant threshold coercion and dead test cleanup - Coerce the overdue threshold with flt() once when reading it, instead of calling flt() on it at each of the three use sites. - Remove a no-op set_overdue_billing_threshold() call in the feature-disabled block (the threshold was already set to that value) and the trailing reset, which is dead since each test is rolled back. No behaviour change. * fix: compute overdue amount from payment terms, matching the Overdue status The overdue amount keyed on Sales Invoice.due_date, which set_due_date() sets to the LAST payment term. An invoice whose first term was past due and unpaid was therefore counted as zero, even though ERPNext already shows it as Overdue in the invoice list. The gate and the UI could disagree. get_customer_overdue_amount now follows the same rule as is_overdue(): per invoice, the amount that has fallen due (sum of payment schedule terms past their due date) minus what has been paid, clamped to the outstanding balance. Invoices without a schedule (POS, opening) still fall back to the invoice due date, mirroring is_overdue()'s own guard. The ledger stays the source of truth for what is unpaid: the outstanding per invoice is still SUM(debit) - SUM(credit) from GL Entry. base_payment_amount is always stored in company currency, so no currency conversion is needed and the comparison against the threshold stays consistent. Adds a test covering a two-term invoice: only the past-due term counts, and paying it off clears the overdue amount. * feat: honour the overdue billing threshold set on the customer group The threshold lives on Customer Credit Limit, which is also rendered on Customer Group. A threshold set there was stored but never evaluated, so the configuration was a silent no-op. get_overdue_billing_threshold now reads the customer's row and falls back to its customer group, mirroring get_credit_limit. The group's bypass_credit_limit_check is deliberately not consulted: it is labelled for the credit limit check at sales order and is unrelated to overdue billing. get_customer_group_details also dropped the threshold when copying group rows onto a customer, because it copied a single hardcoded field per table. It now copies a list of fields per table, so credit_limit and overdue_billing_threshold both carry over. * refactor: clearer labels for the overdue billing control (#57298) refactor: clearer labels and messages, drop "threshold" wording User-facing text only, no field or behaviour changes: - Accounts Settings toggle label -> "Restrict Customer Over Billing". - Bypass role label -> "Role Allowed to Bypass Over Billing Restriction". - Customer Credit Limit field label -> "Overdue Limit". - Rewrote the descriptions and the block message to match and to stop saying "threshold". * fix: treat zero overdue limit as opt-out and isolate settings in test get_overdue_billing_threshold treated an explicit 0 on the customer's credit limit row as "not set" and fell back to the customer group. A customer could not be exempted from the group restriction while keeping a credit limit row, and every existing row defaults to 0, so enabling the feature on a group blocked all its customers that had any credit limit row. Guard the group fallback on "threshold is None" (no row for the company) instead of a falsy check, so an explicit 0 acts as an opt-out. test_overdue_billing_threshold_on_submit mutated the Accounts Settings singleton without restoring it, so a failed assertion mid-test leaked enable_overdue_billing_threshold and the bypass role into later tests that submit sales invoices. Wrap the mutations in try/finally and restore the originals. * fix: let a zero customer overdue limit inherit the group's limit A 0 on the customer's credit limit row falls back to the customer group again; only a non-zero value on the customer overrides the group. Reverts the earlier opt-out interpretation and updates the fallback test to expect the group's limit.
This commit is contained in:
@@ -75,6 +75,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",
|
||||
@@ -271,6 +273,21 @@
|
||||
"label": "Role Allowed to over bill ",
|
||||
"options": "Role"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"description": "Block a new Sales Invoice when the customer's overdue amount exceeds the Overdue Limit set on the customer.",
|
||||
"fieldname": "enable_overdue_billing_threshold",
|
||||
"fieldtype": "Check",
|
||||
"label": "Restrict Customer Over Billing"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:doc.enable_overdue_billing_threshold",
|
||||
"description": "Users with this role can still submit invoices for customers who have crossed their Overdue Limit.",
|
||||
"fieldname": "role_allowed_to_bypass_overdue_billing",
|
||||
"fieldtype": "Link",
|
||||
"label": "Role Allowed to Bypass Over Billing Restriction",
|
||||
"options": "Role"
|
||||
},
|
||||
{
|
||||
"fieldname": "period_closing_settings_section",
|
||||
"fieldtype": "Section Break"
|
||||
|
||||
@@ -77,6 +77,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"]
|
||||
@@ -95,6 +96,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
|
||||
@@ -150,6 +152,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()
|
||||
|
||||
@@ -241,6 +247,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,
|
||||
|
||||
@@ -517,6 +517,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()
|
||||
@@ -778,6 +779,11 @@ class SalesInvoice(SellingController):
|
||||
pos_invoice_doc = frappe.get_doc("POS Invoice", pos_invoice)
|
||||
pos_invoice_doc.cancel()
|
||||
|
||||
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=False):
|
||||
pos = self.set_pos_fields(for_validate)
|
||||
|
||||
@@ -475,10 +475,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 Restrict Customer Over Billing is enabled, new invoices are also blocked when the customer's overdue amount exceeds the Overdue Limit.",
|
||||
"fieldname": "credit_limits",
|
||||
"fieldtype": "Table",
|
||||
"label": "Credit Limit",
|
||||
"label": "Credit & Overdue Limits",
|
||||
"options": "Customer Credit Limit",
|
||||
"show_description_on_click": 1
|
||||
},
|
||||
|
||||
@@ -16,7 +16,7 @@ from frappe.model.mapper import get_mapped_doc
|
||||
from frappe.model.naming import set_name_by_naming_series, set_name_from_naming_options
|
||||
from frappe.model.utils.rename_doc import update_linked_doctypes
|
||||
from frappe.query_builder import Field, functions
|
||||
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 (
|
||||
@@ -204,17 +204,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):
|
||||
@@ -403,6 +407,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
|
||||
)
|
||||
@@ -674,6 +681,124 @@ def send_emails(customer, customer_outstanding, credit_limit, credit_controller_
|
||||
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(
|
||||
_("Overdue Limit crossed for customer {0}. Overdue amount {1} exceeds the allowed limit {2}.").format(
|
||||
customer,
|
||||
fmt_money(overdue_amount, currency=company_currency),
|
||||
fmt_money(threshold, currency=company_currency),
|
||||
),
|
||||
title=_("Overdue Limit Crossed"),
|
||||
)
|
||||
|
||||
|
||||
def get_overdue_billing_threshold(customer: str, company: str) -> float:
|
||||
"""Overdue limit 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 overdue limit
|
||||
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):
|
||||
# Outstanding based on GL Entries
|
||||
cond = ""
|
||||
|
||||
@@ -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,
|
||||
make_quotation,
|
||||
parse_full_name,
|
||||
)
|
||||
@@ -77,7 +79,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()
|
||||
@@ -97,6 +103,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()
|
||||
|
||||
@@ -352,6 +359,139 @@ 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")
|
||||
original_enable = settings.enable_overdue_billing_threshold
|
||||
original_bypass_role = settings.role_allowed_to_bypass_overdue_billing
|
||||
try:
|
||||
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)
|
||||
finally:
|
||||
settings.enable_overdue_billing_threshold = original_enable
|
||||
settings.role_allowed_to_bypass_overdue_billing = original_bypass_role
|
||||
settings.save()
|
||||
|
||||
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)
|
||||
|
||||
# a 0 on the customer inherits the group's limit
|
||||
set_overdue_billing_threshold("_Test Customer", "_Test Company", 0)
|
||||
self.assertEqual(get_overdue_billing_threshold("_Test Customer", "_Test Company"), 5000)
|
||||
|
||||
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"
|
||||
@@ -451,6 +591,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
|
||||
|
||||
@@ -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 'Restrict Customer Over Billing' in Accounts Settings.",
|
||||
"fieldname": "overdue_billing_threshold",
|
||||
"fieldtype": "Currency",
|
||||
"hidden": 1,
|
||||
"in_list_view": 1,
|
||||
"label": "Overdue Limit"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_2",
|
||||
"fieldtype": "Column Break"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -132,7 +132,7 @@
|
||||
{
|
||||
"fieldname": "credit_limits",
|
||||
"fieldtype": "Table",
|
||||
"label": "Credit Limit",
|
||||
"label": "Credit & Overdue Limits",
|
||||
"options": "Customer Credit Limit"
|
||||
}
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user