Skip to content

Commit e476dff

Browse files
Jatin3128ruthra-kumar
authored andcommitted
feat(payment request): create payment request as per payment schedules
1 parent 6010859 commit e476dff

10 files changed

Lines changed: 356 additions & 99 deletions

File tree

erpnext/accounts/doctype/payment_reference/payment_reference.json

Lines changed: 11 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@
77
"engine": "InnoDB",
88
"field_order": [
99
"payment_term",
10-
"manually_selected",
11-
"auto_selected",
10+
"column_break_lnjp",
11+
"payment_schedule",
1212
"section_break_fjhh",
1313
"description",
1414
"section_break_mjlv",
@@ -58,25 +58,23 @@
5858
"precision": "2"
5959
},
6060
{
61-
"default": "0",
62-
"fieldname": "manually_selected",
63-
"fieldtype": "Check",
64-
"hidden": 1,
65-
"label": "Manually Selected"
61+
"fieldname": "column_break_lnjp",
62+
"fieldtype": "Column Break"
6663
},
6764
{
68-
"default": "1",
69-
"fieldname": "auto_selected",
70-
"fieldtype": "Check",
71-
"hidden": 1,
72-
"label": "Auto Selected"
65+
"allow_on_submit": 1,
66+
"fieldname": "payment_schedule",
67+
"fieldtype": "Link",
68+
"label": "Payment Schedule",
69+
"options": "Payment Schedule",
70+
"read_only": 1
7371
}
7472
],
7573
"grid_page_length": 50,
7674
"index_web_pages_for_search": 1,
7775
"istable": 1,
7876
"links": [],
79-
"modified": "2025-12-05 11:26:29.877050",
77+
"modified": "2026-01-19 02:21:36.455830",
8078
"modified_by": "Administrator",
8179
"module": "Accounts",
8280
"name": "Payment Reference",

erpnext/accounts/doctype/payment_reference/payment_reference.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,12 @@ class PaymentReference(Document):
1515
from frappe.types import DF
1616

1717
amount: DF.Currency
18-
auto_selected: DF.Check
1918
description: DF.SmallText | None
2019
due_date: DF.Date | None
21-
manually_selected: DF.Check
2220
parent: DF.Data
2321
parentfield: DF.Data
2422
parenttype: DF.Data
23+
payment_schedule: DF.Link | None
2524
payment_term: DF.Link | None
2625
# end: auto-generated types
2726

erpnext/accounts/doctype/payment_request/payment_request.json

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@
2121
"reference_name",
2222
"payment_reference_section",
2323
"payment_reference",
24-
"calculate_total_amount_by_selected_rows",
2524
"transaction_details",
2625
"grand_total",
2726
"currency",
@@ -160,6 +159,7 @@
160159
"label": "Amount",
161160
"non_negative": 1,
162161
"options": "currency",
162+
"read_only_depends_on": "eval:doc.payment_reference.length>0",
163163
"reqd": 1
164164
},
165165
{
@@ -465,24 +465,20 @@
465465
"fieldname": "payment_reference_section",
466466
"fieldtype": "Section Break"
467467
},
468-
{
469-
"fieldname": "calculate_total_amount_by_selected_rows",
470-
"fieldtype": "Button",
471-
"label": "Calculate Total Amount by Selected Rows"
472-
},
473468
{
474469
"fieldname": "payment_reference",
475470
"fieldtype": "Table",
476471
"label": "Payment Reference",
477-
"options": "Payment Reference"
472+
"options": "Payment Reference",
473+
"read_only": 1
478474
}
479475
],
480476
"grid_page_length": 50,
481477
"in_create": 1,
482478
"index_web_pages_for_search": 1,
483479
"is_submittable": 1,
484480
"links": [],
485-
"modified": "2025-12-05 11:27:51.406257",
481+
"modified": "2026-01-13 12:53:00.963274",
486482
"modified_by": "Administrator",
487483
"module": "Accounts",
488484
"name": "Payment Request",

