diff --git a/erpnext/accounts/doctype/payment_reference/payment_reference.json b/erpnext/accounts/doctype/payment_reference/payment_reference.json index 32d947d00dd..a1adb181d35 100644 --- a/erpnext/accounts/doctype/payment_reference/payment_reference.json +++ b/erpnext/accounts/doctype/payment_reference/payment_reference.json @@ -7,8 +7,8 @@ "engine": "InnoDB", "field_order": [ "payment_term", - "manually_selected", - "auto_selected", + "column_break_lnjp", + "payment_schedule", "section_break_fjhh", "description", "section_break_mjlv", @@ -58,25 +58,23 @@ "precision": "2" }, { - "default": "0", - "fieldname": "manually_selected", - "fieldtype": "Check", - "hidden": 1, - "label": "Manually Selected" + "fieldname": "column_break_lnjp", + "fieldtype": "Column Break" }, { - "default": "1", - "fieldname": "auto_selected", - "fieldtype": "Check", - "hidden": 1, - "label": "Auto Selected" + "allow_on_submit": 1, + "fieldname": "payment_schedule", + "fieldtype": "Link", + "label": "Payment Schedule", + "options": "Payment Schedule", + "read_only": 1 } ], "grid_page_length": 50, "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2025-12-05 11:26:29.877050", + "modified": "2026-01-19 02:21:36.455830", "modified_by": "Administrator", "module": "Accounts", "name": "Payment Reference", diff --git a/erpnext/accounts/doctype/payment_reference/payment_reference.py b/erpnext/accounts/doctype/payment_reference/payment_reference.py index 8c67a247dc5..6e1956644c9 100644 --- a/erpnext/accounts/doctype/payment_reference/payment_reference.py +++ b/erpnext/accounts/doctype/payment_reference/payment_reference.py @@ -15,13 +15,12 @@ class PaymentReference(Document): from frappe.types import DF amount: DF.Currency - auto_selected: DF.Check description: DF.SmallText | None due_date: DF.Date | None - manually_selected: DF.Check parent: DF.Data parentfield: DF.Data parenttype: DF.Data + payment_schedule: DF.Link | None payment_term: DF.Link | None # end: auto-generated types diff --git a/erpnext/accounts/doctype/payment_request/payment_request.json b/erpnext/accounts/doctype/payment_request/payment_request.json index adff47f4639..8fcf1f2f41f 100644 --- a/erpnext/accounts/doctype/payment_request/payment_request.json +++ b/erpnext/accounts/doctype/payment_request/payment_request.json @@ -21,7 +21,6 @@ "reference_name", "payment_reference_section", "payment_reference", - "calculate_total_amount_by_selected_rows", "transaction_details", "grand_total", "currency", @@ -160,6 +159,7 @@ "label": "Amount", "non_negative": 1, "options": "currency", + "read_only_depends_on": "eval:doc.payment_reference.length>0", "reqd": 1 }, { @@ -465,16 +465,12 @@ "fieldname": "payment_reference_section", "fieldtype": "Section Break" }, - { - "fieldname": "calculate_total_amount_by_selected_rows", - "fieldtype": "Button", - "label": "Calculate Total Amount by Selected Rows" - }, { "fieldname": "payment_reference", "fieldtype": "Table", "label": "Payment Reference", - "options": "Payment Reference" + "options": "Payment Reference", + "read_only": 1 } ], "grid_page_length": 50, @@ -482,7 +478,7 @@ "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2025-12-05 11:27:51.406257", + "modified": "2026-01-13 12:53:00.963274", "modified_by": "Administrator", "module": "Accounts", "name": "Payment Request", diff --git a/erpnext/accounts/doctype/payment_request/payment_request.py b/erpnext/accounts/doctype/payment_request/payment_request.py index 3437655dff3..c95945bf6e2 100644 --- a/erpnext/accounts/doctype/payment_request/payment_request.py +++ b/erpnext/accounts/doctype/payment_request/payment_request.py @@ -111,15 +111,36 @@ class PaymentRequest(Document): if self.get("__islocal"): self.status = "Draft" self.validate_reference_document() + self.validate_against_payment_reference() self.validate_payment_request_amount() # self.validate_currency() self.validate_subscription_details() + def validate_against_payment_reference(self): + if not self.payment_reference: + return + + expected = sum(flt(r.amount) for r in self.payment_reference) + if flt(expected, self.precision("grand_total")) != flt(self.grand_total): + frappe.throw(_("Grand Total must match sum of Payment References")) + + seen = set() + for r in self.payment_reference: + if not r.payment_schedule: + continue # legacy mode → skip + + if r.payment_schedule in seen: + frappe.throw(_("Duplicate Payment Schedule selected")) + + seen.add(r.payment_schedule) + def validate_reference_document(self): if not self.reference_doctype or not self.reference_name: frappe.throw(_("To create a Payment Request reference document is required")) def validate_payment_request_amount(self): + if self.payment_reference: + return if self.grand_total == 0: frappe.throw( _("{0} cannot be zero").format(self.get_label_from_fieldname("grand_total")), @@ -554,9 +575,63 @@ def make_payment_request(**args): ref_doc = args.ref_doc or frappe.get_doc(args.dt, args.dn) if not args.get("company"): args.company = ref_doc.company + gateway_account = get_gateway_details(args) or frappe._dict() - grand_total = get_amount(ref_doc, gateway_account.get("payment_account")) + # Schedule-based PRs are allowed only if no Payment Entry exists for this document. + # Any existing Payment Entry forces legacy (amount-based) flow. + selected_payment_schedules = json.loads(args.get("schedules")) if args.get("schedules") else [] + + # Backend guard: + # If any Payment Entry exists, schedule-based PRs are not allowed. + if selected_payment_schedules and get_existing_payment_entry(ref_doc.name): + frappe.throw( + _( + "Payment Schedule based Payment Requests cannot be created because a Payment Entry already exists for this document." + ) + ) + + has_payment_entry = bool(get_existing_payment_entry(ref_doc.name)) + + payment_reference = [] + + if selected_payment_schedules: + existing_payment_references = get_existing_payment_references(ref_doc.name) + + if existing_payment_references: + existing_ids = {r["payment_schedule"] for r in existing_payment_references} + selected_ids = {r["name"] for r in selected_payment_schedules} + duplicate_ids = existing_ids & selected_ids + + if duplicate_ids: + duplicate_schedules = [] + for row in selected_payment_schedules: + if row["name"] in duplicate_ids: + existing_ref = next( + (r for r in existing_payment_references if r["payment_schedule"] == row["name"]), + {}, + ) + existing_pr = existing_ref.get("parent") + duplicate_schedules.append( + f"Payment Term: {row.get('payment_term')}, " + f"Due Date: {row.get('due_date')}, " + f"Amount: {row.get('payment_amount')} " + f"(already requested in PR {existing_pr})" + ) + frappe.throw( + _("The following payment schedule(s) already exist:\n{0}").format( + "\n".join(duplicate_schedules) + ) + ) + + payment_reference = set_payment_references(args.get("schedules")) + + # Determine grand_total + if selected_payment_schedules and not has_payment_entry: + grand_total = sum(row.get("payment_amount") for row in selected_payment_schedules) + else: + grand_total = get_amount(ref_doc, gateway_account.get("payment_account")) + if not grand_total: frappe.throw(_("Payment Entry is already created")) @@ -566,7 +641,6 @@ def make_payment_request(**args): loyalty_amount = validate_loyalty_points(ref_doc, int(args.loyalty_points)) # sets fields on ref_doc ref_doc.db_update() grand_total = grand_total - loyalty_amount - # fetches existing payment request `grand_total` amount existing_payment_request_amount = get_existing_payment_request_amount(ref_doc) @@ -586,21 +660,20 @@ def make_payment_request(**args): else: # If PR's are processed, cancel all of them. cancel_old_payment_requests(ref_doc.doctype, ref_doc.name) - else: + elif not selected_payment_schedules: grand_total = validate_and_calculate_grand_total(grand_total, existing_payment_request_amount) - draft_payment_request = frappe.db.get_value( "Payment Request", {"reference_doctype": ref_doc.doctype, "reference_name": ref_doc.name, "docstatus": 0}, ) if draft_payment_request: - frappe.db.set_value( - "Payment Request", draft_payment_request, "grand_total", grand_total, update_modified=False - ) pr = frappe.get_doc("Payment Request", draft_payment_request) - set_payment_references(pr, ref_doc) + if selected_payment_schedules: + apply_payment_references(pr, payment_reference) + pr.save() + else: bank_account = ( get_party_bank_account(args.get("party_type"), args.get("party")) @@ -621,8 +694,6 @@ def make_payment_request(**args): party_account = get_party_account(party_type, ref_doc.get(party_type.lower()), ref_doc.company) party_account_currency = get_account_currency(party_account) - set_payment_references(pr, ref_doc) - pr.update( { "payment_gateway_account": gateway_account.get("name"), @@ -657,7 +728,10 @@ def make_payment_request(**args): } ) - # Update dimensions + if selected_payment_schedules: + apply_payment_references(pr, payment_reference) + + # Dimensions pr.update( { "cost_center": ref_doc.get("cost_center"), @@ -686,6 +760,51 @@ def make_payment_request(**args): return pr.as_dict() +def apply_payment_references(pr, payment_reference): + existing_refs = pr.get("payment_reference") or [] + + existing_ids = {r.get("payment_schedule") for r in existing_refs if r.get("payment_schedule")} + new_refs = [r for r in (payment_reference or []) if r.get("payment_schedule") not in existing_ids] + pr.set("payment_reference", existing_refs + new_refs) + pr.set("grand_total", sum(flt(r.get("amount")) for r in pr.get("payment_reference"))) + + +def set_payment_references(payment_schedules): + payment_schedules = json.loads(payment_schedules) if payment_schedules else [] + payment_reference = [] + + for row in payment_schedules: + payment_reference.append( + { + "payment_term": row.get("payment_term"), + "payment_schedule": row.get("name"), + "description": row.get("description"), + "due_date": row.get("due_date"), + "amount": row.get("payment_amount"), + } + ) + + return payment_reference + + +def get_existing_payment_entry(ref_docname): + pe = frappe.qb.DocType("Payment Entry") + per = frappe.qb.DocType("Payment Entry Reference") + + existing_pe = ( + frappe.qb.from_(pe) + .join(per) + .on(per.parent == pe.name) + .select(pe.name) + .where(pe.docstatus < 2) + .where(per.reference_name == ref_docname) + .limit(1) + .run() + ) + + return existing_pe + + def get_amount(ref_doc, payment_account=None): """get amount based on doctype""" grand_total = 0 @@ -1032,36 +1151,20 @@ def get_irequests_of_payment_request(doc: str | None = None) -> list: return res -def set_payment_references(payment_request, ref_doc): +@frappe.whitelist() +def get_available_payment_schedules(reference_doctype, reference_name): + ref_doc = frappe.get_doc(reference_doctype, reference_name) + if not hasattr(ref_doc, "payment_schedule") or not ref_doc.payment_schedule: - return + return [] - existing_refs = get_existing_payment_references(ref_doc.name) + if get_existing_payment_entry(reference_name): + return [] - existing_map = {make_key(r.payment_term, r.due_date, r.amount): r for r in existing_refs} + existing_refs = get_existing_payment_references(reference_name) + existing_ids = {r["payment_schedule"] for r in existing_refs if r.get("payment_schedule")} - payment_request.reference = [] - - for row in ref_doc.payment_schedule: - key = make_key(row.payment_term, row.due_date, row.payment_amount) - - existing = existing_map.get(key) - if existing and (existing.manually_selected or existing.auto_selected): - continue - - payment_request.append( - "payment_reference", - { - "payment_term": row.payment_term, - "description": row.description, - "due_date": row.due_date, - "amount": row.payment_amount, - }, - ) - - -def make_key(payment_term, due_date, amount): - return (payment_term, due_date, flt(amount)) + return [r for r in ref_doc.payment_schedule if r.name not in existing_ids] def get_existing_payment_references(reference_name): @@ -1075,14 +1178,15 @@ def get_existing_payment_references(reference_name): .select( PRF.payment_term, PRF.due_date, - PRF.amount, - PRF.manually_selected, - PRF.auto_selected, + PRF.amount.as_("payment_amount"), + PRF.payment_schedule, PRF.parent, ) .where(PR.reference_name == reference_name) - .where(PR.docstatus == 1) - .where(PR.status.isin(["Initiated", "Partially Paid", "Payment Ordered", "Paid"])) + .where(PR.docstatus < 2) + .where( + PR.status.isin(["Draft", "Requested", "Initiated", "Partially Paid", "Payment Ordered", "Paid"]) + ) ).run(as_dict=True) return result diff --git a/erpnext/accounts/doctype/payment_request/test_payment_request.py b/erpnext/accounts/doctype/payment_request/test_payment_request.py index 1374e64c032..41375d9d9af 100644 --- a/erpnext/accounts/doctype/payment_request/test_payment_request.py +++ b/erpnext/accounts/doctype/payment_request/test_payment_request.py @@ -1,12 +1,14 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors # See license.txt +import json import re import unittest from unittest.mock import patch import frappe from frappe.tests import IntegrationTestCase +from frappe.utils import add_days, nowdate from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry from erpnext.accounts.doctype.payment_entry.test_payment_entry import create_payment_terms_template @@ -852,64 +854,122 @@ class TestPaymentRequest(IntegrationTestCase): self.assertEqual(pr.grand_total, pi.outstanding_amount) - def test_payment_schedule_row_selection(self): - from frappe.utils import add_days, nowdate - - po = create_purchase_order(do_not_save=1, currency="INR", qty=1, rate=86) - + def test_payment_request_grand_total_from_selected_schedules(self): + po = create_purchase_order(do_not_save=1, currency="INR", qty=1, rate=100) po.payment_schedule = [] - po.append("payment_schedule", {"due_date": nowdate(), "payment_amount": 33}) - po.append("payment_schedule", {"due_date": add_days(nowdate(), 1), "payment_amount": 33}) - po.append("payment_schedule", {"due_date": add_days(nowdate(), 2), "payment_amount": 20}) + po.append("payment_schedule", {"due_date": nowdate(), "payment_amount": 30}) + po.append("payment_schedule", {"due_date": add_days(nowdate(), 1), "payment_amount": 30}) + po.append("payment_schedule", {"due_date": add_days(nowdate(), 2), "payment_amount": 40}) po.save() po.submit() - pr1 = make_payment_request( - dt="Purchase Order", - dn=po.name, - mute_email=1, - submit_doc=False, - return_doc=True, - ) - pr1.payment_reference[0].manually_selected = 1 - pr1.payment_reference[1].auto_selected = 0 - pr1.payment_reference[2].manually_selected = 1 - pr1.grand_total = 53 - pr1.submit() - - pr2 = make_payment_request( + schedules = json.dumps( + [ + { + "payment_term": row.payment_term, + "name": row.name, + "due_date": row.due_date, + "payment_amount": row.payment_amount, + "description": row.description, + } + for row in [po.payment_schedule[0], po.payment_schedule[2]] + ] + ) + pr = make_payment_request( dt="Purchase Order", dn=po.name, mute_email=1, submit_doc=False, return_doc=True, + schedules=schedules, ) - self.assertEqual(len(pr2.payment_reference), 1) - self.assertEqual(pr2.payment_reference[0].amount, 33) + pr.submit() - def test_auto_selected_rows_are_not_reused(self): + self.assertEqual(pr.grand_total, 70) + self.assertEqual(len(pr.payment_reference), 2) + + def test_draft_pr_reuse_merges_payment_references(self): from frappe.utils import add_days, nowdate - po = create_purchase_order(do_not_save=1, currency="INR", qty=1, rate=80) + po = create_purchase_order(do_not_save=1, currency="INR", qty=1, rate=100) po.payment_schedule = [] - po.append("payment_schedule", {"due_date": nowdate(), "payment_amount": 40}) - po.append("payment_schedule", {"due_date": add_days(nowdate(), 1), "payment_amount": 10}) - po.append("payment_schedule", {"due_date": add_days(nowdate(), 2), "payment_amount": 30}) + po.append("payment_schedule", {"due_date": nowdate(), "payment_amount": 50}) + po.append("payment_schedule", {"due_date": add_days(nowdate(), 1), "payment_amount": 50}) + po.save() + po.submit() + schedules = json.dumps( + [ + { + "payment_term": row.payment_term, + "name": row.name, + "due_date": row.due_date, + "payment_amount": row.payment_amount, + "description": row.description, + } + for row in [po.payment_schedule[0]] + ] + ) + pr = make_payment_request( + dt="Purchase Order", + dn=po.name, + mute_email=1, + submit_doc=False, + return_doc=True, + schedules=schedules, + ) + + pr.save() + schedules = json.dumps( + [ + { + "payment_term": row.payment_term, + "name": row.name, + "due_date": row.due_date, + "payment_amount": row.payment_amount, + "description": row.description, + } + for row in [po.payment_schedule[1]] + ] + ) + # call make_payment_request again → reuse draft + pr_reused = make_payment_request( + dt="Purchase Order", + dn=po.name, + mute_email=1, + submit_doc=False, + return_doc=True, + schedules=schedules, + ) + + self.assertEqual(pr.name, pr_reused.name) + self.assertEqual(pr_reused.grand_total, 100) + self.assertEqual(len(pr_reused.payment_reference), 2) + + def test_schedule_pr_not_allowed_if_payment_entry_exists(self): + po = create_purchase_order(do_not_save=1, currency="INR", qty=1, rate=100) + po.payment_schedule = [] + row = po.append("payment_schedule", {"due_date": nowdate(), "payment_amount": 100}) po.save() po.submit() - pr1 = make_payment_request( - dt="Purchase Order", - dn=po.name, - mute_email=1, - submit_doc=False, - return_doc=True, - ) + # create PE first + pr = make_payment_request(dt="Purchase Order", dn=po.name, mute_email=1, submit_doc=1, return_doc=1) + pr.create_payment_entry() - pr1.submit() + schedules = json.dumps( + [ + { + "name": row.name, + "payment_term": row.payment_term, + "due_date": row.due_date, + "payment_amount": row.payment_amount, + "description": row.description, + } + ] + ) with self.assertRaises(frappe.ValidationError): make_payment_request( @@ -918,4 +978,5 @@ class TestPaymentRequest(IntegrationTestCase): mute_email=1, submit_doc=False, return_doc=True, + schedules=schedules, ) diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js index 0aa6fa85605..ac214fdac43 100644 --- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js +++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js @@ -134,7 +134,7 @@ erpnext.accounts.PurchaseInvoice = class PurchaseInvoice extends erpnext.buying. this.frm.add_custom_button( __("Payment Request"), function () { - me.make_payment_request(); + me.make_payment_request_with_schedule(); }, __("Create") ); diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.js b/erpnext/accounts/doctype/sales_invoice/sales_invoice.js index 6682270b4c9..64728cd1e0a 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.js +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.js @@ -138,7 +138,7 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends ( this.frm.add_custom_button( __("Payment Request"), function () { - me.make_payment_request(); + me.make_payment_request_with_schedule(); }, __("Create") ); diff --git a/erpnext/buying/doctype/purchase_order/purchase_order.js b/erpnext/buying/doctype/purchase_order/purchase_order.js index 073312a8450..87435f19393 100644 --- a/erpnext/buying/doctype/purchase_order/purchase_order.js +++ b/erpnext/buying/doctype/purchase_order/purchase_order.js @@ -428,7 +428,7 @@ erpnext.buying.PurchaseOrderController = class PurchaseOrderController extends ( this.frm.add_custom_button( __("Payment Request"), function () { - me.make_payment_request(); + me.make_payment_request_with_schedule(); }, __("Create") ); diff --git a/erpnext/public/js/controllers/transaction.js b/erpnext/public/js/controllers/transaction.js index f7a53709f79..aa02eec7b62 100644 --- a/erpnext/public/js/controllers/transaction.js +++ b/erpnext/public/js/controllers/transaction.js @@ -450,7 +450,106 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe }, }); } + make_payment_request_with_schedule = async function () { + let frm = this.frm; + const { message: schedules } = await frappe.call({ + method: "erpnext.accounts.doctype.payment_request.payment_request.get_available_payment_schedules", + args: { + reference_doctype: frm.doctype, + reference_name: frm.doc.name, + }, + }); + if (!schedules.length) { + this.make_payment_request(); + return; + } + if (!schedules || !schedules.length) { + frappe.msgprint(__("No pending payment schedules available.")); + return; + } + + const dialog = new frappe.ui.Dialog({ + title: __("Select Payment Schedule"), + fields: [ + { + fieldtype: "Table", + fieldname: "payment_schedules", + label: __("Payment Schedules"), + cannot_add_rows: true, + in_place_edit: false, + data: schedules, + fields: [ + { + fieldtype: "Data", + fieldname: "name", + label: __("Schedule Name"), + read_only: 1, + }, + { + fieldtype: "Data", + fieldname: "payment_term", + label: __("Payment Term"), + in_list_view: 1, + read_only: 1, + }, + { + fieldtype: "Date", + fieldname: "due_date", + label: __("Due Date"), + in_list_view: 1, + read_only: 1, + }, + { + fieldtype: "Currency", + fieldname: "payment_amount", + label: __("Amount"), + in_list_view: 1, + read_only: 1, + }, + ], + }, + ], + primary_action_label: __("Create Payment Request"), + primary_action: async () => { + const values = dialog.get_values(); + const selected = values.payment_schedules.filter((r) => r.__checked); + + if (!selected.length) { + frappe.msgprint(__("Please select at least one schedule.")); + return; + } + console.log(selected); + dialog.hide(); + let me = this; + const payment_request_type = ["Sales Order", "Sales Invoice"].includes(this.frm.doc.doctype) + ? "Inward" + : "Outward"; + const { message: pr_name } = await frappe.call({ + method: "erpnext.accounts.doctype.payment_request.payment_request.make_payment_request", + args: { + dt: me.frm.doc.doctype, + dn: me.frm.doc.name, + recipient_id: me.frm.doc.contact_email, + payment_request_type: payment_request_type, + party_type: payment_request_type == "Outward" ? "Supplier" : "Customer", + party: payment_request_type == "Outward" ? me.frm.doc.supplier : me.frm.doc.customer, + party_name: + payment_request_type == "Outward" + ? me.frm.doc.supplier_name + : me.frm.doc.customer_name, + reference_doctype: frm.doctype, + reference_name: frm.docname, + schedules: selected, + }, + }); + + frappe.set_route("Form", "Payment Request", pr_name.name); + }, + }); + + dialog.show(); + }; onload_post_render() { if ( this.frm.doc.__islocal && diff --git a/erpnext/selling/doctype/sales_order/sales_order.js b/erpnext/selling/doctype/sales_order/sales_order.js index c228c45b175..c47ce90a865 100644 --- a/erpnext/selling/doctype/sales_order/sales_order.js +++ b/erpnext/selling/doctype/sales_order/sales_order.js @@ -1150,7 +1150,7 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex if (flt(doc.per_billed) < 100 + frappe.boot.sysdefaults.over_billing_allowance) { this.frm.add_custom_button( __("Payment Request"), - () => this.make_payment_request(), + () => this.make_payment_request_with_schedule(), __("Create") );