Merge pull request #62678 from aerele/fix/payment-reference-outstanding

fix(accounts): prevent over-allocation of invoice and dunning payments
This commit is contained in:
mergify[bot]
2026-10-29 10:29:08 +00:00
committed by GitHub
4 changed files with 128 additions and 11 deletions
+11 -5
View File
@@ -527,11 +527,17 @@ def get_current_outstanding(overdue_payment) -> float:
["outstanding_amount", "currency", "party_account_currency"],
as_dict=True,
)
schedule_outstanding = (
flt(frappe.db.get_value("Payment Schedule", overdue_payment.payment_schedule, "outstanding"))
if overdue_payment.payment_schedule
else flt(overdue_payment.outstanding)
)
if overdue_payment.payment_schedule:
payment_schedule = frappe.get_all(
"Payment Schedule",
filters={"parent": overdue_payment.sales_invoice, "parenttype": "Sales Invoice"},
fields=["name", "idx", "due_date", "outstanding"],
)
schedule_outstanding = get_payment_schedule_outstanding(invoice, payment_schedule).get(
overdue_payment.payment_schedule, 0
)
else:
schedule_outstanding = flt(overdue_payment.outstanding)
if flt(invoice.outstanding_amount) <= 0 or schedule_outstanding <= 0:
return 0.0
@@ -466,6 +466,41 @@ class TestDunning(ERPNextTestSuite):
self.assertEqual(sales_invoice.outstanding_amount, 0)
self.assertEqual(dunning.status, "Resolved")
def test_payment_after_partial_invoice_payment_without_term_allocation(self):
create_payment_terms_template_for_dunning()
template = frappe.get_doc("Payment Terms Template", "_Test 50-50 for Dunning")
template.allocate_payment_based_on_payment_terms = 0
template.save()
self.addCleanup(template.db_set, "allocate_payment_based_on_payment_terms", 1)
invoice = create_sales_invoice_against_cost_center(
posting_date=add_days(today(), -15), qty=1, rate=100, do_not_submit=True
)
invoice.payment_terms_template = template.name
invoice.submit()
dunning = create_dunning_from_sales_invoice(invoice.name)
dunning.submit()
self.assertEqual(len(dunning.overdue_payments), 2)
partial_payment = get_payment_entry(invoice.doctype, invoice.name)
partial_payment.paid_amount = partial_payment.received_amount = 60
partial_payment.references[0].allocated_amount = 60
partial_payment.submit()
self.assertEqual(invoice.reload().outstanding_amount, 40)
self.assertEqual([row.outstanding for row in invoice.payment_schedule], [50, 50])
payment = get_payment_entry("Dunning", dunning.name)
self.assertEqual(payment.paid_amount, 40)
self.assertEqual(len(payment.references), 1)
self.assertEqual(payment.references[0].payment_term, invoice.payment_schedule[1].payment_term)
self.assertEqual(payment.references[0].allocated_amount, 40)
payment.submit()
self.assertEqual(invoice.reload().outstanding_amount, 0)
self.assertEqual(dunning.reload().status, "Resolved")
payment.cancel()
self.assertEqual(invoice.reload().outstanding_amount, 40)
self.assertEqual(dunning.reload().status, "Unresolved")
self.assertEqual(get_payment_entry("Dunning", dunning.name).paid_amount, 40)
def test_dunning_resolution_from_credit_note(self):
"""
Test that dunning is resolved when a credit note is issued against the original invoice.
@@ -519,6 +519,8 @@ class PaymentEntry(AccountsController):
d = frappe._dict(d)
latest_lookup.setdefault((d.voucher_type, d.voucher_no), frappe._dict())[d.payment_term] = d
allocated_amounts = {}
allocated_term_amounts = {}
for idx, d in enumerate(self.get("references"), start=1):
latest = latest_lookup.get((d.reference_doctype, d.reference_name)) or frappe._dict()
@@ -556,28 +558,36 @@ class PaymentEntry(AccountsController):
precision = d.precision("allocated_amount")
allocated_amount = flt(d.allocated_amount, precision)
outstanding_amount = flt(latest.outstanding_amount, precision)
key = (d.reference_doctype, d.reference_name)
allocated_amounts[key] = allocated_amounts.get(key, 0) + allocated_amount
total_allocated_amount = flt(allocated_amounts[key], precision)
outstanding_amount = flt(
latest.get("invoice_outstanding_amount", latest.outstanding_amount), precision
)
term_key = (*key, latest.payment_term)
allocated_term_amounts[term_key] = allocated_term_amounts.get(term_key, 0) + allocated_amount
allocated_term_amount = flt(allocated_term_amounts[term_key], precision)
if (
d.payment_term
and (
allocated_amount > 0
allocated_term_amount > 0
and latest.payment_term_outstanding
and (allocated_amount > flt(latest.payment_term_outstanding, precision))
and (allocated_term_amount > flt(latest.payment_term_outstanding, precision))
)
and self.term_based_allocation_enabled_for_reference(d.reference_doctype, d.reference_name)
):
frappe.throw(
_(
"Row #{0}: Allocated amount:{1} is greater than outstanding amount:{2} for Payment Term {3}"
).format(d.idx, d.allocated_amount, latest.payment_term_outstanding, d.payment_term)
).format(d.idx, allocated_term_amount, latest.payment_term_outstanding, d.payment_term)
)
if allocated_amount > 0 and allocated_amount > outstanding_amount:
if allocated_amount > 0 and total_allocated_amount > outstanding_amount:
frappe.throw(fail_message.format(d.idx))
# Check for negative outstanding invoices as well
if allocated_amount < 0 and allocated_amount < outstanding_amount:
if allocated_amount < 0 and total_allocated_amount < outstanding_amount:
frappe.throw(fail_message.format(d.idx))
def delink_advance_entry_references(self):
@@ -2375,6 +2385,7 @@ def get_split_invoice_rows(invoice: dict, payment_term_template: str, exc_rates:
"voucher_type": invoice.voucher_type,
"posting_date": invoice.posting_date,
"invoice_amount": flt(invoice.invoice_amount),
"invoice_outstanding_amount": invoice.outstanding_amount,
"outstanding_amount": payment_term_outstanding
if payment_term_outstanding
else invoice.outstanding_amount,
@@ -7,12 +7,14 @@ from frappe import qb
from frappe.utils import add_days, flt, nowdate
from erpnext.accounts.doctype.account.test_account import create_account
from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry
from erpnext.accounts.doctype.payment_entry.payment_entry import (
get_outstanding_reference_documents,
get_party_details,
get_payment_entry,
get_reference_details,
)
from erpnext.accounts.doctype.payment_request.payment_request import make_payment_request
from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import (
make_purchase_invoice,
make_purchase_invoice_against_cost_center,
@@ -1565,6 +1567,69 @@ class TestPaymentEntry(ERPNextTestSuite):
self.assertRaises(frappe.ValidationError, pe_draft.submit)
def test_total_allocation_across_reference_rows(self):
invoice = create_sales_invoice(qty=1, rate=100)
payment_request = make_payment_request(
dt=invoice.doctype, dn=invoice.name, submit_doc=True, return_doc=True, mute_email=True
)
payment = get_payment_entry(invoice.doctype, invoice.name)
self.assertEqual(payment.references[0].payment_request, payment_request.name)
payment.append("references", frappe.copy_doc(payment.references[0]))
payment.references[1].payment_request = None
payment.paid_amount = payment.received_amount = 200
with self.assertRaisesRegex(frappe.ValidationError, "Allocated Amount cannot be greater"):
payment.submit()
self.assertEqual(invoice.reload().outstanding_amount, 100)
payment.references[0].allocated_amount = 40
payment.references[1].allocated_amount = 60
payment.paid_amount = payment.received_amount = 100
payment.submit()
self.assertEqual(invoice.reload().outstanding_amount, 0)
payment.cancel()
self.assertEqual(invoice.reload().outstanding_amount, 100)
create_payment_terms_template()
invoice = create_sales_invoice(qty=1, rate=118, do_not_save=True)
invoice.payment_terms_template = "Test Receivable Template"
invoice.submit()
payment = get_payment_entry(invoice.doctype, invoice.name)
payment.references[0].allocated_amount = 80
payment.references[1].allocated_amount = -10
payment.paid_amount = payment.received_amount = 70
with self.assertRaisesRegex(frappe.ValidationError, "Allocated Amount cannot be greater"):
payment.submit()
self.assertEqual(invoice.reload().outstanding_amount, 118)
self.assertEqual([row.paid_amount for row in invoice.payment_schedule], [0, 0])
journal = make_journal_entry("_Test Cash - _TC", invoice.debit_to, 80, save=False)
journal.accounts[1].update(
{
"party_type": "Customer",
"party": invoice.customer,
"reference_type": invoice.doctype,
"reference_name": invoice.name,
}
)
journal.submit()
self.assertEqual(invoice.reload().outstanding_amount, 38)
payment = get_payment_entry(invoice.doctype, invoice.name)
self.assertEqual(len(payment.references), 2)
payment.references[0].allocated_amount = 30
payment.references[1].allocated_amount = 18
payment.paid_amount = payment.received_amount = 48
with self.assertRaisesRegex(frappe.ValidationError, "Allocated Amount cannot be greater"):
payment.submit()
payment.references[0].allocated_amount = 20
payment.paid_amount = payment.received_amount = 38
payment.submit()
self.assertEqual(invoice.reload().outstanding_amount, 0)
payment.cancel()
self.assertEqual(invoice.reload().outstanding_amount, 38)
def test_duplicate_payment_entry_partial_allocate_amount(self):
si = create_sales_invoice()