diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.js b/erpnext/accounts/doctype/payment_entry/payment_entry.js
index 4a260205b1d1..c4f14d71036d 100644
--- a/erpnext/accounts/doctype/payment_entry/payment_entry.js
+++ b/erpnext/accounts/doctype/payment_entry/payment_entry.js
@@ -174,6 +174,17 @@ frappe.ui.form.on("Payment Entry", {
};
});
+ frm.set_query("payment_request", "references", function (doc, cdt, cdn) {
+ const row = frappe.get_doc(cdt, cdn);
+ return {
+ query: "erpnext.accounts.doctype.payment_request.payment_request.get_open_payment_requests_query",
+ filters: {
+ reference_doctype: row.reference_doctype,
+ reference_name: row.reference_name,
+ },
+ };
+ });
+
frm.set_query("sales_taxes_and_charges_template", function () {
return {
filters: {
@@ -191,7 +202,15 @@ frappe.ui.form.on("Payment Entry", {
},
};
});
+
+ frm.add_fetch(
+ "payment_request",
+ "outstanding_amount",
+ "payment_request_outstanding",
+ "Payment Entry Reference"
+ );
},
+
refresh: function (frm) {
erpnext.hide_company(frm);
frm.events.hide_unhide_fields(frm);
@@ -216,6 +235,7 @@ frappe.ui.form.on("Payment Entry", {
);
}
erpnext.accounts.unreconcile_payment.add_unreconcile_btn(frm);
+ frappe.flags.allocate_payment_amount = true;
},
validate_company: (frm) => {
@@ -797,7 +817,7 @@ frappe.ui.form.on("Payment Entry", {
);
if (frm.doc.payment_type == "Pay")
- frm.events.allocate_party_amount_against_ref_docs(frm, frm.doc.received_amount, 1);
+ frm.events.allocate_party_amount_against_ref_docs(frm, frm.doc.received_amount, true);
else frm.events.set_unallocated_amount(frm);
frm.set_paid_amount_based_on_received_amount = false;
@@ -818,7 +838,7 @@ frappe.ui.form.on("Payment Entry", {
}
if (frm.doc.payment_type == "Receive")
- frm.events.allocate_party_amount_against_ref_docs(frm, frm.doc.paid_amount, 1);
+ frm.events.allocate_party_amount_against_ref_docs(frm, frm.doc.paid_amount, true);
else frm.events.set_unallocated_amount(frm);
},
@@ -989,6 +1009,7 @@ frappe.ui.form.on("Payment Entry", {
c.outstanding_amount = d.outstanding_amount;
c.bill_no = d.bill_no;
c.payment_term = d.payment_term;
+ c.payment_term_outstanding = d.payment_term_outstanding;
c.allocated_amount = d.allocated_amount;
c.account = d.account;
@@ -1038,7 +1059,8 @@ frappe.ui.form.on("Payment Entry", {
frm.events.allocate_party_amount_against_ref_docs(
frm,
- frm.doc.payment_type == "Receive" ? frm.doc.paid_amount : frm.doc.received_amount
+ frm.doc.payment_type == "Receive" ? frm.doc.paid_amount : frm.doc.received_amount,
+ false
);
},
});
@@ -1052,93 +1074,13 @@ frappe.ui.form.on("Payment Entry", {
return ["Sales Invoice", "Purchase Invoice"];
},
- allocate_party_amount_against_ref_docs: function (frm, paid_amount, paid_amount_change) {
- var total_positive_outstanding_including_order = 0;
- var total_negative_outstanding = 0;
- var total_deductions = frappe.utils.sum(
- $.map(frm.doc.deductions || [], function (d) {
- return flt(d.amount);
- })
- );
-
- paid_amount -= total_deductions;
-
- $.each(frm.doc.references || [], function (i, row) {
- if (flt(row.outstanding_amount) > 0)
- total_positive_outstanding_including_order += flt(row.outstanding_amount);
- else total_negative_outstanding += Math.abs(flt(row.outstanding_amount));
+ allocate_party_amount_against_ref_docs: async function (frm, paid_amount, paid_amount_change) {
+ await frm.call("allocate_amount_to_references", {
+ paid_amount: paid_amount,
+ paid_amount_change: paid_amount_change,
+ allocate_payment_amount: frappe.flags.allocate_payment_amount ?? false,
});
- var allocated_negative_outstanding = 0;
- if (
- (frm.doc.payment_type == "Receive" && frm.doc.party_type == "Customer") ||
- (frm.doc.payment_type == "Pay" && frm.doc.party_type == "Supplier") ||
- (frm.doc.payment_type == "Pay" && frm.doc.party_type == "Employee")
- ) {
- if (total_positive_outstanding_including_order > paid_amount) {
- var remaining_outstanding = total_positive_outstanding_including_order - paid_amount;
- allocated_negative_outstanding =
- total_negative_outstanding < remaining_outstanding
- ? total_negative_outstanding
- : remaining_outstanding;
- }
-
- var allocated_positive_outstanding = paid_amount + allocated_negative_outstanding;
- } else if (["Customer", "Supplier"].includes(frm.doc.party_type)) {
- total_negative_outstanding = flt(total_negative_outstanding, precision("outstanding_amount"));
- if (paid_amount > total_negative_outstanding) {
- if (total_negative_outstanding == 0) {
- frappe.msgprint(
- __("Cannot {0} {1} {2} without any negative outstanding invoice", [
- frm.doc.payment_type,
- frm.doc.party_type == "Customer" ? "to" : "from",
- frm.doc.party_type,
- ])
- );
- return false;
- } else {
- frappe.msgprint(
- __("Paid Amount cannot be greater than total negative outstanding amount {0}", [
- total_negative_outstanding,
- ])
- );
- return false;
- }
- } else {
- allocated_positive_outstanding = total_negative_outstanding - paid_amount;
- allocated_negative_outstanding =
- paid_amount +
- (total_positive_outstanding_including_order < allocated_positive_outstanding
- ? total_positive_outstanding_including_order
- : allocated_positive_outstanding);
- }
- }
-
- $.each(frm.doc.references || [], function (i, row) {
- if (frappe.flags.allocate_payment_amount == 0) {
- //If allocate payment amount checkbox is unchecked, set zero to allocate amount
- row.allocated_amount = 0;
- } else if (
- frappe.flags.allocate_payment_amount != 0 &&
- (!row.allocated_amount || paid_amount_change)
- ) {
- if (row.outstanding_amount > 0 && allocated_positive_outstanding >= 0) {
- row.allocated_amount =
- row.outstanding_amount >= allocated_positive_outstanding
- ? allocated_positive_outstanding
- : row.outstanding_amount;
- allocated_positive_outstanding -= flt(row.allocated_amount);
- } else if (row.outstanding_amount < 0 && allocated_negative_outstanding) {
- row.allocated_amount =
- Math.abs(row.outstanding_amount) >= allocated_negative_outstanding
- ? -1 * allocated_negative_outstanding
- : row.outstanding_amount;
- allocated_negative_outstanding -= Math.abs(flt(row.allocated_amount));
- }
- }
- });
-
- frm.refresh_fields();
frm.events.set_total_allocated_amount(frm);
},
@@ -1686,6 +1628,62 @@ frappe.ui.form.on("Payment Entry", {
return current_tax_amount;
},
+
+ cost_center: function (frm) {
+ if (frm.doc.posting_date && (frm.doc.paid_from || frm.doc.paid_to)) {
+ return frappe.call({
+ method: "erpnext.accounts.doctype.payment_entry.payment_entry.get_party_and_account_balance",
+ args: {
+ company: frm.doc.company,
+ date: frm.doc.posting_date,
+ paid_from: frm.doc.paid_from,
+ paid_to: frm.doc.paid_to,
+ ptype: frm.doc.party_type,
+ pty: frm.doc.party,
+ cost_center: frm.doc.cost_center,
+ },
+ callback: function (r, rt) {
+ if (r.message) {
+ frappe.run_serially([
+ () => {
+ frm.set_value(
+ "paid_from_account_balance",
+ r.message.paid_from_account_balance
+ );
+ frm.set_value("paid_to_account_balance", r.message.paid_to_account_balance);
+ frm.set_value("party_balance", r.message.party_balance);
+ },
+ ]);
+ }
+ },
+ });
+ }
+ },
+
+ after_save: function (frm) {
+ const { matched_payment_requests } = frappe.last_response;
+ if (!matched_payment_requests) return;
+
+ const COLUMN_LABEL = [
+ [__("Reference DocType"), __("Reference Name"), __("Allocated Amount"), __("Payment Request")],
+ ];
+
+ frappe.msgprint({
+ title: __("Unset Matched Payment Request"),
+ message: COLUMN_LABEL.concat(matched_payment_requests),
+ as_table: true,
+ wide: true,
+ primary_action: {
+ label: __("Allocate Payment Request"),
+ action() {
+ frappe.hide_msgprint();
+ frm.call("set_matched_payment_requests", { matched_payment_requests }, () => {
+ frm.dirty();
+ });
+ },
+ },
+ });
+ },
});
frappe.ui.form.on("Payment Entry Reference", {
@@ -1778,35 +1776,3 @@ frappe.ui.form.on("Payment Entry Deduction", {
frm.events.set_unallocated_amount(frm);
},
});
-frappe.ui.form.on("Payment Entry", {
- cost_center: function (frm) {
- if (frm.doc.posting_date && (frm.doc.paid_from || frm.doc.paid_to)) {
- return frappe.call({
- method: "erpnext.accounts.doctype.payment_entry.payment_entry.get_party_and_account_balance",
- args: {
- company: frm.doc.company,
- date: frm.doc.posting_date,
- paid_from: frm.doc.paid_from,
- paid_to: frm.doc.paid_to,
- ptype: frm.doc.party_type,
- pty: frm.doc.party,
- cost_center: frm.doc.cost_center,
- },
- callback: function (r, rt) {
- if (r.message) {
- frappe.run_serially([
- () => {
- frm.set_value(
- "paid_from_account_balance",
- r.message.paid_from_account_balance
- );
- frm.set_value("paid_to_account_balance", r.message.paid_to_account_balance);
- frm.set_value("party_balance", r.message.party_balance);
- },
- ]);
- }
- },
- });
- }
- },
-});
diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.py b/erpnext/accounts/doctype/payment_entry/payment_entry.py
index 15bf48a9c1c0..d688b1fa3e07 100644
--- a/erpnext/accounts/doctype/payment_entry/payment_entry.py
+++ b/erpnext/accounts/doctype/payment_entry/payment_entry.py
@@ -7,8 +7,10 @@
import frappe
from frappe import ValidationError, _, qb, scrub, throw
+from frappe.query_builder import Tuple
+from frappe.query_builder.functions import Count
from frappe.utils import cint, comma_or, flt, getdate, nowdate
-from frappe.utils.data import comma_and, fmt_money
+from frappe.utils.data import comma_and, fmt_money, get_link_to_form
from pypika import Case
from pypika.functions import Coalesce, Sum
@@ -180,14 +182,17 @@ def validate(self):
self.set_status()
self.set_total_in_words()
+ def before_save(self):
+ self.set_matched_unset_payment_requests_to_response()
+
def on_submit(self):
if self.difference_amount:
frappe.throw(_("Difference Amount must be zero"))
self.make_gl_entries()
self.update_outstanding_amounts()
- self.update_advance_paid()
self.update_payment_schedule()
- self.set_payment_req_status()
+ self.update_payment_requests()
+ self.update_advance_paid() # advance_paid_status depends on the payment request amount
self.set_status()
def set_liability_account(self):
@@ -259,30 +264,34 @@ def on_cancel(self):
super().on_cancel()
self.make_gl_entries(cancel=1)
self.update_outstanding_amounts()
- self.update_advance_paid()
self.delink_advance_entry_references()
self.update_payment_schedule(cancel=1)
- self.set_payment_req_status()
+ self.update_payment_requests(cancel=True)
+ self.update_advance_paid() # advance_paid_status depends on the payment request amount
self.set_status()
- def set_payment_req_status(self):
- from erpnext.accounts.doctype.payment_request.payment_request import update_payment_req_status
+ def update_payment_requests(self, cancel=False):
+ from erpnext.accounts.doctype.payment_request.payment_request import (
+ update_payment_requests_as_per_pe_references,
+ )
- update_payment_req_status(self, None)
+ update_payment_requests_as_per_pe_references(self.references, cancel=cancel)
def update_outstanding_amounts(self):
self.set_missing_ref_details(force=True)
def validate_duplicate_entry(self):
- reference_names = []
+ reference_names = set()
for d in self.get("references"):
- if (d.reference_doctype, d.reference_name, d.payment_term) in reference_names:
+ key = (d.reference_doctype, d.reference_name, d.payment_term, d.payment_request)
+ if key in reference_names:
frappe.throw(
_("Row #{0}: Duplicate entry in References {1} {2}").format(
d.idx, d.reference_doctype, d.reference_name
)
)
- reference_names.append((d.reference_doctype, d.reference_name, d.payment_term))
+
+ reference_names.add(key)
def set_bank_account_data(self):
if self.bank_account:
@@ -308,6 +317,8 @@ def validate_allocated_amount(self):
if self.payment_type == "Internal Transfer":
return
+ self.validate_allocated_amount_as_per_payment_request()
+
if self.party_type in ("Customer", "Supplier"):
self.validate_allocated_amount_with_latest_data()
else:
@@ -320,6 +331,27 @@ def validate_allocated_amount(self):
if flt(d.allocated_amount) < 0 and flt(d.allocated_amount) < flt(d.outstanding_amount):
frappe.throw(fail_message.format(d.idx))
+ def validate_allocated_amount_as_per_payment_request(self):
+ """
+ Allocated amount should not be greater than the outstanding amount of the Payment Request.
+ """
+ if not self.references:
+ return
+
+ pr_outstanding_amounts = get_payment_request_outstanding_set_in_references(self.references)
+
+ if not pr_outstanding_amounts:
+ return
+
+ for ref in self.references:
+ if ref.payment_request and ref.allocated_amount > pr_outstanding_amounts[ref.payment_request]:
+ frappe.throw(
+ msg=_(
+ "Row #{0}: Allocated Amount cannot be greater than Outstanding Amount of Payment Request {1}"
+ ).format(ref.idx, get_link_to_form("Payment Request", ref.payment_request)),
+ title=_("Invalid Allocated Amount"),
+ )
+
def term_based_allocation_enabled_for_reference(
self, reference_doctype: str, reference_name: str
) -> bool:
@@ -1692,6 +1724,380 @@ def get_current_tax_fraction(self, tax):
return current_tax_fraction
+ def set_matched_unset_payment_requests_to_response(self):
+ """
+ Find matched Payment Requests for those references which have no Payment Request set.\n
+ And set to `frappe.response` to show in the frontend for allocation.
+ """
+ if not self.references:
+ return
+
+ matched_payment_requests = get_matched_payment_request_of_references(
+ [row for row in self.references if not row.payment_request]
+ )
+
+ if not matched_payment_requests:
+ return
+
+ frappe.response["matched_payment_requests"] = matched_payment_requests
+
+ @frappe.whitelist()
+ def allocate_amount_to_references(self, paid_amount, paid_amount_change, allocate_payment_amount):
+ """
+ Allocate `Allocated Amount` and `Payment Request` against `Reference` based on `Paid Amount` and `Outstanding Amount`.\n
+ :param paid_amount: Paid Amount / Received Amount.
+ :param paid_amount_change: Flag to check if `Paid Amount` is changed or not.
+ :param allocate_payment_amount: Flag to allocate amount or not. (Payment Request is also dependent on this flag)
+ """
+ if not self.references:
+ return
+
+ if not allocate_payment_amount:
+ for ref in self.references:
+ ref.allocated_amount = 0
+ return
+
+ # calculating outstanding amounts
+ precision = self.precision("paid_amount")
+ total_positive_outstanding_including_order = 0
+ total_negative_outstanding = 0
+ paid_amount -= sum(flt(d.amount, precision) for d in self.deductions)
+
+ for ref in self.references:
+ reference_outstanding_amount = ref.outstanding_amount
+ abs_outstanding_amount = abs(reference_outstanding_amount)
+
+ if reference_outstanding_amount > 0:
+ total_positive_outstanding_including_order += abs_outstanding_amount
+ else:
+ total_negative_outstanding += abs_outstanding_amount
+
+ # calculating allocated outstanding amounts
+ allocated_negative_outstanding = 0
+ allocated_positive_outstanding = 0
+
+ # checking party type and payment type
+ if (self.payment_type == "Receive" and self.party_type == "Customer") or (
+ self.payment_type == "Pay" and self.party_type in ("Supplier", "Employee")
+ ):
+ if total_positive_outstanding_including_order > paid_amount:
+ remaining_outstanding = flt(
+ total_positive_outstanding_including_order - paid_amount, precision
+ )
+ allocated_negative_outstanding = min(remaining_outstanding, total_negative_outstanding)
+
+ allocated_positive_outstanding = paid_amount + allocated_negative_outstanding
+
+ elif self.party_type in ("Supplier", "Employee"):
+ if paid_amount > total_negative_outstanding:
+ if total_negative_outstanding == 0:
+ frappe.msgprint(
+ _("Cannot {0} from {2} without any negative outstanding invoice").format(
+ self.payment_type,
+ self.party_type,
+ )
+ )
+ else:
+ frappe.msgprint(
+ _("Paid Amount cannot be greater than total negative outstanding amount {0}").format(
+ total_negative_outstanding
+ )
+ )
+
+ return
+
+ else:
+ allocated_positive_outstanding = flt(total_negative_outstanding - paid_amount, precision)
+ allocated_negative_outstanding = paid_amount + min(
+ total_positive_outstanding_including_order, allocated_positive_outstanding
+ )
+
+ # inner function to set `allocated_amount` to those row which have no PR
+ def _allocation_to_unset_pr_row(
+ row, outstanding_amount, allocated_positive_outstanding, allocated_negative_outstanding
+ ):
+ if outstanding_amount > 0 and allocated_positive_outstanding >= 0:
+ row.allocated_amount = min(allocated_positive_outstanding, outstanding_amount)
+ allocated_positive_outstanding = flt(
+ allocated_positive_outstanding - row.allocated_amount, precision
+ )
+ elif outstanding_amount < 0 and allocated_negative_outstanding:
+ row.allocated_amount = min(allocated_negative_outstanding, abs(outstanding_amount)) * -1
+ allocated_negative_outstanding = flt(
+ allocated_negative_outstanding - abs(row.allocated_amount), precision
+ )
+ return allocated_positive_outstanding, allocated_negative_outstanding
+
+ # allocate amount based on `paid_amount` is changed or not
+ if not paid_amount_change:
+ for ref in self.references:
+ allocated_positive_outstanding, allocated_negative_outstanding = _allocation_to_unset_pr_row(
+ ref,
+ ref.outstanding_amount,
+ allocated_positive_outstanding,
+ allocated_negative_outstanding,
+ )
+
+ allocate_open_payment_requests_to_references(self.references, self.precision("paid_amount"))
+
+ else:
+ payment_request_outstanding_amounts = (
+ get_payment_request_outstanding_set_in_references(self.references) or {}
+ )
+ references_outstanding_amounts = get_references_outstanding_amount(self.references) or {}
+ remaining_references_allocated_amounts = references_outstanding_amounts.copy()
+
+ # Re allocate amount to those references which have PR set (Higher priority)
+ for ref in self.references:
+ if not ref.payment_request:
+ continue
+
+ # fetch outstanding_amount of `Reference` (Payment Term) and `Payment Request` to allocate new amount
+ key = (ref.reference_doctype, ref.reference_name, ref.get("payment_term"))
+ reference_outstanding_amount = references_outstanding_amounts[key]
+ pr_outstanding_amount = payment_request_outstanding_amounts[ref.payment_request]
+
+ if reference_outstanding_amount > 0 and allocated_positive_outstanding >= 0:
+ # allocate amount according to outstanding amounts
+ outstanding_amounts = (
+ allocated_positive_outstanding,
+ reference_outstanding_amount,
+ pr_outstanding_amount,
+ )
+
+ ref.allocated_amount = min(outstanding_amounts)
+
+ # update amounts to track allocation
+ allocated_amount = ref.allocated_amount
+ allocated_positive_outstanding = flt(
+ allocated_positive_outstanding - allocated_amount, precision
+ )
+ remaining_references_allocated_amounts[key] = flt(
+ remaining_references_allocated_amounts[key] - allocated_amount, precision
+ )
+ payment_request_outstanding_amounts[ref.payment_request] = flt(
+ payment_request_outstanding_amounts[ref.payment_request] - allocated_amount, precision
+ )
+
+ elif reference_outstanding_amount < 0 and allocated_negative_outstanding:
+ # allocate amount according to outstanding amounts
+ outstanding_amounts = (
+ allocated_negative_outstanding,
+ abs(reference_outstanding_amount),
+ pr_outstanding_amount,
+ )
+
+ ref.allocated_amount = min(outstanding_amounts) * -1
+
+ # update amounts to track allocation
+ allocated_amount = abs(ref.allocated_amount)
+ allocated_negative_outstanding = flt(
+ allocated_negative_outstanding - allocated_amount, precision
+ )
+ remaining_references_allocated_amounts[key] += allocated_amount # negative amount
+ payment_request_outstanding_amounts[ref.payment_request] = flt(
+ payment_request_outstanding_amounts[ref.payment_request] - allocated_amount, precision
+ )
+ # Re allocate amount to those references which have no PR (Lower priority)
+ for ref in self.references:
+ if ref.payment_request:
+ continue
+
+ key = (ref.reference_doctype, ref.reference_name, ref.get("payment_term"))
+ reference_outstanding_amount = remaining_references_allocated_amounts[key]
+
+ allocated_positive_outstanding, allocated_negative_outstanding = _allocation_to_unset_pr_row(
+ ref,
+ reference_outstanding_amount,
+ allocated_positive_outstanding,
+ allocated_negative_outstanding,
+ )
+
+ @frappe.whitelist()
+ def set_matched_payment_requests(self, matched_payment_requests):
+ """
+ Set `Payment Request` against `Reference` based on `matched_payment_requests`.\n
+ :param matched_payment_requests: List of tuple of matched Payment Requests.
+
+ ---
+ Example: [(reference_doctype, reference_name, allocated_amount, payment_request), ...]
+ """
+ if not self.references or not matched_payment_requests:
+ return
+
+ if isinstance(matched_payment_requests, str):
+ matched_payment_requests = json.loads(matched_payment_requests)
+
+ # modify matched_payment_requests
+ # like (reference_doctype, reference_name, allocated_amount): payment_request
+ payment_requests = {}
+
+ for row in matched_payment_requests:
+ key = tuple(row[:3])
+ payment_requests[key] = row[3]
+
+ for ref in self.references:
+ if ref.payment_request:
+ continue
+
+ key = (ref.reference_doctype, ref.reference_name, ref.allocated_amount)
+
+ if key in payment_requests:
+ ref.payment_request = payment_requests[key]
+ del payment_requests[key] # to avoid duplicate allocation
+
+
+def get_matched_payment_request_of_references(references=None):
+ """
+ Get those `Payment Requests` which are matched with `References`.\n
+ - Amount must be same.
+ - Only single `Payment Request` available for this amount.
+
+ Example: [(reference_doctype, reference_name, allocated_amount, payment_request), ...]
+ """
+ if not references:
+ return
+
+ # to fetch matched rows
+ refs = {
+ (row.reference_doctype, row.reference_name, row.allocated_amount)
+ for row in references
+ if row.reference_doctype and row.reference_name and row.allocated_amount
+ }
+
+ if not refs:
+ return
+
+ PR = frappe.qb.DocType("Payment Request")
+
+ # query to group by reference_doctype, reference_name, outstanding_amount
+ subquery = (
+ frappe.qb.from_(PR)
+ .select(
+ PR.reference_doctype,
+ PR.reference_name,
+ PR.outstanding_amount.as_("allocated_amount"),
+ PR.name.as_("payment_request"),
+ Count("*").as_("count"),
+ )
+ .where(Tuple(PR.reference_doctype, PR.reference_name, PR.outstanding_amount).isin(refs))
+ .where(PR.status != "Paid")
+ .where(PR.docstatus == 1)
+ .groupby(PR.reference_doctype, PR.reference_name, PR.outstanding_amount)
+ )
+
+ # query to fetch matched rows which are single
+ matched_prs = (
+ frappe.qb.from_(subquery)
+ .select(
+ subquery.reference_doctype,
+ subquery.reference_name,
+ subquery.allocated_amount,
+ subquery.payment_request,
+ )
+ .where(subquery.count == 1)
+ .run()
+ )
+
+ return matched_prs if matched_prs else None
+
+
+def get_references_outstanding_amount(references=None):
+ """
+ Fetch accurate outstanding amount of `References`.\n
+ - If `Payment Term` is set, then fetch outstanding amount from `Payment Schedule`.
+ - If `Payment Term` is not set, then fetch outstanding amount from `References` it self.
+
+ Example: {(reference_doctype, reference_name, payment_term): outstanding_amount, ...}
+ """
+ if not references:
+ return
+
+ refs_with_payment_term = get_outstanding_of_references_with_payment_term(references) or {}
+ refs_without_payment_term = get_outstanding_of_references_with_no_payment_term(references) or {}
+
+ return {**refs_with_payment_term, **refs_without_payment_term}
+
+
+def get_outstanding_of_references_with_payment_term(references=None):
+ """
+ Fetch outstanding amount of `References` which have `Payment Term` set.\n
+ Example: {(reference_doctype, reference_name, payment_term): outstanding_amount, ...}
+ """
+ if not references:
+ return
+
+ refs = {
+ (row.reference_doctype, row.reference_name, row.payment_term)
+ for row in references
+ if row.reference_doctype and row.reference_name and row.payment_term
+ }
+
+ if not refs:
+ return
+
+ PS = frappe.qb.DocType("Payment Schedule")
+
+ response = (
+ frappe.qb.from_(PS)
+ .select(PS.parenttype, PS.parent, PS.payment_term, PS.outstanding)
+ .where(Tuple(PS.parenttype, PS.parent, PS.payment_term).isin(refs))
+ ).run(as_dict=True)
+
+ if not response:
+ return
+
+ return {(row.parenttype, row.parent, row.payment_term): row.outstanding for row in response}
+
+
+def get_outstanding_of_references_with_no_payment_term(references):
+ """
+ Fetch outstanding amount of `References` which have no `Payment Term` set.\n
+ - Fetch outstanding amount from `References` it self.
+
+ Note: `None` is used for allocation of `Payment Request`
+ Example: {(reference_doctype, reference_name, None): outstanding_amount, ...}
+ """
+ if not references:
+ return
+
+ outstanding_amounts = {}
+
+ for ref in references:
+ if ref.payment_term:
+ continue
+
+ key = (ref.reference_doctype, ref.reference_name, None)
+
+ if key not in outstanding_amounts:
+ outstanding_amounts[key] = ref.outstanding_amount
+
+ return outstanding_amounts
+
+
+def get_payment_request_outstanding_set_in_references(references=None):
+ """
+ Fetch outstanding amount of `Payment Request` which are set in `References`.\n
+ Example: {payment_request: outstanding_amount, ...}
+ """
+ if not references:
+ return
+
+ referenced_payment_requests = {row.payment_request for row in references if row.payment_request}
+
+ if not referenced_payment_requests:
+ return
+
+ PR = frappe.qb.DocType("Payment Request")
+
+ response = (
+ frappe.qb.from_(PR)
+ .select(PR.name, PR.outstanding_amount)
+ .where(PR.name.isin(referenced_payment_requests))
+ ).run()
+
+ return dict(response) if response else None
+
def validate_inclusive_tax(tax, doc):
def _on_previous_row_error(row_range):
@@ -2323,6 +2729,7 @@ def get_payment_entry(
payment_type=None,
reference_date=None,
ignore_permissions=False,
+ created_from_payment_request=False,
):
doc = frappe.get_doc(dt, dn)
over_billing_allowance = frappe.db.get_single_value("Accounts Settings", "over_billing_allowance")
@@ -2472,9 +2879,179 @@ def get_payment_entry(
pe.set_difference_amount()
+ # If PE is created from PR directly, then no need to find open PRs for the references
+ if not created_from_payment_request:
+ allocate_open_payment_requests_to_references(pe.references, pe.precision("paid_amount"))
+
return pe
+def get_open_payment_requests_for_references(references=None):
+ """
+ Fetch all unpaid Payment Requests for the references. \n
+ - Each reference can have multiple Payment Requests. \n
+
+ Example: {("Sales Invoice", "SINV-00001"): {"PREQ-00001": 1000, "PREQ-00002": 2000}}
+ """
+ if not references:
+ return
+
+ refs = {
+ (row.reference_doctype, row.reference_name)
+ for row in references
+ if row.reference_doctype and row.reference_name and row.allocated_amount
+ }
+
+ if not refs:
+ return
+
+ PR = frappe.qb.DocType("Payment Request")
+
+ response = (
+ frappe.qb.from_(PR)
+ .select(PR.name, PR.reference_doctype, PR.reference_name, PR.outstanding_amount)
+ .where(Tuple(PR.reference_doctype, PR.reference_name).isin(list(refs)))
+ .where(PR.status != "Paid")
+ .where(PR.docstatus == 1)
+ .orderby(Coalesce(PR.transaction_date, PR.creation), order=frappe.qb.asc)
+ ).run(as_dict=True)
+
+ if not response:
+ return
+
+ reference_payment_requests = {}
+
+ for row in response:
+ key = (row.reference_doctype, row.reference_name)
+
+ if key not in reference_payment_requests:
+ reference_payment_requests[key] = {row.name: row.outstanding_amount}
+ else:
+ reference_payment_requests[key][row.name] = row.outstanding_amount
+
+ return reference_payment_requests
+
+
+def allocate_open_payment_requests_to_references(references=None, precision=None):
+ """
+ Allocate unpaid Payment Requests to the references. \n
+ ---
+ - Allocation based on below factors
+ - Reference Allocated Amount
+ - Reference Outstanding Amount (With Payment Terms or without Payment Terms)
+ - Reference Payment Request's outstanding amount
+ ---
+ - Allocation based on below scenarios
+ - Reference's Allocated Amount == Payment Request's Outstanding Amount
+ - Allocate the Payment Request to the reference
+ - This PR will not be allocated further
+ - Reference's Allocated Amount < Payment Request's Outstanding Amount
+ - Allocate the Payment Request to the reference
+ - Reduce the PR's outstanding amount by the allocated amount
+ - This PR can be allocated further
+ - Reference's Allocated Amount > Payment Request's Outstanding Amount
+ - Allocate the Payment Request to the reference
+ - Reduce Allocated Amount of the reference by the PR's outstanding amount
+ - Create a new row for the remaining amount until the Allocated Amount is 0
+ - Allocate PR if available
+ ---
+ - Note:
+ - Priority is given to the first Payment Request of respective references.
+ - Single Reference can have multiple rows.
+ - With Payment Terms or without Payment Terms
+ - With Payment Request or without Payment Request
+ """
+ if not references:
+ return
+
+ # get all unpaid payment requests for the references
+ references_open_payment_requests = get_open_payment_requests_for_references(references)
+
+ if not references_open_payment_requests:
+ return
+
+ if not precision:
+ precision = references[0].precision("allocated_amount")
+
+ # to manage new rows
+ row_number = 1
+ MOVE_TO_NEXT_ROW = 1
+ TO_SKIP_NEW_ROW = 2
+
+ while row_number <= len(references):
+ row = references[row_number - 1]
+ reference_key = (row.reference_doctype, row.reference_name)
+
+ # update the idx to maintain the order
+ row.idx = row_number
+
+ # unpaid payment requests for the reference
+ reference_payment_requests = references_open_payment_requests.get(reference_key)
+
+ if not reference_payment_requests:
+ row_number += MOVE_TO_NEXT_ROW # to move to next reference row
+ continue
+
+ # get the first payment request and its outstanding amount
+ payment_request, pr_outstanding_amount = next(iter(reference_payment_requests.items()))
+ allocated_amount = row.allocated_amount
+
+ # allocate the payment request to the reference and PR's outstanding amount
+ row.payment_request = payment_request
+
+ if pr_outstanding_amount == allocated_amount:
+ del reference_payment_requests[payment_request]
+ row_number += MOVE_TO_NEXT_ROW
+
+ elif pr_outstanding_amount > allocated_amount:
+ # reduce the outstanding amount of the payment request
+ reference_payment_requests[payment_request] -= allocated_amount
+ row_number += MOVE_TO_NEXT_ROW
+
+ else:
+ # split the reference row to allocate the remaining amount
+ del reference_payment_requests[payment_request]
+ row.allocated_amount = pr_outstanding_amount
+ allocated_amount = flt(allocated_amount - pr_outstanding_amount, precision)
+
+ # set the remaining amount to the next row
+ while allocated_amount:
+ # create a new row for the remaining amount
+ new_row = frappe.copy_doc(row)
+ references.insert(row_number, new_row)
+
+ # get the first payment request and its outstanding amount
+ payment_request, pr_outstanding_amount = next(
+ iter(reference_payment_requests.items()), (None, None)
+ )
+
+ # update new row
+ new_row.idx = row_number + 1
+ new_row.payment_request = payment_request
+ new_row.allocated_amount = min(
+ pr_outstanding_amount if pr_outstanding_amount else allocated_amount, allocated_amount
+ )
+
+ if not payment_request or not pr_outstanding_amount:
+ row_number += TO_SKIP_NEW_ROW
+ break
+
+ elif pr_outstanding_amount == allocated_amount:
+ del reference_payment_requests[payment_request]
+ row_number += TO_SKIP_NEW_ROW
+ break
+
+ elif pr_outstanding_amount > allocated_amount:
+ reference_payment_requests[payment_request] -= allocated_amount
+ row_number += TO_SKIP_NEW_ROW
+ break
+
+ else:
+ allocated_amount = flt(allocated_amount - pr_outstanding_amount, precision)
+ del reference_payment_requests[payment_request]
+ row_number += MOVE_TO_NEXT_ROW
+
+
def update_accounting_dimensions(pe, doc):
"""
Updates accounting dimensions in Payment Entry based on the accounting dimensions in the reference document
diff --git a/erpnext/accounts/doctype/payment_entry_reference/payment_entry_reference.json b/erpnext/accounts/doctype/payment_entry_reference/payment_entry_reference.json
index 352ece24f06b..f5d39c134b50 100644
--- a/erpnext/accounts/doctype/payment_entry_reference/payment_entry_reference.json
+++ b/erpnext/accounts/doctype/payment_entry_reference/payment_entry_reference.json
@@ -10,6 +10,7 @@
"due_date",
"bill_no",
"payment_term",
+ "payment_term_outstanding",
"account_type",
"payment_type",
"column_break_4",
@@ -18,7 +19,9 @@
"allocated_amount",
"exchange_rate",
"exchange_gain_loss",
- "account"
+ "account",
+ "payment_request",
+ "payment_request_outstanding"
],
"fields": [
{
@@ -120,12 +123,33 @@
"fieldname": "payment_type",
"fieldtype": "Data",
"label": "Payment Type"
+ },
+ {
+ "fieldname": "payment_request",
+ "fieldtype": "Link",
+ "label": "Payment Request",
+ "options": "Payment Request"
+ },
+ {
+ "depends_on": "eval: doc.payment_term",
+ "fieldname": "payment_term_outstanding",
+ "fieldtype": "Float",
+ "label": "Payment Term Outstanding",
+ "read_only": 1
+ },
+ {
+ "depends_on": "eval: doc.payment_request && doc.payment_request_outstanding",
+ "fieldname": "payment_request_outstanding",
+ "fieldtype": "Float",
+ "is_virtual": 1,
+ "label": "Payment Request Outstanding",
+ "read_only": 1
}
],
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
- "modified": "2024-04-05 09:44:08.310593",
+ "modified": "2024-09-16 18:11:50.019343",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Payment Entry Reference",
diff --git a/erpnext/accounts/doctype/payment_entry_reference/payment_entry_reference.py b/erpnext/accounts/doctype/payment_entry_reference/payment_entry_reference.py
index 4a027b4ee32b..2ac92ba4a841 100644
--- a/erpnext/accounts/doctype/payment_entry_reference/payment_entry_reference.py
+++ b/erpnext/accounts/doctype/payment_entry_reference/payment_entry_reference.py
@@ -1,7 +1,7 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
-
+import frappe
from frappe.model.document import Document
@@ -25,11 +25,19 @@ class PaymentEntryReference(Document):
parent: DF.Data
parentfield: DF.Data
parenttype: DF.Data
+ payment_request: DF.Link | None
+ payment_request_outstanding: DF.Float
payment_term: DF.Link | None
+ payment_term_outstanding: DF.Float
payment_type: DF.Data | None
reference_doctype: DF.Link
reference_name: DF.DynamicLink
total_amount: DF.Float
# end: auto-generated types
- pass
+ @property
+ def payment_request_outstanding(self):
+ if not self.payment_request:
+ return
+
+ return frappe.db.get_value("Payment Request", self.payment_request, "outstanding_amount")
diff --git a/erpnext/accounts/doctype/payment_request/payment_request.js b/erpnext/accounts/doctype/payment_request/payment_request.js
index f12facfbf5a6..44313e5c0d2c 100644
--- a/erpnext/accounts/doctype/payment_request/payment_request.js
+++ b/erpnext/accounts/doctype/payment_request/payment_request.js
@@ -52,8 +52,8 @@ frappe.ui.form.on("Payment Request", "refresh", function (frm) {
}
if (
- (!frm.doc.payment_gateway_account || frm.doc.payment_request_type == "Outward") &&
- frm.doc.status == "Initiated"
+ frm.doc.payment_request_type == "Outward" &&
+ ["Initiated", "Partially Paid"].includes(frm.doc.status)
) {
frm.add_custom_button(__("Create Payment Entry"), function () {
frappe.call({
diff --git a/erpnext/accounts/doctype/payment_request/payment_request.json b/erpnext/accounts/doctype/payment_request/payment_request.json
index 70c1fc6d2f7f..36ef7a59ca8f 100644
--- a/erpnext/accounts/doctype/payment_request/payment_request.json
+++ b/erpnext/accounts/doctype/payment_request/payment_request.json
@@ -20,9 +20,11 @@
"reference_name",
"transaction_details",
"grand_total",
+ "currency",
"is_a_subscription",
"column_break_18",
- "currency",
+ "outstanding_amount",
+ "party_account_currency",
"subscription_section",
"subscription_plans",
"bank_account_details",
@@ -71,6 +73,7 @@
{
"fieldname": "transaction_date",
"fieldtype": "Date",
+ "in_preview": 1,
"label": "Transaction Date"
},
{
@@ -135,7 +138,8 @@
"no_copy": 1,
"options": "reference_doctype",
"print_hide": 1,
- "read_only": 1
+ "read_only": 1,
+ "search_index": 1
},
{
"fieldname": "transaction_details",
@@ -143,12 +147,14 @@
"label": "Transaction Details"
},
{
- "description": "Amount in customer's currency",
+ "description": "Amount in transaction currency",
"fieldname": "grand_total",
"fieldtype": "Currency",
+ "in_preview": 1,
"label": "Amount",
"non_negative": 1,
- "options": "currency"
+ "options": "currency",
+ "reqd": 1
},
{
"default": "0",
@@ -403,6 +409,17 @@
"print_hide": 1,
"read_only": 1
},
+ {
+ "depends_on": "eval: doc.docstatus === 1",
+ "description": "Amount in party's bank account currency",
+ "fieldname": "outstanding_amount",
+ "fieldtype": "Currency",
+ "in_preview": 1,
+ "label": "Outstanding Amount",
+ "non_negative": 1,
+ "options": "party_account_currency",
+ "read_only": 1
+ },
{
"fieldname": "company",
"fieldtype": "Link",
@@ -413,13 +430,20 @@
{
"fieldname": "column_break_pnyv",
"fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "party_account_currency",
+ "fieldtype": "Link",
+ "label": "Party Account Currency",
+ "options": "Currency",
+ "read_only": 1
}
],
"in_create": 1,
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],
- "modified": "2024-08-07 16:39:54.288002",
+ "modified": "2024-09-16 17:50:54.440090",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Payment Request",
@@ -454,7 +478,8 @@
"write": 1
}
],
+ "show_preview_popup": 1,
"sort_field": "creation",
"sort_order": "DESC",
"states": []
-}
+}
\ No newline at end of file
diff --git a/erpnext/accounts/doctype/payment_request/payment_request.py b/erpnext/accounts/doctype/payment_request/payment_request.py
index 2a18ae510deb..87cd23c25ba5 100644
--- a/erpnext/accounts/doctype/payment_request/payment_request.py
+++ b/erpnext/accounts/doctype/payment_request/payment_request.py
@@ -7,6 +7,7 @@
from frappe.utils import flt, nowdate
from frappe.utils.background_jobs import enqueue
+from erpnext import get_company_currency
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
get_accounting_dimensions,
)
@@ -46,9 +47,11 @@ class PaymentRequest(Document):
bank_account: DF.Link | None
bank_account_no: DF.ReadOnly | None
branch_code: DF.ReadOnly | None
+ company: DF.Link | None
cost_center: DF.Link | None
currency: DF.Link | None
email_to: DF.Data | None
+ failed_reason: DF.Data | None
grand_total: DF.Currency
iban: DF.ReadOnly | None
is_a_subscription: DF.Check
@@ -57,16 +60,18 @@ class PaymentRequest(Document):
mode_of_payment: DF.Link | None
mute_email: DF.Check
naming_series: DF.Literal["ACC-PRQ-.YYYY.-"]
+ outstanding_amount: DF.Currency
party: DF.DynamicLink | None
+ party_account_currency: DF.Link | None
party_type: DF.Link | None
payment_account: DF.ReadOnly | None
- payment_channel: DF.Literal["", "Email", "Phone"]
+ payment_channel: DF.Literal["", "Email", "Phone", "Other"]
payment_gateway: DF.ReadOnly | None
payment_gateway_account: DF.Link | None
payment_order: DF.Link | None
payment_request_type: DF.Literal["Outward", "Inward"]
payment_url: DF.Data | None
- print_format: DF.Literal
+ print_format: DF.Literal[None]
project: DF.Link | None
reference_doctype: DF.Link | None
reference_name: DF.DynamicLink | None
@@ -85,7 +90,6 @@ class PaymentRequest(Document):
subscription_plans: DF.Table[SubscriptionPlanDetail]
swift_number: DF.ReadOnly | None
transaction_date: DF.Date | None
- company: DF.Link | None
# end: auto-generated types
def validate(self):
@@ -101,6 +105,12 @@ def validate_reference_document(self):
frappe.throw(_("To create a Payment Request reference document is required"))
def validate_payment_request_amount(self):
+ if self.grand_total == 0:
+ frappe.throw(
+ _("{0} cannot be zero").format(self.get_label_from_fieldname("grand_total")),
+ title=_("Invalid Amount"),
+ )
+
existing_payment_request_amount = flt(
get_existing_payment_request_amount(self.reference_doctype, self.reference_name)
)
@@ -150,16 +160,28 @@ def validate_subscription_details(self):
).format(self.grand_total, amount)
)
- def on_change(self):
- ref_doc = frappe.get_doc(self.reference_doctype, self.reference_name)
- advance_payment_doctypes = frappe.get_hooks("advance_payment_receivable_doctypes") + frappe.get_hooks(
- "advance_payment_payable_doctypes"
- )
- if self.reference_doctype in advance_payment_doctypes:
- # set advance payment status
- ref_doc.set_advance_payment_status()
-
def before_submit(self):
+ if (
+ self.currency != self.party_account_currency
+ and self.party_account_currency == get_company_currency(self.company)
+ ):
+ # set outstanding amount in party account currency
+ invoice = frappe.get_value(
+ self.reference_doctype,
+ self.reference_name,
+ ["rounded_total", "grand_total", "base_rounded_total", "base_grand_total"],
+ as_dict=1,
+ )
+ grand_total = invoice.get("rounded_total") or invoice.get("grand_total")
+ base_grand_total = invoice.get("base_rounded_total") or invoice.get("base_grand_total")
+ self.outstanding_amount = flt(
+ self.grand_total / grand_total * base_grand_total,
+ self.precision("outstanding_amount"),
+ )
+
+ else:
+ self.outstanding_amount = self.grand_total
+
if self.payment_request_type == "Outward":
self.status = "Initiated"
elif self.payment_request_type == "Inward":
@@ -174,6 +196,9 @@ def before_submit(self):
self.send_email()
self.make_communication_entry()
+ def on_submit(self):
+ self.update_reference_advance_payment_status()
+
def request_phone_payment(self):
controller = _get_payment_gateway_controller(self.payment_gateway)
request_amount = self.get_request_amount()
@@ -211,6 +236,7 @@ def get_request_amount(self):
def on_cancel(self):
self.check_if_payment_entry_exists()
self.set_as_cancelled()
+ self.update_reference_advance_payment_status()
def make_invoice(self):
from erpnext.selling.doctype.sales_order.sales_order import make_sales_invoice
@@ -267,7 +293,7 @@ def get_payment_url(self):
def set_as_paid(self):
if self.payment_channel == "Phone":
- self.db_set("status", "Paid")
+ self.db_set({"status": "Paid", "outstanding_amount": 0})
else:
payment_entry = self.create_payment_entry()
@@ -289,26 +315,32 @@ def create_payment_entry(self, submit=True):
else:
party_account = get_party_account("Customer", ref_doc.get("customer"), ref_doc.company)
- party_account_currency = ref_doc.get("party_account_currency") or get_account_currency(party_account)
+ party_account_currency = (
+ self.get("party_account_currency")
+ or ref_doc.get("party_account_currency")
+ or get_account_currency(party_account)
+ )
+
+ party_amount = bank_amount = self.outstanding_amount
- bank_amount = self.grand_total
if party_account_currency == ref_doc.company_currency and party_account_currency != self.currency:
- party_amount = ref_doc.get("base_rounded_total") or ref_doc.get("base_grand_total")
- else:
- party_amount = self.grand_total
+ exchange_rate = ref_doc.get("conversion_rate")
+ bank_amount = flt(self.outstanding_amount / exchange_rate, self.precision("grand_total"))
+ # outstanding amount is already in Part's account currency
payment_entry = get_payment_entry(
self.reference_doctype,
self.reference_name,
party_amount=party_amount,
bank_account=self.payment_account,
bank_amount=bank_amount,
+ created_from_payment_request=True,
)
payment_entry.update(
{
"mode_of_payment": self.mode_of_payment,
- "reference_no": self.name,
+ "reference_no": self.name, # to prevent validation error
"reference_date": nowdate(),
"remarks": "Payment Entry against {} {} via Payment Request {}".format(
self.reference_doctype, self.reference_name, self.name
@@ -316,6 +348,9 @@ def create_payment_entry(self, submit=True):
}
)
+ # Allocate payment_request for each reference in payment_entry (Payment Term can splits the row)
+ self._allocate_payment_request_to_pe_references(references=payment_entry.references)
+
# Update dimensions
payment_entry.update(
{
@@ -324,14 +359,6 @@ def create_payment_entry(self, submit=True):
}
)
- if party_account_currency == ref_doc.company_currency and party_account_currency != self.currency:
- amount = payment_entry.base_paid_amount
- else:
- amount = self.grand_total
-
- payment_entry.received_amount = amount
- payment_entry.get("references")[0].allocated_amount = amount
-
# Update 'Paid Amount' on Forex transactions
if self.currency != ref_doc.company_currency:
if (
@@ -426,6 +453,70 @@ def create_subscription(self, payment_provider, gateway_controller, data):
return create_stripe_subscription(gateway_controller, data)
+ def update_reference_advance_payment_status(self):
+ advance_payment_doctypes = frappe.get_hooks("advance_payment_receivable_doctypes") + frappe.get_hooks(
+ "advance_payment_payable_doctypes"
+ )
+ if self.reference_doctype in advance_payment_doctypes:
+ ref_doc = frappe.get_doc(self.reference_doctype, self.reference_name)
+ ref_doc.set_advance_payment_status()
+
+ def _allocate_payment_request_to_pe_references(self, references):
+ """
+ Allocate the Payment Request to the Payment Entry references based on\n
+ - Allocated Amount.
+ - Outstanding Amount of Payment Request.\n
+ Payment Request is doc itself and references are the rows of Payment Entry.
+ """
+ if len(references) == 1:
+ references[0].payment_request = self.name
+ return
+
+ precision = references[0].precision("allocated_amount")
+ outstanding_amount = self.outstanding_amount
+
+ # to manage rows
+ row_number = 1
+ MOVE_TO_NEXT_ROW = 1
+ TO_SKIP_NEW_ROW = 2
+ NEW_ROW_ADDED = False
+
+ while row_number <= len(references):
+ row = references[row_number - 1]
+
+ # update the idx to maintain the order
+ row.idx = row_number
+
+ if outstanding_amount == 0:
+ if not NEW_ROW_ADDED:
+ break
+
+ row_number += MOVE_TO_NEXT_ROW
+ continue
+
+ # allocate the payment request to the row
+ row.payment_request = self.name
+
+ if row.allocated_amount <= outstanding_amount:
+ outstanding_amount = flt(outstanding_amount - row.allocated_amount, precision)
+ row_number += MOVE_TO_NEXT_ROW
+ else:
+ remaining_allocated_amount = flt(row.allocated_amount - outstanding_amount, precision)
+ row.allocated_amount = outstanding_amount
+ outstanding_amount = 0
+
+ # create a new row without PR for remaining unallocated amount
+ new_row = frappe.copy_doc(row)
+ references.insert(row_number, new_row)
+
+ # update new row
+ new_row.idx = row_number + 1
+ new_row.payment_request = None
+ new_row.allocated_amount = remaining_allocated_amount
+
+ NEW_ROW_ADDED = True
+ row_number += TO_SKIP_NEW_ROW
+
@frappe.whitelist(allow_guest=True)
def make_payment_request(**args):
@@ -468,11 +559,15 @@ def make_payment_request(**args):
{"reference_doctype": ref_doc.doctype, "reference_name": ref_doc.name, "docstatus": 0},
)
+ # fetches existing payment request `grand_total` amount
existing_payment_request_amount = get_existing_payment_request_amount(ref_doc.doctype, ref_doc.name)
if existing_payment_request_amount:
grand_total -= existing_payment_request_amount
+ if not grand_total:
+ frappe.throw(_("Payment Request is already created"))
+
if draft_payment_request:
frappe.db.set_value(
"Payment Request", draft_payment_request, "grand_total", grand_total, update_modified=False
@@ -486,6 +581,13 @@ def make_payment_request(**args):
"Outward" if args.get("dt") in ["Purchase Order", "Purchase Invoice"] else "Inward"
)
+ party_type = args.get("party_type") or "Customer"
+ party_account_currency = ref_doc.party_account_currency
+
+ if not party_account_currency:
+ party_account = get_party_account(party_type, ref_doc.get(party_type.lower()), ref_doc.company)
+ party_account_currency = get_account_currency(party_account)
+
pr.update(
{
"payment_gateway_account": gateway_account.get("name"),
@@ -494,6 +596,7 @@ def make_payment_request(**args):
"payment_channel": gateway_account.get("payment_channel"),
"payment_request_type": args.get("payment_request_type"),
"currency": ref_doc.currency,
+ "party_account_currency": party_account_currency,
"grand_total": grand_total,
"mode_of_payment": args.mode_of_payment,
"email_to": args.recipient_id or ref_doc.owner,
@@ -502,7 +605,7 @@ def make_payment_request(**args):
"reference_doctype": ref_doc.doctype,
"reference_name": ref_doc.name,
"company": ref_doc.get("company"),
- "party_type": args.get("party_type") or "Customer",
+ "party_type": party_type,
"party": args.get("party") or ref_doc.get("customer"),
"bank_account": bank_account,
"make_sales_invoice": (
@@ -551,13 +654,14 @@ def get_amount(ref_doc, payment_account=None):
dt = ref_doc.doctype
if dt in ["Sales Order", "Purchase Order"]:
grand_total = flt(ref_doc.rounded_total) or flt(ref_doc.grand_total)
- grand_total -= get_paid_amount_against_order(dt, ref_doc.name)
elif dt in ["Sales Invoice", "Purchase Invoice"]:
if not ref_doc.get("is_pos"):
if ref_doc.party_account_currency == ref_doc.currency:
- grand_total = flt(ref_doc.grand_total)
+ grand_total = flt(ref_doc.rounded_total or ref_doc.grand_total)
else:
- grand_total = flt(ref_doc.base_grand_total) / ref_doc.conversion_rate
+ grand_total = flt(
+ flt(ref_doc.base_rounded_total or ref_doc.base_grand_total) / ref_doc.conversion_rate
+ )
elif dt == "Sales Invoice":
for pay in ref_doc.payments:
if pay.type == "Phone" and pay.account == payment_account:
@@ -579,24 +683,20 @@ def get_amount(ref_doc, payment_account=None):
def get_existing_payment_request_amount(ref_dt, ref_dn):
"""
- Get the existing payment request which are unpaid or partially paid for payment channel other than Phone
- and get the summation of existing paid payment request for Phone payment channel.
+ Return the total amount of Payment Requests against a reference document.
"""
- existing_payment_request_amount = frappe.db.sql(
- """
- select sum(grand_total)
- from `tabPayment Request`
- where
- reference_doctype = %s
- and reference_name = %s
- and docstatus = 1
- and (status != 'Paid'
- or (payment_channel = 'Phone'
- and status = 'Paid'))
- """,
- (ref_dt, ref_dn),
+ PR = frappe.qb.DocType("Payment Request")
+
+ response = (
+ frappe.qb.from_(PR)
+ .select(Sum(PR.grand_total))
+ .where(PR.reference_doctype == ref_dt)
+ .where(PR.reference_name == ref_dn)
+ .where(PR.docstatus == 1)
+ .run()
)
- return flt(existing_payment_request_amount[0][0]) if existing_payment_request_amount else 0
+
+ return response[0][0] if response[0] else 0
def get_gateway_details(args): # nosemgrep
@@ -638,41 +738,66 @@ def make_payment_entry(docname):
return doc.create_payment_entry(submit=False).as_dict()
-def update_payment_req_status(doc, method):
- from erpnext.accounts.doctype.payment_entry.payment_entry import get_reference_details
+def update_payment_requests_as_per_pe_references(references=None, cancel=False):
+ """
+ Update Payment Request's `Status` and `Outstanding Amount` based on Payment Entry Reference's `Allocated Amount`.
+ """
+ if not references:
+ return
+
+ precision = references[0].precision("allocated_amount")
- for ref in doc.references:
- payment_request_name = frappe.db.get_value(
- "Payment Request",
- {
- "reference_doctype": ref.reference_doctype,
- "reference_name": ref.reference_name,
- "docstatus": 1,
- },
+ referenced_payment_requests = frappe.get_all(
+ "Payment Request",
+ filters={"name": ["in", {row.payment_request for row in references if row.payment_request}]},
+ fields=[
+ "name",
+ "grand_total",
+ "outstanding_amount",
+ "payment_request_type",
+ ],
+ )
+
+ referenced_payment_requests = {pr.name: pr for pr in referenced_payment_requests}
+
+ for ref in references:
+ if not ref.payment_request:
+ continue
+
+ payment_request = referenced_payment_requests[ref.payment_request]
+ pr_outstanding = payment_request["outstanding_amount"]
+
+ # update outstanding amount
+ new_outstanding_amount = flt(
+ pr_outstanding + ref.allocated_amount if cancel else pr_outstanding - ref.allocated_amount,
+ precision,
)
- if payment_request_name:
- ref_details = get_reference_details(
- ref.reference_doctype,
- ref.reference_name,
- doc.party_account_currency,
- doc.party_type,
- doc.party,
+ # to handle same payment request for the multiple allocations
+ payment_request["outstanding_amount"] = new_outstanding_amount
+
+ if not cancel and new_outstanding_amount < 0:
+ frappe.throw(
+ msg=_(
+ "The allocated amount is greater than the outstanding amount of Payment Request {0}"
+ ).format(ref.payment_request),
+ title=_("Invalid Allocated Amount"),
)
- pay_req_doc = frappe.get_doc("Payment Request", payment_request_name)
- status = pay_req_doc.status
- if status != "Paid" and not ref_details.outstanding_amount:
- status = "Paid"
- elif status != "Partially Paid" and ref_details.outstanding_amount != ref_details.total_amount:
- status = "Partially Paid"
- elif ref_details.outstanding_amount == ref_details.total_amount:
- if pay_req_doc.payment_request_type == "Outward":
- status = "Initiated"
- elif pay_req_doc.payment_request_type == "Inward":
- status = "Requested"
+ # update status
+ if new_outstanding_amount == payment_request["grand_total"]:
+ status = "Initiated" if payment_request["payment_request_type"] == "Outward" else "Requested"
+ elif new_outstanding_amount == 0:
+ status = "Paid"
+ elif new_outstanding_amount > 0:
+ status = "Partially Paid"
- pay_req_doc.db_set("status", status)
+ # update database
+ frappe.db.set_value(
+ "Payment Request",
+ ref.payment_request,
+ {"outstanding_amount": new_outstanding_amount, "status": status},
+ )
def get_dummy_message(doc):
@@ -783,3 +908,35 @@ def get_paid_amount_against_order(dt, dn):
)
)
).run()[0][0] or 0
+
+
+@frappe.whitelist()
+def get_open_payment_requests_query(doctype, txt, searchfield, start, page_len, filters):
+ # permission checks in `get_list()`
+ reference_doctype = filters.get("reference_doctype")
+ reference_name = filters.get("reference_doctype")
+
+ if not reference_doctype or not reference_name:
+ return []
+
+ open_payment_requests = frappe.get_list(
+ "Payment Request",
+ filters={
+ "reference_doctype": filters["reference_doctype"],
+ "reference_name": filters["reference_name"],
+ "status": ["!=", "Paid"],
+ "outstanding_amount": ["!=", 0], # for compatibility with old data
+ "docstatus": 1,
+ },
+ fields=["name", "grand_total", "outstanding_amount"],
+ order_by="transaction_date ASC,creation ASC",
+ )
+
+ return [
+ (
+ pr.name,
+ _("Grand Total: {0}").format(pr.grand_total),
+ _("Outstanding Amount: {0}").format(pr.outstanding_amount),
+ )
+ for pr in open_payment_requests
+ ]
diff --git a/erpnext/accounts/doctype/payment_request/test_payment_request.py b/erpnext/accounts/doctype/payment_request/test_payment_request.py
index 8aa169fa3a24..0b2cdef8b541 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 re
import unittest
from unittest.mock import patch
import frappe
from frappe.tests.utils import FrappeTestCase
+from erpnext.accounts.doctype.payment_entry.test_payment_entry import create_payment_terms_template
from erpnext.accounts.doctype.payment_request.payment_request import make_payment_request
from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
@@ -415,3 +417,256 @@ def test_conversion_on_foreign_currency_accounts(self):
self.assertEqual(pe.paid_amount, 800)
self.assertEqual(pe.base_received_amount, 800)
self.assertEqual(pe.received_amount, 10)
+
+ def test_multiple_payment_if_partially_paid_for_same_currency(self):
+ so = make_sales_order(currency="INR", qty=1, rate=1000)
+
+ self.assertEqual(so.advance_payment_status, "Not Requested")
+
+ pr = make_payment_request(
+ dt="Sales Order",
+ dn=so.name,
+ mute_email=1,
+ submit_doc=1,
+ return_doc=1,
+ )
+
+ self.assertEqual(pr.grand_total, 1000)
+ self.assertEqual(pr.outstanding_amount, pr.grand_total)
+ self.assertEqual(pr.party_account_currency, pr.currency) # INR
+ self.assertEqual(pr.status, "Requested")
+
+ so.load_from_db()
+ self.assertEqual(so.advance_payment_status, "Requested")
+
+ # to make partial payment
+ pe = pr.create_payment_entry(submit=False)
+ pe.paid_amount = 200
+ pe.references[0].allocated_amount = 200
+ pe.submit()
+
+ self.assertEqual(pe.references[0].payment_request, pr.name)
+
+ so.load_from_db()
+ self.assertEqual(so.advance_payment_status, "Partially Paid")
+
+ pr.load_from_db()
+ self.assertEqual(pr.status, "Partially Paid")
+ self.assertEqual(pr.outstanding_amount, 800)
+ self.assertEqual(pr.grand_total, 1000)
+
+ # complete payment
+ pe = pr.create_payment_entry()
+
+ self.assertEqual(pe.paid_amount, 800) # paid amount set from pr's outstanding amount
+ self.assertEqual(pe.references[0].allocated_amount, 800)
+ self.assertEqual(pe.references[0].outstanding_amount, 800) # for Orders it is not zero
+ self.assertEqual(pe.references[0].payment_request, pr.name)
+
+ so.load_from_db()
+ self.assertEqual(so.advance_payment_status, "Fully Paid")
+
+ pr.load_from_db()
+ self.assertEqual(pr.status, "Paid")
+ self.assertEqual(pr.outstanding_amount, 0)
+ self.assertEqual(pr.grand_total, 1000)
+
+ # creating a more payment Request must not allowed
+ self.assertRaisesRegex(
+ frappe.exceptions.ValidationError,
+ re.compile(r"Payment Request is already created"),
+ make_payment_request,
+ dt="Sales Order",
+ dn=so.name,
+ mute_email=1,
+ submit_doc=1,
+ return_doc=1,
+ )
+
+ def test_multiple_payment_if_partially_paid_for_multi_currency(self):
+ pi = make_purchase_invoice(currency="USD", conversion_rate=50, qty=1, rate=100)
+
+ pr = make_payment_request(
+ dt="Purchase Invoice",
+ dn=pi.name,
+ mute_email=1,
+ submit_doc=1,
+ return_doc=1,
+ )
+
+ # 100 USD -> 5000 INR
+ self.assertEqual(pr.grand_total, 100)
+ self.assertEqual(pr.outstanding_amount, 5000)
+ self.assertEqual(pr.currency, "USD")
+ self.assertEqual(pr.party_account_currency, "INR")
+ self.assertEqual(pr.status, "Initiated")
+
+ # to make partial payment
+ pe = pr.create_payment_entry(submit=False)
+ pe.paid_amount = 2000
+ pe.references[0].allocated_amount = 2000
+ pe.submit()
+
+ self.assertEqual(pe.references[0].payment_request, pr.name)
+
+ pr.load_from_db()
+ self.assertEqual(pr.status, "Partially Paid")
+ self.assertEqual(pr.outstanding_amount, 3000)
+ self.assertEqual(pr.grand_total, 100)
+
+ # complete payment
+ pe = pr.create_payment_entry()
+ self.assertEqual(pe.paid_amount, 3000) # paid amount set from pr's outstanding amount
+ self.assertEqual(pe.references[0].allocated_amount, 3000)
+ self.assertEqual(pe.references[0].outstanding_amount, 0) # for Invoices it will zero
+ self.assertEqual(pe.references[0].payment_request, pr.name)
+
+ pr.load_from_db()
+ self.assertEqual(pr.status, "Paid")
+ self.assertEqual(pr.outstanding_amount, 0)
+ self.assertEqual(pr.grand_total, 100)
+
+ # creating a more payment Request must not allowed
+ self.assertRaisesRegex(
+ frappe.exceptions.ValidationError,
+ re.compile(r"Payment Request is already created"),
+ make_payment_request,
+ dt="Purchase Invoice",
+ dn=pi.name,
+ mute_email=1,
+ submit_doc=1,
+ return_doc=1,
+ )
+
+ def test_single_payment_with_payment_term_for_same_currency(self):
+ create_payment_terms_template()
+
+ po = create_purchase_order(do_not_save=1, currency="INR", qty=1, rate=20000)
+ po.payment_terms_template = "Test Receivable Template" # 84.746 and 15.254
+ po.save()
+ po.submit()
+
+ self.assertEqual(po.advance_payment_status, "Not Initiated")
+
+ pr = make_payment_request(
+ dt="Purchase Order",
+ dn=po.name,
+ mute_email=1,
+ submit_doc=1,
+ return_doc=1,
+ )
+
+ self.assertEqual(pr.grand_total, 20000)
+ self.assertEqual(pr.outstanding_amount, pr.grand_total)
+ self.assertEqual(pr.party_account_currency, pr.currency) # INR
+ self.assertEqual(pr.status, "Initiated")
+
+ po.load_from_db()
+ self.assertEqual(po.advance_payment_status, "Initiated")
+
+ pe = pr.create_payment_entry()
+
+ self.assertEqual(len(pe.references), 2)
+ self.assertEqual(pe.paid_amount, 20000)
+
+ # check 1st payment term
+ self.assertEqual(pe.references[0].allocated_amount, 16949.2)
+ self.assertEqual(pe.references[0].payment_request, pr.name)
+
+ # check 2nd payment term
+ self.assertEqual(pe.references[1].allocated_amount, 3050.8)
+ self.assertEqual(pe.references[1].payment_request, pr.name)
+
+ po.load_from_db()
+ self.assertEqual(po.advance_payment_status, "Fully Paid")
+
+ pr.load_from_db()
+ self.assertEqual(pr.status, "Paid")
+ self.assertEqual(pr.outstanding_amount, 0)
+ self.assertEqual(pr.grand_total, 20000)
+
+ def test_single_payment_with_payment_term_for_multi_currency(self):
+ create_payment_terms_template()
+
+ si = create_sales_invoice(do_not_save=1, currency="USD", qty=1, rate=200, conversion_rate=50)
+ si.payment_terms_template = "Test Receivable Template" # 84.746 and 15.254
+ si.save()
+ si.submit()
+
+ pr = make_payment_request(
+ dt="Sales Invoice",
+ dn=si.name,
+ mute_email=1,
+ submit_doc=1,
+ return_doc=1,
+ )
+
+ # 200 USD -> 10000 INR
+ self.assertEqual(pr.grand_total, 200)
+ self.assertEqual(pr.outstanding_amount, 10000)
+ self.assertEqual(pr.currency, "USD")
+ self.assertEqual(pr.party_account_currency, "INR")
+ self.assertEqual(pr.status, "Requested")
+
+ pe = pr.create_payment_entry()
+ self.assertEqual(len(pe.references), 2)
+ self.assertEqual(pe.paid_amount, 10000)
+
+ # check 1st payment term
+ # convert it via dollar and conversion_rate
+ self.assertEqual(pe.references[0].allocated_amount, 8474.5) # multi currency conversion
+ self.assertEqual(pe.references[0].payment_request, pr.name)
+
+ # check 2nd payment term
+ self.assertEqual(pe.references[1].allocated_amount, 1525.5) # multi currency conversion
+ self.assertEqual(pe.references[1].payment_request, pr.name)
+
+ pr.load_from_db()
+ self.assertEqual(pr.status, "Paid")
+ self.assertEqual(pr.outstanding_amount, 0)
+ self.assertEqual(pr.grand_total, 200)
+
+ def test_payment_cancel_process(self):
+ so = make_sales_order(currency="INR", qty=1, rate=1000)
+ self.assertEqual(so.advance_payment_status, "Not Requested")
+
+ pr = make_payment_request(
+ dt="Sales Order",
+ dn=so.name,
+ mute_email=1,
+ submit_doc=1,
+ return_doc=1,
+ )
+
+ self.assertEqual(pr.status, "Requested")
+ self.assertEqual(pr.grand_total, 1000)
+ self.assertEqual(pr.outstanding_amount, pr.grand_total)
+
+ so.load_from_db()
+ self.assertEqual(so.advance_payment_status, "Requested")
+
+ pe = pr.create_payment_entry(submit=False)
+ pe.paid_amount = 800
+ pe.references[0].allocated_amount = 800
+ pe.submit()
+
+ self.assertEqual(pe.references[0].payment_request, pr.name)
+
+ so.load_from_db()
+ self.assertEqual(so.advance_payment_status, "Partially Paid")
+
+ pr.load_from_db()
+ self.assertEqual(pr.status, "Partially Paid")
+ self.assertEqual(pr.outstanding_amount, 200)
+ self.assertEqual(pr.grand_total, 1000)
+
+ # cancelling PE
+ pe.cancel()
+
+ pr.load_from_db()
+ self.assertEqual(pr.status, "Requested")
+ self.assertEqual(pr.outstanding_amount, 1000)
+ self.assertEqual(pr.grand_total, 1000)
+
+ so.load_from_db()
+ self.assertEqual(so.advance_payment_status, "Requested")
diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py
index 4aa1d236fa43..cb367a3dfb31 100644
--- a/erpnext/controllers/accounts_controller.py
+++ b/erpnext/controllers/accounts_controller.py
@@ -1966,33 +1966,24 @@ def set_total_advance_paid(self):
def set_advance_payment_status(self):
new_status = None
- stati = frappe.get_all(
- "Payment Request",
- {
+ paid_amount = frappe.get_value(
+ doctype="Payment Request",
+ filters={
"reference_doctype": self.doctype,
"reference_name": self.name,
"docstatus": 1,
},
- pluck="status",
+ fieldname="sum(grand_total - outstanding_amount)",
)
- if self.doctype in frappe.get_hooks("advance_payment_receivable_doctypes"):
- if not stati:
- new_status = "Not Requested"
- elif "Requested" in stati or "Failed" in stati:
- new_status = "Requested"
- elif "Partially Paid" in stati:
- new_status = "Partially Paid"
- elif "Paid" in stati:
- new_status = "Fully Paid"
- if self.doctype in frappe.get_hooks("advance_payment_payable_doctypes"):
- if not stati:
- new_status = "Not Initiated"
- elif "Initiated" in stati or "Failed" in stati or "Payment Ordered" in stati:
- new_status = "Initiated"
- elif "Partially Paid" in stati:
- new_status = "Partially Paid"
- elif "Paid" in stati:
- new_status = "Fully Paid"
+
+ if not paid_amount:
+ if self.doctype in frappe.get_hooks("advance_payment_receivable_doctypes"):
+ new_status = "Not Requested" if paid_amount is None else "Requested"
+ elif self.doctype in frappe.get_hooks("advance_payment_payable_doctypes"):
+ new_status = "Not Initiated" if paid_amount is None else "Initiated"
+ else:
+ total_amount = self.get("rounded_total") or self.get("grand_total")
+ new_status = "Fully Paid" if paid_amount == total_amount else "Partially Paid"
if new_status == self.advance_payment_status:
return