erpnext/accounts/doctype/payment_request/payment_request.py

Lines changed: 146 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -111,15 +111,36 @@ def validate(self):
111111
if self.get("__islocal"):
112112
self.status = "Draft"
113113
self.validate_reference_document()
114+
self.validate_against_payment_reference()
114115
self.validate_payment_request_amount()
115116
# self.validate_currency()
116117
self.validate_subscription_details()
117118

119+
def validate_against_payment_reference(self):
120+
if not self.payment_reference:
121+
return
122+
123+
expected = sum(flt(r.amount) for r in self.payment_reference)
124+
if flt(expected, self.precision("grand_total")) != flt(self.grand_total):
125+
frappe.throw(_("Grand Total must match sum of Payment References"))
126+
127+
seen = set()
128+
for r in self.payment_reference:
129+
if not r.payment_schedule:
130+
continue # legacy mode → skip
131+
132+
if r.payment_schedule in seen:
133+
frappe.throw(_("Duplicate Payment Schedule selected"))
134+
135+
seen.add(r.payment_schedule)
136+
118137
def validate_reference_document(self):
119138
if not self.reference_doctype or not self.reference_name:
120139
frappe.throw(_("To create a Payment Request reference document is required"))
121140

122141
def validate_payment_request_amount(self):
142+
if self.payment_reference:
143+
return
123144
if self.grand_total == 0:
124145
frappe.throw(
125146
_("{0} cannot be zero").format(self.get_label_from_fieldname("grand_total")),
@@ -554,9 +575,63 @@ def make_payment_request(**args):
554575
ref_doc = args.ref_doc or frappe.get_doc(args.dt, args.dn)
555576
if not args.get("company"):
556577
args.company = ref_doc.company
578+
557579
gateway_account = get_gateway_details(args) or frappe._dict()
558580

559-
grand_total = get_amount(ref_doc, gateway_account.get("payment_account"))
581+
# Schedule-based PRs are allowed only if no Payment Entry exists for this document.
582+
# Any existing Payment Entry forces legacy (amount-based) flow.
583+
selected_payment_schedules = json.loads(args.get("schedules")) if args.get("schedules") else []
584+
585+
# Backend guard:
586+
# If any Payment Entry exists, schedule-based PRs are not allowed.
587+
if selected_payment_schedules and get_existing_payment_entry(ref_doc.name):
588+
frappe.throw(
589+
_(
590+
"Payment Schedule based Payment Requests cannot be created because a Payment Entry already exists for this document."
591+
)
592+
)
593+
594+
has_payment_entry = bool(get_existing_payment_entry(ref_doc.name))
595+
596+
payment_reference = []
597+
598+
if selected_payment_schedules:
599+
existing_payment_references = get_existing_payment_references(ref_doc.name)
600+
601+
if existing_payment_references:
602+
existing_ids = {r["payment_schedule"] for r in existing_payment_references}
603+
selected_ids = {r["name"] for r in selected_payment_schedules}
604+
duplicate_ids = existing_ids & selected_ids
605+
606+
if duplicate_ids:
607+
duplicate_schedules = []
608+
for row in selected_payment_schedules:
609+
if row["name"] in duplicate_ids:
610+
existing_ref = next(
611+
(r for r in existing_payment_references if r["payment_schedule"] == row["name"]),
612+
{},
613+
)
614+
existing_pr = existing_ref.get("parent")
615+
duplicate_schedules.append(
616+
f"Payment Term: {row.get('payment_term')}, "
617+
f"Due Date: {row.get('due_date')}, "
618+
f"Amount: {row.get('payment_amount')} "
619+
f"(already requested in PR {existing_pr})"
620+
)
621+
frappe.throw(
622+
_("The following payment schedule(s) already exist:\n{0}").format(
623+
"\n".join(duplicate_schedules)
624+
)
625+
)
626+
627+
payment_reference = set_payment_references(args.get("schedules"))
628+
629+
# Determine grand_total
630+
if selected_payment_schedules and not has_payment_entry:
631+
grand_total = sum(row.get("payment_amount") for row in selected_payment_schedules)
632+
else:
633+
grand_total = get_amount(ref_doc, gateway_account.get("payment_account"))
634+
560635
if not grand_total:
561636
frappe.throw(_("Payment Entry is already created"))
562637

@@ -566,7 +641,6 @@ def make_payment_request(**args):
566641
loyalty_amount = validate_loyalty_points(ref_doc, int(args.loyalty_points)) # sets fields on ref_doc
567642
ref_doc.db_update()
568643
grand_total = grand_total - loyalty_amount
569-
570644
# fetches existing payment request `grand_total` amount
571645
existing_payment_request_amount = get_existing_payment_request_amount(ref_doc)
572646

@@ -586,21 +660,20 @@ def validate_and_calculate_grand_total(grand_total, existing_payment_request_amo
586660
else:
587661
# If PR's are processed, cancel all of them.
588662
cancel_old_payment_requests(ref_doc.doctype, ref_doc.name)
589-
else:
663+
elif not selected_payment_schedules:
590664
grand_total = validate_and_calculate_grand_total(grand_total, existing_payment_request_amount)
591-
592665
draft_payment_request = frappe.db.get_value(
593666
"Payment Request",
594667
{"reference_doctype": ref_doc.doctype, "reference_name": ref_doc.name, "docstatus": 0},
595668
)
596669

597670
if draft_payment_request:
598-
frappe.db.set_value(
599-
"Payment Request", draft_payment_request, "grand_total", grand_total, update_modified=False
600-
)
601671
pr = frappe.get_doc("Payment Request", draft_payment_request)
602672

603-
set_payment_references(pr, ref_doc)
673+
if selected_payment_schedules:
674+
apply_payment_references(pr, payment_reference)
675+
pr.save()
676+
604677
else:
605678
bank_account = (
606679
get_party_bank_account(args.get("party_type"), args.get("party"))
@@ -621,8 +694,6 @@ def validate_and_calculate_grand_total(grand_total, existing_payment_request_amo
621694
party_account = get_party_account(party_type, ref_doc.get(party_type.lower()), ref_doc.company)
622695
party_account_currency = get_account_currency(party_account)
623696

624-
set_payment_references(pr, ref_doc)
625-
626697
pr.update(
627698
{
628699
"payment_gateway_account": gateway_account.get("name"),
@@ -657,7 +728,10 @@ def validate_and_calculate_grand_total(grand_total, existing_payment_request_amo
657728
}
658729
)
659730

660-
# Update dimensions
731+
if selected_payment_schedules:
732+
apply_payment_references(pr, payment_reference)
733+
734+
# Dimensions
661735
pr.update(
662736
{
663737
"cost_center": ref_doc.get("cost_center"),
@@ -686,6 +760,51 @@ def validate_and_calculate_grand_total(grand_total, existing_payment_request_amo
686760
return pr.as_dict()
687761

688762

763+
def apply_payment_references(pr, payment_reference):
764+
existing_refs = pr.get("payment_reference") or []
765+
766+
existing_ids = {r.get("payment_schedule") for r in existing_refs if r.get("payment_schedule")}
767+
new_refs = [r for r in (payment_reference or []) if r.get("payment_schedule") not in existing_ids]
768+
pr.set("payment_reference", existing_refs + new_refs)
769+
pr.set("grand_total", sum(flt(r.get("amount")) for r in pr.get("payment_reference")))
770+
771+
772+
def set_payment_references(payment_schedules):
773+
payment_schedules = json.loads(payment_schedules) if payment_schedules else []
774+
payment_reference = []
775+
776+
for row in payment_schedules:
777+
payment_reference.append(
778+
{
779+
"payment_term": row.get("payment_term"),
780+
"payment_schedule": row.get("name"),
781+
"description": row.get("description"),
782+
"due_date": row.get("due_date"),
783+
"amount": row.get("payment_amount"),
784+
}
785+
)
786+
787+
return payment_reference
788+
789+
790+
def get_existing_payment_entry(ref_docname):
791+
pe = frappe.qb.DocType("Payment Entry")
792+
per = frappe.qb.DocType("Payment Entry Reference")
793+
794+
existing_pe = (
795+
frappe.qb.from_(pe)
796+
.join(per)
797+
.on(per.parent == pe.name)
798+
.select(pe.name)
799+
.where(pe.docstatus < 2)
800+
.where(per.reference_name == ref_docname)
801+
.limit(1)
802+
.run()
803+
)
804+
805+
return existing_pe
806+
807+
689808
def get_amount(ref_doc, payment_account=None):
690809
"""get amount based on doctype"""
691810
grand_total = 0
@@ -1032,36 +1151,20 @@ def get_irequests_of_payment_request(doc: str | None = None) -> list:
10321151
return res
10331152

10341153

1035-
def set_payment_references(payment_request, ref_doc):
1036-
if not hasattr(ref_doc, "payment_schedule") or not ref_doc.payment_schedule:
1037-
return
1038-
1039-
existing_refs = get_existing_payment_references(ref_doc.name)
1040-
1041-
existing_map = {make_key(r.payment_term, r.due_date, r.amount): r for r in existing_refs}
1042-
1043-
payment_request.reference = []
1044-
1045-
for row in ref_doc.payment_schedule:
1046-
key = make_key(row.payment_term, row.due_date, row.payment_amount)
1154+
@frappe.whitelist()
1155+
def get_available_payment_schedules(reference_doctype, reference_name):
1156+
ref_doc = frappe.get_doc(reference_doctype, reference_name)
10471157

1048-
existing = existing_map.get(key)
1049-
if existing and (existing.manually_selected or existing.auto_selected):
1050-
continue
1158+
if not hasattr(ref_doc, "payment_schedule") or not ref_doc.payment_schedule:
1159+
return []
10511160

1052-
payment_request.append(
1053-
"payment_reference",
1054-
{
1055-
"payment_term": row.payment_term,
1056-
"description": row.description,
1057-
"due_date": row.due_date,
1058-
"amount": row.payment_amount,
1059-
},
1060-
)
1161+
if get_existing_payment_entry(reference_name):
1162+
return []
10611163

1164+
existing_refs = get_existing_payment_references(reference_name)
1165+
existing_ids = {r["payment_schedule"] for r in existing_refs if r.get("payment_schedule")}
10621166

1063-
def make_key(payment_term, due_date, amount):
1064-
return (payment_term, due_date, flt(amount))
1167+
return [r for r in ref_doc.payment_schedule if r.name not in existing_ids]
10651168

10661169

10671170
def get_existing_payment_references(reference_name):
@@ -1075,14 +1178,15 @@ def get_existing_payment_references(reference_name):
10751178
.select(
10761179
PRF.payment_term,
10771180
PRF.due_date,
1078-
PRF.amount,
1079-
PRF.manually_selected,
1080-
PRF.auto_selected,
1181+
PRF.amount.as_("payment_amount"),
1182+
PRF.payment_schedule,
10811183
PRF.parent,
10821184
)
10831185
.where(PR.reference_name == reference_name)
1084-
.where(PR.docstatus == 1)
1085-
.where(PR.status.isin(["Initiated", "Partially Paid", "Payment Ordered", "Paid"]))
1186+
.where(PR.docstatus < 2)
1187+
.where(
1188+
PR.status.isin(["Draft", "Requested", "Initiated", "Partially Paid", "Payment Ordered", "Paid"])
1189+
)
10861190
).run(as_dict=True)
10871191

10881192
return result

0 commit comments

Comments
 (0)