# 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.query_builder.functions import Sum 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.purchase_invoice.test_purchase_invoice import make_purchase_invoice 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.customer_type = "Individual" if currency: customer.default_currency = currency customer.save() return customer.name else: return customer_name def make_supplier(supplier_name, currency=None): if not frappe.db.exists("Supplier", supplier_name): supplier = frappe.new_doc("Supplier") supplier.supplier_name = supplier_name supplier.supplier_type = "Individual" supplier.supplier_group = "All Supplier Groups" if currency: supplier.default_currency = currency supplier.save() return supplier.name else: return supplier_name class TestAccountsController(FrappeTestCase): """ Test Exchange Gain/Loss booking on various scenarios. Test Cases are numbered for better organization 10 series - Sales Invoice against Payment Entries 20 series - Sales Invoice against Journals 30 series - Sales Invoice against Credit Notes """ def setUp(self): self.create_company() self.create_account() self.create_item() self.create_parties() self.clear_old_entries() def tearDown(self): frappe.db.rollback() def create_company(self): company_name = "_Test Company" self.company_abbr = abbr = "_TC" 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_parties(self): self.create_customer() self.create_supplier() def create_customer(self): self.customer = make_customer("_Test MC Customer USD", "USD") def create_supplier(self): self.supplier = make_supplier("_Test MC Supplier 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, conversion_rate=80, 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=conversion_rate, 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, acc1_exc_rate=None, acc2_exc_rate=None, acc2=None, acc1_amount=0, acc2_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" je.multi_currency = True if not cost_center: cost_center = self.cost_center je.set( "accounts", [ { "account": acc1, "exchange_rate": acc1_exc_rate or 1, "cost_center": cost_center, "debit_in_account_currency": acc1_amount if acc1_amount > 0 else 0, "credit_in_account_currency": abs(acc1_amount) if acc1_amount < 0 else 0, "debit": acc1_amount * acc1_exc_rate if acc1_amount > 0 else 0, "credit": abs(acc1_amount * acc1_exc_rate) if acc1_amount < 0 else 0, }, { "account": acc2, "exchange_rate": acc2_exc_rate or 1, "cost_center": cost_center, "credit_in_account_currency": acc2_amount if acc2_amount > 0 else 0, "debit_in_account_currency": abs(acc2_amount) if acc2_amount < 0 else 0, "credit": acc2_amount * acc2_exc_rate if acc2_amount > 0 else 0, "debit": abs(acc2_amount * acc2_exc_rate) if acc2_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 assert_ledger_outstanding( self, voucher_type: str, voucher_no: str, outstanding: float, outstanding_in_account_currency: float, ) -> None: """ Assert outstanding amount based on ledger on both company/base currency and account currency """ ple = qb.DocType("Payment Ledger Entry") current_outstanding = ( qb.from_(ple) .select( Sum(ple.amount).as_("outstanding"), Sum(ple.amount_in_account_currency).as_("outstanding_in_account_currency"), ) .where( (ple.against_voucher_type == voucher_type) & (ple.against_voucher_no == voucher_no) & (ple.delinked == 0) ) .run(as_dict=True)[0] ) self.assertEqual(outstanding, current_outstanding.outstanding) self.assertEqual( outstanding_in_account_currency, current_outstanding.outstanding_in_account_currency ) def test_10_payment_against_sales_invoice(self): # Sales Invoice in Foreign Currency rate = 80 rate_in_account_currency = 1 si = self.create_sales_invoice(qty=1, rate=rate_in_account_currency) # Test payments with different exchange rates for exc_rate in [75.9, 83.1, 80.01]: with self.subTest(exc_rate=exc_rate): pe = self.create_payment_entry(amount=1, source_exc_rate=exc_rate).save() pe.append( "references", {"reference_doctype": si.doctype, "reference_name": si.name, "allocated_amount": 1}, ) pe = pe.save().submit() # Outstanding in both currencies should be '0' si.reload() self.assertEqual(si.outstanding_amount, 0) self.assert_ledger_outstanding(si.doctype, si.name, 0.0, 0.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() # outstanding should be same as grand total si.reload() self.assertEqual(si.outstanding_amount, rate_in_account_currency) self.assert_ledger_outstanding(si.doctype, si.name, rate, rate_in_account_currency) # 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_11_advance_against_sales_invoice(self): # Advance Payment adv = self.create_payment_entry(amount=1, source_exc_rate=85).save().submit() adv.reload() # Sales Invoices in different exchange rates for exc_rate in [75.9, 83.1, 80.01]: with self.subTest(exc_rate=exc_rate): si = self.create_sales_invoice(qty=1, conversion_rate=exc_rate, rate=1, do_not_submit=True) advances = si.get_advance_entries() self.assertEqual(len(advances), 1) self.assertEqual(advances[0].reference_name, adv.name) si.append( "advances", { "doctype": "Sales Invoice Advance", "reference_type": advances[0].reference_type, "reference_name": advances[0].reference_name, "reference_row": advances[0].reference_row, "advance_amount": 1, "allocated_amount": 1, "ref_exchange_rate": advances[0].exchange_rate, "remarks": advances[0].remarks, }, ) si = si.save() si = si.submit() # Outstanding in both currencies should be '0' adv.reload() self.assertEqual(si.outstanding_amount, 0) self.assert_ledger_outstanding(si.doctype, si.name, 0.0, 0.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_12_partial_advance_and_payment_for_sales_invoice(self): """ Sales invoice with partial advance payment, and a normal payment reconciled """ # Partial Advance adv = self.create_payment_entry(amount=1, source_exc_rate=85).save().submit() adv.reload() # sales invoice with advance(partial amount) rate = 80 rate_in_account_currency = 1 si = self.create_sales_invoice( qty=2, conversion_rate=80, rate=rate_in_account_currency, do_not_submit=True ) advances = si.get_advance_entries() self.assertEqual(len(advances), 1) self.assertEqual(advances[0].reference_name, adv.name) si.append( "advances", { "doctype": "Sales Invoice Advance", "reference_type": advances[0].reference_type, "reference_name": advances[0].reference_name, "advance_amount": 1, "allocated_amount": 1, "ref_exchange_rate": advances[0].exchange_rate, "remarks": advances[0].remarks, }, ) si = si.save() si = si.submit() # Outstanding should be there in both currencies si.reload() self.assertEqual(si.outstanding_amount, 1) # account currency self.assert_ledger_outstanding(si.doctype, si.name, 80.0, 1.0) # 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 for remaining amount 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() # Outstanding in both currencies should be '0' si.reload() self.assertEqual(si.outstanding_amount, 0) self.assert_ledger_outstanding(si.doctype, si.name, 0.0, 0.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_13_partial_advance_and_payment_for_invoice_with_cancellation(self): """ Invoice with partial advance payment, and a normal payment. Then cancel advance and payment. """ # Partial Advance adv = self.create_payment_entry(amount=1, source_exc_rate=85).save().submit() adv.reload() # invoice with advance(partial amount) si = self.create_sales_invoice(qty=2, conversion_rate=80, rate=1, do_not_submit=True) advances = si.get_advance_entries() self.assertEqual(len(advances), 1) self.assertEqual(advances[0].reference_name, adv.name) si.append( "advances", { "doctype": "Sales Invoice Advance", "reference_type": advances[0].reference_type, "reference_name": advances[0].reference_name, "advance_amount": 1, "allocated_amount": 1, "ref_exchange_rate": advances[0].exchange_rate, "remarks": advances[0].remarks, }, ) si = si.save() si = si.submit() # Outstanding should be there in both currencies si.reload() self.assertEqual(si.outstanding_amount, 1) # account currency self.assert_ledger_outstanding(si.doctype, si.name, 80.0, 1.0) # 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(remaining amount) 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() # Outstanding should be '0' in both currencies si.reload() self.assertEqual(si.outstanding_amount, 0) self.assert_ledger_outstanding(si.doctype, si.name, 0.0, 0.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() # Outstanding should be there in both currencies, since advance is cancelled. si.reload() self.assertEqual(si.outstanding_amount, 1) # account currency self.assert_ledger_outstanding(si.doctype, si.name, 80.0, 1.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) 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_14_same_payment_split_against_invoice(self): # Invoice in Foreign Currency si = self.create_sales_invoice(qty=2, conversion_rate=80, 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() # There should be outstanding in both currencies si.reload() self.assertEqual(si.outstanding_amount, 1) self.assert_ledger_outstanding(si.doctype, si.name, 80.0, 1.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]) # 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) 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 gain/loss journal should have been creaetd for the reconciled amount 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) # There should be no outstanding si.reload() self.assertEqual(si.outstanding_amount, 0) self.assert_ledger_outstanding(si.doctype, si.name, 0.0, 0.0) # Cancel Payment pe.reload() pe.cancel() si.reload() self.assertEqual(si.outstanding_amount, 2) self.assert_ledger_outstanding(si.doctype, si.name, 160.0, 2.0) # 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_20_journal_against_sales_invoice(self): # Invoice in Foreign Currency si = self.create_sales_invoice(qty=1, conversion_rate=80, rate=1) # Payment je = self.create_journal_entry( acc1=self.debit_usd, acc1_exc_rate=75, acc2=self.cash, acc1_amount=-1, acc2_amount=-75, acc2_exc_rate=1, ) je.accounts[0].party_type = "Customer" je.accounts[0].party = self.customer je = je.save().submit() # Reconcile the remaining amount pr = self.create_payment_reconciliation() # pr.receivable_payable_account = self.debit_usd pr.get_unreconciled_entries() self.assertEqual(len(pr.invoices), 1) self.assertEqual(len(pr.payments), 1) 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) # There should be no outstanding in both currencies si.reload() self.assertEqual(si.outstanding_amount, 0) self.assert_ledger_outstanding(si.doctype, si.name, 0.0, 0.0) # Exchange Gain/Loss Journal should've been created. exc_je_for_si = self.get_journals_for(si.doctype, si.name) exc_je_for_je = self.get_journals_for(je.doctype, je.name) self.assertNotEqual(exc_je_for_si, []) self.assertEqual( len(exc_je_for_si), 2 ) # payment also has reference. so, there are 2 journals referencing invoice self.assertEqual(len(exc_je_for_je), 1) self.assertIn(exc_je_for_je[0], exc_je_for_si) # Cancel Payment je.reload() je.cancel() si.reload() self.assertEqual(si.outstanding_amount, 1) self.assert_ledger_outstanding(si.doctype, si.name, 80.0, 1.0) # Exchange Gain/Loss Journal should've been cancelled exc_je_for_si = self.get_journals_for(si.doctype, si.name) exc_je_for_je = self.get_journals_for(je.doctype, je.name) self.assertEqual(exc_je_for_si, []) self.assertEqual(exc_je_for_je, []) def test_21_advance_journal_against_sales_invoice(self): # Advance Payment adv_exc_rate = 80 adv = self.create_journal_entry( acc1=self.debit_usd, acc1_exc_rate=adv_exc_rate, acc2=self.cash, acc1_amount=-1, acc2_amount=adv_exc_rate * -1, acc2_exc_rate=1, ) adv.accounts[0].party_type = "Customer" adv.accounts[0].party = self.customer adv.accounts[0].is_advance = "Yes" adv = adv.save().submit() adv.reload() # Sales Invoices in different exchange rates for exc_rate in [75.9, 83.1]: with self.subTest(exc_rate=exc_rate): si = self.create_sales_invoice(qty=1, conversion_rate=exc_rate, rate=1, do_not_submit=True) advances = si.get_advance_entries() self.assertEqual(len(advances), 1) self.assertEqual(advances[0].reference_name, adv.name) si.append( "advances", { "doctype": "Sales Invoice Advance", "reference_type": advances[0].reference_type, "reference_name": advances[0].reference_name, "reference_row": advances[0].reference_row, "advance_amount": 1, "allocated_amount": 1, "ref_exchange_rate": advances[0].exchange_rate, "remarks": advances[0].remarks, }, ) si = si.save() si = si.submit() # Outstanding in both currencies should be '0' adv.reload() self.assertEqual(si.outstanding_amount, 0) self.assert_ledger_outstanding(si.doctype, si.name, 0.0, 0.0) # Exchange Gain/Loss Journal should've been created. exc_je_for_si = [x for x in self.get_journals_for(si.doctype, si.name) if x.parent != adv.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_22_partial_advance_and_payment_for_invoice_with_cancellation(self): """ Invoice with partial advance payment as Journal, and a normal payment. Then cancel advance and payment. """ # Partial Advance adv_exc_rate = 75 adv = self.create_journal_entry( acc1=self.debit_usd, acc1_exc_rate=adv_exc_rate, acc2=self.cash, acc1_amount=-1, acc2_amount=adv_exc_rate * -1, acc2_exc_rate=1, ) adv.accounts[0].party_type = "Customer" adv.accounts[0].party = self.customer adv.accounts[0].is_advance = "Yes" adv = adv.save().submit() adv.reload() # invoice with advance(partial amount) si = self.create_sales_invoice(qty=3, conversion_rate=80, rate=1, do_not_submit=True) advances = si.get_advance_entries() self.assertEqual(len(advances), 1) self.assertEqual(advances[0].reference_name, adv.name) si.append( "advances", { "doctype": "Sales Invoice Advance", "reference_type": advances[0].reference_type, "reference_name": advances[0].reference_name, "reference_row": advances[0].reference_row, "advance_amount": 1, "allocated_amount": 1, "ref_exchange_rate": advances[0].exchange_rate, "remarks": advances[0].remarks, }, ) si = si.save() si = si.submit() # Outstanding should be there in both currencies si.reload() self.assertEqual(si.outstanding_amount, 2) # account currency self.assert_ledger_outstanding(si.doctype, si.name, 160.0, 2.0) # Exchange Gain/Loss Journal should've been created for the partial advance exc_je_for_si = [x for x in self.get_journals_for(si.doctype, si.name) if x.parent != adv.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 adv2_exc_rate = 83 pay = self.create_journal_entry( acc1=self.debit_usd, acc1_exc_rate=adv2_exc_rate, acc2=self.cash, acc1_amount=-2, acc2_amount=adv2_exc_rate * -2, acc2_exc_rate=1, ) pay.accounts[0].party_type = "Customer" pay.accounts[0].party = self.customer pay.accounts[0].is_advance = "Yes" pay = pay.save().submit() pay.reload() # Reconcile the remaining amount pr = self.create_payment_reconciliation() # pr.receivable_payable_account = self.debit_usd pr.get_unreconciled_entries() self.assertEqual(len(pr.invoices), 1) self.assertEqual(len(pr.payments), 1) 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) # Outstanding should be '0' in both currencies si.reload() self.assertEqual(si.outstanding_amount, 0) self.assert_ledger_outstanding(si.doctype, si.name, 0.0, 0.0) # Exchange Gain/Loss Journal should've been created for the payment exc_je_for_si = [ x for x in self.get_journals_for(si.doctype, si.name) if x.parent != adv.name and x.parent != pay.name ] exc_je_for_pe = self.get_journals_for(pay.doctype, pay.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() # Outstanding should be there in both currencies, since advance is cancelled. si.reload() self.assertEqual(si.outstanding_amount, 1) # account currency self.assert_ledger_outstanding(si.doctype, si.name, 80.0, 1.0) exc_je_for_si = [ x for x in self.get_journals_for(si.doctype, si.name) if x.parent != adv.name and x.parent != pay.name ] exc_je_for_pe = self.get_journals_for(pay.doctype, pay.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_23_same_journal_split_against_single_invoice(self): # Invoice in Foreign Currency si = self.create_sales_invoice(qty=2, conversion_rate=80, rate=1) # Payment je = self.create_journal_entry( acc1=self.debit_usd, acc1_exc_rate=75, acc2=self.cash, acc1_amount=-2, acc2_amount=-150, acc2_exc_rate=1, ) je.accounts[0].party_type = "Customer" je.accounts[0].party = self.customer je = je.save().submit() # Reconcile the first half pr = self.create_payment_reconciliation() pr.get_unreconciled_entries() self.assertEqual(len(pr.invoices), 1) self.assertEqual(len(pr.payments), 1) 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})) difference_amount = pr.calculate_difference_on_allocation_change( [x.as_dict() for x in pr.payments], [x.as_dict() for x in pr.invoices], 1 ) pr.allocation[0].allocated_amount = 1 pr.allocation[0].difference_amount = difference_amount pr.reconcile() self.assertEqual(len(pr.invoices), 1) self.assertEqual(len(pr.payments), 1) # There should be outstanding in both currencies si.reload() self.assertEqual(si.outstanding_amount, 1) self.assert_ledger_outstanding(si.doctype, si.name, 80.0, 1.0) # Exchange Gain/Loss Journal should've been created. exc_je_for_si = [x for x in self.get_journals_for(si.doctype, si.name) if x.parent != je.name] exc_je_for_je = self.get_journals_for(je.doctype, je.name) self.assertNotEqual(exc_je_for_si, []) self.assertEqual(len(exc_je_for_si), 1) self.assertEqual(len(exc_je_for_je), 1) self.assertIn(exc_je_for_je[0], exc_je_for_si) # reconcile remaining half pr.get_unreconciled_entries() self.assertEqual(len(pr.invoices), 1) self.assertEqual(len(pr.payments), 1) 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.allocation[0].allocated_amount = 1 pr.allocation[0].difference_amount = difference_amount pr.reconcile() self.assertEqual(len(pr.invoices), 0) self.assertEqual(len(pr.payments), 0) # Exchange Gain/Loss Journal should've been created. exc_je_for_si = [x for x in self.get_journals_for(si.doctype, si.name) if x.parent != je.name] exc_je_for_je = self.get_journals_for(je.doctype, je.name) self.assertNotEqual(exc_je_for_si, []) self.assertEqual(len(exc_je_for_si), 2) self.assertEqual(len(exc_je_for_je), 2) self.assertIn(exc_je_for_je[0], exc_je_for_si) si.reload() self.assertEqual(si.outstanding_amount, 0) self.assert_ledger_outstanding(si.doctype, si.name, 0.0, 0.0) # Cancel Payment je.reload() je.cancel() si.reload() self.assertEqual(si.outstanding_amount, 2) self.assert_ledger_outstanding(si.doctype, si.name, 160.0, 2.0) # Exchange Gain/Loss Journal should've been cancelled exc_je_for_si = self.get_journals_for(si.doctype, si.name) exc_je_for_je = self.get_journals_for(je.doctype, je.name) self.assertEqual(exc_je_for_si, []) self.assertEqual(exc_je_for_je, []) def test_24_journal_against_multiple_invoices(self): si1 = self.create_sales_invoice(qty=1, conversion_rate=80, rate=1) si2 = self.create_sales_invoice(qty=1, conversion_rate=80, rate=1) # Payment je = self.create_journal_entry( acc1=self.debit_usd, acc1_exc_rate=75, acc2=self.cash, acc1_amount=-2, acc2_amount=-150, acc2_exc_rate=1, ) je.accounts[0].party_type = "Customer" je.accounts[0].party = self.customer je = je.save().submit() pr = self.create_payment_reconciliation() pr.get_unreconciled_entries() self.assertEqual(len(pr.invoices), 2) self.assertEqual(len(pr.payments), 1) 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) si1.reload() si2.reload() self.assertEqual(si1.outstanding_amount, 0) self.assertEqual(si2.outstanding_amount, 0) self.assert_ledger_outstanding(si1.doctype, si1.name, 0.0, 0.0) self.assert_ledger_outstanding(si2.doctype, si2.name, 0.0, 0.0) # Exchange Gain/Loss Journal should've been cancelled # remove payment JE from list exc_je_for_si1 = [x for x in self.get_journals_for(si1.doctype, si1.name) if x.parent != je.name] exc_je_for_si2 = [x for x in self.get_journals_for(si2.doctype, si2.name) if x.parent != je.name] exc_je_for_je = [x for x in self.get_journals_for(je.doctype, je.name) if x.parent != je.name] self.assertEqual(len(exc_je_for_si1), 1) self.assertEqual(len(exc_je_for_si2), 1) self.assertEqual(len(exc_je_for_je), 2) def test_30_cr_note_against_sales_invoice(self): """ Reconciling Cr Note against Sales Invoice, both having different exchange rates """ # Invoice in Foreign currency si = self.create_sales_invoice(qty=2, conversion_rate=80, rate=1) # Cr Note in Foreign currency of different exchange rate cr_note = self.create_sales_invoice(qty=-2, conversion_rate=75, rate=1, do_not_save=True) cr_note.is_return = 1 cr_note.save().submit() # Reconcile the first half pr = self.create_payment_reconciliation() pr.get_unreconciled_entries() self.assertEqual(len(pr.invoices), 1) self.assertEqual(len(pr.payments), 1) 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})) difference_amount = pr.calculate_difference_on_allocation_change( [x.as_dict() for x in pr.payments], [x.as_dict() for x in pr.invoices], 1 ) pr.allocation[0].allocated_amount = 1 pr.allocation[0].difference_amount = difference_amount pr.reconcile() self.assertEqual(len(pr.invoices), 1) self.assertEqual(len(pr.payments), 1) # Exchange Gain/Loss Journal should've been created. exc_je_for_si = self.get_journals_for(si.doctype, si.name) exc_je_for_cr = self.get_journals_for(cr_note.doctype, cr_note.name) self.assertNotEqual(exc_je_for_si, []) self.assertEqual(len(exc_je_for_si), 2) self.assertEqual(len(exc_je_for_cr), 2) self.assertEqual(exc_je_for_cr, exc_je_for_si) si.reload() self.assertEqual(si.outstanding_amount, 1) self.assert_ledger_outstanding(si.doctype, si.name, 80.0, 1.0) cr_note.reload() cr_note.cancel() # Exchange Gain/Loss Journal should've been created. exc_je_for_si = self.get_journals_for(si.doctype, si.name) exc_je_for_cr = self.get_journals_for(cr_note.doctype, cr_note.name) self.assertNotEqual(exc_je_for_si, []) self.assertEqual(len(exc_je_for_si), 1) self.assertEqual(len(exc_je_for_cr), 0) # The Credit Note JE is still active and is referencing the sales invoice # So, outstanding stays the same si.reload() self.assertEqual(si.outstanding_amount, 1) self.assert_ledger_outstanding(si.doctype, si.name, 80.0, 1.0)