From 298ea33922700f094e1d7e38249e0f3f2dd0f09a Mon Sep 17 00:00:00 2001 From: Jatin3128 Date: Sat, 6 Dec 2025 20:56:44 +0530 Subject: [PATCH] feat(payment_request): add option to calculate request amount using payment schedule (cherry picked from commit 60108590b07d89fffe080c457d5c159d0f3be7a6) --- .../doctype/payment_reference/__init__.py | 0 .../payment_reference/payment_reference.json | 90 +++++++++++++++++++ .../payment_reference/payment_reference.py | 28 ++++++ .../payment_request/payment_request.js | 26 ++++++ .../payment_request/payment_request.json | 20 ++++- .../payment_request/payment_request.py | 62 +++++++++++++ .../payment_request/test_payment_request.py | 68 ++++++++++++++ 7 files changed, 293 insertions(+), 1 deletion(-) create mode 100644 erpnext/accounts/doctype/payment_reference/__init__.py create mode 100644 erpnext/accounts/doctype/payment_reference/payment_reference.json create mode 100644 erpnext/accounts/doctype/payment_reference/payment_reference.py diff --git a/erpnext/accounts/doctype/payment_reference/__init__.py b/erpnext/accounts/doctype/payment_reference/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/accounts/doctype/payment_reference/payment_reference.json b/erpnext/accounts/doctype/payment_reference/payment_reference.json new file mode 100644 index 00000000000..32d947d00dd --- /dev/null +++ b/erpnext/accounts/doctype/payment_reference/payment_reference.json @@ -0,0 +1,90 @@ +{ + "actions": [], + "allow_rename": 1, + "creation": "2025-12-02 17:50:08.648006", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "payment_term", + "manually_selected", + "auto_selected", + "section_break_fjhh", + "description", + "section_break_mjlv", + "due_date", + "column_break_qghl", + "amount" + ], + "fields": [ + { + "fieldname": "payment_term", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Payment Term", + "options": "Payment Term" + }, + { + "collapsible": 1, + "fieldname": "section_break_fjhh", + "fieldtype": "Section Break", + "label": "Description" + }, + { + "fieldname": "description", + "fieldtype": "Small Text", + "in_list_view": 1, + "label": "Description" + }, + { + "fieldname": "section_break_mjlv", + "fieldtype": "Section Break" + }, + { + "fieldname": "due_date", + "fieldtype": "Date", + "in_list_view": 1, + "label": "Due Date" + }, + { + "fieldname": "column_break_qghl", + "fieldtype": "Column Break" + }, + { + "fieldname": "amount", + "fieldtype": "Currency", + "in_list_view": 1, + "label": "Amount", + "precision": "2" + }, + { + "default": "0", + "fieldname": "manually_selected", + "fieldtype": "Check", + "hidden": 1, + "label": "Manually Selected" + }, + { + "default": "1", + "fieldname": "auto_selected", + "fieldtype": "Check", + "hidden": 1, + "label": "Auto Selected" + } + ], + "grid_page_length": 50, + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2025-12-05 11:26:29.877050", + "modified_by": "Administrator", + "module": "Accounts", + "name": "Payment Reference", + "owner": "Administrator", + "permissions": [], + "row_format": "Dynamic", + "rows_threshold_for_grid_search": 20, + "sort_field": "creation", + "sort_order": "DESC", + "states": [] +} diff --git a/erpnext/accounts/doctype/payment_reference/payment_reference.py b/erpnext/accounts/doctype/payment_reference/payment_reference.py new file mode 100644 index 00000000000..8c67a247dc5 --- /dev/null +++ b/erpnext/accounts/doctype/payment_reference/payment_reference.py @@ -0,0 +1,28 @@ +# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class PaymentReference(Document): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + 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_term: DF.Link | None + # end: auto-generated types + + pass diff --git a/erpnext/accounts/doctype/payment_request/payment_request.js b/erpnext/accounts/doctype/payment_request/payment_request.js index 1d4c8d5280d..9696a6bfc2a 100644 --- a/erpnext/accounts/doctype/payment_request/payment_request.js +++ b/erpnext/accounts/doctype/payment_request/payment_request.js @@ -105,3 +105,29 @@ frappe.ui.form.on("Payment Request", "is_a_subscription", function (frm) { }); } }); + +frappe.ui.form.on("Payment Request", "calculate_total_amount_by_selected_rows", function (frm) { + if (frm.doc.docstatus !== 0) { + frappe.msgprint(__("Cannot fetch selected rows for submitted Payment Request")); + return; + } + const selected = frm.get_selected()?.payment_reference || []; + if (!selected.length) { + frappe.throw(__("No rows selected")); + } + let total = 0; + selected.forEach((name) => { + const row = frm.doc.payment_reference.find((d) => d.name === name); + if (row) { + row.manually_selected = 1; + + total += row.amount; + } + }); + frm.doc.payment_reference.forEach((row) => { + row.auto_selected = 0; + }); + frm.set_value("grand_total", total); + frm.refresh_field("grand_total"); + frm.save(); +}); diff --git a/erpnext/accounts/doctype/payment_request/payment_request.json b/erpnext/accounts/doctype/payment_request/payment_request.json index 81b879c285d..adff47f4639 100644 --- a/erpnext/accounts/doctype/payment_request/payment_request.json +++ b/erpnext/accounts/doctype/payment_request/payment_request.json @@ -19,6 +19,9 @@ "column_break_4", "reference_doctype", "reference_name", + "payment_reference_section", + "payment_reference", + "calculate_total_amount_by_selected_rows", "transaction_details", "grand_total", "currency", @@ -457,6 +460,21 @@ "fieldname": "phone_number", "fieldtype": "Data", "label": "Phone Number" + }, + { + "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" } ], "grid_page_length": 50, @@ -464,7 +482,7 @@ "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2025-08-29 11:52:48.555415", + "modified": "2025-12-05 11:27:51.406257", "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 fd28dab5a29..3437655dff3 100644 --- a/erpnext/accounts/doctype/payment_request/payment_request.py +++ b/erpnext/accounts/doctype/payment_request/payment_request.py @@ -45,6 +45,7 @@ class PaymentRequest(Document): if TYPE_CHECKING: from frappe.types import DF + from erpnext.accounts.doctype.payment_reference.payment_reference import PaymentReference from erpnext.accounts.doctype.subscription_plan_detail.subscription_plan_detail import ( SubscriptionPlanDetail, ) @@ -78,6 +79,7 @@ class PaymentRequest(Document): payment_gateway: DF.ReadOnly | None payment_gateway_account: DF.Link | None payment_order: DF.Link | None + payment_reference: DF.Table[PaymentReference] payment_request_type: DF.Literal["Outward", "Inward"] payment_url: DF.Data | None phone_number: DF.Data | None @@ -597,6 +599,8 @@ def make_payment_request(**args): "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) else: bank_account = ( get_party_bank_account(args.get("party_type"), args.get("party")) @@ -617,6 +621,8 @@ 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"), @@ -1024,3 +1030,59 @@ def get_irequests_of_payment_request(doc: str | None = None) -> list: }, ) return res + + +def set_payment_references(payment_request, ref_doc): + if not hasattr(ref_doc, "payment_schedule") or not ref_doc.payment_schedule: + return + + existing_refs = get_existing_payment_references(ref_doc.name) + + existing_map = {make_key(r.payment_term, r.due_date, r.amount): r for r in existing_refs} + + 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)) + + +def get_existing_payment_references(reference_name): + PR = frappe.qb.DocType("Payment Request") + PRF = frappe.qb.DocType("Payment Reference") + + result = ( + frappe.qb.from_(PR) + .join(PRF) + .on(PR.name == PRF.parent) + .select( + PRF.payment_term, + PRF.due_date, + PRF.amount, + PRF.manually_selected, + PRF.auto_selected, + PRF.parent, + ) + .where(PR.reference_name == reference_name) + .where(PR.docstatus == 1) + .where(PR.status.isin(["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 1f97b8b5784..1374e64c032 100644 --- a/erpnext/accounts/doctype/payment_request/test_payment_request.py +++ b/erpnext/accounts/doctype/payment_request/test_payment_request.py @@ -851,3 +851,71 @@ class TestPaymentRequest(IntegrationTestCase): pr.load_from_db() 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) + + 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.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( + dt="Purchase Order", + dn=po.name, + mute_email=1, + submit_doc=False, + return_doc=True, + ) + + self.assertEqual(len(pr2.payment_reference), 1) + self.assertEqual(pr2.payment_reference[0].amount, 33) + + def test_auto_selected_rows_are_not_reused(self): + from frappe.utils import add_days, nowdate + + po = create_purchase_order(do_not_save=1, currency="INR", qty=1, rate=80) + 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.save() + po.submit() + + pr1 = make_payment_request( + dt="Purchase Order", + dn=po.name, + mute_email=1, + submit_doc=False, + return_doc=True, + ) + + pr1.submit() + + with self.assertRaises(frappe.ValidationError): + make_payment_request( + dt="Purchase Order", + dn=po.name, + mute_email=1, + submit_doc=False, + return_doc=True, + )