Merge pull request #32424 from deepeshgarg007/loan_schedule_types

feat: Repayment schedule types for term loans
diff --git a/erpnext/accounts/report/payment_ledger/__init__.py b/erpnext/accounts/report/payment_ledger/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/erpnext/accounts/report/payment_ledger/__init__.py
diff --git a/erpnext/accounts/report/payment_ledger/payment_ledger.js b/erpnext/accounts/report/payment_ledger/payment_ledger.js
new file mode 100644
index 0000000..9779844
--- /dev/null
+++ b/erpnext/accounts/report/payment_ledger/payment_ledger.js
@@ -0,0 +1,59 @@
+// Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and contributors
+// For license information, please see license.txt
+/* eslint-disable */
+
+function get_filters() {
+	let filters = [
+		{
+			"fieldname":"company",
+			"label": __("Company"),
+			"fieldtype": "Link",
+			"options": "Company",
+			"default": frappe.defaults.get_user_default("Company"),
+			"reqd": 1
+		},
+		{
+			"fieldname":"period_start_date",
+			"label": __("Start Date"),
+			"fieldtype": "Date",
+			"reqd": 1,
+			"default": frappe.datetime.add_months(frappe.datetime.get_today(), -1)
+		},
+		{
+			"fieldname":"period_end_date",
+			"label": __("End Date"),
+			"fieldtype": "Date",
+			"reqd": 1,
+			"default": frappe.datetime.get_today()
+		},
+		{
+			"fieldname":"account",
+			"label": __("Account"),
+			"fieldtype": "MultiSelectList",
+			"options": "Account",
+			get_data: function(txt) {
+				return frappe.db.get_link_options('Account', txt, {
+					company: frappe.query_report.get_filter_value("company")
+				});
+			}
+		},
+		{
+			"fieldname":"voucher_no",
+			"label": __("Voucher No"),
+			"fieldtype": "Data",
+			"width": 100,
+		},
+		{
+			"fieldname":"against_voucher_no",
+			"label": __("Against Voucher No"),
+			"fieldtype": "Data",
+			"width": 100,
+		},
+
+	]
+	return filters;
+}
+
+frappe.query_reports["Payment Ledger"] = {
+	"filters": get_filters()
+};
diff --git a/erpnext/accounts/report/payment_ledger/payment_ledger.json b/erpnext/accounts/report/payment_ledger/payment_ledger.json
new file mode 100644
index 0000000..716329f
--- /dev/null
+++ b/erpnext/accounts/report/payment_ledger/payment_ledger.json
@@ -0,0 +1,32 @@
+{
+ "add_total_row": 0,
+ "columns": [],
+ "creation": "2022-06-06 08:50:43.933708",
+ "disable_prepared_report": 0,
+ "disabled": 0,
+ "docstatus": 0,
+ "doctype": "Report",
+ "filters": [],
+ "idx": 0,
+ "is_standard": "Yes",
+ "modified": "2022-06-06 08:50:43.933708",
+ "modified_by": "Administrator",
+ "module": "Accounts",
+ "name": "Payment Ledger",
+ "owner": "Administrator",
+ "prepared_report": 0,
+ "ref_doctype": "Payment Ledger Entry",
+ "report_name": "Payment Ledger",
+ "report_type": "Script Report",
+ "roles": [
+  {
+   "role": "Accounts User"
+  },
+  {
+   "role": "Accounts Manager"
+  },
+  {
+   "role": "Auditor"
+  }
+ ]
+}
\ No newline at end of file
diff --git a/erpnext/accounts/report/payment_ledger/payment_ledger.py b/erpnext/accounts/report/payment_ledger/payment_ledger.py
new file mode 100644
index 0000000..e470c27
--- /dev/null
+++ b/erpnext/accounts/report/payment_ledger/payment_ledger.py
@@ -0,0 +1,222 @@
+# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and contributors
+# For license information, please see license.txt
+
+from collections import OrderedDict
+
+import frappe
+from frappe import _, qb
+from frappe.query_builder import Criterion
+
+
+class PaymentLedger(object):
+	def __init__(self, filters=None):
+		self.filters = filters
+		self.columns, self.data = [], []
+		self.voucher_dict = OrderedDict()
+		self.voucher_amount = []
+		self.ple = qb.DocType("Payment Ledger Entry")
+
+	def init_voucher_dict(self):
+
+		if self.voucher_amount:
+			s = set()
+			# build  a set of unique vouchers
+			for ple in self.voucher_amount:
+				key = (ple.voucher_type, ple.voucher_no, ple.party)
+				s.add(key)
+
+			# for each unique vouchers, initialize +/- list
+			for key in s:
+				self.voucher_dict[key] = frappe._dict(increase=list(), decrease=list())
+
+			# for each ple, using against voucher and amount, assign it to +/- list
+			# group by against voucher
+			for ple in self.voucher_amount:
+				against_key = (ple.against_voucher_type, ple.against_voucher_no, ple.party)
+				target = None
+				if self.voucher_dict.get(against_key):
+					if ple.amount > 0:
+						target = self.voucher_dict.get(against_key).increase
+					else:
+						target = self.voucher_dict.get(against_key).decrease
+
+				# this if condition will lose unassigned ple entries(against_voucher doc doesn't have ple)
+				# need to somehow include the stray entries as well.
+				if target is not None:
+					entry = frappe._dict(
+						company=ple.company,
+						account=ple.account,
+						party_type=ple.party_type,
+						party=ple.party,
+						voucher_type=ple.voucher_type,
+						voucher_no=ple.voucher_no,
+						against_voucher_type=ple.against_voucher_type,
+						against_voucher_no=ple.against_voucher_no,
+						amount=ple.amount,
+						currency=ple.account_currency,
+					)
+
+					if self.filters.include_account_currency:
+						entry["amount_in_account_currency"] = ple.amount_in_account_currency
+
+					target.append(entry)
+
+	def build_data(self):
+		self.data.clear()
+
+		for value in self.voucher_dict.values():
+			voucher_data = []
+			if value.increase != []:
+				voucher_data.extend(value.increase)
+			if value.decrease != []:
+				voucher_data.extend(value.decrease)
+
+			if voucher_data:
+				# balance row
+				total = 0
+				total_in_account_currency = 0
+
+				for x in voucher_data:
+					total += x.amount
+					if self.filters.include_account_currency:
+						total_in_account_currency += x.amount_in_account_currency
+
+				entry = frappe._dict(
+					against_voucher_no="Outstanding:",
+					amount=total,
+					currency=voucher_data[0].currency,
+				)
+
+				if self.filters.include_account_currency:
+					entry["amount_in_account_currency"] = total_in_account_currency
+
+				voucher_data.append(entry)
+
+				# empty row
+				voucher_data.append(frappe._dict())
+				self.data.extend(voucher_data)
+
+	def build_conditions(self):
+		self.conditions = []
+
+		if self.filters.company:
+			self.conditions.append(self.ple.company == self.filters.company)
+
+		if self.filters.account:
+			self.conditions.append(self.ple.account.isin(self.filters.account))
+
+		if self.filters.period_start_date:
+			self.conditions.append(self.ple.posting_date.gte(self.filters.period_start_date))
+
+		if self.filters.period_end_date:
+			self.conditions.append(self.ple.posting_date.lte(self.filters.period_end_date))
+
+		if self.filters.voucher_no:
+			self.conditions.append(self.ple.voucher_no == self.filters.voucher_no)
+
+		if self.filters.against_voucher_no:
+			self.conditions.append(self.ple.against_voucher_no == self.filters.against_voucher_no)
+
+	def get_data(self):
+		ple = self.ple
+
+		self.build_conditions()
+
+		# fetch data from table
+		self.voucher_amount = (
+			qb.from_(ple)
+			.select(ple.star)
+			.where(ple.delinked == 0)
+			.where(Criterion.all(self.conditions))
+			.run(as_dict=True)
+		)
+
+	def get_columns(self):
+		options = None
+		self.columns.append(
+			dict(label=_("Company"), fieldname="company", fieldtype="data", options=options, width="100")
+		)
+
+		self.columns.append(
+			dict(label=_("Account"), fieldname="account", fieldtype="data", options=options, width="100")
+		)
+
+		self.columns.append(
+			dict(
+				label=_("Party Type"), fieldname="party_type", fieldtype="data", options=options, width="100"
+			)
+		)
+		self.columns.append(
+			dict(label=_("Party"), fieldname="party", fieldtype="data", options=options, width="100")
+		)
+		self.columns.append(
+			dict(
+				label=_("Voucher Type"),
+				fieldname="voucher_type",
+				fieldtype="data",
+				options=options,
+				width="100",
+			)
+		)
+		self.columns.append(
+			dict(
+				label=_("Voucher No"), fieldname="voucher_no", fieldtype="data", options=options, width="100"
+			)
+		)
+		self.columns.append(
+			dict(
+				label=_("Against Voucher Type"),
+				fieldname="against_voucher_type",
+				fieldtype="data",
+				options=options,
+				width="100",
+			)
+		)
+		self.columns.append(
+			dict(
+				label=_("Against Voucher No"),
+				fieldname="against_voucher_no",
+				fieldtype="data",
+				options=options,
+				width="100",
+			)
+		)
+		self.columns.append(
+			dict(
+				label=_("Amount"),
+				fieldname="amount",
+				fieldtype="Currency",
+				options="Company:company:default_currency",
+				width="100",
+			)
+		)
+
+		if self.filters.include_account_currency:
+			self.columns.append(
+				dict(
+					label=_("Amount in Account Currency"),
+					fieldname="amount_in_account_currency",
+					fieldtype="Currency",
+					options="currency",
+					width="100",
+				)
+			)
+		self.columns.append(
+			dict(label=_("Currency"), fieldname="currency", fieldtype="Currency", hidden=True)
+		)
+
+	def run(self):
+		self.get_columns()
+		self.get_data()
+
+		# initialize dictionary and group using against voucher
+		self.init_voucher_dict()
+
+		# convert dictionary to list and add balance rows
+		self.build_data()
+
+		return self.columns, self.data
+
+
+def execute(filters=None):
+	return PaymentLedger(filters).run()
diff --git a/erpnext/accounts/report/payment_ledger/test_payment_ledger.py b/erpnext/accounts/report/payment_ledger/test_payment_ledger.py
new file mode 100644
index 0000000..5ae9b87
--- /dev/null
+++ b/erpnext/accounts/report/payment_ledger/test_payment_ledger.py
@@ -0,0 +1,65 @@
+import unittest
+
+import frappe
+from frappe import qb
+from frappe.tests.utils import FrappeTestCase
+
+from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry
+from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
+from erpnext.accounts.report.payment_ledger.payment_ledger import execute
+
+
+class TestPaymentLedger(FrappeTestCase):
+	def setUp(self):
+		self.create_company()
+		self.cleanup()
+
+	def cleanup(self):
+		doctypes = []
+		doctypes.append(qb.DocType("GL Entry"))
+		doctypes.append(qb.DocType("Payment Ledger Entry"))
+		doctypes.append(qb.DocType("Sales Invoice"))
+		doctypes.append(qb.DocType("Payment Entry"))
+
+		for doctype in doctypes:
+			qb.from_(doctype).delete().where(doctype.company == self.company).run()
+
+	def create_company(self):
+		name = "Test Payment Ledger"
+		company = None
+		if frappe.db.exists("Company", name):
+			company = frappe.get_doc("Company", name)
+		else:
+			company = frappe.get_doc(
+				{
+					"doctype": "Company",
+					"company_name": name,
+					"country": "India",
+					"default_currency": "INR",
+					"create_chart_of_accounts_based_on": "Standard Template",
+					"chart_of_accounts": "Standard",
+				}
+			)
+			company = company.save()
+		self.company = company.name
+		self.cost_center = company.cost_center
+		self.warehouse = "All Warehouses" + " - " + company.abbr
+		self.income_account = company.default_income_account
+		self.expense_account = company.default_expense_account
+		self.debit_to = company.default_receivable_account
+
+	def test_unpaid_invoice_outstanding(self):
+		sinv = create_sales_invoice(
+			company=self.company,
+			debit_to=self.debit_to,
+			expense_account=self.expense_account,
+			cost_center=self.cost_center,
+			income_account=self.income_account,
+			warehouse=self.warehouse,
+		)
+		pe = get_payment_entry(sinv.doctype, sinv.name).save().submit()
+
+		filters = frappe._dict({"company": self.company})
+		columns, data = execute(filters=filters)
+		outstanding = [x for x in data if x.get("against_voucher_no") == "Outstanding:"]
+		self.assertEqual(outstanding[0].get("amount"), 0)
diff --git a/erpnext/public/js/utils/party.js b/erpnext/public/js/utils/party.js
index 58594b0..644adff 100644
--- a/erpnext/public/js/utils/party.js
+++ b/erpnext/public/js/utils/party.js
@@ -242,20 +242,29 @@
 	});
 };
 
-erpnext.utils.get_contact_details = function(frm) {
+erpnext.utils.get_contact_details = function (frm) {
 	if (frm.updating_party_details) return;
 
 	if (frm.doc["contact_person"]) {
 		frappe.call({
 			method: "frappe.contacts.doctype.contact.contact.get_contact_details",
-			args: {contact: frm.doc.contact_person },
-			callback: function(r) {
-				if (r.message)
-					frm.set_value(r.message);
-			}
-		})
+			args: { contact: frm.doc.contact_person },
+			callback: function (r) {
+				if (r.message) frm.set_value(r.message);
+			},
+		});
+	} else {
+		frm.set_value({
+			contact_person: "",
+			contact_display: "",
+			contact_email: "",
+			contact_mobile: "",
+			contact_phone: "",
+			contact_designation: "",
+			contact_department: "",
+		});
 	}
-}
+};
 
 erpnext.utils.validate_mandatory = function(frm, label, value, trigger_on) {
 	if (!value) {