fix(selling): read overdue amount from payment ledger, not gl tags (#57786)

This commit is contained in:
Sudharsanan Ashok
2026-08-11 15:37:17 +05:30
committed by GitHub
parent e258921681
commit b5a3815a64
2 changed files with 65 additions and 12 deletions

View File

@@ -632,8 +632,8 @@ def get_overdue_billing_threshold(customer: str, company: str) -> float:
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.
Reads the Payment Ledger, the same source as `outstanding_amount`, so this agrees
with the Overdue status the invoice list already shows.
"""
invoices = get_outstanding_invoices_for_customer(customer, company)
if not invoices:
@@ -646,27 +646,28 @@ def get_customer_overdue_amount(customer: str, company: str) -> float:
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")
ple = frappe.qb.DocType("Payment Ledger 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)
# the Payment Ledger, not the GL, carries allocations made after submit (reconciled advances).
# `amount` is booked in company currency, so this is comparable to the overdue limit.
outstanding = Sum(ple.amount)
return (
frappe.qb.from_(gl_entry)
frappe.qb.from_(ple)
.inner_join(sales_invoice)
.on(sales_invoice.name == gl_entry.against_voucher)
.on(sales_invoice.name == ple.against_voucher_no)
.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")
.where(ple.party_type == "Customer")
.where(ple.party == customer)
.where(ple.company == company)
.where(ple.delinked == 0)
.where(ple.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)

View File

@@ -438,6 +438,37 @@ class TestCustomer(ERPNextTestSuite):
pe.submit()
self.assertEqual(get_customer_overdue_amount("_Test Customer", "_Test Company"), baseline)
def test_get_customer_overdue_amount_ignores_advance_reconciled_after_submit(self):
from erpnext.accounts.doctype.payment_entry.test_payment_entry import create_payment_entry
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
baseline = get_customer_overdue_amount("_Test Customer", "_Test Company")
# advance received before the invoice exists, so it carries no reference row
pe = create_payment_entry(
company="_Test Company",
party_type="Customer",
party="_Test Customer",
payment_type="Receive",
paid_from="Debtors - _TC",
paid_to="Cash - _TC",
paid_amount=800,
)
pe.posting_date = add_days(nowdate(), -60)
pe.submit()
si = create_sales_invoice(qty=1, rate=800, posting_date=add_days(nowdate(), -30))
self.assertEqual(get_customer_overdue_amount("_Test Customer", "_Test Company"), baseline + 800)
reconcile_payment_against_invoice(pe, si)
# reconciliation settles the invoice without re-tagging the payment's GL entries, so an
# overdue amount read off the GL would still count the full 800 here
si.reload()
self.assertEqual(si.outstanding_amount, 0)
self.assertEqual(si.status, "Paid")
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
@@ -632,6 +663,27 @@ def set_credit_limit(customer, company, credit_limit):
customer.credit_limits[-1].db_insert()
def reconcile_payment_against_invoice(payment_entry, sales_invoice):
"""Allocate an unlinked payment against an invoice through the reconciliation tool."""
pr = frappe.get_doc(
doctype="Payment Reconciliation",
company=sales_invoice.company,
party_type="Customer",
party=sales_invoice.customer,
receivable_payable_account=sales_invoice.debit_to,
)
pr.get_unreconciled_entries()
pr.allocate_entries(
frappe._dict(
{
"invoices": [d.as_dict() for d in pr.invoices if d.invoice_number == sales_invoice.name],
"payments": [d.as_dict() for d in pr.payments if d.reference_name == payment_entry.name],
}
)
)
pr.reconcile()
def set_overdue_billing_threshold(customer, company, threshold):
customer = frappe.get_doc("Customer", customer)
for d in customer.credit_limits: