fix(accounts): validate payment entry references with latest data. (#31166)

* test: payment entry over allocation.

* fix: validate allocated_amount against latest outstanding amount.

* fix: payment entry get outstanding documents for advance payments

* fix: only fetch latest outstanding_amount.

* fix: throw if reference is allocated

* test: throw error if a reference has been partially allocated after inital creation.

* chore: test name

* fix: remove unused part of test

* chore: linter

* chore: more user friendly error messages

* fix: only validate outstanding amount if partly paid and don't filter by cost center

* chore: minor refactor for doc.cost_center

Co-authored-by: Deepesh Garg <deepeshgarg6@gmail.com>

---------

Co-authored-by: Anand Baburajan <anandbaburajan@gmail.com>
Co-authored-by: Deepesh Garg <deepeshgarg6@gmail.com>
diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.py b/erpnext/accounts/doctype/payment_entry/payment_entry.py
index 3df48e2..b6d3e5a 100644
--- a/erpnext/accounts/doctype/payment_entry/payment_entry.py
+++ b/erpnext/accounts/doctype/payment_entry/payment_entry.py
@@ -148,19 +148,57 @@
 			)
 
 	def validate_allocated_amount(self):
-		for d in self.get("references"):
+		if self.payment_type == "Internal Transfer":
+			return
+
+		latest_references = get_outstanding_reference_documents(
+			{
+				"posting_date": self.posting_date,
+				"company": self.company,
+				"party_type": self.party_type,
+				"payment_type": self.payment_type,
+				"party": self.party,
+				"party_account": self.paid_from if self.payment_type == "Receive" else self.paid_to,
+			}
+		)
+
+		# Group latest_references by (voucher_type, voucher_no)
+		latest_lookup = {}
+		for d in latest_references:
+			d = frappe._dict(d)
+			latest_lookup.update({(d.voucher_type, d.voucher_no): d})
+
+		for d in self.get("references").copy():
+			latest = latest_lookup.get((d.reference_doctype, d.reference_name))
+
+			# The reference has already been fully paid
+			if not latest:
+				frappe.throw(
+					_("{0} {1} has already been fully paid.").format(d.reference_doctype, d.reference_name)
+				)
+			# The reference has already been partly paid
+			elif (
+				latest.outstanding_amount < latest.invoice_amount
+				and d.outstanding_amount != latest.outstanding_amount
+			):
+				frappe.throw(
+					_(
+						"{0} {1} has already been partly paid. Please use the 'Get Outstanding Invoice' button to get the latest outstanding amount."
+					).format(d.reference_doctype, d.reference_name)
+				)
+
+			d.outstanding_amount = latest.outstanding_amount
+
+			fail_message = _("Row #{0}: Allocated Amount cannot be greater than outstanding amount.")
+
 			if (flt(d.allocated_amount)) > 0:
 				if flt(d.allocated_amount) > flt(d.outstanding_amount):
-					frappe.throw(
-						_("Row #{0}: Allocated Amount cannot be greater than outstanding amount.").format(d.idx)
-					)
+					frappe.throw(fail_message.format(d.idx))
 
 			# Check for negative outstanding invoices as well
 			if flt(d.allocated_amount) < 0:
 				if flt(d.allocated_amount) < flt(d.outstanding_amount):
-					frappe.throw(
-						_("Row #{0}: Allocated Amount cannot be greater than outstanding amount.").format(d.idx)
-					)
+					frappe.throw(fail_message.format(d.idx))
 
 	def delink_advance_entry_references(self):
 		for reference in self.references:
@@ -373,7 +411,7 @@
 		for k, v in no_oustanding_refs.items():
 			frappe.msgprint(
 				_(
-					"{} - {} now have {} as they had no outstanding amount left before submitting the Payment Entry."
+					"{} - {} now has {} as it had no outstanding amount left before submitting the Payment Entry."
 				).format(
 					_(k),
 					frappe.bold(", ".join(d.reference_name for d in v)),
@@ -1449,7 +1487,7 @@
 	if voucher_type:
 		doc = frappe.get_doc({"doctype": voucher_type})
 		condition = ""
-		if doc and hasattr(doc, "cost_center"):
+		if doc and hasattr(doc, "cost_center") and doc.cost_center:
 			condition = " and cost_center='%s'" % cost_center
 
 	orders = []
@@ -1495,9 +1533,15 @@
 
 	order_list = []
 	for d in orders:
-		if not (
-			flt(d.outstanding_amount) >= flt(filters.get("outstanding_amt_greater_than"))
-			and flt(d.outstanding_amount) <= flt(filters.get("outstanding_amt_less_than"))
+		if (
+			filters
+			and filters.get("outstanding_amt_greater_than")
+			and filters.get("outstanding_amt_less_than")
+			and not (
+				flt(filters.get("outstanding_amt_greater_than"))
+				<= flt(d.outstanding_amount)
+				<= flt(filters.get("outstanding_amt_less_than"))
+			)
 		):
 			continue
 
diff --git a/erpnext/accounts/doctype/payment_entry/test_payment_entry.py b/erpnext/accounts/doctype/payment_entry/test_payment_entry.py
index 68f333d..278b12f 100644
--- a/erpnext/accounts/doctype/payment_entry/test_payment_entry.py
+++ b/erpnext/accounts/doctype/payment_entry/test_payment_entry.py
@@ -1013,6 +1013,30 @@
 		employee = make_employee("test_payment_entry@salary.com", company="_Test Company")
 		create_payment_entry(party_type="Employee", party=employee, save=True)
 
+	def test_duplicate_payment_entry_allocate_amount(self):
+		si = create_sales_invoice()
+
+		pe_draft = get_payment_entry("Sales Invoice", si.name)
+		pe_draft.insert()
+
+		pe = get_payment_entry("Sales Invoice", si.name)
+		pe.submit()
+
+		self.assertRaises(frappe.ValidationError, pe_draft.submit)
+
+	def test_duplicate_payment_entry_partial_allocate_amount(self):
+		si = create_sales_invoice()
+
+		pe_draft = get_payment_entry("Sales Invoice", si.name)
+		pe_draft.insert()
+
+		pe = get_payment_entry("Sales Invoice", si.name)
+		pe.received_amount = si.total / 2
+		pe.references[0].allocated_amount = si.total / 2
+		pe.submit()
+
+		self.assertRaises(frappe.ValidationError, pe_draft.submit)
+
 
 def create_payment_entry(**args):
 	payment_entry = frappe.new_doc("Payment Entry")