feat(payment request): create payment request as per payment schedules

(cherry picked from commit e476dff842)
This commit is contained in:
Jatin3128
2026-01-16 05:39:43 +05:30
committed by Mergify
parent 298ea33922
commit 751a081253
10 changed files with 363 additions and 106 deletions

View File

@@ -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",

View File

@@ -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

View File

@@ -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",

View File

@@ -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

View File

@@ -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,
)

View File

@@ -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")
);

View File

@@ -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")
);

View File

@@ -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")
);

View File

@@ -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 &&

View File

@@ -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")
);