mirror of
https://github.com/frappe/erpnext.git
synced 2026-04-20 07:15:11 +00:00
feat(payment request): create payment request as per payment schedules
(cherry picked from commit e476dff842)
This commit is contained in:
@@ -7,8 +7,8 @@
|
|||||||
"engine": "InnoDB",
|
"engine": "InnoDB",
|
||||||
"field_order": [
|
"field_order": [
|
||||||
"payment_term",
|
"payment_term",
|
||||||
"manually_selected",
|
"column_break_lnjp",
|
||||||
"auto_selected",
|
"payment_schedule",
|
||||||
"section_break_fjhh",
|
"section_break_fjhh",
|
||||||
"description",
|
"description",
|
||||||
"section_break_mjlv",
|
"section_break_mjlv",
|
||||||
@@ -58,25 +58,23 @@
|
|||||||
"precision": "2"
|
"precision": "2"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"default": "0",
|
"fieldname": "column_break_lnjp",
|
||||||
"fieldname": "manually_selected",
|
"fieldtype": "Column Break"
|
||||||
"fieldtype": "Check",
|
|
||||||
"hidden": 1,
|
|
||||||
"label": "Manually Selected"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"default": "1",
|
"allow_on_submit": 1,
|
||||||
"fieldname": "auto_selected",
|
"fieldname": "payment_schedule",
|
||||||
"fieldtype": "Check",
|
"fieldtype": "Link",
|
||||||
"hidden": 1,
|
"label": "Payment Schedule",
|
||||||
"label": "Auto Selected"
|
"options": "Payment Schedule",
|
||||||
|
"read_only": 1
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"grid_page_length": 50,
|
"grid_page_length": 50,
|
||||||
"index_web_pages_for_search": 1,
|
"index_web_pages_for_search": 1,
|
||||||
"istable": 1,
|
"istable": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
"modified": "2025-12-05 11:26:29.877050",
|
"modified": "2026-01-19 02:21:36.455830",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "Accounts",
|
"module": "Accounts",
|
||||||
"name": "Payment Reference",
|
"name": "Payment Reference",
|
||||||
|
|||||||
@@ -15,13 +15,12 @@ class PaymentReference(Document):
|
|||||||
from frappe.types import DF
|
from frappe.types import DF
|
||||||
|
|
||||||
amount: DF.Currency
|
amount: DF.Currency
|
||||||
auto_selected: DF.Check
|
|
||||||
description: DF.SmallText | None
|
description: DF.SmallText | None
|
||||||
due_date: DF.Date | None
|
due_date: DF.Date | None
|
||||||
manually_selected: DF.Check
|
|
||||||
parent: DF.Data
|
parent: DF.Data
|
||||||
parentfield: DF.Data
|
parentfield: DF.Data
|
||||||
parenttype: DF.Data
|
parenttype: DF.Data
|
||||||
|
payment_schedule: DF.Link | None
|
||||||
payment_term: DF.Link | None
|
payment_term: DF.Link | None
|
||||||
# end: auto-generated types
|
# end: auto-generated types
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,6 @@
|
|||||||
"reference_name",
|
"reference_name",
|
||||||
"payment_reference_section",
|
"payment_reference_section",
|
||||||
"payment_reference",
|
"payment_reference",
|
||||||
"calculate_total_amount_by_selected_rows",
|
|
||||||
"transaction_details",
|
"transaction_details",
|
||||||
"grand_total",
|
"grand_total",
|
||||||
"currency",
|
"currency",
|
||||||
@@ -160,6 +159,7 @@
|
|||||||
"label": "Amount",
|
"label": "Amount",
|
||||||
"non_negative": 1,
|
"non_negative": 1,
|
||||||
"options": "currency",
|
"options": "currency",
|
||||||
|
"read_only_depends_on": "eval:doc.payment_reference.length>0",
|
||||||
"reqd": 1
|
"reqd": 1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -465,16 +465,12 @@
|
|||||||
"fieldname": "payment_reference_section",
|
"fieldname": "payment_reference_section",
|
||||||
"fieldtype": "Section Break"
|
"fieldtype": "Section Break"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"fieldname": "calculate_total_amount_by_selected_rows",
|
|
||||||
"fieldtype": "Button",
|
|
||||||
"label": "Calculate Total Amount by Selected Rows"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"fieldname": "payment_reference",
|
"fieldname": "payment_reference",
|
||||||
"fieldtype": "Table",
|
"fieldtype": "Table",
|
||||||
"label": "Payment Reference",
|
"label": "Payment Reference",
|
||||||
"options": "Payment Reference"
|
"options": "Payment Reference",
|
||||||
|
"read_only": 1
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"grid_page_length": 50,
|
"grid_page_length": 50,
|
||||||
@@ -482,7 +478,7 @@
|
|||||||
"index_web_pages_for_search": 1,
|
"index_web_pages_for_search": 1,
|
||||||
"is_submittable": 1,
|
"is_submittable": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
"modified": "2025-12-05 11:27:51.406257",
|
"modified": "2026-01-13 12:53:00.963274",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "Accounts",
|
"module": "Accounts",
|
||||||
"name": "Payment Request",
|
"name": "Payment Request",
|
||||||
|
|||||||
@@ -111,15 +111,36 @@ class PaymentRequest(Document):
|
|||||||
if self.get("__islocal"):
|
if self.get("__islocal"):
|
||||||
self.status = "Draft"
|
self.status = "Draft"
|
||||||
self.validate_reference_document()
|
self.validate_reference_document()
|
||||||
|
self.validate_against_payment_reference()
|
||||||
self.validate_payment_request_amount()
|
self.validate_payment_request_amount()
|
||||||
# self.validate_currency()
|
# self.validate_currency()
|
||||||
self.validate_subscription_details()
|
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):
|
def validate_reference_document(self):
|
||||||
if not self.reference_doctype or not self.reference_name:
|
if not self.reference_doctype or not self.reference_name:
|
||||||
frappe.throw(_("To create a Payment Request reference document is required"))
|
frappe.throw(_("To create a Payment Request reference document is required"))
|
||||||
|
|
||||||
def validate_payment_request_amount(self):
|
def validate_payment_request_amount(self):
|
||||||
|
if self.payment_reference:
|
||||||
|
return
|
||||||
if self.grand_total == 0:
|
if self.grand_total == 0:
|
||||||
frappe.throw(
|
frappe.throw(
|
||||||
_("{0} cannot be zero").format(self.get_label_from_fieldname("grand_total")),
|
_("{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)
|
ref_doc = args.ref_doc or frappe.get_doc(args.dt, args.dn)
|
||||||
if not args.get("company"):
|
if not args.get("company"):
|
||||||
args.company = ref_doc.company
|
args.company = ref_doc.company
|
||||||
|
|
||||||
gateway_account = get_gateway_details(args) or frappe._dict()
|
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:
|
if not grand_total:
|
||||||
frappe.throw(_("Payment Entry is already created"))
|
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
|
loyalty_amount = validate_loyalty_points(ref_doc, int(args.loyalty_points)) # sets fields on ref_doc
|
||||||
ref_doc.db_update()
|
ref_doc.db_update()
|
||||||
grand_total = grand_total - loyalty_amount
|
grand_total = grand_total - loyalty_amount
|
||||||
|
|
||||||
# fetches existing payment request `grand_total` amount
|
# fetches existing payment request `grand_total` amount
|
||||||
existing_payment_request_amount = get_existing_payment_request_amount(ref_doc)
|
existing_payment_request_amount = get_existing_payment_request_amount(ref_doc)
|
||||||
|
|
||||||
@@ -586,21 +660,20 @@ def make_payment_request(**args):
|
|||||||
else:
|
else:
|
||||||
# If PR's are processed, cancel all of them.
|
# If PR's are processed, cancel all of them.
|
||||||
cancel_old_payment_requests(ref_doc.doctype, ref_doc.name)
|
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)
|
grand_total = validate_and_calculate_grand_total(grand_total, existing_payment_request_amount)
|
||||||
|
|
||||||
draft_payment_request = frappe.db.get_value(
|
draft_payment_request = frappe.db.get_value(
|
||||||
"Payment Request",
|
"Payment Request",
|
||||||
{"reference_doctype": ref_doc.doctype, "reference_name": ref_doc.name, "docstatus": 0},
|
{"reference_doctype": ref_doc.doctype, "reference_name": ref_doc.name, "docstatus": 0},
|
||||||
)
|
)
|
||||||
|
|
||||||
if draft_payment_request:
|
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)
|
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:
|
else:
|
||||||
bank_account = (
|
bank_account = (
|
||||||
get_party_bank_account(args.get("party_type"), args.get("party"))
|
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 = get_party_account(party_type, ref_doc.get(party_type.lower()), ref_doc.company)
|
||||||
party_account_currency = get_account_currency(party_account)
|
party_account_currency = get_account_currency(party_account)
|
||||||
|
|
||||||
set_payment_references(pr, ref_doc)
|
|
||||||
|
|
||||||
pr.update(
|
pr.update(
|
||||||
{
|
{
|
||||||
"payment_gateway_account": gateway_account.get("name"),
|
"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(
|
pr.update(
|
||||||
{
|
{
|
||||||
"cost_center": ref_doc.get("cost_center"),
|
"cost_center": ref_doc.get("cost_center"),
|
||||||
@@ -686,6 +760,51 @@ def make_payment_request(**args):
|
|||||||
return pr.as_dict()
|
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):
|
def get_amount(ref_doc, payment_account=None):
|
||||||
"""get amount based on doctype"""
|
"""get amount based on doctype"""
|
||||||
grand_total = 0
|
grand_total = 0
|
||||||
@@ -1032,36 +1151,20 @@ def get_irequests_of_payment_request(doc: str | None = None) -> list:
|
|||||||
return res
|
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:
|
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 = []
|
return [r for r in ref_doc.payment_schedule if r.name not in existing_ids]
|
||||||
|
|
||||||
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):
|
def get_existing_payment_references(reference_name):
|
||||||
@@ -1075,14 +1178,15 @@ def get_existing_payment_references(reference_name):
|
|||||||
.select(
|
.select(
|
||||||
PRF.payment_term,
|
PRF.payment_term,
|
||||||
PRF.due_date,
|
PRF.due_date,
|
||||||
PRF.amount,
|
PRF.amount.as_("payment_amount"),
|
||||||
PRF.manually_selected,
|
PRF.payment_schedule,
|
||||||
PRF.auto_selected,
|
|
||||||
PRF.parent,
|
PRF.parent,
|
||||||
)
|
)
|
||||||
.where(PR.reference_name == reference_name)
|
.where(PR.reference_name == reference_name)
|
||||||
.where(PR.docstatus == 1)
|
.where(PR.docstatus < 2)
|
||||||
.where(PR.status.isin(["Initiated", "Partially Paid", "Payment Ordered", "Paid"]))
|
.where(
|
||||||
|
PR.status.isin(["Draft", "Requested", "Initiated", "Partially Paid", "Payment Ordered", "Paid"])
|
||||||
|
)
|
||||||
).run(as_dict=True)
|
).run(as_dict=True)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
|
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
|
||||||
# See license.txt
|
# See license.txt
|
||||||
|
|
||||||
|
import json
|
||||||
import re
|
import re
|
||||||
import unittest
|
import unittest
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
import frappe
|
import frappe
|
||||||
from frappe.tests import IntegrationTestCase
|
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.payment_entry import get_payment_entry
|
||||||
from erpnext.accounts.doctype.payment_entry.test_payment_entry import create_payment_terms_template
|
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)
|
self.assertEqual(pr.grand_total, pi.outstanding_amount)
|
||||||
|
|
||||||
def test_payment_schedule_row_selection(self):
|
def test_payment_request_grand_total_from_selected_schedules(self):
|
||||||
from frappe.utils import add_days, nowdate
|
po = create_purchase_order(do_not_save=1, currency="INR", qty=1, rate=100)
|
||||||
|
|
||||||
po = create_purchase_order(do_not_save=1, currency="INR", qty=1, rate=86)
|
|
||||||
|
|
||||||
po.payment_schedule = []
|
po.payment_schedule = []
|
||||||
|
|
||||||
po.append("payment_schedule", {"due_date": nowdate(), "payment_amount": 33})
|
po.append("payment_schedule", {"due_date": nowdate(), "payment_amount": 30})
|
||||||
po.append("payment_schedule", {"due_date": add_days(nowdate(), 1), "payment_amount": 33})
|
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": 20})
|
po.append("payment_schedule", {"due_date": add_days(nowdate(), 2), "payment_amount": 40})
|
||||||
|
|
||||||
po.save()
|
po.save()
|
||||||
po.submit()
|
po.submit()
|
||||||
|
|
||||||
pr1 = make_payment_request(
|
schedules = json.dumps(
|
||||||
dt="Purchase Order",
|
[
|
||||||
dn=po.name,
|
{
|
||||||
mute_email=1,
|
"payment_term": row.payment_term,
|
||||||
submit_doc=False,
|
"name": row.name,
|
||||||
return_doc=True,
|
"due_date": row.due_date,
|
||||||
)
|
"payment_amount": row.payment_amount,
|
||||||
pr1.payment_reference[0].manually_selected = 1
|
"description": row.description,
|
||||||
pr1.payment_reference[1].auto_selected = 0
|
}
|
||||||
pr1.payment_reference[2].manually_selected = 1
|
for row in [po.payment_schedule[0], po.payment_schedule[2]]
|
||||||
pr1.grand_total = 53
|
]
|
||||||
pr1.submit()
|
)
|
||||||
|
pr = make_payment_request(
|
||||||
pr2 = make_payment_request(
|
|
||||||
dt="Purchase Order",
|
dt="Purchase Order",
|
||||||
dn=po.name,
|
dn=po.name,
|
||||||
mute_email=1,
|
mute_email=1,
|
||||||
submit_doc=False,
|
submit_doc=False,
|
||||||
return_doc=True,
|
return_doc=True,
|
||||||
|
schedules=schedules,
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(len(pr2.payment_reference), 1)
|
pr.submit()
|
||||||
self.assertEqual(pr2.payment_reference[0].amount, 33)
|
|
||||||
|
|
||||||
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
|
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.payment_schedule = []
|
||||||
po.append("payment_schedule", {"due_date": nowdate(), "payment_amount": 40})
|
po.append("payment_schedule", {"due_date": nowdate(), "payment_amount": 50})
|
||||||
po.append("payment_schedule", {"due_date": add_days(nowdate(), 1), "payment_amount": 10})
|
po.append("payment_schedule", {"due_date": add_days(nowdate(), 1), "payment_amount": 50})
|
||||||
po.append("payment_schedule", {"due_date": add_days(nowdate(), 2), "payment_amount": 30})
|
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.save()
|
||||||
po.submit()
|
po.submit()
|
||||||
|
|
||||||
pr1 = make_payment_request(
|
# create PE first
|
||||||
dt="Purchase Order",
|
pr = make_payment_request(dt="Purchase Order", dn=po.name, mute_email=1, submit_doc=1, return_doc=1)
|
||||||
dn=po.name,
|
pr.create_payment_entry()
|
||||||
mute_email=1,
|
|
||||||
submit_doc=False,
|
|
||||||
return_doc=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
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):
|
with self.assertRaises(frappe.ValidationError):
|
||||||
make_payment_request(
|
make_payment_request(
|
||||||
@@ -918,4 +978,5 @@ class TestPaymentRequest(IntegrationTestCase):
|
|||||||
mute_email=1,
|
mute_email=1,
|
||||||
submit_doc=False,
|
submit_doc=False,
|
||||||
return_doc=True,
|
return_doc=True,
|
||||||
|
schedules=schedules,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ erpnext.accounts.PurchaseInvoice = class PurchaseInvoice extends erpnext.buying.
|
|||||||
this.frm.add_custom_button(
|
this.frm.add_custom_button(
|
||||||
__("Payment Request"),
|
__("Payment Request"),
|
||||||
function () {
|
function () {
|
||||||
me.make_payment_request();
|
me.make_payment_request_with_schedule();
|
||||||
},
|
},
|
||||||
__("Create")
|
__("Create")
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends (
|
|||||||
this.frm.add_custom_button(
|
this.frm.add_custom_button(
|
||||||
__("Payment Request"),
|
__("Payment Request"),
|
||||||
function () {
|
function () {
|
||||||
me.make_payment_request();
|
me.make_payment_request_with_schedule();
|
||||||
},
|
},
|
||||||
__("Create")
|
__("Create")
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -428,7 +428,7 @@ erpnext.buying.PurchaseOrderController = class PurchaseOrderController extends (
|
|||||||
this.frm.add_custom_button(
|
this.frm.add_custom_button(
|
||||||
__("Payment Request"),
|
__("Payment Request"),
|
||||||
function () {
|
function () {
|
||||||
me.make_payment_request();
|
me.make_payment_request_with_schedule();
|
||||||
},
|
},
|
||||||
__("Create")
|
__("Create")
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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() {
|
onload_post_render() {
|
||||||
if (
|
if (
|
||||||
this.frm.doc.__islocal &&
|
this.frm.doc.__islocal &&
|
||||||
|
|||||||
@@ -1150,7 +1150,7 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex
|
|||||||
if (flt(doc.per_billed) < 100 + frappe.boot.sysdefaults.over_billing_allowance) {
|
if (flt(doc.per_billed) < 100 + frappe.boot.sysdefaults.over_billing_allowance) {
|
||||||
this.frm.add_custom_button(
|
this.frm.add_custom_button(
|
||||||
__("Payment Request"),
|
__("Payment Request"),
|
||||||
() => this.make_payment_request(),
|
() => this.make_payment_request_with_schedule(),
|
||||||
__("Create")
|
__("Create")
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user