# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt


import json
from functools import reduce

import frappe
from frappe import ValidationError, _, qb, scrub, throw
from frappe.model.meta import get_field_precision
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, get_link_to_form
from pypika import Case
from pypika.functions import Coalesce, Sum

import erpnext
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import get_dimensions
from erpnext.accounts.doctype.bank_account.bank_account import (
	get_bank_account_details,
	get_default_company_bank_account,
	get_party_bank_account,
)
from erpnext.accounts.doctype.invoice_discounting.invoice_discounting import (
	get_party_account_based_on_invoice_discounting,
)
from erpnext.accounts.doctype.journal_entry.journal_entry import get_default_bank_cash_account
from erpnext.accounts.doctype.repost_accounting_ledger.repost_accounting_ledger import (
	validate_docs_for_deferred_accounting,
	validate_docs_for_voucher_types,
)
from erpnext.accounts.doctype.tax_withholding_entry.tax_withholding_entry import PaymentTaxWithholding
from erpnext.accounts.general_ledger import (
	make_gl_entries,
	make_reverse_gl_entries,
	process_gl_map,
)
from erpnext.accounts.party import (
	complete_contact_details,
	get_default_contact,
	get_party_account,
)
from erpnext.accounts.utils import (
	cancel_exchange_gain_loss_journal,
	get_account_currency,
	get_advance_payment_doctypes,
	get_outstanding_invoices,
	get_reconciliation_effect_date,
)
from erpnext.controllers.accounts_controller import (
	AccountsController,
	get_supplier_block_status,
	validate_taxes_and_charges,
)
from erpnext.setup.utils import get_exchange_rate


class InvalidPaymentEntry(ValidationError):
	pass


