test: different scenarios for exchange booking
diff --git a/erpnext/controllers/tests/test_accounts_controller.py b/erpnext/controllers/tests/test_accounts_controller.py
new file mode 100644
index 0000000..31aa857
--- /dev/null
+++ b/erpnext/controllers/tests/test_accounts_controller.py
@@ -0,0 +1,501 @@
+# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and contributors
+# For license information, please see license.txt
+
+import unittest
+
+import frappe
+from frappe import qb
+from frappe.tests.utils import FrappeTestCase, change_settings
+from frappe.utils import add_days, flt, nowdate
+
+from erpnext import get_default_cost_center
+from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry
+from erpnext.accounts.doctype.payment_entry.test_payment_entry import create_payment_entry
+from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
+from erpnext.accounts.party import get_party_account
+from erpnext.stock.doctype.item.test_item import create_item
+
+
+def make_customer(customer_name, currency=None):
+	if not frappe.db.exists("Customer", customer_name):
+		customer = frappe.new_doc("Customer")
+		customer.customer_name = customer_name
+		customer.type = "Individual"
+
+		if currency:
+			customer.default_currency = currency
+		customer.save()
+		return customer.name
+	else:
+		return customer_name
+
+
+class TestAccountsController(FrappeTestCase):
+	"""
+	Test Exchange Gain/Loss booking on various scenarios
+	"""
+
+	def setUp(self):
+		self.create_company()
+		self.create_account()
+		self.create_item()
+		self.create_customer()
+		self.clear_old_entries()
+
+	def tearDown(self):
+		frappe.db.rollback()
+
+	def create_company(self):
+		company_name = "_Test Company MC"
+		self.company_abbr = abbr = "_CM"
+		if frappe.db.exists("Company", company_name):
+			company = frappe.get_doc("Company", company_name)
+		else:
+			company = frappe.get_doc(
+				{
+					"doctype": "Company",
+					"company_name": company_name,
+					"country": "India",
+					"default_currency": "INR",
+					"create_chart_of_accounts_based_on": "Standard Template",
+					"chart_of_accounts": "Standard",
+				}
+			)
+			company = company.save()
+
+		self.company = company.name
+		self.cost_center = company.cost_center
+		self.warehouse = "Stores - " + abbr
+		self.finished_warehouse = "Finished Goods - " + abbr
+		self.income_account = "Sales - " + abbr
+		self.expense_account = "Cost of Goods Sold - " + abbr
+		self.debit_to = "Debtors - " + abbr
+		self.debit_usd = "Debtors USD - " + abbr
+		self.cash = "Cash - " + abbr
+		self.creditors = "Creditors - " + abbr
+
+	def create_item(self):
+		item = create_item(
+			item_code="_Test Notebook", is_stock_item=0, company=self.company, warehouse=self.warehouse
+		)
+		self.item = item if isinstance(item, str) else item.item_code
+
+	def create_customer(self):
+		self.customer = make_customer("_Test MC Customer USD", "USD")
+
+	def create_account(self):
+		account_name = "Debtors USD"
+		if not frappe.db.get_value(
+			"Account", filters={"account_name": account_name, "company": self.company}
+		):
+			acc = frappe.new_doc("Account")
+			acc.account_name = account_name
+			acc.parent_account = "Accounts Receivable - " + self.company_abbr
+			acc.company = self.company
+			acc.account_currency = "USD"
+			acc.account_type = "Receivable"
+			acc.insert()
+		else:
+			name = frappe.db.get_value(
+				"Account",
+				filters={"account_name": account_name, "company": self.company},
+				fieldname="name",
+				pluck=True,
+			)
+			acc = frappe.get_doc("Account", name)
+		self.debtors_usd = acc.name
+
+	def create_sales_invoice(
+		self, qty=1, rate=1, posting_date=nowdate(), do_not_save=False, do_not_submit=False
+	):
+		"""
+		Helper function to populate default values in sales invoice
+		"""
+		sinv = create_sales_invoice(
+			qty=qty,
+			rate=rate,
+			company=self.company,
+			customer=self.customer,
+			item_code=self.item,
+			item_name=self.item,
+			cost_center=self.cost_center,
+			warehouse=self.warehouse,
+			debit_to=self.debit_usd,
+			parent_cost_center=self.cost_center,
+			update_stock=0,
+			currency="USD",
+			conversion_rate=80,
+			is_pos=0,
+			is_return=0,
+			return_against=None,
+			income_account=self.income_account,
+			expense_account=self.expense_account,
+			do_not_save=do_not_save,
+			do_not_submit=do_not_submit,
+		)
+		return sinv
+
+	def create_payment_entry(
+		self, amount=1, source_exc_rate=75, posting_date=nowdate(), customer=None
+	):
+		"""
+		Helper function to populate default values in payment entry
+		"""
+		payment = create_payment_entry(
+			company=self.company,
+			payment_type="Receive",
+			party_type="Customer",
+			party=customer or self.customer,
+			paid_from=self.debit_usd,
+			paid_to=self.cash,
+			paid_amount=amount,
+		)
+		payment.source_exchange_rate = source_exc_rate
+		payment.received_amount = source_exc_rate * amount
+		payment.posting_date = posting_date
+		return payment
+
+	def clear_old_entries(self):
+		doctype_list = [
+			"GL Entry",
+			"Payment Ledger Entry",
+			"Sales Invoice",
+			"Purchase Invoice",
+			"Payment Entry",
+			"Journal Entry",
+		]
+		for doctype in doctype_list:
+			qb.from_(qb.DocType(doctype)).delete().where(qb.DocType(doctype).company == self.company).run()
+
+	def create_payment_reconciliation(self):
+		pr = frappe.new_doc("Payment Reconciliation")
+		pr.company = self.company
+		pr.party_type = "Customer"
+		pr.party = self.customer
+		pr.receivable_payable_account = get_party_account(pr.party_type, pr.party, pr.company)
+		pr.from_invoice_date = pr.to_invoice_date = pr.from_payment_date = pr.to_payment_date = nowdate()
+		return pr
+
+	def create_journal_entry(
+		self, acc1=None, acc2=None, amount=0, posting_date=None, cost_center=None
+	):
+		je = frappe.new_doc("Journal Entry")
+		je.posting_date = posting_date or nowdate()
+		je.company = self.company
+		je.user_remark = "test"
+		if not cost_center:
+			cost_center = self.cost_center
+		je.set(
+			"accounts",
+			[
+				{
+					"account": acc1,
+					"cost_center": cost_center,
+					"debit_in_account_currency": amount if amount > 0 else 0,
+					"credit_in_account_currency": abs(amount) if amount < 0 else 0,
+				},
+				{
+					"account": acc2,
+					"cost_center": cost_center,
+					"credit_in_account_currency": amount if amount > 0 else 0,
+					"debit_in_account_currency": abs(amount) if amount < 0 else 0,
+				},
+			],
+		)
+		return je
+
+	def get_journals_for(self, voucher_type: str, voucher_no: str) -> list:
+		journals = []
+		if voucher_type and voucher_no:
+			journals = frappe.db.get_all(
+				"Journal Entry Account",
+				filters={"reference_type": voucher_type, "reference_name": voucher_no, "docstatus": 1},
+				fields=["parent"],
+			)
+		return journals
+
+	def test_01_payment_against_invoice(self):
+		# Invoice in Foreign Currency
+		si = self.create_sales_invoice(qty=1, rate=1)
+		# Payment
+		pe = self.create_payment_entry(amount=1, source_exc_rate=75).save()
+		pe.append(
+			"references",
+			{"reference_doctype": si.doctype, "reference_name": si.name, "allocated_amount": 1},
+		)
+		pe = pe.save().submit()
+
+		si.reload()
+		self.assertEqual(si.outstanding_amount, 0)
+
+		# Exchange Gain/Loss Journal should've been created.
+		exc_je_for_si = self.get_journals_for(si.doctype, si.name)
+		exc_je_for_pe = self.get_journals_for(pe.doctype, pe.name)
+
+		self.assertNotEqual(exc_je_for_si, [])
+		self.assertEqual(len(exc_je_for_si), 1)
+		self.assertEqual(len(exc_je_for_pe), 1)
+		self.assertEqual(exc_je_for_si[0], exc_je_for_pe[0])
+
+		# Cancel Payment
+		pe.cancel()
+
+		si.reload()
+		self.assertEqual(si.outstanding_amount, 1)
+
+		# Exchange Gain/Loss Journal should've been cancelled
+		exc_je_for_si = self.get_journals_for(si.doctype, si.name)
+		exc_je_for_pe = self.get_journals_for(pe.doctype, pe.name)
+
+		self.assertEqual(exc_je_for_si, [])
+		self.assertEqual(exc_je_for_pe, [])
+
+	def test_02_advance_against_invoice(self):
+		# Advance Payment
+		adv = self.create_payment_entry(amount=1, source_exc_rate=85).save().submit()
+		adv.reload()
+
+		# Invoice in Foreign Currency
+		si = self.create_sales_invoice(qty=1, rate=1, do_not_submit=True)
+		si.append(
+			"advances",
+			{
+				"doctype": "Sales Invoice Advance",
+				"reference_type": adv.doctype,
+				"reference_name": adv.name,
+				"advance_amount": 1,
+				"allocated_amount": 1,
+				"ref_exchange_rate": 85,
+				"remarks": "Test",
+			},
+		)
+		si = si.save()
+		si = si.submit()
+
+		adv.reload()
+		self.assertEqual(si.outstanding_amount, 0)
+
+		# Exchange Gain/Loss Journal should've been created.
+		exc_je_for_si = self.get_journals_for(si.doctype, si.name)
+		exc_je_for_adv = self.get_journals_for(adv.doctype, adv.name)
+
+		self.assertNotEqual(exc_je_for_si, [])
+		self.assertEqual(len(exc_je_for_si), 1)
+		self.assertEqual(len(exc_je_for_adv), 1)
+		self.assertEqual(exc_je_for_si, exc_je_for_adv)
+
+		# Cancel Invoice
+		si.cancel()
+
+		# Exchange Gain/Loss Journal should've been cancelled
+		exc_je_for_si = self.get_journals_for(si.doctype, si.name)
+		exc_je_for_adv = self.get_journals_for(adv.doctype, adv.name)
+
+		self.assertEqual(exc_je_for_si, [])
+		self.assertEqual(exc_je_for_adv, [])
+
+	def test_03_partial_advance_and_payment_for_invoice(self):
+		"""
+		Invoice with partial advance payment, and a normal payment
+		"""
+		# Partial Advance
+		adv = self.create_payment_entry(amount=1, source_exc_rate=85).save().submit()
+		adv.reload()
+
+		# Invoice in Foreign Currency linked with advance
+		si = self.create_sales_invoice(qty=2, rate=1, do_not_submit=True)
+		si.append(
+			"advances",
+			{
+				"doctype": "Sales Invoice Advance",
+				"reference_type": adv.doctype,
+				"reference_name": adv.name,
+				"advance_amount": 1,
+				"allocated_amount": 1,
+				"ref_exchange_rate": 85,
+				"remarks": "Test",
+			},
+		)
+		si = si.save()
+		si = si.submit()
+
+		si.reload()
+		self.assertEqual(si.outstanding_amount, 1)
+
+		# Exchange Gain/Loss Journal should've been created for the partial advance
+		exc_je_for_si = self.get_journals_for(si.doctype, si.name)
+		exc_je_for_adv = self.get_journals_for(adv.doctype, adv.name)
+
+		self.assertNotEqual(exc_je_for_si, [])
+		self.assertEqual(len(exc_je_for_si), 1)
+		self.assertEqual(len(exc_je_for_adv), 1)
+		self.assertEqual(exc_je_for_si, exc_je_for_adv)
+
+		# Payment
+		pe = self.create_payment_entry(amount=1, source_exc_rate=75).save()
+		pe.append(
+			"references",
+			{"reference_doctype": si.doctype, "reference_name": si.name, "allocated_amount": 1},
+		)
+		pe = pe.save().submit()
+
+		si.reload()
+		self.assertEqual(si.outstanding_amount, 0)
+
+		# Exchange Gain/Loss Journal should've been created for the payment
+		exc_je_for_si = self.get_journals_for(si.doctype, si.name)
+		exc_je_for_pe = self.get_journals_for(pe.doctype, pe.name)
+
+		self.assertNotEqual(exc_je_for_si, [])
+		# There should be 2 JE's now. One for the advance and one for the payment
+		self.assertEqual(len(exc_je_for_si), 2)
+		self.assertEqual(len(exc_je_for_pe), 1)
+		self.assertEqual(exc_je_for_si, exc_je_for_pe + exc_je_for_adv)
+
+		# Cancel Invoice
+		si.reload()
+		si.cancel()
+
+		# Exchange Gain/Loss Journal should been cancelled
+		exc_je_for_si = self.get_journals_for(si.doctype, si.name)
+		exc_je_for_pe = self.get_journals_for(pe.doctype, pe.name)
+		exc_je_for_adv = self.get_journals_for(adv.doctype, adv.name)
+
+		self.assertEqual(exc_je_for_si, [])
+		self.assertEqual(exc_je_for_pe, [])
+		self.assertEqual(exc_je_for_adv, [])
+
+	def test_04_partial_advance_and_payment_for_invoice_with_cancellation(self):
+		"""
+		Invoice with partial advance payment, and a normal payment. Cancel advance and payment.
+		"""
+		# Partial Advance
+		adv = self.create_payment_entry(amount=1, source_exc_rate=85).save().submit()
+		adv.reload()
+
+		# Invoice in Foreign Currency linked with advance
+		si = self.create_sales_invoice(qty=2, rate=1, do_not_submit=True)
+		si.append(
+			"advances",
+			{
+				"doctype": "Sales Invoice Advance",
+				"reference_type": adv.doctype,
+				"reference_name": adv.name,
+				"advance_amount": 1,
+				"allocated_amount": 1,
+				"ref_exchange_rate": 85,
+				"remarks": "Test",
+			},
+		)
+		si = si.save()
+		si = si.submit()
+
+		si.reload()
+		self.assertEqual(si.outstanding_amount, 1)
+
+		# Exchange Gain/Loss Journal should've been created for the partial advance
+		exc_je_for_si = self.get_journals_for(si.doctype, si.name)
+		exc_je_for_adv = self.get_journals_for(adv.doctype, adv.name)
+
+		self.assertNotEqual(exc_je_for_si, [])
+		self.assertEqual(len(exc_je_for_si), 1)
+		self.assertEqual(len(exc_je_for_adv), 1)
+		self.assertEqual(exc_je_for_si, exc_je_for_adv)
+
+		# Payment
+		pe = self.create_payment_entry(amount=1, source_exc_rate=75).save()
+		pe.append(
+			"references",
+			{"reference_doctype": si.doctype, "reference_name": si.name, "allocated_amount": 1},
+		)
+		pe = pe.save().submit()
+
+		si.reload()
+		self.assertEqual(si.outstanding_amount, 0)
+
+		# Exchange Gain/Loss Journal should've been created for the payment
+		exc_je_for_si = self.get_journals_for(si.doctype, si.name)
+		exc_je_for_pe = self.get_journals_for(pe.doctype, pe.name)
+
+		self.assertNotEqual(exc_je_for_si, [])
+		# There should be 2 JE's now. One for the advance and one for the payment
+		self.assertEqual(len(exc_je_for_si), 2)
+		self.assertEqual(len(exc_je_for_pe), 1)
+		self.assertEqual(exc_je_for_si, exc_je_for_pe + exc_je_for_adv)
+
+		adv.reload()
+		adv.cancel()
+
+		si.reload()
+		self.assertEqual(si.outstanding_amount, 1)
+
+		exc_je_for_si = self.get_journals_for(si.doctype, si.name)
+		exc_je_for_pe = self.get_journals_for(pe.doctype, pe.name)
+		exc_je_for_adv = self.get_journals_for(adv.doctype, adv.name)
+
+		# Exchange Gain/Loss Journal for advance should been cancelled
+		self.assertEqual(len(exc_je_for_si), 1)
+		self.assertEqual(len(exc_je_for_pe), 1)
+		self.assertEqual(exc_je_for_adv, [])
+
+	def test_05_same_payment_split_against_invoice(self):
+		# Invoice in Foreign Currency
+		si = self.create_sales_invoice(qty=2, rate=1)
+		# Payment
+		pe = self.create_payment_entry(amount=2, source_exc_rate=75).save()
+		pe.append(
+			"references",
+			{"reference_doctype": si.doctype, "reference_name": si.name, "allocated_amount": 1},
+		)
+		pe = pe.save().submit()
+
+		si.reload()
+		self.assertEqual(si.outstanding_amount, 1)
+
+		# Exchange Gain/Loss Journal should've been created.
+		exc_je_for_si = self.get_journals_for(si.doctype, si.name)
+		exc_je_for_pe = self.get_journals_for(pe.doctype, pe.name)
+
+		self.assertNotEqual(exc_je_for_si, [])
+		self.assertEqual(len(exc_je_for_si), 1)
+		self.assertEqual(len(exc_je_for_pe), 1)
+		self.assertEqual(exc_je_for_si[0], exc_je_for_pe[0])
+
+		# Reconcile the remaining amount
+		pr = frappe.get_doc("Payment Reconciliation")
+		pr.company = self.company
+		pr.party_type = "Customer"
+		pr.party = self.customer
+		pr.receivable_payable_account = self.debit_usd
+
+		pr.get_unreconciled_entries()
+		self.assertEqual(len(pr.invoices), 1)
+		self.assertEqual(len(pr.payments), 1)
+
+		# Test exact payment allocation
+		invoices = [x.as_dict() for x in pr.invoices]
+		payments = [x.as_dict() for x in pr.payments]
+		pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments}))
+
+		pr.reconcile()
+		self.assertEqual(len(pr.invoices), 0)
+		self.assertEqual(len(pr.payments), 0)
+
+		exc_je_for_si = self.get_journals_for(si.doctype, si.name)
+		exc_je_for_pe = self.get_journals_for(pe.doctype, pe.name)
+		self.assertEqual(len(exc_je_for_si), 2)
+		self.assertEqual(len(exc_je_for_pe), 2)
+		self.assertEqual(exc_je_for_si, exc_je_for_pe)
+
+		# Cancel Payment
+		pe.reload()
+		pe.cancel()
+
+		si.reload()
+		self.assertEqual(si.outstanding_amount, 2)
+
+		# Exchange Gain/Loss Journal should've been cancelled
+		exc_je_for_si = self.get_journals_for(si.doctype, si.name)
+		exc_je_for_pe = self.get_journals_for(pe.doctype, pe.name)
+		self.assertEqual(exc_je_for_si, [])
+		self.assertEqual(exc_je_for_pe, [])