mirror of
https://github.com/frappe/erpnext.git
synced 2026-10-29 22:57:42 +00:00
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:
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user