class PaymentEntry(AccountsController):
	# begin: auto-generated types
	# This code is auto-generated. Do not modify anything in this block.

	from typing import TYPE_CHECKING

	if TYPE_CHECKING:
		from frappe.types import DF

		from erpnext.accounts.doctype.advance_taxes_and_charges.advance_taxes_and_charges import (
			AdvanceTaxesandCharges,
		)
		from erpnext.accounts.doctype.payment_entry_deduction.payment_entry_deduction import (
			PaymentEntryDeduction,
		)
		from erpnext.accounts.doctype.payment_entry_reference.payment_entry_reference import (
			PaymentEntryReference,
		)
		from erpnext.accounts.doctype.tax_withholding_entry.tax_withholding_entry import TaxWithholdingEntry

		amended_from: DF.Link | None
		apply_tds: DF.Check
		auto_repeat: DF.Link | None
		bank: DF.ReadOnly | None
		bank_account: DF.Link | None
		bank_account_no: DF.ReadOnly | None
		base_in_words: DF.SmallText | None
		base_paid_amount: DF.Currency
		base_paid_amount_after_tax: DF.Currency
		base_received_amount: DF.Currency
		base_received_amount_after_tax: DF.Currency
		base_total_allocated_amount: DF.Currency
		base_total_taxes_and_charges: DF.Currency
		book_advance_payments_in_separate_party_account: DF.Check
		clearance_date: DF.Date | None
		company: DF.Link
		contact_email: DF.Data | None
		contact_person: DF.Link | None
		cost_center: DF.Link | None
		custom_remarks: DF.Check
		deductions: DF.Table[PaymentEntryDeduction]
		difference_amount: DF.Currency
		ignore_tax_withholding_threshold: DF.Check
		in_words: DF.SmallText | None
		is_opening: DF.Literal["No", "Yes"]
		letter_head: DF.Link | None
		mode_of_payment: DF.Link | None
		naming_series: DF.Literal["ACC-PAY-.YYYY.-"]
		override_tax_withholding_entries: DF.Check
		paid_amount: DF.Currency
		paid_amount_after_tax: DF.Currency
		paid_from: DF.Link
		paid_from_account_currency: DF.Link
		paid_from_account_type: DF.Data | None
		paid_to: DF.Link
		paid_to_account_currency: DF.Link
		paid_to_account_type: DF.Data | None
		party: DF.DynamicLink | None
		party_bank_account: DF.Link | None
		party_name: DF.Data | None
		party_type: DF.Link | None
		payment_order: DF.Link | None
		payment_order_status: DF.Literal["Initiated", "Payment Ordered"]
		payment_type: DF.Literal["Receive", "Pay", "Internal Transfer"]
		posting_date: DF.Date
		print_heading: DF.Link | None
		project: DF.Link | None
		purchase_taxes_and_charges_template: DF.Link | None
		received_amount: DF.Currency
		received_amount_after_tax: DF.Currency
		reconcile_on_advance_payment_date: DF.Check
		reference_date: DF.Date | None
		reference_no: DF.Data | None
		references: DF.Table[PaymentEntryReference]
		remarks: DF.SmallText | None
		sales_taxes_and_charges_template: DF.Link | None
		source_exchange_rate: DF.Float
		status: DF.Literal["", "Draft", "Submitted", "Cancelled"]
		target_exchange_rate: DF.Float
		tax_withholding_category: DF.Link | None
		tax_withholding_entries: DF.Table[TaxWithholdingEntry]
		tax_withholding_group: DF.Link | None
		taxes: DF.Table[AdvanceTaxesandCharges]
		title: DF.Data | None
		total_allocated_amount: DF.Currency
		total_taxes_and_charges: DF.Currency
		unallocated_amount: DF.Currency
	# end: auto-generated types

	def __init__(self, *args, **kwargs):
		super().__init__(*args, **kwargs)
		if not self.is_new():
			self.setup_party_account_field()

	def setup_party_account_field(self):
		self.party_account_field = None
		self.party_account = None
		self.party_account_currency = None

		if self.payment_type == "Receive":
			self.party_account_field = "paid_from"
			self.party_account = self.paid_from
			self.party_account_currency = self.paid_from_account_currency

		elif self.payment_type == "Pay":
			self.party_account_field = "paid_to"
			self.party_account = self.paid_to
			self.party_account_currency = self.paid_to_account_currency

	def validate(self):
		self.setup_party_account_field()
		self.set_missing_values()
		self.set_liability_account()
		self.set_missing_ref_details(force=True)
		self.validate_payment_type()
		self.validate_party_details()
		self.set_exchange_rate()
		self.validate_mandatory()
		self.validate_reference_documents()
		self.set_amounts()
		self.validate_amounts()
		self.apply_taxes()
		self.set_amounts_after_tax()
		self.clear_unallocated_reference_document_rows()
		self.validate_transaction_reference()
		self.set_title()
		self.set_remarks()
		self.validate_duplicate_entry()
		self.validate_payment_type_with_outstanding()
		self.validate_allocated_amount()
		self.validate_paid_invoices()
		self.ensure_supplier_is_not_blocked()
		PaymentTaxWithholding(self).on_validate()
		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"))
		PaymentTaxWithholding(self).on_submit()
		self.update_payment_requests()
		self.update_payment_schedule()
		self.make_gl_entries()
		self.update_outstanding_amounts()
		self.set_status()

	def validate_for_repost(self):
		validate_docs_for_voucher_types(["Payment Entry"])
		validate_docs_for_deferred_accounting([self.name], [])

	def on_update_after_submit(self):
		# Flag will be set on Reconciliation
		# Reconciliation tool will anyways repost ledger entries. So, no need to check and do implicit repost.
		if self.flags.get("ignore_reposting_on_reconciliation"):
			return

		self.needs_repost = self.check_if_fields_updated(
			fields_to_check=[], child_tables={"references": [], "taxes": [], "deductions": []}
		)
		if self.needs_repost:
			self.validate_for_repost()
			self.repost_accounting_entries()

	def set_liability_account(self):
		# Auto setting liability account should only be done during 'draft' status
		if self.docstatus > 0 or self.payment_type == "Internal Transfer":
			return

		self.book_advance_payments_in_separate_party_account = False
		if self.party_type not in ("Customer", "Supplier"):
			self.is_opening = "No"
			return

		if not frappe.db.get_value(
			"Company", self.company, "book_advance_payments_in_separate_party_account"
		):
			self.is_opening = "No"
			return

		# Important to set this flag for the gl building logic to work properly
		self.book_advance_payments_in_separate_party_account = True
		account_type = frappe.get_value(
			"Account", {"name": self.party_account, "company": self.company}, "account_type"
		)

		if (account_type == "Payable" and self.party_type == "Customer") or (
			account_type == "Receivable" and self.party_type == "Supplier"
		):
			self.is_opening = "No"
			return

		if self.references:
			allowed_types = frozenset(["Sales Order", "Purchase Order"])
			reference_types = set([x.reference_doctype for x in self.references])

			# If there are referencers other than `allowed_types`, treat this as a normal payment entry
			if reference_types - allowed_types:
				self.book_advance_payments_in_separate_party_account = False
				self.is_opening = "No"
				return

		accounts = get_party_account(self.party_type, self.party, self.company, include_advance=True)

		liability_account = accounts[1] if len(accounts) > 1 else None
		fieldname = (
			"default_advance_received_account"
			if self.party_type == "Customer"
			else "default_advance_paid_account"
		)

		if not liability_account:
			throw(
				_("Please set default {0} in Company {1}").format(
					frappe.bold(frappe.get_meta("Company").get_label(fieldname)), frappe.bold(self.company)
				)
			)

		self.set(self.party_account_field, liability_account)

		frappe.msgprint(
			_(
				"Book Advance Payments as Liability option is chosen. Paid From account changed from {0} to {1}."
			).format(
				frappe.bold(self.party_account),
				frappe.bold(liability_account),
			),
			alert=True,
		)

	def on_cancel(self):
		self.ignore_linked_doctypes = (
			"GL Entry",
			"Stock Ledger Entry",
			"Payment Ledger Entry",
			"Repost Payment Ledger",
			"Repost Payment Ledger Items",
			"Repost Accounting Ledger",
			"Repost Accounting Ledger Items",
			"Unreconcile Payment",
			"Unreconcile Payment Entries",
			"Advance Payment Ledger Entry",
			"Tax Withholding Entry",
		)
		super().on_cancel()
		PaymentTaxWithholding(self).on_cancel()
		self.update_payment_requests(cancel=True)
		self.update_payment_schedule(cancel=1)
		self.make_gl_entries(cancel=1)
		self.update_outstanding_amounts()
		self.delink_advance_entry_references()
		self.set_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_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 = set()
		for d in self.get("references"):
			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.add(key)

	def set_bank_account_data(self):
		if not self.bank_account:
			return

		bank_data = get_bank_account_details(self.bank_account)

		field = "paid_from" if self.payment_type == "Pay" else "paid_to"

		self.bank = bank_data.bank
		self.bank_account_no = bank_data.bank_account_no

		if not self.get(field):
			self.set(field, bank_data.account)

	def validate_payment_type_with_outstanding(self):
		total_outstanding = sum(d.allocated_amount for d in self.get("references"))
		if total_outstanding < 0 and self.party_type == "Customer" and self.payment_type == "Receive":
			frappe.throw(
				_("Cannot receive from customer against negative outstanding"),
				title=_("Incorrect Payment Type"),
			)

	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()
			return

		fail_message = _("Row #{0}: Allocated Amount cannot be greater than outstanding amount.")
		for d in self.get("references"):
			if (flt(d.allocated_amount)) > 0 and flt(d.allocated_amount) > flt(d.outstanding_amount):
				frappe.throw(fail_message.format(d.idx))

			# Check for negative outstanding invoices as well
			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:
		if (
			reference_doctype
			and reference_doctype in ["Sales Invoice", "Sales Order", "Purchase Order", "Purchase Invoice"]
			and reference_name
		):
			if template := frappe.db.get_value(reference_doctype, reference_name, "payment_terms_template"):
				return frappe.db.get_value(
					"Payment Terms Template", template, "allocate_payment_based_on_payment_terms"
				)
		return False

	def validate_allocated_amount_with_latest_data(self):
		if not self.references:
			return

		uniq_vouchers = {(x.reference_doctype, x.reference_name) for x in self.references}
		vouchers = [frappe._dict({"voucher_type": x[0], "voucher_no": x[1]}) for x in uniq_vouchers]
		latest_references = get_outstanding_reference_documents(
			{
				"posting_date": self.posting_date,
				"company": self.company,
				"party_type": self.party_type,
				"payment_type": self.payment_type,
				"party": self.party,
				"party_account": self.paid_from if self.payment_type == "Receive" else self.paid_to,
				"get_outstanding_invoices": True,
				"get_orders_to_be_billed": True,
				"vouchers": vouchers,
				"book_advance_payments_in_separate_party_account": self.book_advance_payments_in_separate_party_account,
			},
			validate=True,
		)

		# Group latest_references by (voucher_type, voucher_no)
		latest_lookup = {}
		for d in latest_references:
			d = frappe._dict(d)
			latest_lookup.setdefault((d.voucher_type, d.voucher_no), frappe._dict())[d.payment_term] = d

		for idx, d in enumerate(self.get("references"), start=1):
			latest = latest_lookup.get((d.reference_doctype, d.reference_name)) or frappe._dict()

			# If term based allocation is enabled, throw
			if (
				d.payment_term is None or d.payment_term == ""
			) and self.term_based_allocation_enabled_for_reference(d.reference_doctype, d.reference_name):
				frappe.throw(
					_(
						"{0} has Payment Term based allocation enabled. Select a Payment Term for Row #{1} in Payment References section"
					).format(frappe.bold(d.reference_name), frappe.bold(idx))
				)

			# if no payment template is used by invoice and has a custom term(no `payment_term`), then invoice outstanding will be in 'None' key
			latest = latest.get(d.payment_term) or latest.get(None)
			# The reference has already been fully paid
			if not latest:
				frappe.throw(
					_("{0} {1} has already been fully paid.").format(_(d.reference_doctype), d.reference_name)
				)
			# The reference has already been partly paid
			elif (
				latest.outstanding_amount < latest.invoice_amount
				and flt(d.outstanding_amount, d.precision("outstanding_amount"))
				!= flt(latest.outstanding_amount, d.precision("outstanding_amount"))
				and d.payment_term == ""
			):
				frappe.throw(
					_(
						"{0} {1} has already been partly paid. Please use the 'Get Outstanding Invoice' or the 'Get Outstanding Orders' button to get the latest outstanding amounts."
					).format(_(d.reference_doctype), d.reference_name)
				)

			fail_message = _("Row #{0}: Allocated Amount cannot be greater than outstanding amount.")

			if (
				d.payment_term
				and (
					(flt(d.allocated_amount)) > 0
					and latest.payment_term_outstanding
					and (flt(d.allocated_amount) > flt(latest.payment_term_outstanding))
				)
				and self.term_based_allocation_enabled_for_reference(d.reference_doctype, d.reference_name)
			):
				frappe.throw(
					_(
						"Row #{0}: Allocated amount:{1} is greater than outstanding amount:{2} for Payment Term {3}"
					).format(d.idx, d.allocated_amount, latest.payment_term_outstanding, d.payment_term)
				)

			if (flt(d.allocated_amount)) > 0 and flt(d.allocated_amount) > flt(latest.outstanding_amount):
				frappe.throw(fail_message.format(d.idx))

			# Check for negative outstanding invoices as well
			if flt(d.allocated_amount) < 0 and flt(d.allocated_amount) < flt(latest.outstanding_amount):
				frappe.throw(fail_message.format(d.idx))

	def delink_advance_entry_references(self):
		for reference in self.references:
			if reference.reference_doctype in ("Sales Invoice", "Purchase Invoice"):
				doc = frappe.get_lazy_doc(reference.reference_doctype, reference.reference_name)
				doc.delink_advance_entries(self.name)

	def set_missing_values(self):
		if self.payment_type == "Internal Transfer":
			for field in (
				"party",
				"total_allocated_amount",
				"base_total_allocated_amount",
				"unallocated_amount",
			):
				self.set(field, None)
			self.references = []
		else:
			if not self.party_type:
				frappe.throw(_("Party Type is mandatory"))

			if not self.party:
				frappe.throw(_("Party is mandatory"))

			_party_name = "title" if self.party_type == "Shareholder" else self.party_type.lower() + "_name"

			if frappe.db.has_column(self.party_type, _party_name):
				self.party_name = frappe.db.get_value(self.party_type, self.party, _party_name)
			else:
				self.party_name = frappe.db.get_value(self.party_type, self.party, "name")

		if self.party:
			if self.party_type == "Employee":
				self.contact_person = None
			elif not self.contact_person:
				self.contact_person = get_default_contact(self.party_type, self.party)

			complete_contact_details(self)

			if not self.party_account:
				party_account = get_party_account(self.party_type, self.party, self.company)
				self.set(self.party_account_field, party_account)
				self.party_account = party_account

		if self.paid_from and (not self.paid_from_account_currency or not self.paid_from_account_type):
			acc = get_account_details(self.paid_from, self.posting_date, self.cost_center)
			self.paid_from_account_currency = acc.account_currency
			self.paid_from_account_type = acc.account_type

		if self.paid_to and (not self.paid_to_account_currency or not self.paid_to_account_type):
			acc = get_account_details(self.paid_to, self.posting_date, self.cost_center)
			self.paid_to_account_currency = acc.account_currency
			self.paid_to_account_type = acc.account_type

		self.party_account_currency = (
			self.paid_from_account_currency
			if self.payment_type == "Receive"
			else self.paid_to_account_currency
		)

	def set_missing_ref_details(
		self,
		force: bool = False,
		update_ref_details_only_for: list | None = None,
		reference_exchange_details: dict | None = None,
	) -> None:
		for d in self.get("references"):
			if not d.allocated_amount:
				continue

			if (
				update_ref_details_only_for
				and (d.reference_doctype, d.reference_name) not in update_ref_details_only_for
			):
				continue

			ref_details = get_reference_details(
				d.reference_doctype,
				d.reference_name,
				self.party_account_currency,
				self.party_type,
				self.party,
			)

			# Only update exchange rate when the reference is Journal Entry
			if (
				reference_exchange_details
				and d.reference_doctype == reference_exchange_details.reference_doctype
				and d.reference_name == reference_exchange_details.reference_name
			):
				ref_details.update({"exchange_rate": reference_exchange_details.exchange_rate})

			for field, value in ref_details.items():
				if d.exchange_gain_loss:
					# for cases where gain/loss is booked into invoice
					# exchange_gain_loss is calculated from invoice & populated
					# and row.exchange_rate is already set to payment entry's exchange rate
					# refer -> `update_reference_in_payment_entry()` in utils.py
					continue

				if field == "exchange_rate" or not d.get(field) or force:
					if self.get("_action") in ("submit", "cancel"):
						d.db_set(field, value)
					else:
						d.set(field, value)

	def validate_payment_type(self):
		if self.payment_type not in ("Receive", "Pay", "Internal Transfer"):
			frappe.throw(_("Payment Type must be one of Receive, Pay and Internal Transfer"))

	def validate_party_details(self):
		if self.party and not frappe.db.exists(self.party_type, self.party):
			frappe.throw(_("{0} {1} does not exist").format(_(self.party_type), self.party))

	def set_exchange_rate(self, ref_doc=None):
		self.set_source_exchange_rate(ref_doc)
		self.set_target_exchange_rate(ref_doc)

	def set_source_exchange_rate(self, ref_doc=None):
		if self.paid_from:
			if self.paid_from_account_currency == self.company_currency:
				self.source_exchange_rate = 1
			elif ref_doc and self.paid_from_account_currency == ref_doc.currency:
				self.source_exchange_rate = ref_doc.get("exchange_rate") or ref_doc.get("conversion_rate")

			if not self.source_exchange_rate:
				self.source_exchange_rate = get_exchange_rate(
					self.paid_from_account_currency, self.company_currency, self.posting_date
				)

	def set_target_exchange_rate(self, ref_doc=None):
		if self.paid_from_account_currency == self.paid_to_account_currency:
			self.target_exchange_rate = self.source_exchange_rate
		elif self.paid_to and not self.target_exchange_rate:
			if ref_doc and self.paid_to_account_currency == ref_doc.currency:
				self.target_exchange_rate = ref_doc.get("exchange_rate") or ref_doc.get("conversion_rate")

			if not self.target_exchange_rate:
				self.target_exchange_rate = get_exchange_rate(
					self.paid_to_account_currency, self.company_currency, self.posting_date
				)

	def validate_mandatory(self):
		for field in ("paid_amount", "received_amount", "source_exchange_rate", "target_exchange_rate"):
			if not self.get(field):
				frappe.throw(_("{0} is mandatory").format(_(self.meta.get_label(field))))

	def validate_reference_documents(self):
		valid_reference_doctypes = self.get_valid_reference_doctypes()

		if not valid_reference_doctypes:
			return

		for d in self.get("references"):
			if not d.allocated_amount:
				continue
			if d.reference_doctype not in valid_reference_doctypes:
				frappe.throw(
					_("Reference Doctype must be one of {0}").format(
						comma_or([_(d) for d in valid_reference_doctypes])
					)
				)

			elif d.reference_name:
				if not frappe.db.exists(d.reference_doctype, d.reference_name):
					frappe.throw(_("{0} {1} does not exist").format(d.reference_doctype, d.reference_name))

				ref_doc = frappe.get_lazy_doc(d.reference_doctype, d.reference_name)

				if d.reference_doctype != "Journal Entry":
					if self.party != ref_doc.get(scrub(self.party_type)):
						frappe.throw(
							_("{0} {1} is not associated with {2} {3}").format(
								_(d.reference_doctype), d.reference_name, _(self.party_type), self.party
							)
						)
				else:
					self.validate_journal_entry()

				if d.reference_doctype in frappe.get_hooks("invoice_doctypes"):
					if self.party_type == "Customer":
						ref_party_account = (
							get_party_account_based_on_invoice_discounting(d.reference_name)
							or ref_doc.debit_to
						)
					elif self.party_type == "Supplier":
						ref_party_account = ref_doc.credit_to
					elif self.party_type == "Employee":
						ref_party_account = ref_doc.payable_account

					if (
						ref_party_account != self.party_account
						and not self.book_advance_payments_in_separate_party_account
					):
						frappe.throw(
							_("{0} {1} is associated with {2}, but Party Account is {3}").format(
								_(d.reference_doctype),
								d.reference_name,
								ref_party_account,
								self.party_account,
							)
						)

					if ref_doc.doctype == "Purchase Invoice" and ref_doc.get("on_hold"):
						frappe.throw(
							_("{0} {1} is on hold").format(_(d.reference_doctype), d.reference_name),
							title=_("Invalid Purchase Invoice"),
						)

				if ref_doc.docstatus != 1:
					frappe.throw(
						_("{0} {1} must be submitted").format(_(d.reference_doctype), d.reference_name)
					)

	def get_valid_reference_doctypes(self):
		if self.party_type == "Customer":
			return ("Sales Order", "Sales Invoice", "Journal Entry", "Dunning", "Payment Entry")
		elif self.party_type in ["Shareholder", "Employee"]:
			return ("Journal Entry",)
		elif self.party_type == "Supplier":
			return ("Purchase Order", "Purchase Invoice", "Journal Entry", "Payment Entry")

	def validate_paid_invoices(self):
		no_oustanding_refs = {}

		for d in self.get("references"):
			if not d.allocated_amount:
				continue

			if d.reference_doctype in ("Sales Invoice", "Purchase Invoice"):
				outstanding_amount, is_return = frappe.get_cached_value(
					d.reference_doctype, d.reference_name, ["outstanding_amount", "is_return"]
				)
				if outstanding_amount <= 0 and not is_return:
					no_oustanding_refs.setdefault(d.reference_doctype, []).append(d)

		for reference_doctype, references in no_oustanding_refs.items():
			frappe.msgprint(
				_(
					"References {0} of type {1} had no outstanding amount left before submitting the Payment Entry. Now they have a negative outstanding amount."
				).format(
					frappe.bold(comma_and([d.reference_name for d in references])),
					_(reference_doctype),
				)
				+ "<br><br>"
				+ _("If this is undesirable please cancel the corresponding Payment Entry."),
				title=_("Warning"),
				indicator="orange",
			)

	def validate_journal_entry(self):
		for d in self.get("references"):
			if d.allocated_amount and d.reference_doctype == "Journal Entry":
				je_accounts = frappe.db.sql(
					"""select debit, credit from `tabJournal Entry Account`
					where account = %s and party=%s and docstatus = 1 and parent = %s
					and (reference_type is null or reference_type in ("", "Sales Order", "Purchase Order"))
					""",
					(self.party_account, self.party, d.reference_name),
					as_dict=True,
				)

				if not je_accounts:
					frappe.throw(
						_(
							"Row #{0}: Journal Entry {1} does not have account {2} or already matched against another voucher"
						).format(d.idx, d.reference_name, self.party_account)
					)
				else:
					dr_or_cr = "debit" if self.payment_type == "Receive" else "credit"
					valid = False
					for jvd in je_accounts:
						if flt(jvd[dr_or_cr]) > 0:
							valid = True
					if not valid:
						frappe.throw(
							_("Against Journal Entry {0} does not have any unmatched {1} entry").format(
								d.reference_name, _(dr_or_cr)
							)
						)

	def update_payment_schedule(self, cancel=0):
		invoice_payment_amount_map = {}
		invoice_paid_amount_map = {}

		for ref in self.get("references"):
			if not ref.payment_term or not ref.reference_name:
				continue

			key = (ref.payment_term, ref.reference_name, ref.reference_doctype)
			invoice_payment_amount_map.setdefault(key, 0.0)
			invoice_payment_amount_map[key] += ref.allocated_amount

			if not invoice_paid_amount_map.get(key):
				payment_schedule = frappe.get_all(
					"Payment Schedule",
					filters={"parent": ref.reference_name},
					fields=[
						"paid_amount",
						"payment_amount",
						"payment_term",
						"discount",
						"outstanding",
						"discount_type",
					],
				)
				for term in payment_schedule:
					invoice_key = (term.payment_term, ref.reference_name, ref.reference_doctype)
					invoice_paid_amount_map.setdefault(invoice_key, {})
					invoice_paid_amount_map[invoice_key]["outstanding"] = term.outstanding
					if not (term.discount_type and term.discount):
						continue

					if term.discount_type == "Percentage":
						invoice_paid_amount_map[invoice_key]["discounted_amt"] = ref.total_amount * (
							term.discount / 100
						)
					else:
						invoice_paid_amount_map[invoice_key]["discounted_amt"] = term.discount

		for idx, (key, allocated_amount) in enumerate(invoice_payment_amount_map.items(), 1):
			if not invoice_paid_amount_map.get(key):
				frappe.throw(_("Payment term {0} not used in {1}").format(key[0], key[1]))

			allocated_amount = self.get_allocated_amount_in_transaction_currency(
				allocated_amount, key[2], key[1]
			)

			outstanding = flt(invoice_paid_amount_map.get(key, {}).get("outstanding"))
			discounted_amt = flt(invoice_paid_amount_map.get(key, {}).get("discounted_amt"))

			conversion_rate = frappe.db.get_value(key[2], {"name": key[1]}, "conversion_rate")
			base_paid_amount_precision = get_field_precision(
				frappe.get_meta("Payment Schedule").get_field("base_paid_amount")
			)
			base_outstanding_precision = get_field_precision(
				frappe.get_meta("Payment Schedule").get_field("base_outstanding")
			)

			base_paid_amount = flt(
				(allocated_amount - discounted_amt) * conversion_rate, base_paid_amount_precision
			)
			base_outstanding = flt(allocated_amount * conversion_rate, base_outstanding_precision)

			if cancel:
				frappe.db.sql(
					"""
					UPDATE `tabPayment Schedule`
					SET
						paid_amount = `paid_amount` - %s,
						base_paid_amount = `base_paid_amount` - %s,
						discounted_amount = `discounted_amount` - %s,
						outstanding = `outstanding` + %s,
						base_outstanding = `base_outstanding` - %s
					WHERE parent = %s and payment_term = %s""",
					(
						allocated_amount - discounted_amt,
						base_paid_amount,
						discounted_amt,
						allocated_amount,
						base_outstanding,
						key[1],
						key[0],
					),
				)
			else:
				if allocated_amount > outstanding:
					frappe.throw(
						_("Row #{0}: Cannot allocate more than {1} against payment term {2}").format(
							idx, fmt_money(outstanding), key[0]
						)
					)

				if allocated_amount and outstanding:
					frappe.db.sql(
						"""
						UPDATE `tabPayment Schedule`
						SET
							paid_amount = `paid_amount` + %s,
							base_paid_amount = `base_paid_amount` + %s,
							discounted_amount = `discounted_amount` + %s,
							outstanding = `outstanding` - %s,
							base_outstanding = `base_outstanding` - %s
						WHERE parent = %s and payment_term = %s""",
						(
							allocated_amount - discounted_amt,
							base_paid_amount,
							discounted_amt,
							allocated_amount,
							base_outstanding,
							key[1],
							key[0],
						),
					)

	def get_allocated_amount_in_transaction_currency(
		self, allocated_amount, reference_doctype, reference_docname
	):
		"""
		Payment Entry could be in base currency while reference's payment schedule
		is always in transaction currency.
		E.g.
		* SI with base=INR and currency=USD
		* SI with payment schedule in USD
		* PE in INR (accounting done in base currency)
		"""
		ref_currency, ref_exchange_rate = frappe.db.get_value(
			reference_doctype, reference_docname, ["currency", "conversion_rate"]
		)
		is_single_currency = self.paid_from_account_currency == self.paid_to_account_currency
		# PE in different currency
		reference_is_multi_currency = self.paid_from_account_currency != ref_currency

		if not (is_single_currency and reference_is_multi_currency):
			return allocated_amount

		allocated_amount = flt(allocated_amount / ref_exchange_rate, self.precision("total_allocated_amount"))

		return allocated_amount

	def set_status(self):
		if self.docstatus == 2:
			self.status = "Cancelled"
		elif self.docstatus == 1:
			self.status = "Submitted"
		else:
			self.status = "Draft"

		self.db_set("status", self.status, update_modified=True)

	def set_total_in_words(self):
		from frappe.utils import money_in_words

		if self.payment_type in ("Pay", "Internal Transfer"):
			base_amount = abs(self.base_paid_amount)
			amount = abs(self.paid_amount)
			currency = self.paid_from_account_currency
		elif self.payment_type == "Receive":
			base_amount = abs(self.base_received_amount)
			amount = abs(self.received_amount)
			currency = self.paid_to_account_currency

		self.base_in_words = money_in_words(base_amount, self.company_currency)
		self.in_words = money_in_words(amount, currency)

	def apply_taxes(self):
		self.initialize_taxes()
		self.determine_exclusive_rate()
		self.calculate_taxes()

	def set_amounts(self):
		self.set_received_amount()
		self.set_amounts_in_company_currency()
		self.set_total_allocated_amount()
		self.set_unallocated_amount()
		self.set_exchange_gain_loss()
		self.set_difference_amount()

	def validate_amounts(self):
		self.validate_received_amount()

	def validate_received_amount(self):
		if self.paid_from_account_currency == self.paid_to_account_currency:
			if self.paid_amount < self.received_amount:
				frappe.throw(_("Received Amount cannot be greater than Paid Amount"))

	def set_received_amount(self):
		self.base_received_amount = self.base_paid_amount
		if (
			self.paid_from_account_currency == self.paid_to_account_currency
			and self.payment_type != "Internal Transfer"
		):
			self.received_amount = self.paid_amount

	def set_amounts_after_tax(self):
		applicable_tax = 0
		base_applicable_tax = 0
		for tax in self.get("taxes"):
			if tax.included_in_paid_amount:
				continue

			amount = -1 * tax.tax_amount if tax.add_deduct_tax == "Deduct" else tax.tax_amount
			base_amount = -1 * tax.base_tax_amount if tax.add_deduct_tax == "Deduct" else tax.base_tax_amount

			applicable_tax += amount
			base_applicable_tax += base_amount

		self.paid_amount_after_tax = flt(
			flt(self.paid_amount) + flt(applicable_tax), self.precision("paid_amount_after_tax")
		)
		self.base_paid_amount_after_tax = flt(
			flt(self.paid_amount_after_tax) * flt(self.source_exchange_rate),
			self.precision("base_paid_amount_after_tax"),
		)

		self.received_amount_after_tax = flt(
			flt(self.received_amount) + flt(applicable_tax), self.precision("paid_amount_after_tax")
		)
		self.base_received_amount_after_tax = flt(
			flt(self.received_amount_after_tax) * flt(self.target_exchange_rate),
			self.precision("base_paid_amount_after_tax"),
		)

	def set_amounts_in_company_currency(self):
		self.base_paid_amount, self.base_received_amount, self.difference_amount = 0, 0, 0
		if self.paid_amount:
			self.base_paid_amount = flt(
				flt(self.paid_amount) * flt(self.source_exchange_rate), self.precision("base_paid_amount")
			)

		if self.received_amount:
			self.base_received_amount = flt(
				flt(self.received_amount) * flt(self.target_exchange_rate),
				self.precision("base_received_amount"),
			)

	def calculate_base_allocated_amount_for_reference(self, d) -> float:
		base_allocated_amount = 0
		if d.reference_doctype in get_advance_payment_doctypes():
			# When referencing Sales/Purchase Order, use the source/target exchange rate depending on payment type.
			# This is so there are no Exchange Gain/Loss generated for such doctypes

			exchange_rate = 1
			if self.payment_type == "Receive":
				exchange_rate = self.source_exchange_rate
			elif self.payment_type == "Pay":
				exchange_rate = self.target_exchange_rate

			base_allocated_amount += flt(
				flt(d.allocated_amount) * flt(exchange_rate), self.precision("base_paid_amount")
			)
		else:
			# Use source/target exchange rate, so no difference amount is calculated.
			# then update exchange gain/loss amount in reference table
			# if there is an exchange gain/loss amount in reference table, submit a JE for that

			exchange_rate = 1
			if self.payment_type == "Receive":
				exchange_rate = self.source_exchange_rate
			elif self.payment_type == "Pay":
				exchange_rate = self.target_exchange_rate

			base_allocated_amount += flt(
				flt(d.allocated_amount) * flt(exchange_rate), self.precision("base_paid_amount")
			)

			# on rare case, when `exchange_rate` is unset, gain/loss amount is incorrectly calculated
			# for base currency transactions
			if d.exchange_rate is None:
				d.exchange_rate = 1

			allocated_amount_in_ref_exchange_rate = flt(
				flt(d.allocated_amount) * flt(d.exchange_rate), self.precision("base_paid_amount")
			)
			d.exchange_gain_loss = base_allocated_amount - allocated_amount_in_ref_exchange_rate
		return base_allocated_amount

	def set_total_allocated_amount(self):
		if self.payment_type == "Internal Transfer":
			return

		total_allocated_amount, base_total_allocated_amount = 0, 0
		for d in self.get("references"):
			if d.allocated_amount:
				total_allocated_amount += flt(d.allocated_amount)
				base_total_allocated_amount += self.calculate_base_allocated_amount_for_reference(d)

		self.total_allocated_amount = abs(total_allocated_amount)
		self.base_total_allocated_amount = abs(base_total_allocated_amount)

	def set_unallocated_amount(self):
		self.unallocated_amount = 0
		if not self.party:
			return

		deductions_to_consider = sum(
			flt(d.amount) for d in self.get("deductions") if not d.is_exchange_gain_loss
		)
		included_taxes = self.get_included_taxes()

		if self.payment_type == "Receive" and self.base_total_allocated_amount < (
			self.base_paid_amount + deductions_to_consider
		):
			self.unallocated_amount = (
				self.base_paid_amount
				+ deductions_to_consider
				- self.base_total_allocated_amount
				- included_taxes
			) / self.source_exchange_rate
		elif self.payment_type == "Pay" and self.base_total_allocated_amount < (
			self.base_received_amount - deductions_to_consider
		):
			self.unallocated_amount = (
				self.base_received_amount
				- deductions_to_consider
				- self.base_total_allocated_amount
				- included_taxes
			) / self.target_exchange_rate

	def set_exchange_gain_loss(self):
		exchange_gain_loss = flt(
			self.base_paid_amount - self.base_received_amount,
			self.precision("amount", "deductions"),
		)

		exchange_gain_loss_rows = [row for row in self.get("deductions") if row.is_exchange_gain_loss]
		exchange_gain_loss_row = exchange_gain_loss_rows.pop(0) if exchange_gain_loss_rows else None

		for row in exchange_gain_loss_rows:
			self.remove(row)

		if not exchange_gain_loss:
			if exchange_gain_loss_row:
				self.remove(exchange_gain_loss_row)

			return

		if not exchange_gain_loss_row:
			values = frappe.get_cached_value(
				"Company", self.company, ("exchange_gain_loss_account", "cost_center"), as_dict=True
			)

			for fieldname, value in values.items():
				if value:
					continue

				label = _(frappe.get_meta("Company").get_label(fieldname))
				return frappe.msgprint(
					_("Please set {0} in Company {1} to account for Exchange Gain / Loss").format(
						label, get_link_to_form("Company", self.company)
					),
					title=_("Missing Default in Company"),
					indicator="red" if self.docstatus.is_submitted() else "yellow",
					raise_exception=self.docstatus.is_submitted(),
				)

			exchange_gain_loss_row = self.append(
				"deductions",
				{
					"account": values.exchange_gain_loss_account,
					"cost_center": values.cost_center,
					"is_exchange_gain_loss": 1,
				},
			)

		exchange_gain_loss_row.amount = exchange_gain_loss

	def set_difference_amount(self):
		base_unallocated_amount = flt(self.unallocated_amount) * (
			flt(self.source_exchange_rate)
			if self.payment_type == "Receive"
			else flt(self.target_exchange_rate)
		)

		base_party_amount = flt(self.base_total_allocated_amount) + flt(base_unallocated_amount)
		included_taxes = self.get_included_taxes()

		if self.payment_type == "Receive":
			self.difference_amount = base_party_amount - self.base_received_amount + included_taxes
		elif self.payment_type == "Pay":
			self.difference_amount = self.base_paid_amount - base_party_amount - included_taxes
		else:
			self.difference_amount = self.base_paid_amount - flt(self.base_received_amount) - included_taxes

		total_deductions = sum(flt(d.amount) for d in self.get("deductions"))

		self.difference_amount = flt(
			self.difference_amount - total_deductions, self.precision("difference_amount")
		)

	def get_included_taxes(self):
		included_taxes = 0
		for tax in self.get("taxes"):
			if not tax.included_in_paid_amount:
				continue

			if tax.add_deduct_tax == "Add":
				included_taxes += tax.base_tax_amount
			else:
				included_taxes -= tax.base_tax_amount

		return included_taxes

	# Paid amount is auto allocated in the reference document by default.
	# Clear the reference document which doesn't have allocated amount on validate so that form can be loaded fast
	def clear_unallocated_reference_document_rows(self):
		self.set("references", self.get("references", {"allocated_amount": ["not in", [0, None, ""]]}))
		frappe.db.sql(
			"""delete from `tabPayment Entry Reference`
			where parent = %s and allocated_amount = 0""",
			self.name,
		)

	def set_title(self):
		if frappe.flags.in_import and self.title:
			# do not set title dynamically if title exists during data import.
			return

		if self.payment_type in ("Receive", "Pay"):
			self.title = self.party
		else:
			self.title = self.paid_from + " - " + self.paid_to

	def validate_transaction_reference(self):
		bank_account = self.paid_to if self.payment_type == "Receive" else self.paid_from
		bank_account_type = frappe.get_cached_value("Account", bank_account, "account_type")

		if bank_account_type == "Bank":
			if not self.reference_no or not self.reference_date:
				frappe.throw(_("Reference No and Reference Date is mandatory for Bank transaction"))

	def set_remarks(self):
		if self.custom_remarks:
			return

		if self.payment_type == "Internal Transfer":
			remarks = [
				_("Amount {0} {1} transferred from {2} to {3}").format(
					_(self.paid_from_account_currency), self.paid_amount, self.paid_from, self.paid_to
				)
			]
		else:
			remarks = [
				_("Amount {0} {1} {2} {3}").format(
					_(self.paid_to_account_currency)
					if self.payment_type == "Receive"
					else _(self.paid_from_account_currency),
					self.paid_amount if self.payment_type == "Receive" else self.received_amount,
					_("received from") if self.payment_type == "Receive" else _("paid to"),
					self.party,
				)
			]

		if self.reference_no:
			remarks.append(
				_("Transaction reference no {0} dated {1}").format(self.reference_no, self.reference_date)
			)

		if self.payment_type in ["Receive", "Pay"]:
			for d in self.get("references"):
				if d.allocated_amount:
					remarks.append(
						_("Amount {0} {1} against {2} {3}").format(
							_(self.party_account_currency),
							d.allocated_amount,
							d.reference_doctype,
							d.reference_name,
						)
					)

		for d in self.get("deductions"):
			if d.amount:
				remarks.append(
					_("Amount {0} {1} deducted against {2}").format(
						_(self.company_currency), d.amount, d.account
					)
				)

		self.set("remarks", "\n".join(remarks))

	def set_transaction_currency_and_rate(self):
		company_currency = erpnext.get_company_currency(self.company)
		self.transaction_currency = company_currency
		self.transaction_exchange_rate = 1

		if self.paid_from_account_currency != company_currency:
			self.transaction_currency = self.paid_from_account_currency
			self.transaction_exchange_rate = self.source_exchange_rate
		elif self.paid_to_account_currency != company_currency:
			self.transaction_currency = self.paid_to_account_currency
			self.transaction_exchange_rate = self.target_exchange_rate

	def build_gl_map(self):
		if self.payment_type in ("Receive", "Pay") and not self.get("party_account_field"):
			self.setup_party_account_field()
		self.set_transaction_currency_and_rate()

		gl_entries = []
		self.add_party_gl_entries(gl_entries)
		self.add_bank_gl_entries(gl_entries)
		self.add_deductions_gl_entries(gl_entries)
		self.add_tax_gl_entries(gl_entries)
		add_regional_gl_entries(gl_entries, self)
		return gl_entries

	def make_gl_entries(self, cancel=0, adv_adj=0):
		gl_entries = self.build_gl_map()
		gl_entries = process_gl_map(gl_entries)
		make_gl_entries(gl_entries, cancel=cancel, adv_adj=adv_adj)
		if cancel:
			cancel_exchange_gain_loss_journal(frappe._dict(doctype=self.doctype, name=self.name))
		else:
			self.make_exchange_gain_loss_journal()

		self.make_advance_gl_entries(cancel=cancel)

	def add_party_gl_entries(self, gl_entries):
		if not self.party_account:
			return

		advance_payment_doctypes = get_advance_payment_doctypes()
		if self.payment_type == "Receive":
			against_account = self.paid_to
		else:
			against_account = self.paid_from

		party_account_type = frappe.db.get_value("Party Type", self.party_type, "account_type")

		party_gl_dict = self.get_gl_dict(
			{
				"account": self.party_account,
				"party_type": self.party_type,
				"party": self.party,
				"against": against_account,
				"account_currency": self.party_account_currency,
				"cost_center": self.cost_center,
			},
			item=self,
		)

		for d in self.get("references"):
			# re-defining dr_or_cr for every reference in order to avoid the last value affecting calculation of reverse
			dr_or_cr = "credit" if self.payment_type == "Receive" else "debit"
			cost_center = self.cost_center
			if d.reference_doctype == "Sales Invoice" and not cost_center:
				cost_center = frappe.db.get_value(d.reference_doctype, d.reference_name, "cost_center")

			gle = party_gl_dict.copy()

			allocated_amount_in_company_currency = self.calculate_base_allocated_amount_for_reference(d)

			if (
				d.reference_doctype in ["Sales Invoice", "Purchase Invoice"]
				and d.allocated_amount < 0
				and (
					(party_account_type == "Receivable" and self.payment_type == "Pay")
					or (party_account_type == "Payable" and self.payment_type == "Receive")
				)
			):
				# reversing dr_cr because because it will get reversed in gl processing due to negative amount
				dr_or_cr = "debit" if dr_or_cr == "credit" else "credit"

			gle.update(
				self.get_gl_dict(
					{
						"account": self.party_account,
						"party_type": self.party_type,
						"party": self.party,
						"against": against_account,
						"account_currency": self.party_account_currency,
						"cost_center": cost_center,
						dr_or_cr + "_in_account_currency": d.allocated_amount,
						dr_or_cr: allocated_amount_in_company_currency,
						dr_or_cr + "_in_transaction_currency": d.allocated_amount
						if self.transaction_currency == self.party_account_currency
						else allocated_amount_in_company_currency / self.transaction_exchange_rate,
						"advance_voucher_type": d.advance_voucher_type,
						"advance_voucher_no": d.advance_voucher_no,
						"transaction_exchange_rate": self.target_exchange_rate,
					},
					item=self,
				)
			)

			if d.reference_doctype in advance_payment_doctypes:
				# advance reference
				gle.update(
					{
						"against_voucher_type": self.doctype,
						"against_voucher": self.name,
						"advance_voucher_type": d.reference_doctype,
						"advance_voucher_no": d.reference_name,
					}
				)

			elif self.book_advance_payments_in_separate_party_account:
				# Do not reference Invoices while Advance is in separate party account
				gle.update({"against_voucher_type": self.doctype, "against_voucher": self.name})
			else:
				gle.update(
					{
						"against_voucher_type": d.reference_doctype,
						"against_voucher": d.reference_name,
					}
				)

			gl_entries.append(gle)

		if self.unallocated_amount:
			dr_or_cr = "credit" if self.payment_type == "Receive" else "debit"
			exchange_rate = self.get_exchange_rate()
			base_unallocated_amount = self.unallocated_amount * exchange_rate

			gle = party_gl_dict.copy()

			gle.update(
				self.get_gl_dict(
					{
						"account": self.party_account,
						"party_type": self.party_type,
						"party": self.party,
						"against": against_account,
						"account_currency": self.party_account_currency,
						"cost_center": self.cost_center,
						dr_or_cr + "_in_account_currency": self.unallocated_amount,
						dr_or_cr + "_in_transaction_currency": self.unallocated_amount
						if self.party_account_currency == self.transaction_currency
						else base_unallocated_amount / self.transaction_exchange_rate,
						dr_or_cr: base_unallocated_amount,
					},
					item=self,
				)
			)
			if self.book_advance_payments_in_separate_party_account:
				gle.update(
					{
						"against_voucher_type": "Payment Entry",
						"against_voucher": self.name,
					}
				)
			gl_entries.append(gle)

	def make_advance_gl_entries(
		self, entry: object | dict = None, cancel: bool = 0, update_outstanding: str = "Yes"
	):
		self.set_transaction_currency_and_rate()
		gl_entries = []
		self.add_advance_gl_entries(gl_entries, entry)

		if cancel:
			make_reverse_gl_entries(gl_entries, partial_cancel=True)
		else:
			make_gl_entries(gl_entries, update_outstanding=update_outstanding)

	def add_advance_gl_entries(self, gl_entries: list, entry: object | dict | None):
		"""
		If 'entry' is passed, GL entries only for that reference is added.
		"""
		if self.book_advance_payments_in_separate_party_account:
			references = [x for x in self.get("references")]
			if entry:
				references = [x for x in self.get("references") if x.name == entry.name]

			for ref in references:
				if ref.reference_doctype in (
					"Sales Invoice",
					"Purchase Invoice",
					"Journal Entry",
					"Payment Entry",
				):
					self.add_advance_gl_for_reference(gl_entries, ref)

	def get_dr_and_account_for_advances(self, reference):
		if reference.reference_doctype == "Sales Invoice":
			return "credit", reference.account

		if reference.reference_doctype == "Purchase Invoice":
			return "debit", reference.account

		if reference.reference_doctype == "Payment Entry":
			# reference.account_type and reference.payment_type is only available for Reverse payments
			if reference.account_type == "Receivable" and reference.payment_type == "Pay":
				return "credit", self.party_account
			else:
				return "debit", self.party_account

		if reference.reference_doctype == "Journal Entry":
			if self.party_type == "Customer" and self.payment_type == "Receive":
				return "credit", reference.account
			else:
				return "debit", reference.account

	def add_advance_gl_for_reference(self, gl_entries, invoice):
		args_dict = {
			"party_type": self.party_type,
			"party": self.party,
			"account_currency": self.party_account_currency,
			"cost_center": self.cost_center,
			"voucher_type": "Payment Entry",
			"voucher_no": self.name,
			"voucher_detail_no": invoice.name,
		}
		if invoice.reconcile_effect_on:
			posting_date = invoice.reconcile_effect_on
		else:
			# For backwards compatibility
			# Supporting reposting on payment entries reconciled before select field introduction
			posting_date = get_reconciliation_effect_date(
				invoice.reference_doctype, invoice.reference_name, self.company, self.posting_date
			)
			frappe.db.set_value("Payment Entry Reference", invoice.name, "reconcile_effect_on", posting_date)

		dr_or_cr, account = self.get_dr_and_account_for_advances(invoice)
		base_allocated_amount = self.calculate_base_allocated_amount_for_reference(invoice)
		args_dict["account"] = account
		args_dict[dr_or_cr] = base_allocated_amount
		args_dict[dr_or_cr + "_in_account_currency"] = invoice.allocated_amount
		args_dict[dr_or_cr + "_in_transaction_currency"] = (
			invoice.allocated_amount
			if self.party_account_currency == self.transaction_currency
			else base_allocated_amount / self.transaction_exchange_rate
		)

		args_dict.update(
			{
				"against_voucher_type": invoice.reference_doctype,
				"against_voucher": invoice.reference_name,
				"advance_voucher_type": invoice.advance_voucher_type,
				"advance_voucher_no": invoice.advance_voucher_no,
				"posting_date": posting_date,
			}
		)
		gle = self.get_gl_dict(
			args_dict,
			item=self,
		)
		gl_entries.append(gle)

		args_dict[dr_or_cr] = 0
		args_dict[dr_or_cr + "_in_account_currency"] = 0
		dr_or_cr = "debit" if dr_or_cr == "credit" else "credit"
		args_dict["account"] = self.party_account
		args_dict[dr_or_cr] = base_allocated_amount
		args_dict[dr_or_cr + "_in_account_currency"] = invoice.allocated_amount
		args_dict[dr_or_cr + "_in_transaction_currency"] = (
			invoice.allocated_amount
			if self.party_account_currency == self.transaction_currency
			else base_allocated_amount / self.transaction_exchange_rate
		)
		args_dict.update(
			{
				"against_voucher_type": "Payment Entry",
				"against_voucher": self.name,
				"advance_voucher_type": invoice.advance_voucher_type,
				"advance_voucher_no": invoice.advance_voucher_no,
			}
		)
		gle = self.get_gl_dict(
			args_dict,
			item=self,
		)
		gl_entries.append(gle)

	def add_bank_gl_entries(self, gl_entries):
		if self.payment_type in ("Pay", "Internal Transfer"):
			gl_entries.append(
				self.get_gl_dict(
					{
						"account": self.paid_from,
						"account_currency": self.paid_from_account_currency,
						"against": self.party if self.payment_type == "Pay" else self.paid_to,
						"credit_in_account_currency": self.paid_amount,
						"credit_in_transaction_currency": self.paid_amount
						if self.paid_from_account_currency == self.transaction_currency
						else self.base_paid_amount / self.transaction_exchange_rate,
						"credit": self.base_paid_amount,
						"cost_center": self.cost_center,
						"post_net_value": True,
					},
					item=self,
				)
			)
		if self.payment_type in ("Receive", "Internal Transfer"):
			gl_entries.append(
				self.get_gl_dict(
					{
						"account": self.paid_to,
						"account_currency": self.paid_to_account_currency,
						"against": self.party if self.payment_type == "Receive" else self.paid_from,
						"debit_in_account_currency": self.received_amount,
						"debit_in_transaction_currency": self.received_amount
						if self.paid_to_account_currency == self.transaction_currency
						else self.base_received_amount / self.transaction_exchange_rate,
						"debit": self.base_received_amount,
						"cost_center": self.cost_center,
					},
					item=self,
				)
			)

	def add_tax_gl_entries(self, gl_entries):
		for d in self.get("taxes"):
			account_currency = get_account_currency(d.account_head)
			if account_currency != self.company_currency:
				frappe.throw(_("Currency for {0} must be {1}").format(d.account_head, self.company_currency))

			if self.payment_type in ("Pay", "Internal Transfer"):
				dr_or_cr = "debit" if d.add_deduct_tax == "Add" else "credit"
				rev_dr_or_cr = "credit" if dr_or_cr == "debit" else "debit"
				against = self.party or self.paid_from
			elif self.payment_type == "Receive":
				dr_or_cr = "credit" if d.add_deduct_tax == "Add" else "debit"
				rev_dr_or_cr = "credit" if dr_or_cr == "debit" else "debit"
				against = self.party or self.paid_to

			payment_account = self.get_party_account_for_taxes()
			tax_amount = d.tax_amount
			base_tax_amount = d.base_tax_amount

			gl_entries.append(
				self.get_gl_dict(
					{
						"account": d.account_head,
						"against": against,
						dr_or_cr: tax_amount,
						dr_or_cr + "_in_account_currency": base_tax_amount
						if account_currency == self.company_currency
						else d.tax_amount,
						dr_or_cr + "_in_transaction_currency": base_tax_amount
						/ self.transaction_exchange_rate,
						"cost_center": d.cost_center,
						"post_net_value": True,
					},
					account_currency,
					item=d,
				)
			)

			if not d.included_in_paid_amount:
				if get_account_currency(payment_account) != self.company_currency:
					if self.payment_type == "Receive":
						exchange_rate = self.target_exchange_rate
					elif self.payment_type in ["Pay", "Internal Transfer"]:
						exchange_rate = self.source_exchange_rate
					base_tax_amount = flt((tax_amount / exchange_rate), self.precision("paid_amount"))

				gl_entries.append(
					self.get_gl_dict(
						{
							"account": payment_account,
							"against": against,
							rev_dr_or_cr: tax_amount,
							rev_dr_or_cr + "_in_account_currency": base_tax_amount
							if account_currency == self.company_currency
							else d.tax_amount,
							rev_dr_or_cr + "_in_transaction_currency": base_tax_amount
							/ self.transaction_exchange_rate,
							"cost_center": self.cost_center,
							"post_net_value": True,
						},
						account_currency,
						item=d,
					)
				)

	def add_deductions_gl_entries(self, gl_entries):
		for d in self.get("deductions"):
			if not d.amount:
				continue

			account_currency = get_account_currency(d.account)
			if account_currency != self.company_currency:
				frappe.throw(_("Currency for {0} must be {1}").format(d.account, self.company_currency))

			gl_entries.append(
				self.get_gl_dict(
					{
						"account": d.account,
						"account_currency": account_currency,
						"against": self.party or self.paid_from,
						"debit_in_account_currency": d.amount,
						"debit_in_transaction_currency": d.amount / self.transaction_exchange_rate,
						"debit": d.amount,
						"cost_center": d.cost_center,
					},
					item=d,
				)
			)

	def get_party_account_for_taxes(self):
		if self.payment_type == "Receive":
			return self.paid_to
		elif self.payment_type in ("Pay", "Internal Transfer"):
			return self.paid_from

	def get_value_in_transaction_currency(self, account_currency, gl_dict, field):
		company_currency = erpnext.get_company_currency(self.company)
		conversion_rate = self.target_exchange_rate
		if self.paid_from_account_currency != company_currency:
			conversion_rate = self.source_exchange_rate

		return flt(gl_dict.get(field, 0) / (conversion_rate or 1))

	def on_recurring(self, reference_doc, auto_repeat_doc):
		self.reference_no = reference_doc.name
		self.reference_date = nowdate()

	def calculate_deductions(self, tax_details):
		return {
			"account": tax_details["tax"]["account_head"],
			"cost_center": frappe.get_cached_value("Company", self.company, "cost_center"),
			"amount": self.total_allocated_amount * (tax_details["tax"]["rate"] / 100),
		}

	def set_gain_or_loss(self, account_details=None):
		if not self.difference_amount:
			self.set_difference_amount()

		row = {"amount": self.difference_amount}

		if account_details:
			row.update(account_details)

		if not row.get("amount"):
			# if no difference amount
			return

		self.append("deductions", row)
		self.set_unallocated_amount()

	def get_exchange_rate(self):
		return self.source_exchange_rate if self.payment_type == "Receive" else self.target_exchange_rate

	def initialize_taxes(self):
		for tax in self.get("taxes"):
			validate_taxes_and_charges(tax)
			validate_inclusive_tax(tax, self)

			tax_fields = ["total", "tax_fraction_for_current_item", "grand_total_fraction_for_current_item"]

			if tax.charge_type != "Actual":
				tax_fields.append("tax_amount")

			for fieldname in tax_fields:
				tax.set(fieldname, 0.0)

		self.paid_amount_after_tax = self.base_paid_amount

	def determine_exclusive_rate(self):
		if not any(cint(tax.included_in_paid_amount) for tax in self.get("taxes")):
			return

		cumulated_tax_fraction = 0
		for i, tax in enumerate(self.get("taxes")):
			tax.tax_fraction_for_current_item = self.get_current_tax_fraction(tax)
			if i == 0:
				tax.grand_total_fraction_for_current_item = 1 + tax.tax_fraction_for_current_item
			else:
				tax.grand_total_fraction_for_current_item = (
					self.get("taxes")[i - 1].grand_total_fraction_for_current_item
					+ tax.tax_fraction_for_current_item
				)

			cumulated_tax_fraction += tax.tax_fraction_for_current_item

		self.paid_amount_after_tax = flt(self.base_paid_amount / (1 + cumulated_tax_fraction))

	def calculate_taxes(self):
		self.total_taxes_and_charges = 0.0
		self.base_total_taxes_and_charges = 0.0

		actual_tax_dict = dict(
			[
				[tax.idx, flt(tax.tax_amount, tax.precision("tax_amount"))]
				for tax in self.get("taxes")
				if tax.charge_type == "Actual"
			]
		)

		for i, tax in enumerate(self.get("taxes")):
			current_tax_amount = self.get_current_tax_amount(tax)

			if tax.charge_type == "Actual":
				actual_tax_dict[tax.idx] -= current_tax_amount
				if i == len(self.get("taxes")) - 1:
					current_tax_amount += actual_tax_dict[tax.idx]

			tax.tax_amount = current_tax_amount
			tax.base_tax_amount = current_tax_amount

			if tax.add_deduct_tax == "Deduct":
				current_tax_amount *= -1.0
			else:
				current_tax_amount *= 1.0

			if i == 0:
				tax.total = flt(self.paid_amount_after_tax + current_tax_amount, self.precision("total", tax))
			else:
				tax.total = flt(
					self.get("taxes")[i - 1].total + current_tax_amount, self.precision("total", tax)
				)

			tax.base_total = tax.total

			if self.payment_type == "Pay":
				if tax.currency != self.paid_to_account_currency:
					self.total_taxes_and_charges += flt(current_tax_amount / self.target_exchange_rate)
				else:
					self.total_taxes_and_charges += current_tax_amount
			elif self.payment_type == "Receive":
				if tax.currency != self.paid_from_account_currency:
					self.total_taxes_and_charges += flt(current_tax_amount / self.source_exchange_rate)
				else:
					self.total_taxes_and_charges += current_tax_amount

			self.base_total_taxes_and_charges += current_tax_amount

		if self.get("taxes"):
			self.paid_amount_after_tax = self.get("taxes")[-1].base_total

	def get_current_tax_amount(self, tax):
		tax_rate = tax.rate

		# To set row_id by default as previous row.
		if tax.charge_type in ["On Previous Row Amount", "On Previous Row Total"]:
			if tax.idx == 1:
				frappe.throw(
					_(
						"Cannot select charge type as 'On Previous Row Amount' or 'On Previous Row Total' for first row"
					)
				)

			if not tax.row_id:
				tax.row_id = tax.idx - 1

		if tax.charge_type == "Actual":
			current_tax_amount = flt(tax.tax_amount, self.precision("tax_amount", tax))
		elif tax.charge_type == "On Paid Amount":
			current_tax_amount = (tax_rate / 100.0) * self.paid_amount_after_tax
		elif tax.charge_type == "On Previous Row Amount":
			current_tax_amount = (tax_rate / 100.0) * self.get("taxes")[cint(tax.row_id) - 1].tax_amount

		elif tax.charge_type == "On Previous Row Total":
			current_tax_amount = (tax_rate / 100.0) * self.get("taxes")[cint(tax.row_id) - 1].total

		return current_tax_amount

	def get_current_tax_fraction(self, tax):
		current_tax_fraction = 0

		if cint(tax.included_in_paid_amount):
			tax_rate = tax.rate

			if tax.charge_type == "On Paid Amount":
				current_tax_fraction = tax_rate / 100.0
			elif tax.charge_type == "On Previous Row Amount":
				current_tax_fraction = (tax_rate / 100.0) * self.get("taxes")[
					cint(tax.row_id) - 1
				].tax_fraction_for_current_item
			elif tax.charge_type == "On Previous Row Total":
				current_tax_fraction = (tax_rate / 100.0) * self.get("taxes")[
					cint(tax.row_id) - 1
				].grand_total_fraction_for_current_item

		if getattr(tax, "add_deduct_tax", None) and tax.add_deduct_tax == "Deduct":
			current_tax_fraction *= -1.0

		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 = flt(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", "Customer"):
			if paid_amount > total_negative_outstanding:
				if total_negative_outstanding == 0:
					frappe.msgprint(
						_("Cannot {0} from {1} 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.reference_doctype and ref.reference_name and 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 or not (ref.reference_doctype and ref.reference_name):
					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):
		throw(
			_("To include tax in row {0} in Item rate, taxes in rows {1} must also be included").format(
				tax.idx, row_range
			)
		)

	if cint(getattr(tax, "included_in_paid_amount", None)):
		if tax.charge_type == "Actual":
			# inclusive tax cannot be of type Actual
			throw(
				_("Charge of type 'Actual' in row {0} cannot be included in Item Rate or Paid Amount").format(
					tax.idx
				)
			)
		elif tax.charge_type == "On Previous Row Amount" and not cint(
			doc.get("taxes")[cint(tax.row_id) - 1].included_in_paid_amount
		):
			# referred row should also be inclusive
			_on_previous_row_error(tax.row_id)
		elif tax.charge_type == "On Previous Row Total" and not all(
			[cint(t.included_in_paid_amount for t in doc.get("taxes")[: cint(tax.row_id) - 1])]
		):
			# all rows about the referred tax should be inclusive
			_on_previous_row_error("1 - %d" % (cint(tax.row_id),))
		elif tax.get("category") == "Valuation":
			frappe.throw(_("Valuation type charges can not be marked as Inclusive"))


@frappe.whitelist()
def get_outstanding_reference_documents(args, validate=False):
	if isinstance(args, str):
		args = json.loads(args)

	if args.get("party_type") == "Member":
		return

	if not args.get("get_outstanding_invoices") and not args.get("get_orders_to_be_billed"):
		args["get_outstanding_invoices"] = True

	ple = qb.DocType("Payment Ledger Entry")
	common_filter = []
	accounting_dimensions_filter = []
	posting_and_due_date = []

	# confirm that Supplier is not blocked
	if args.get("party_type") == "Supplier":
		supplier_status = get_supplier_block_status(args["party"])
		if supplier_status["on_hold"]:
			if supplier_status["hold_type"] == "All":
				return []
			elif supplier_status["hold_type"] == "Payments":
				if (
					not supplier_status["release_date"]
					or getdate(nowdate()) <= supplier_status["release_date"]
				):
					return []

	party_account_currency = get_account_currency(args.get("party_account"))
	company_currency = frappe.get_cached_value("Company", args.get("company"), "default_currency")

	# Get positive outstanding sales /purchase invoices
	condition = ""
	if args.get("voucher_type") and args.get("voucher_no"):
		condition = " and voucher_type={} and voucher_no={}".format(
			frappe.db.escape(args["voucher_type"]), frappe.db.escape(args["voucher_no"])
		)
		common_filter.append(ple.voucher_type == args["voucher_type"])
		common_filter.append(ple.voucher_no == args["voucher_no"])

	# Add cost center condition
	if args.get("cost_center"):
		condition += " and cost_center='%s'" % args.get("cost_center")
		accounting_dimensions_filter.append(ple.cost_center == args.get("cost_center"))

	# dynamic dimension filters
	active_dimensions = get_dimensions()[0]
	for dim in active_dimensions:
		if args.get(dim.fieldname):
			condition += f" and {dim.fieldname}='{args.get(dim.fieldname)}'"
			accounting_dimensions_filter.append(ple[dim.fieldname] == args.get(dim.fieldname))

	date_fields_dict = {
		"posting_date": ["from_posting_date", "to_posting_date"],
		"due_date": ["from_due_date", "to_due_date"],
	}

	for fieldname, date_fields in date_fields_dict.items():
		if args.get(date_fields[0]) and args.get(date_fields[1]):
			condition += " and {} between '{}' and '{}'".format(
				fieldname, args.get(date_fields[0]), args.get(date_fields[1])
			)
			posting_and_due_date.append(ple[fieldname][args.get(date_fields[0]) : args.get(date_fields[1])])
		elif args.get(date_fields[0]):
			# if only from date is supplied
			condition += f" and {fieldname} >= '{args.get(date_fields[0])}'"
			posting_and_due_date.append(ple[fieldname].gte(args.get(date_fields[0])))
		elif args.get(date_fields[1]):
			# if only to date is supplied
			condition += f" and {fieldname} <= '{args.get(date_fields[1])}'"
			posting_and_due_date.append(ple[fieldname].lte(args.get(date_fields[1])))

	if args.get("company"):
		condition += " and company = {}".format(frappe.db.escape(args.get("company")))
		common_filter.append(ple.company == args.get("company"))

	outstanding_invoices = []
	negative_outstanding_invoices = []

	party_account = args.get("party_account")

	# get party account if advance account is set.
	if args.get("book_advance_payments_in_separate_party_account"):
		accounts = get_party_account(
			args.get("party_type"), args.get("party"), args.get("company"), include_advance=True
		)
		advance_account = accounts[1] if len(accounts) > 1 else None

		if party_account == advance_account:
			party_account = accounts[0]

	if args.get("get_outstanding_invoices"):
		outstanding_invoices = get_outstanding_invoices(
			args.get("party_type"),
			args.get("party"),
			[party_account],
			common_filter=common_filter,
			posting_date=posting_and_due_date,
			min_outstanding=args.get("outstanding_amt_greater_than"),
			max_outstanding=args.get("outstanding_amt_less_than"),
			accounting_dimensions=accounting_dimensions_filter,
			vouchers=args.get("vouchers") or None,
		)

		outstanding_invoices = split_invoices_based_on_payment_terms(
			outstanding_invoices, args.get("company")
		)

		for d in outstanding_invoices:
			d["exchange_rate"] = 1
			if party_account_currency != company_currency:
				if d.voucher_type in frappe.get_hooks("invoice_doctypes"):
					d["exchange_rate"] = frappe.db.get_value(d.voucher_type, d.voucher_no, "conversion_rate")
				elif d.voucher_type == "Journal Entry":
					d["exchange_rate"] = get_exchange_rate(
						party_account_currency, company_currency, d.posting_date
					)
			if d.voucher_type in ("Purchase Invoice"):
				d["bill_no"] = frappe.db.get_value(d.voucher_type, d.voucher_no, "bill_no")

		# Get negative outstanding sales /purchase invoices
		if args.get("party_type") != "Employee":
			negative_outstanding_invoices = get_negative_outstanding_invoices(
				args.get("party_type"),
				args.get("party"),
				args.get("party_account"),
				party_account_currency,
				company_currency,
				condition=condition,
			)

	# Get all SO / PO which are not fully billed or against which full advance not paid
	orders_to_be_billed = []
	if args.get("get_orders_to_be_billed"):
		orders_to_be_billed = get_orders_to_be_billed(
			args.get("posting_date"),
			args.get("party_type"),
			args.get("party"),
			args.get("company"),
			party_account_currency,
			company_currency,
			filters=args,
		)

	data = negative_outstanding_invoices + outstanding_invoices + orders_to_be_billed

	if not data:
		if args.get("get_outstanding_invoices") and args.get("get_orders_to_be_billed"):
			ref_document_type = "invoices or orders"
		elif args.get("get_outstanding_invoices"):
			ref_document_type = "invoices"
		elif args.get("get_orders_to_be_billed"):
			ref_document_type = "orders"

		if not validate:
			frappe.msgprint(
				_(
					"No outstanding {0} found for the {1} {2} which qualify the filters you have specified."
				).format(
					_(ref_document_type), _(args.get("party_type")).lower(), frappe.bold(args.get("party"))
				)
			)

	return data


def split_invoices_based_on_payment_terms(outstanding_invoices, company) -> list:
	"""Split a list of invoices based on their payment terms."""
	exc_rates = get_currency_data(outstanding_invoices, company)

	outstanding_invoices_after_split = []
	for entry in outstanding_invoices:
		if entry.voucher_type in ["Sales Invoice", "Purchase Invoice"]:
			if payment_term_template := frappe.db.get_value(
				entry.voucher_type, entry.voucher_no, "payment_terms_template"
			):
				split_rows = get_split_invoice_rows(entry, payment_term_template, exc_rates)
				if not split_rows:
					continue

				if len(split_rows) > 1:
					frappe.msgprint(
						_("Splitting {0} {1} into {2} rows as per Payment Terms").format(
							_(entry.voucher_type), frappe.bold(entry.voucher_no), len(split_rows)
						),
						alert=True,
					)
				outstanding_invoices_after_split += split_rows
				continue

		# If not an invoice or no payment terms template, add as it is
		outstanding_invoices_after_split.append(entry)

	return outstanding_invoices_after_split


def get_currency_data(outstanding_invoices: list, company: str | None = None) -> dict:
	"""Get currency and conversion data for a list of invoices."""
	exc_rates = frappe._dict()
	company_currency = frappe.db.get_value("Company", company, "default_currency") if company else None

	for doctype in ["Sales Invoice", "Purchase Invoice"]:
		invoices = [x.voucher_no for x in outstanding_invoices if x.voucher_type == doctype]
		for x in frappe.db.get_all(
			doctype,
			filters={"name": ["in", invoices]},
			fields=["name", "currency", "conversion_rate", "party_account_currency"],
		):
			exc_rates[x.name] = frappe._dict(
				conversion_rate=x.conversion_rate,
				currency=x.currency,
				party_account_currency=x.party_account_currency,
				company_currency=company_currency,
			)

	return exc_rates


def get_split_invoice_rows(invoice: dict, payment_term_template: str, exc_rates: dict) -> list:
	"""Split invoice based on its payment schedule table."""
	split_rows = []
	allocate_payment_based_on_payment_terms = frappe.db.get_value(
		"Payment Terms Template", payment_term_template, "allocate_payment_based_on_payment_terms"
	)

	if not allocate_payment_based_on_payment_terms:
		return [invoice]

	payment_schedule = frappe.get_all(
		"Payment Schedule", filters={"parent": invoice.voucher_no}, fields=["*"], order_by="due_date"
	)
	for payment_term in payment_schedule:
		if payment_term.outstanding <= 0.1:
			continue

		doc_details = exc_rates.get(payment_term.parent, None)
		is_multi_currency_acc = (doc_details.currency != doc_details.company_currency) and (
			doc_details.party_account_currency != doc_details.company_currency
		)
		payment_term_outstanding = flt(payment_term.outstanding)
		if not is_multi_currency_acc:
			payment_term_outstanding = doc_details.conversion_rate * flt(payment_term.outstanding)

		split_rows.append(
			frappe._dict(
				{
					"due_date": invoice.due_date,
					"currency": invoice.currency,
					"voucher_no": invoice.voucher_no,
					"voucher_type": invoice.voucher_type,
					"posting_date": invoice.posting_date,
					"invoice_amount": flt(invoice.invoice_amount),
					"outstanding_amount": payment_term_outstanding
					if payment_term_outstanding
					else invoice.outstanding_amount,
					"payment_term_outstanding": payment_term_outstanding,
					"payment_amount": payment_term.payment_amount,
					"payment_term": payment_term.payment_term,
				}
			)
		)

	return split_rows


def get_orders_to_be_billed(
	posting_date,
	party_type,
	party,
	company,
	party_account_currency,
	company_currency,
	cost_center=None,
	filters=None,
):
	voucher_type = None
	if party_type == "Customer":
		voucher_type = "Sales Order"
	elif party_type == "Supplier":
		voucher_type = "Purchase Order"

	if not voucher_type:
		return []

	# Add cost center condition
	doc = frappe.get_doc({"doctype": voucher_type})
	condition = ""
	if doc and hasattr(doc, "cost_center") and doc.cost_center:
		condition = " and cost_center='%s'" % cost_center

	# dynamic dimension filters
	active_dimensions = get_dimensions()[0]
	for dim in active_dimensions:
		if filters.get(dim.fieldname):
			condition += f" and {dim.fieldname}='{filters.get(dim.fieldname)}'"

	if party_account_currency == company_currency:
		grand_total_field = "base_grand_total"
		rounded_total_field = "base_rounded_total"
	else:
		grand_total_field = "grand_total"
		rounded_total_field = "rounded_total"

	orders = frappe.db.sql(
		"""
		select
			name as voucher_no,
			if({rounded_total_field}, {rounded_total_field}, {grand_total_field}) as invoice_amount,
			(if({rounded_total_field}, {rounded_total_field}, {grand_total_field}) - advance_paid) as outstanding_amount,
			transaction_date as posting_date
		from
			`tab{voucher_type}`
		where
			{party_type} = %s
			and docstatus = 1
			and company = %s
			and status != "Closed"
			and if({rounded_total_field}, {rounded_total_field}, {grand_total_field}) > advance_paid
			and abs(100 - per_billed) > 0.01
			{condition}
		order by
			transaction_date, name
	""".format(
			**{
				"rounded_total_field": rounded_total_field,
				"grand_total_field": grand_total_field,
				"voucher_type": voucher_type,
				"party_type": scrub(party_type),
				"condition": condition,
			}
		),
		(party, company),
		as_dict=True,
	)

	order_list = []
	for d in orders:
		if (
			filters
			and filters.get("outstanding_amt_greater_than")
			and filters.get("outstanding_amt_less_than")
			and not (
				flt(filters.get("outstanding_amt_greater_than"))
				<= flt(d.outstanding_amount)
				<= flt(filters.get("outstanding_amt_less_than"))
			)
		):
			continue

		d["voucher_type"] = voucher_type
		# This assumes that the exchange rate required is the one in the SO
		d["exchange_rate"] = get_exchange_rate(party_account_currency, company_currency, posting_date)
		order_list.append(d)

	return order_list


def get_negative_outstanding_invoices(
	party_type,
	party,
	party_account,
	party_account_currency,
	company_currency,
	cost_center=None,
	condition=None,
):
	if party_type not in ["Customer", "Supplier"]:
		return []
	voucher_type = "Sales Invoice" if party_type == "Customer" else "Purchase Invoice"
	account = "debit_to" if voucher_type == "Sales Invoice" else "credit_to"
	supplier_condition = ""
	if voucher_type == "Purchase Invoice":
		supplier_condition = "and (release_date is null or release_date <= CURRENT_DATE)"
	if party_account_currency == company_currency:
		grand_total_field = "base_grand_total"
		rounded_total_field = "base_rounded_total"
	else:
		grand_total_field = "grand_total"
		rounded_total_field = "rounded_total"

	return frappe.db.sql(
		"""
		select
			"{voucher_type}" as voucher_type, name as voucher_no, {account} as account,
			if({rounded_total_field}, {rounded_total_field}, {grand_total_field}) as invoice_amount,
			outstanding_amount, posting_date,
			due_date, conversion_rate as exchange_rate
		from
			`tab{voucher_type}`
		where
			{party_type} = %s and {party_account} = %s and docstatus = 1 and
			outstanding_amount < 0
			{supplier_condition}
			{condition}
		order by
			posting_date, name
		""".format(
			**{
				"supplier_condition": supplier_condition,
				"condition": condition,
				"rounded_total_field": rounded_total_field,
				"grand_total_field": grand_total_field,
				"voucher_type": voucher_type,
				"party_type": scrub(party_type),
				"party_account": "debit_to" if party_type == "Customer" else "credit_to",
				"cost_center": cost_center,
				"account": account,
			}
		),
		(party, party_account),
		as_dict=True,
	)


@frappe.whitelist()
def get_party_details(company, party_type, party, date, cost_center=None):
	bank_account = ""
	party_bank_account = ""

	if not frappe.db.exists(party_type, party):
		frappe.throw(_("{0} {1} does not exist").format(_(party_type), party))

	party_account = get_party_account(party_type, party, company)
	account_currency = get_account_currency(party_account)
	_party_name = "title" if party_type == "Shareholder" else party_type.lower() + "_name"
	party_name = frappe.db.get_value(party_type, party, _party_name)

	if party_type in ["Customer", "Supplier"]:
		party_bank_account = get_party_bank_account(party_type, party)
		bank_account = get_default_company_bank_account(company, party_type, party)

	return {
		"party_account": party_account,
		"party_name": party_name,
		"party_account_currency": account_currency,
		"party_bank_account": party_bank_account,
		"bank_account": bank_account,
	}


@frappe.whitelist()
def get_account_details(account, date, cost_center=None):
	frappe.has_permission("Payment Entry", throw=True)

	# to check if the passed account is accessible under reference doctype Payment Entry
	account_list = frappe.get_list("Account", {"name": account}, reference_doctype="Payment Entry", limit=1)

	# There might be some user permissions which will allow account under certain doctypes
	# except for Payment Entry, only in such case we should throw permission error
	if not account_list:
		frappe.throw(_("Account: {0} is not permitted under Payment Entry").format(account))

	return frappe._dict(
		{
			"account_currency": get_account_currency(account),
			"account_type": frappe.get_cached_value("Account", account, "account_type"),
		}
	)


@frappe.whitelist()
def get_company_defaults(company):
	fields = ["write_off_account", "exchange_gain_loss_account", "cost_center"]
	return frappe.get_cached_value("Company", company, fields, as_dict=1)


def get_outstanding_on_journal_entry(voucher_no, party_type, party):
	ple = frappe.qb.DocType("Payment Ledger Entry")

	outstanding = (
		frappe.qb.from_(ple)
		.select(Sum(ple.amount_in_account_currency))
		.where(
			(ple.against_voucher_no == voucher_no)
			& (ple.party_type == party_type)
			& (ple.party == party)
			& (ple.delinked == 0)
		)
	).run()

	outstanding_amount = outstanding[0][0] if outstanding else 0

	total = (
		frappe.qb.from_(ple)
		.select(Sum(ple.amount_in_account_currency))
		.where(
			(ple.voucher_no == voucher_no)
			& (ple.party_type == party_type)
			& (ple.party == party)
			& (ple.delinked == 0)
		)
	).run()

	total_amount = total[0][0] if total else 0

	return outstanding_amount, total_amount


@frappe.whitelist()
def get_reference_details(
	reference_doctype, reference_name, party_account_currency, party_type=None, party=None
):
	total_amount = outstanding_amount = exchange_rate = account = None

	ref_doc = frappe.get_lazy_doc(reference_doctype, reference_name)
	company_currency = ref_doc.get("company_currency") or erpnext.get_company_currency(ref_doc.company)

	# Only applies for Reverse Payment Entries
	account_type = None
	payment_type = None

	if reference_doctype == "Dunning":
		total_amount = outstanding_amount = ref_doc.get("dunning_amount")
		exchange_rate = 1

	elif reference_doctype == "Journal Entry" and ref_doc.docstatus == 1:
		if ref_doc.multi_currency:
			exchange_rate = get_exchange_rate(party_account_currency, company_currency, ref_doc.posting_date)
		else:
			exchange_rate = 1
			outstanding_amount, total_amount = get_outstanding_on_journal_entry(
				reference_name, party_type, party
			)

	elif reference_doctype == "Payment Entry":
		if reverse_payment_details := frappe.db.get_all(
			"Payment Entry",
			filters={"name": reference_name},
			fields=["payment_type", "party_type"],
		)[0]:
			payment_type = reverse_payment_details.payment_type
			account_type = frappe.db.get_value(
				"Party Type", reverse_payment_details.party_type, "account_type"
			)
		exchange_rate = 1

	elif reference_doctype != "Journal Entry":
		if not total_amount:
			if party_account_currency == company_currency:
				# for handling cases that don't have multi-currency (base field)
				total_amount = (
					ref_doc.get("base_rounded_total")
					or ref_doc.get("rounded_total")
					or ref_doc.get("base_grand_total")
					or ref_doc.get("grand_total")
				)
				exchange_rate = 1
			else:
				total_amount = ref_doc.get("rounded_total") or ref_doc.get("grand_total")
		if not exchange_rate:
			# Get the exchange rate from the original ref doc
			# or get it based on the posting date of the ref doc.
			exchange_rate = ref_doc.get("conversion_rate") or get_exchange_rate(
				party_account_currency, company_currency, ref_doc.posting_date
			)

		if reference_doctype in ("Sales Invoice", "Purchase Invoice"):
			outstanding_amount = ref_doc.get("outstanding_amount")
			account = (
				ref_doc.get("debit_to") if reference_doctype == "Sales Invoice" else ref_doc.get("credit_to")
			)
		else:
			outstanding_amount = flt(total_amount) - flt(ref_doc.get("advance_paid"))

		if reference_doctype in ["Sales Order", "Purchase Order"]:
			party_type = "Customer" if reference_doctype == "Sales Order" else "Supplier"
			party_field = "customer" if reference_doctype == "Sales Order" else "supplier"
			party = ref_doc.get(party_field)
			account = get_party_account(party_type, party, ref_doc.company)
	else:
		# Get the exchange rate based on the posting date of the ref doc.
		exchange_rate = get_exchange_rate(party_account_currency, company_currency, ref_doc.posting_date)

	res = frappe._dict(
		{
			"due_date": ref_doc.get("due_date"),
			"total_amount": flt(total_amount),
			"outstanding_amount": flt(outstanding_amount),
			"exchange_rate": flt(exchange_rate),
			"bill_no": ref_doc.get("bill_no"),
			"account_type": account_type,
			"payment_type": payment_type,
		}
	)
	if account:
		res.update({"account": account})
	return res


@frappe.whitelist()
def get_payment_entry(
	dt,
	dn,
	party_amount=None,
	bank_account=None,
	bank_amount=None,
	party_type=None,
	payment_type=None,
	reference_date=None,
	created_from_payment_request=False,
):
	doc = frappe.get_doc(dt, dn)
	over_billing_allowance = frappe.get_single_value("Accounts Settings", "over_billing_allowance")
	if dt in ("Sales Order", "Purchase Order") and flt(doc.per_billed, 2) >= (100.0 + over_billing_allowance):
		frappe.throw(_("Can only make payment against unbilled {0}").format(_(dt)))

	if not party_type:
		party_type = set_party_type(dt)

	party_account = set_party_account(dt, dn, doc, party_type)
	party_account_currency = set_party_account_currency(dt, party_account, doc)

	if not payment_type:
		payment_type = set_payment_type(dt, doc)

	grand_total, outstanding_amount = set_grand_total_and_outstanding_amount(
		party_amount, dt, party_account_currency, doc
	)

	# bank or cash
	bank = get_bank_cash_account(doc, bank_account)

	# if default bank or cash account is not set in company master and party has default company bank account, fetch it
	if party_type in ["Customer", "Supplier"] and not bank:
		party_bank_account = get_party_bank_account(party_type, doc.get(scrub(party_type)))
		if party_bank_account:
			account = frappe.db.get_value("Bank Account", party_bank_account, "account")
			bank = get_bank_cash_account(doc, account)

	paid_amount, received_amount = set_paid_amount_and_received_amount(
		dt, party_account_currency, bank, outstanding_amount, payment_type, bank_amount, doc
	)

	reference_date = getdate(reference_date)
	paid_amount, received_amount, discount_amount, valid_discounts = apply_early_payment_discount(
		paid_amount, received_amount, doc, party_account_currency, reference_date
	)

	pe = frappe.new_doc("Payment Entry")
	pe.payment_type = payment_type
	pe.company = doc.company
	pe.cost_center = doc.get("cost_center")
	pe.posting_date = nowdate()
	pe.reference_date = reference_date
	pe.mode_of_payment = doc.get("mode_of_payment")
	pe.party_type = party_type
	pe.party = doc.get(scrub(party_type))
	pe.contact_person = doc.get("contact_person")
	complete_contact_details(pe)
	pe.ensure_supplier_is_not_blocked()

	pe.paid_from = party_account if payment_type == "Receive" else bank.account
	pe.paid_to = party_account if payment_type == "Pay" else bank.account
	pe.paid_from_account_currency = (
		party_account_currency if payment_type == "Receive" else bank.account_currency
	)
	pe.paid_to_account_currency = party_account_currency if payment_type == "Pay" else bank.account_currency
	pe.paid_from_account_type = frappe.db.get_value("Account", pe.paid_from, "account_type")
	pe.paid_to_account_type = frappe.db.get_value("Account", pe.paid_to, "account_type")
	pe.paid_amount = paid_amount
	pe.received_amount = received_amount
	pe.letter_head = doc.get("letter_head")
	pe.bank_account = frappe.db.get_value(
		"Bank Account", {"is_company_account": 1, "is_default": 1, "company": doc.company}, "name"
	)

	if dt in ["Purchase Order", "Sales Order", "Sales Invoice", "Purchase Invoice"]:
		pe.project = doc.get("project") or reduce(
			lambda prev, cur: prev or cur, [x.get("project") for x in doc.get("items")], None
		)  # get first non-empty project from items

	if pe.party_type in ["Customer", "Supplier"]:
		bank_account = get_party_bank_account(pe.party_type, pe.party)
		pe.set("party_bank_account", bank_account)
		pe.set_bank_account_data()

	# only Purchase Invoice can be blocked individually
	if doc.doctype == "Purchase Invoice" and doc.invoice_is_blocked():
		frappe.msgprint(_("{0} is on hold till {1}").format(doc.name, doc.release_date))
	else:
		if doc.doctype in (
			"Sales Invoice",
			"Purchase Invoice",
			"Purchase Order",
			"Sales Order",
		) and frappe.get_cached_value(
			"Payment Terms Template",
			doc.payment_terms_template,
			"allocate_payment_based_on_payment_terms",
		):
			for reference in get_reference_as_per_payment_terms(
				doc.payment_schedule, dt, dn, doc, grand_total, outstanding_amount, party_account_currency
			):
				pe.append("references", reference)
		else:
			if dt == "Dunning":
				for overdue_payment in doc.overdue_payments:
					pe.append(
						"references",
						{
							"reference_doctype": "Sales Invoice",
							"reference_name": overdue_payment.sales_invoice,
							"payment_term": overdue_payment.payment_term,
							"due_date": overdue_payment.due_date,
							"total_amount": overdue_payment.outstanding,
							"outstanding_amount": overdue_payment.outstanding,
							"allocated_amount": overdue_payment.outstanding,
						},
					)

				pe.append(
					"deductions",
					{
						"account": doc.income_account,
						"cost_center": doc.cost_center,
						"amount": -1 * doc.dunning_amount,
						"description": _("Interest and/or dunning fee"),
					},
				)
			else:
				pe.append(
					"references",
					{
						"reference_doctype": dt,
						"reference_name": dn,
						"bill_no": doc.get("bill_no"),
						"due_date": doc.get("due_date"),
						"total_amount": grand_total,
						"outstanding_amount": outstanding_amount,
						"allocated_amount": outstanding_amount,
					},
				)

	pe.setup_party_account_field()
	pe.set_missing_values()
	pe.set_missing_ref_details()

	update_accounting_dimensions(pe, doc)

	if party_account and bank:
		if discount_amount:
			base_total_discount_loss = 0
			if frappe.get_single_value("Accounts Settings", "book_tax_discount_loss"):
				base_total_discount_loss = split_early_payment_discount_loss(pe, doc, valid_discounts)

			set_pending_discount_loss(
				pe, doc, discount_amount, base_total_discount_loss, party_account_currency
			)

		pe.set_exchange_rate(ref_doc=doc)
		pe.set_amounts()

	# 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)
		.where(PR.outstanding_amount > 0)  # to avoid old PRs with 0 outstanding amount
		.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
	"""
	from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
		get_accounting_dimensions,
	)

	for dimension in get_accounting_dimensions():
		pe.set(dimension, doc.get(dimension))


def get_bank_cash_account(doc, bank_account):
	bank = get_default_bank_cash_account(
		doc.company,
		"Bank",
		mode_of_payment=doc.get("mode_of_payment"),
		account=bank_account,
		fetch_balance=False,
	)

	if not bank:
		bank = get_default_bank_cash_account(
			doc.company,
			"Cash",
			mode_of_payment=doc.get("mode_of_payment"),
			account=bank_account,
			fetch_balance=False,
		)

	return bank


def set_party_type(dt):
	if dt in ("Sales Invoice", "Sales Order", "Dunning"):
		party_type = "Customer"
	elif dt in ("Purchase Invoice", "Purchase Order"):
		party_type = "Supplier"
	return party_type


def set_party_account(dt, dn, doc, party_type):
	if dt == "Sales Invoice":
		party_account = get_party_account_based_on_invoice_discounting(dn) or doc.debit_to
	elif dt == "Purchase Invoice":
		party_account = doc.credit_to
	else:
		party_account = get_party_account(party_type, doc.get(party_type.lower()), doc.company)
	return party_account


def set_party_account_currency(dt, party_account, doc):
	if dt not in ("Sales Invoice", "Purchase Invoice"):
		party_account_currency = get_account_currency(party_account)
	else:
		party_account_currency = doc.get("party_account_currency") or get_account_currency(party_account)
	return party_account_currency


def set_payment_type(dt, doc):
	if (
		(dt == "Sales Order" or (dt == "Sales Invoice" and doc.outstanding_amount > 0))
		or (dt == "Purchase Invoice" and doc.outstanding_amount < 0)
		or dt == "Dunning"
	):
		payment_type = "Receive"
	else:
		payment_type = "Pay"
	return payment_type


def set_grand_total_and_outstanding_amount(party_amount, dt, party_account_currency, doc):
	grand_total = outstanding_amount = 0
	if party_amount:
		grand_total = outstanding_amount = party_amount
	elif dt in ("Sales Invoice", "Purchase Invoice"):
		if party_account_currency == doc.company_currency:
			grand_total = doc.base_rounded_total or doc.base_grand_total
		else:
			grand_total = doc.rounded_total or doc.grand_total
		outstanding_amount = doc.outstanding_amount
	elif dt == "Dunning":
		grand_total = doc.grand_total
		outstanding_amount = doc.grand_total
	else:
		if party_account_currency == doc.company_currency:
			grand_total = flt(doc.get("base_rounded_total") or doc.get("base_grand_total"))
		else:
			grand_total = flt(doc.get("rounded_total") or doc.get("grand_total"))
		outstanding_amount = doc.get("outstanding_amount") or (grand_total - flt(doc.advance_paid))
	return grand_total, outstanding_amount


def set_paid_amount_and_received_amount(
	dt, party_account_currency, bank, outstanding_amount, payment_type, bank_amount, doc
):
	paid_amount = received_amount = 0
	if party_account_currency == bank.account_currency:
		paid_amount = received_amount = abs(outstanding_amount)
	else:
		# settings if it is for receive
		paid_amount = abs(outstanding_amount)
		if bank_amount:
			received_amount = bank_amount
		else:
			company_currency = frappe.get_cached_value("Company", doc.get("company"), "default_currency")
			if bank and company_currency != bank.account_currency:
				# doc currency can be different from bank currency
				posting_date = doc.get("posting_date") or doc.get("transaction_date")
				conversion_rate = get_exchange_rate(
					bank.account_currency, party_account_currency, posting_date
				)
				received_amount = paid_amount / conversion_rate
			else:
				received_amount = paid_amount * doc.get("conversion_rate", 1)

		# if payment type is pay, then paid amount and received amount are swapped
		if payment_type == "Pay":
			paid_amount, received_amount = received_amount, paid_amount

	return paid_amount, received_amount


def apply_early_payment_discount(paid_amount, received_amount, doc, party_account_currency, reference_date):
	total_discount = 0
	valid_discounts = []
	eligible_for_payments = ["Sales Order", "Sales Invoice", "Purchase Order", "Purchase Invoice"]
	has_payment_schedule = hasattr(doc, "payment_schedule") and doc.payment_schedule
	is_multi_currency = party_account_currency != doc.company_currency

	if doc.doctype in eligible_for_payments and has_payment_schedule:
		for term in doc.payment_schedule:
			if not term.discounted_amount and term.discount and reference_date <= term.discount_date:
				if term.discount_type == "Percentage":
					grand_total = doc.get("grand_total") if is_multi_currency else doc.get("base_grand_total")
					discount_amount = flt(grand_total) * (term.discount / 100)
				else:
					discount_amount = term.discount

				# if accounting is done in the same currency, paid_amount = received_amount
				conversion_rate = doc.get("conversion_rate", 1) if is_multi_currency else 1
				discount_amount_in_foreign_currency = discount_amount * conversion_rate

				if doc.doctype == "Sales Invoice":
					paid_amount -= discount_amount
					received_amount -= discount_amount_in_foreign_currency
				else:
					received_amount -= discount_amount
					paid_amount -= discount_amount_in_foreign_currency

				valid_discounts.append({"type": term.discount_type, "discount": term.discount})
				total_discount += discount_amount

		if total_discount:
			currency = doc.get("currency") if is_multi_currency else doc.company_currency
			money = frappe.utils.fmt_money(total_discount, currency=currency)
			frappe.msgprint(_("Discount of {} applied as per Payment Term").format(money), alert=1)

	return paid_amount, received_amount, total_discount, valid_discounts


def set_pending_discount_loss(pe, doc, discount_amount, base_total_discount_loss, party_account_currency):
	# If multi-currency, get base discount amount to adjust with base currency deductions/losses
	if party_account_currency != doc.company_currency:
		discount_amount = discount_amount * doc.get("conversion_rate", 1)

	# Avoid considering miniscule losses
	discount_amount = flt(discount_amount - base_total_discount_loss, doc.precision("grand_total"))

	# Set base discount amount (discount loss/pending rounding loss) in deductions
	if discount_amount > 0.0:
		positive_negative = -1 if pe.payment_type == "Pay" else 1

		# If tax loss booking is enabled, pending loss will be rounding loss.
		# Otherwise it will be the total discount loss.
		book_tax_loss = frappe.get_single_value("Accounts Settings", "book_tax_discount_loss")
		account_type = "round_off_account" if book_tax_loss else "default_discount_account"

		pe.append(
			"deductions",
			{
				"account": frappe.get_cached_value("Company", pe.company, account_type),
				"cost_center": pe.cost_center
				or frappe.get_cached_value("Company", pe.company, "cost_center"),
				"amount": discount_amount * positive_negative,
			},
		)


def split_early_payment_discount_loss(pe, doc, valid_discounts) -> float:
	"""Split early payment discount into Income Loss & Tax Loss."""
	total_discount_percent = get_total_discount_percent(doc, valid_discounts)

	if not total_discount_percent:
		return 0.0

	base_loss_on_income = add_income_discount_loss(pe, doc, total_discount_percent)
	base_loss_on_taxes = add_tax_discount_loss(pe, doc, total_discount_percent)

	# Round off total loss rather than individual losses to reduce rounding error
	return flt(base_loss_on_income + base_loss_on_taxes, doc.precision("grand_total"))


def get_total_discount_percent(doc, valid_discounts) -> float:
	"""Get total percentage and amount discount applied as a percentage."""
	total_discount_percent = (
		sum(discount.get("discount") for discount in valid_discounts if discount.get("type") == "Percentage")
		or 0.0
	)

	# Operate in percentages only as it makes the income & tax split easier
	total_discount_amount = (
		sum(discount.get("discount") for discount in valid_discounts if discount.get("type") == "Amount")
		or 0.0
	)

	if total_discount_amount:
		discount_percentage = (total_discount_amount / doc.get("grand_total")) * 100
		total_discount_percent += discount_percentage
		return total_discount_percent

	return total_discount_percent


def add_income_discount_loss(pe, doc, total_discount_percent) -> float:
	"""Add loss on income discount in base currency."""
	precision = doc.precision("total")
	base_loss_on_income = doc.get("base_total") * (total_discount_percent / 100)
	positive_negative = -1 if pe.payment_type == "Pay" else 1

	pe.append(
		"deductions",
		{
			"account": frappe.get_cached_value("Company", pe.company, "default_discount_account"),
			"cost_center": pe.cost_center or frappe.get_cached_value("Company", pe.company, "cost_center"),
			"amount": flt(base_loss_on_income, precision) * positive_negative,
		},
	)

	return base_loss_on_income  # Return loss without rounding


def add_tax_discount_loss(pe, doc, total_discount_percentage) -> float:
	"""Add loss on tax discount in base currency."""
	tax_discount_loss = {}
	base_total_tax_loss = 0
	precision = doc.precision("tax_amount_after_discount_amount", "taxes")
	positive_negative = -1 if pe.payment_type == "Pay" else 1

	# The same account head could be used more than once
	for tax in doc.get("taxes", []):
		base_tax_loss = tax.get("base_tax_amount_after_discount_amount") * (total_discount_percentage / 100)

		account = tax.get("account_head")
		if not tax_discount_loss.get(account):
			tax_discount_loss[account] = base_tax_loss
		else:
			tax_discount_loss[account] += base_tax_loss

	for account, loss in tax_discount_loss.items():
		base_total_tax_loss += loss
		if loss == 0.0:
			continue

		pe.append(
			"deductions",
			{
				"account": account,
				"cost_center": pe.cost_center
				or frappe.get_cached_value("Company", pe.company, "cost_center"),
				"amount": flt(loss, precision) * positive_negative,
			},
		)

	return base_total_tax_loss  # Return loss without rounding


def get_reference_as_per_payment_terms(
	payment_schedule, dt, dn, doc, grand_total, outstanding_amount, party_account_currency
):
	references = []
	is_multi_currency_acc = (doc.currency != doc.company_currency) and (
		party_account_currency != doc.company_currency
	)

	for payment_term in payment_schedule:
		payment_term_outstanding = flt(
			payment_term.payment_amount - payment_term.paid_amount, payment_term.precision("payment_amount")
		)
		if not is_multi_currency_acc:
			# If accounting is done in company currency for multi-currency transaction
			payment_term_outstanding = flt(
				payment_term_outstanding * doc.get("conversion_rate"),
				payment_term.precision("payment_amount"),
			)

		if payment_term_outstanding:
			references.append(
				{
					"reference_doctype": dt,
					"reference_name": dn,
					"bill_no": doc.get("bill_no"),
					"due_date": doc.get("due_date"),
					"total_amount": grand_total,
					"outstanding_amount": outstanding_amount,
					"payment_term_outstanding": payment_term_outstanding,
					"payment_term": payment_term.payment_term,
					"allocated_amount": payment_term_outstanding,
				}
			)

	return references


def get_paid_amount(dt, dn, party_type, party, account, due_date):
	if party_type == "Customer":
		dr_or_cr = "credit_in_account_currency - debit_in_account_currency"
	else:
		dr_or_cr = "debit_in_account_currency - credit_in_account_currency"

	paid_amount = frappe.db.sql(
		f"""
		select ifnull(sum({dr_or_cr}), 0) as paid_amount
		from `tabGL Entry`
		where against_voucher_type = %s
			and against_voucher = %s
			and party_type = %s
			and party = %s
			and account = %s
			and due_date = %s
			and {dr_or_cr} > 0
	""",
		(dt, dn, party_type, party, account, due_date),
	)

	return paid_amount[0][0] if paid_amount else 0


@frappe.whitelist()
def make_payment_order(source_name, target_doc=None):
	from frappe.model.mapper import get_mapped_doc

	def set_missing_values(source, target):
		target.payment_order_type = "Payment Entry"
		target.append(
			"references",
			dict(
				reference_doctype="Payment Entry",
				reference_name=source.name,
				bank_account=source.party_bank_account,
				amount=source.paid_amount,
				account=source.paid_to,
				supplier=source.party,
				mode_of_payment=source.mode_of_payment,
			),
		)

	doclist = get_mapped_doc(
		"Payment Entry",
		source_name,
		{
			"Payment Entry": {
				"doctype": "Payment Order",
				"validation": {"docstatus": ["=", 1]},
			}
		},
		target_doc,
		set_missing_values,
	)

	return doclist


@erpnext.allow_regional
def add_regional_gl_entries(gl_entries, doc):
	return
