From c47a37c3ab1d4b4e1ebb1c27579cf2a9320db1fb Mon Sep 17 00:00:00 2001 From: Gursheen Anand Date: Wed, 26 Jul 2023 16:42:06 +0530 Subject: [PATCH 01/84] fix: fetch ple with party type employee in AP --- .../accounts_receivable.py | 21 +++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/erpnext/accounts/report/accounts_receivable/accounts_receivable.py b/erpnext/accounts/report/accounts_receivable/accounts_receivable.py index 30f7fb38c5f..93c3fb33403 100755 --- a/erpnext/accounts/report/accounts_receivable/accounts_receivable.py +++ b/erpnext/accounts/report/accounts_receivable/accounts_receivable.py @@ -421,6 +421,10 @@ class ReceivablePayableReport(object): # customer / supplier name party_details = self.get_party_details(row.party) or {} row.update(party_details) + if row.voucher_type == "Expense Claim": + row.party_type = "Employee" + else: + row.party_type = self.party_type if self.filters.get(scrub(self.filters.party_type)): row.currency = row.account_currency else: @@ -747,7 +751,10 @@ class ReceivablePayableReport(object): def prepare_conditions(self): self.qb_selection_filter = [] party_type_field = scrub(self.party_type) - self.qb_selection_filter.append(self.ple.party_type == self.party_type) + if self.party_type == "Supplier": + self.qb_selection_filter.append(self.ple.party_type.isin([self.party_type, "Employee"])) + else: + self.qb_selection_filter.append(self.ple.party_type == self.party_type) self.add_common_filters(party_type_field=party_type_field) @@ -901,10 +908,16 @@ class ReceivablePayableReport(object): self.columns = [] self.add_column("Posting Date", fieldtype="Date") self.add_column( - label=_(self.party_type), + label="Party Type", + fieldname="party_type", + fieldtype="Data", + width=100, + ) + self.add_column( + label="Party", fieldname="party", - fieldtype="Link", - options=self.party_type, + fieldtype="Dynamic Link", + options="party_type", width=180, ) self.add_column( From 81cd7873d343d893a6e2a3b41107f17e03eedcd8 Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Thu, 27 Apr 2023 09:46:54 +0530 Subject: [PATCH 02/84] refactor: book exchange gain/loss through journal --- .../doctype/payment_entry/payment_entry.js | 2 +- .../doctype/payment_entry/payment_entry.py | 19 +- .../payment_reconciliation.py | 10 +- .../doctype/sales_invoice/sales_invoice.py | 3 +- erpnext/accounts/utils.py | 34 +++- erpnext/controllers/accounts_controller.py | 170 ++++++++++++------ 6 files changed, 171 insertions(+), 67 deletions(-) diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.js b/erpnext/accounts/doctype/payment_entry/payment_entry.js index ed18feaf57d..105c4767fa3 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry.js +++ b/erpnext/accounts/doctype/payment_entry/payment_entry.js @@ -9,7 +9,7 @@ erpnext.accounts.taxes.setup_tax_filters("Advance Taxes and Charges"); frappe.ui.form.on('Payment Entry', { onload: function(frm) { - frm.ignore_doctypes_on_cancel_all = ['Sales Invoice', 'Purchase Invoice', "Repost Payment Ledger"]; + frm.ignore_doctypes_on_cancel_all = ['Sales Invoice', 'Purchase Invoice', "Journal Entry", "Repost Payment Ledger"]; if(frm.doc.__islocal) { if (!frm.doc.paid_from) frm.set_value("paid_from_account_currency", null); diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.py b/erpnext/accounts/doctype/payment_entry/payment_entry.py index 38d8b8fcad3..eea7f4d6505 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry.py +++ b/erpnext/accounts/doctype/payment_entry/payment_entry.py @@ -143,6 +143,7 @@ class PaymentEntry(AccountsController): "Repost Payment Ledger", "Repost Payment Ledger Items", ) + super(PaymentEntry, self).on_cancel() self.make_gl_entries(cancel=1) self.make_advance_gl_entries(cancel=1) self.update_outstanding_amounts() @@ -808,10 +809,25 @@ class PaymentEntry(AccountsController): 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 refernece table + # if there is an amount, 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(d.exchange_rate), self.precision("base_paid_amount") + flt(d.allocated_amount) * flt(exchange_rate), self.precision("base_paid_amount") ) + allocated_amount_in_pe_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_pe_exchange_rate return base_allocated_amount def set_total_allocated_amount(self): @@ -1002,6 +1018,7 @@ class PaymentEntry(AccountsController): gl_entries = self.build_gl_map() gl_entries = process_gl_map(gl_entries) make_gl_entries(gl_entries, cancel=cancel, adv_adj=adv_adj) + self.make_exchange_gain_loss_journal() def add_party_gl_entries(self, gl_entries): if self.party_account: diff --git a/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py b/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py index 25d94c55d3a..df777f03be4 100644 --- a/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py +++ b/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py @@ -363,11 +363,11 @@ class PaymentReconciliation(Document): payment_details = self.get_payment_details(row, dr_or_cr) reconciled_entry.append(payment_details) - if payment_details.difference_amount and row.reference_type not in [ - "Sales Invoice", - "Purchase Invoice", - ]: - self.make_difference_entry(payment_details) + # if payment_details.difference_amount and row.reference_type not in [ + # "Sales Invoice", + # "Purchase Invoice", + # ]: + # self.make_difference_entry(payment_details) if entry_list: reconcile_against_document(entry_list, skip_ref_details_update_for_pe) diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py index 974a876429c..fa18d8fc0ef 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py @@ -1029,6 +1029,8 @@ class SalesInvoice(SellingController): merge_entries=False, from_repost=from_repost, ) + + self.make_exchange_gain_loss_journal() elif self.docstatus == 2: make_reverse_gl_entries(voucher_type=self.doctype, voucher_no=self.name) @@ -1054,7 +1056,6 @@ class SalesInvoice(SellingController): self.make_customer_gl_entry(gl_entries) self.make_tax_gl_entries(gl_entries) - self.make_exchange_gain_loss_gl_entries(gl_entries) self.make_internal_transfer_gl_entries(gl_entries) self.make_item_gl_entries(gl_entries) diff --git a/erpnext/accounts/utils.py b/erpnext/accounts/utils.py index e3546631514..0b3f45ad76b 100644 --- a/erpnext/accounts/utils.py +++ b/erpnext/accounts/utils.py @@ -614,9 +614,7 @@ def update_reference_in_payment_entry( "total_amount": d.grand_total, "outstanding_amount": d.outstanding_amount, "allocated_amount": d.allocated_amount, - "exchange_rate": d.exchange_rate - if not d.exchange_gain_loss - else payment_entry.get_exchange_rate(), + "exchange_rate": d.exchange_rate if d.exchange_gain_loss else payment_entry.get_exchange_rate(), "exchange_gain_loss": d.exchange_gain_loss, # only populated from invoice in case of advance allocation "account": d.account, } @@ -655,11 +653,41 @@ def update_reference_in_payment_entry( if not skip_ref_details_update_for_pe: payment_entry.set_missing_ref_details() payment_entry.set_amounts() + payment_entry.make_exchange_gain_loss_journal() if not do_not_save: payment_entry.save(ignore_permissions=True) +def cancel_exchange_gain_loss_journal(parent_doc: dict | object) -> None: + """ + Cancel Exchange Gain/Loss for Sales/Purchase Invoice, if they have any. + """ + if parent_doc.doctype in ["Sales Invoice", "Purchase Invoice", "Payment Entry"]: + journals = frappe.db.get_all( + "Journal Entry Account", + filters={ + "reference_type": parent_doc.doctype, + "reference_name": parent_doc.name, + "docstatus": 1, + }, + fields=["parent"], + as_list=1, + ) + if journals: + exchange_journals = frappe.db.get_all( + "Journal Entry", + filters={ + "name": ["in", [x[0] for x in journals]], + "voucher_type": "Exchange Gain Or Loss", + "docstatus": 1, + }, + as_list=1, + ) + for doc in exchange_journals: + frappe.get_doc("Journal Entry", doc[0]).cancel() + + def unlink_ref_doc_from_payment_entries(ref_doc): remove_ref_doc_link_from_jv(ref_doc.doctype, ref_doc.name) remove_ref_doc_link_from_pe(ref_doc.doctype, ref_doc.name) diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index 79404894cd2..ee7dfb737a9 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -5,7 +5,7 @@ import json import frappe -from frappe import _, bold, throw +from frappe import _, bold, qb, throw from frappe.model.workflow import get_workflow_name, is_transition_condition_satisfied from frappe.query_builder.custom import ConstantColumn from frappe.query_builder.functions import Abs, Sum @@ -968,67 +968,119 @@ class AccountsController(TransactionBase): d.exchange_gain_loss = difference - def make_exchange_gain_loss_gl_entries(self, gl_entries): - if self.get("doctype") in ["Purchase Invoice", "Sales Invoice"]: - for d in self.get("advances"): - if d.exchange_gain_loss: - is_purchase_invoice = self.get("doctype") == "Purchase Invoice" - party = self.supplier if is_purchase_invoice else self.customer - party_account = self.credit_to if is_purchase_invoice else self.debit_to - party_type = "Supplier" if is_purchase_invoice else "Customer" - - gain_loss_account = frappe.get_cached_value( - "Company", self.company, "exchange_gain_loss_account" - ) - if not gain_loss_account: - frappe.throw( - _("Please set default Exchange Gain/Loss Account in Company {}").format(self.get("company")) - ) - account_currency = get_account_currency(gain_loss_account) - if account_currency != self.company_currency: - frappe.throw( - _("Currency for {0} must be {1}").format(gain_loss_account, self.company_currency) - ) - - # for purchase - dr_or_cr = "debit" if d.exchange_gain_loss > 0 else "credit" - if not is_purchase_invoice: - # just reverse for sales? - dr_or_cr = "debit" if dr_or_cr == "credit" else "credit" - - gl_entries.append( - self.get_gl_dict( - { - "account": gain_loss_account, - "account_currency": account_currency, - "against": party, - dr_or_cr + "_in_account_currency": abs(d.exchange_gain_loss), - dr_or_cr: abs(d.exchange_gain_loss), - "cost_center": self.cost_center or erpnext.get_default_cost_center(self.company), - "project": self.project, - }, - item=d, + def make_exchange_gain_loss_journal(self) -> None: + """ + Make Exchange Gain/Loss journal for Invoices and Payments + """ + # Cancelling is existing exchange gain/loss journals is handled in on_cancel event + if self.docstatus == 1: + if self.get("doctype") == "Payment Entry": + gain_loss_to_book = [x for x in self.references if x.exchange_gain_loss != 0] + booked = [] + if gain_loss_to_book: + vtypes = [x.reference_doctype for x in gain_loss_to_book] + vnames = [x.reference_name for x in gain_loss_to_book] + je = qb.DocType("Journal Entry") + jea = qb.DocType("Journal Entry Account") + parents = ( + qb.from_(jea) + .select(jea.parent) + .where( + (jea.reference_type == "Payment Entry") + & (jea.reference_name == self.name) + & (jea.docstatus == 1) ) + .run() ) - dr_or_cr = "debit" if dr_or_cr == "credit" else "credit" + booked = [] + if parents: + booked = ( + qb.from_(je) + .inner_join(jea) + .on(je.name == jea.parent) + .select(jea.reference_type, jea.reference_name, jea.reference_detail_no) + .where( + (je.docstatus == 1) + & (je.name.isin(parents)) + & (je.voucher_type == "Exchange Gain or Loss") + ) + .run() + ) - gl_entries.append( - self.get_gl_dict( + for d in gain_loss_to_book: + if d.exchange_gain_loss and ( + (d.reference_doctype, d.reference_name, str(d.idx)) not in booked + ): + journal_entry = frappe.new_doc("Journal Entry") + journal_entry.voucher_type = "Exchange Gain Or Loss" + journal_entry.company = self.company + journal_entry.posting_date = nowdate() + journal_entry.multi_currency = 1 + + if self.payment_type == "Receive": + party_account = self.paid_from + elif self.payment_type == "Pay": + party_account = self.paid_to + + party_account_currency = frappe.get_cached_value( + "Account", party_account, "account_currency" + ) + dr_or_cr = "debit" if d.exchange_gain_loss > 0 else "credit" + reverse_dr_or_cr = "debit" if dr_or_cr == "credit" else "credit" + + gain_loss_account = frappe.get_cached_value( + "Company", self.company, "exchange_gain_loss_account" + ) + if not gain_loss_account: + frappe.throw( + _("Please set default Exchange Gain/Loss Account in Company {}").format( + self.get("company") + ) + ) + gain_loss_account_currency = get_account_currency(gain_loss_account) + if gain_loss_account_currency != self.company_currency: + frappe.throw( + _("Currency for {0} must be {1}").format(gain_loss_account, self.company_currency) + ) + + journal_account = frappe._dict( { "account": party_account, - "party_type": party_type, - "party": party, - "against": gain_loss_account, - dr_or_cr + "_in_account_currency": flt(abs(d.exchange_gain_loss) / self.conversion_rate), + "party_type": self.party_type, + "party": self.party, + "account_currency": party_account_currency, + "exchange_rate": 0, + "cost_center": erpnext.get_default_cost_center(self.company), + "reference_type": d.reference_doctype, + "reference_name": d.reference_name, + "reference_detail_no": d.idx, dr_or_cr: abs(d.exchange_gain_loss), - "cost_center": self.cost_center, - "project": self.project, - }, - self.party_account_currency, - item=self, + dr_or_cr + "_in_account_currency": 0, + } ) - ) + + journal_entry.append("accounts", journal_account) + + journal_account = frappe._dict( + { + "account": gain_loss_account, + "account_currency": gain_loss_account_currency, + "exchange_rate": 1, + "cost_center": erpnext.get_default_cost_center(self.company), + "reference_type": self.doctype, + "reference_name": self.name, + "reference_detail_no": d.idx, + reverse_dr_or_cr + "_in_account_currency": abs(d.exchange_gain_loss), + reverse_dr_or_cr: abs(d.exchange_gain_loss), + } + ) + + journal_entry.append("accounts", journal_account) + + journal_entry.save() + journal_entry.submit() + # frappe.throw("stopping...") def update_against_document_in_jv(self): """ @@ -1090,9 +1142,15 @@ class AccountsController(TransactionBase): reconcile_against_document(lst) def on_cancel(self): - from erpnext.accounts.utils import unlink_ref_doc_from_payment_entries + from erpnext.accounts.utils import ( + cancel_exchange_gain_loss_journal, + unlink_ref_doc_from_payment_entries, + ) + + if self.doctype in ["Sales Invoice", "Purchase Invoice", "Payment Entry"]: + # Cancel Exchange Gain/Loss Journal before unlinking + cancel_exchange_gain_loss_journal(self) - if self.doctype in ["Sales Invoice", "Purchase Invoice"]: if frappe.db.get_single_value("Accounts Settings", "unlink_payment_on_cancellation_of_invoice"): unlink_ref_doc_from_payment_entries(self) From 5e1cd1f22701b7675422b05b3616253d9a3a28db Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Thu, 15 Jun 2023 16:55:56 +0530 Subject: [PATCH 03/84] test: different scenarios for exchange booking --- .../tests/test_accounts_controller.py | 501 ++++++++++++++++++ 1 file changed, 501 insertions(+) create mode 100644 erpnext/controllers/tests/test_accounts_controller.py diff --git a/erpnext/controllers/tests/test_accounts_controller.py b/erpnext/controllers/tests/test_accounts_controller.py new file mode 100644 index 00000000000..31aa857c8f5 --- /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, []) From 7e94a1c51b428202820858f72a7e4a864cde0e9c Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Fri, 16 Jun 2023 11:34:11 +0530 Subject: [PATCH 04/84] refactor: replace with new method in purchase invoice --- erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py index 230a8b3c586..1f9555a3c34 100644 --- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py +++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py @@ -536,6 +536,7 @@ class PurchaseInvoice(BuyingController): merge_entries=False, from_repost=from_repost, ) + self.make_exchange_gain_loss_journal() elif self.docstatus == 2: provisional_entries = [a for a in gl_entries if a.voucher_type == "Purchase Receipt"] make_reverse_gl_entries(voucher_type=self.doctype, voucher_no=self.name) @@ -580,7 +581,6 @@ class PurchaseInvoice(BuyingController): self.get_asset_gl_entry(gl_entries) self.make_tax_gl_entries(gl_entries) - self.make_exchange_gain_loss_gl_entries(gl_entries) self.make_internal_transfer_gl_entries(gl_entries) gl_entries = make_regional_gl_entries(gl_entries, self) From 0587338435a6ffeeb59669ff20dbd9779b9ac740 Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Fri, 16 Jun 2023 12:32:21 +0530 Subject: [PATCH 05/84] chore: patch to update property setter for Journal Entry Accounts --- erpnext/patches.txt | 1 + ...eference_type_in_journal_entry_accounts.py | 22 +++++++++++++++++++ 2 files changed, 23 insertions(+) create mode 100644 erpnext/patches/v14_0/update_reference_type_in_journal_entry_accounts.py diff --git a/erpnext/patches.txt b/erpnext/patches.txt index 0f4238c16b9..641d7550e3e 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -320,6 +320,7 @@ erpnext.patches.v15_0.update_gpa_and_ndb_for_assdeprsch erpnext.patches.v14_0.create_accounting_dimensions_for_closing_balance erpnext.patches.v14_0.update_closing_balances #14-07-2023 execute:frappe.db.set_single_value("Accounts Settings", "merge_similar_account_heads", 0) +erpnext.patches.v14_0.update_reference_type_in_journal_entry_accounts # below migration patches should always run last erpnext.patches.v14_0.migrate_gl_to_payment_ledger execute:frappe.delete_doc_if_exists("Report", "Tax Detail") diff --git a/erpnext/patches/v14_0/update_reference_type_in_journal_entry_accounts.py b/erpnext/patches/v14_0/update_reference_type_in_journal_entry_accounts.py new file mode 100644 index 00000000000..48b6bcf755f --- /dev/null +++ b/erpnext/patches/v14_0/update_reference_type_in_journal_entry_accounts.py @@ -0,0 +1,22 @@ +import frappe + + +def execute(): + """ + Update Propery Setters for Journal Entry with new 'Entry Type' + """ + new_reference_type = "Payment Entry" + prop_setter = frappe.db.get_list( + "Property Setter", + filters={ + "doc_type": "Journal Entry Account", + "field_name": "reference_type", + "property": "options", + }, + ) + if prop_setter: + property_setter_doc = frappe.get_doc("Property Setter", prop_setter[0].get("name")) + + if new_reference_type not in property_setter_doc.value.split("\n"): + property_setter_doc.value += "\n" + new_reference_type + property_setter_doc.save() From 13febcac811507c7c61bc116ca797857d0b5baf5 Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Fri, 16 Jun 2023 14:01:48 +0530 Subject: [PATCH 06/84] refactor: add new reference type in journal entry account --- .../doctype/journal_entry_account/journal_entry_account.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/erpnext/accounts/doctype/journal_entry_account/journal_entry_account.json b/erpnext/accounts/doctype/journal_entry_account/journal_entry_account.json index 47ad19e0f98..3ba8cea94bb 100644 --- a/erpnext/accounts/doctype/journal_entry_account/journal_entry_account.json +++ b/erpnext/accounts/doctype/journal_entry_account/journal_entry_account.json @@ -203,7 +203,7 @@ "fieldtype": "Select", "label": "Reference Type", "no_copy": 1, - "options": "\nSales Invoice\nPurchase Invoice\nJournal Entry\nSales Order\nPurchase Order\nExpense Claim\nAsset\nLoan\nPayroll Entry\nEmployee Advance\nExchange Rate Revaluation\nInvoice Discounting\nFees\nFull and Final Statement" + "options": "\nSales Invoice\nPurchase Invoice\nJournal Entry\nSales Order\nPurchase Order\nExpense Claim\nAsset\nLoan\nPayroll Entry\nEmployee Advance\nExchange Rate Revaluation\nInvoice Discounting\nFees\nFull and Final Statement\nPayment Entry" }, { "fieldname": "reference_name", @@ -284,7 +284,7 @@ "idx": 1, "istable": 1, "links": [], - "modified": "2022-10-26 20:03:10.906259", + "modified": "2023-06-16 14:11:13.507807", "modified_by": "Administrator", "module": "Accounts", "name": "Journal Entry Account", From 34b5e849a290ee02d9b653286dfbe6590d35a800 Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Fri, 16 Jun 2023 14:07:44 +0530 Subject: [PATCH 07/84] chore: fix logic for purchase invoice and some typos --- erpnext/accounts/doctype/payment_entry/payment_entry.py | 4 ++-- erpnext/controllers/accounts_controller.py | 7 +++++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.py b/erpnext/accounts/doctype/payment_entry/payment_entry.py index eea7f4d6505..44e3e898d24 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry.py +++ b/erpnext/accounts/doctype/payment_entry/payment_entry.py @@ -811,8 +811,8 @@ class PaymentEntry(AccountsController): else: # Use source/target exchange rate, so no difference amount is calculated. - # then update exchange gain/loss amount in refernece table - # if there is an amount, submit a JE for that + # 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": diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index ee7dfb737a9..e60719c5c01 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -972,7 +972,7 @@ class AccountsController(TransactionBase): """ Make Exchange Gain/Loss journal for Invoices and Payments """ - # Cancelling is existing exchange gain/loss journals is handled in on_cancel event + # Cancelling existing exchange gain/loss journals is handled in on_cancel event in accounts/utils.py if self.docstatus == 1: if self.get("doctype") == "Payment Entry": gain_loss_to_book = [x for x in self.references if x.exchange_gain_loss != 0] @@ -1027,6 +1027,10 @@ class AccountsController(TransactionBase): "Account", party_account, "account_currency" ) dr_or_cr = "debit" if d.exchange_gain_loss > 0 else "credit" + + if d.reference_doctype == "Purchase Invoice": + dr_or_cr = "debit" if dr_or_cr == "credit" else "credit" + reverse_dr_or_cr = "debit" if dr_or_cr == "credit" else "credit" gain_loss_account = frappe.get_cached_value( @@ -1080,7 +1084,6 @@ class AccountsController(TransactionBase): journal_entry.save() journal_entry.submit() - # frappe.throw("stopping...") def update_against_document_in_jv(self): """ From c1184585eda2e37b74718b95d541fa0419511bd9 Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Mon, 19 Jun 2023 17:51:05 +0530 Subject: [PATCH 08/84] refactor: helper method --- .../doctype/payment_entry/test_payment_entry.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/erpnext/accounts/doctype/payment_entry/test_payment_entry.py b/erpnext/accounts/doctype/payment_entry/test_payment_entry.py index c6e93f3f7a2..afd03c6bd4a 100644 --- a/erpnext/accounts/doctype/payment_entry/test_payment_entry.py +++ b/erpnext/accounts/doctype/payment_entry/test_payment_entry.py @@ -31,6 +31,16 @@ class TestPaymentEntry(FrappeTestCase): def tearDown(self): frappe.db.rollback() + 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_payment_entry_against_order(self): so = make_sales_order() pe = get_payment_entry("Sales Order", so.name, bank_account="_Test Cash - _TC") From 92ae9c220110ddcb32d90a1bb89f0b85e72ff7d0 Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Mon, 19 Jun 2023 09:58:18 +0530 Subject: [PATCH 09/84] refactor: remove unused variable, pe should pull in parent exc rate 1. 'reference_doc' variable is never set. Hence, removing. 2. set_exchange_rate() relies on ref_doc, which was never set due to point [1]. Replacing it with 'doc'. 3. Sales/Purchase Invoice has 'conversion_rate' field for tracking exchange rate. Added a get statement for them as well. --- erpnext/accounts/doctype/payment_entry/payment_entry.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.py b/erpnext/accounts/doctype/payment_entry/payment_entry.py index 44e3e898d24..89241ebfe0d 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry.py +++ b/erpnext/accounts/doctype/payment_entry/payment_entry.py @@ -400,7 +400,7 @@ class PaymentEntry(AccountsController): else: if ref_doc: if self.paid_from_account_currency == ref_doc.currency: - self.source_exchange_rate = ref_doc.get("exchange_rate") + 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( @@ -413,7 +413,7 @@ class PaymentEntry(AccountsController): elif self.paid_to and not self.target_exchange_rate: if ref_doc: if self.paid_to_account_currency == ref_doc.currency: - self.target_exchange_rate = ref_doc.get("exchange_rate") + 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( @@ -2005,7 +2005,6 @@ def get_payment_entry( payment_type=None, reference_date=None, ): - reference_doc = None doc = frappe.get_doc(dt, dn) over_billing_allowance = frappe.db.get_single_value("Accounts Settings", "over_billing_allowance") if dt in ("Sales Order", "Purchase Order") and flt(doc.per_billed, 2) >= ( @@ -2145,7 +2144,7 @@ def get_payment_entry( update_accounting_dimensions(pe, doc) if party_account and bank: - pe.set_exchange_rate(ref_doc=reference_doc) + pe.set_exchange_rate(ref_doc=doc) pe.set_amounts() if discount_amount: From 4ff53e106271e0562e2ba5604802a41c24c999c4 Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Mon, 19 Jun 2023 10:23:23 +0530 Subject: [PATCH 10/84] refactor: assert exchange gain/loss amount in reference table --- .../doctype/payment_entry/test_payment_entry.py | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/erpnext/accounts/doctype/payment_entry/test_payment_entry.py b/erpnext/accounts/doctype/payment_entry/test_payment_entry.py index afd03c6bd4a..997d52bed62 100644 --- a/erpnext/accounts/doctype/payment_entry/test_payment_entry.py +++ b/erpnext/accounts/doctype/payment_entry/test_payment_entry.py @@ -601,21 +601,15 @@ class TestPaymentEntry(FrappeTestCase): pe.target_exchange_rate = 45.263 pe.reference_no = "1" pe.reference_date = "2016-01-01" - - pe.append( - "deductions", - { - "account": "_Test Exchange Gain/Loss - _TC", - "cost_center": "_Test Cost Center - _TC", - "amount": 94.80, - }, - ) - pe.save() self.assertEqual(flt(pe.difference_amount, 2), 0.0) self.assertEqual(flt(pe.unallocated_amount, 2), 0.0) + # the exchange gain/loss amount is captured in reference table and a separate Journal will be submitted for them + # payment entry will not be generating difference amount + self.assertEqual(flt(pe.references[0].exchange_gain_loss, 2), -94.74) + def test_payment_entry_retrieves_last_exchange_rate(self): from erpnext.setup.doctype.currency_exchange.test_currency_exchange import ( save_new_records, From 00a2e42a47fb064afbb31b27653a54d12b6c8097 Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Mon, 19 Jun 2023 11:26:49 +0530 Subject: [PATCH 11/84] refactor(test): exc gain/loss booked through journal --- .../payment_entry/test_payment_entry.py | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/erpnext/accounts/doctype/payment_entry/test_payment_entry.py b/erpnext/accounts/doctype/payment_entry/test_payment_entry.py index 997d52bed62..5f3267427ac 100644 --- a/erpnext/accounts/doctype/payment_entry/test_payment_entry.py +++ b/erpnext/accounts/doctype/payment_entry/test_payment_entry.py @@ -796,33 +796,28 @@ class TestPaymentEntry(FrappeTestCase): pe.reference_no = "1" pe.reference_date = "2016-01-01" pe.source_exchange_rate = 55 - - pe.append( - "deductions", - { - "account": "_Test Exchange Gain/Loss - _TC", - "cost_center": "_Test Cost Center - _TC", - "amount": -500, - }, - ) pe.save() self.assertEqual(pe.unallocated_amount, 0) self.assertEqual(pe.difference_amount, 0) - + self.assertEqual(pe.references[0].exchange_gain_loss, 500) pe.submit() expected_gle = dict( (d[0], d) for d in [ - ["_Test Receivable USD - _TC", 0, 5000, si.name], + ["_Test Receivable USD - _TC", 0, 5500, si.name], ["_Test Bank USD - _TC", 5500, 0, None], - ["_Test Exchange Gain/Loss - _TC", 0, 500, None], ] ) self.validate_gl_entries(pe.name, expected_gle) + # Exchange gain/loss should have been posted through a journal + 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, exc_je_for_pe) outstanding_amount = flt(frappe.db.get_value("Sales Invoice", si.name, "outstanding_amount")) self.assertEqual(outstanding_amount, 0) From 7b516f84636e7219ac17d972c80a8286c385e954 Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Mon, 26 Jun 2023 17:34:28 +0530 Subject: [PATCH 12/84] refactor: exc booking logic for Journal Entry --- erpnext/accounts/utils.py | 3 + erpnext/controllers/accounts_controller.py | 78 +++++++++++++++++++++- 2 files changed, 80 insertions(+), 1 deletion(-) diff --git a/erpnext/accounts/utils.py b/erpnext/accounts/utils.py index 0b3f45ad76b..cfd0133700d 100644 --- a/erpnext/accounts/utils.py +++ b/erpnext/accounts/utils.py @@ -459,6 +459,9 @@ def reconcile_against_document(args, skip_ref_details_update_for_pe=False): # n # update ref in advance entry if voucher_type == "Journal Entry": update_reference_in_journal_entry(entry, doc, do_not_save=True) + # advance section in sales/purchase invoice and reconciliation tool,both pass on exchange gain/loss + # amount and account in args + doc.make_exchange_gain_loss_journal(args) else: update_reference_in_payment_entry( entry, doc, do_not_save=True, skip_ref_details_update_for_pe=skip_ref_details_update_for_pe diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index e60719c5c01..5597b515f63 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -968,13 +968,89 @@ class AccountsController(TransactionBase): d.exchange_gain_loss = difference - def make_exchange_gain_loss_journal(self) -> None: + def make_exchange_gain_loss_journal(self, args=None) -> None: """ Make Exchange Gain/Loss journal for Invoices and Payments """ # Cancelling existing exchange gain/loss journals is handled in on_cancel event in accounts/utils.py if self.docstatus == 1: + if self.get("doctype") == "Journal Entry": + if args: + for arg in args: + print(arg) + if arg.get("difference_amount") != 0 and arg.get("difference_account"): + journal_entry = frappe.new_doc("Journal Entry") + journal_entry.voucher_type = "Exchange Gain Or Loss" + journal_entry.company = self.company + journal_entry.posting_date = nowdate() + journal_entry.multi_currency = 1 + + party_account = arg.account + party_account_currency = frappe.get_cached_value( + "Account", party_account, "account_currency" + ) + dr_or_cr = "debit" if arg.difference_amount > 0 else "credit" + + if arg.reference_doctype == "Purchase Invoice": + dr_or_cr = "debit" if dr_or_cr == "credit" else "credit" + + reverse_dr_or_cr = "debit" if dr_or_cr == "credit" else "credit" + + gain_loss_account = arg.difference_account + + if not gain_loss_account: + frappe.throw( + _("Please set default Exchange Gain/Loss Account in Company {}").format( + self.get("company") + ) + ) + + gain_loss_account_currency = get_account_currency(gain_loss_account) + if gain_loss_account_currency != self.company_currency: + frappe.throw( + _("Currency for {0} must be {1}").format(gain_loss_account, self.company_currency) + ) + + journal_account = frappe._dict( + { + "account": party_account, + "party_type": arg.party_type, + "party": arg.party, + "account_currency": party_account_currency, + "exchange_rate": 0, + "cost_center": erpnext.get_default_cost_center(self.company), + "reference_type": arg.against_voucher_type, + "reference_name": arg.against_voucher, + "reference_detail_no": arg.idx, + dr_or_cr: abs(arg.difference_amount), + dr_or_cr + "_in_account_currency": 0, + } + ) + + journal_entry.append("accounts", journal_account) + + journal_account = frappe._dict( + { + "account": gain_loss_account, + "account_currency": gain_loss_account_currency, + "exchange_rate": 1, + "cost_center": erpnext.get_default_cost_center(self.company), + # TODO: figure out a way to pass reference + # "reference_type": self.doctype, + # "reference_name": self.name, + # "reference_detail_no": arg.idx, + reverse_dr_or_cr + "_in_account_currency": abs(arg.difference_amount), + reverse_dr_or_cr: abs(arg.difference_amount), + } + ) + + journal_entry.append("accounts", journal_account) + + journal_entry.save() + journal_entry.submit() + if self.get("doctype") == "Payment Entry": + # For Payment Entry, exchange_gain_loss field in the `reference` table is the trigger for journal creation gain_loss_to_book = [x for x in self.references if x.exchange_gain_loss != 0] booked = [] if gain_loss_to_book: From ee3ce82ea82df9dd2910e4d29a5c2c4f885be393 Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Mon, 26 Jun 2023 21:43:20 +0530 Subject: [PATCH 13/84] chore: remove debugging statements and fixing failing unit tests --- erpnext/controllers/accounts_controller.py | 23 +++++++++++----------- 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index 5597b515f63..2548bdc7607 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -977,26 +977,25 @@ class AccountsController(TransactionBase): if self.get("doctype") == "Journal Entry": if args: for arg in args: - print(arg) - if arg.get("difference_amount") != 0 and arg.get("difference_account"): + if arg.get("difference_amount", 0) != 0 and arg.get("difference_account"): journal_entry = frappe.new_doc("Journal Entry") journal_entry.voucher_type = "Exchange Gain Or Loss" journal_entry.company = self.company journal_entry.posting_date = nowdate() journal_entry.multi_currency = 1 - party_account = arg.account + party_account = arg.get("account") party_account_currency = frappe.get_cached_value( "Account", party_account, "account_currency" ) - dr_or_cr = "debit" if arg.difference_amount > 0 else "credit" + dr_or_cr = "debit" if arg.get("difference_amount") > 0 else "credit" if arg.reference_doctype == "Purchase Invoice": dr_or_cr = "debit" if dr_or_cr == "credit" else "credit" reverse_dr_or_cr = "debit" if dr_or_cr == "credit" else "credit" - gain_loss_account = arg.difference_account + gain_loss_account = arg.get("difference_account") if not gain_loss_account: frappe.throw( @@ -1014,14 +1013,14 @@ class AccountsController(TransactionBase): journal_account = frappe._dict( { "account": party_account, - "party_type": arg.party_type, - "party": arg.party, + "party_type": arg.get("party_type"), + "party": arg.get("party"), "account_currency": party_account_currency, "exchange_rate": 0, "cost_center": erpnext.get_default_cost_center(self.company), - "reference_type": arg.against_voucher_type, - "reference_name": arg.against_voucher, - "reference_detail_no": arg.idx, + "reference_type": arg.get("against_voucher_type"), + "reference_name": arg.get("against_voucher"), + "reference_detail_no": arg.get("idx"), dr_or_cr: abs(arg.difference_amount), dr_or_cr + "_in_account_currency": 0, } @@ -1039,8 +1038,8 @@ class AccountsController(TransactionBase): # "reference_type": self.doctype, # "reference_name": self.name, # "reference_detail_no": arg.idx, - reverse_dr_or_cr + "_in_account_currency": abs(arg.difference_amount), - reverse_dr_or_cr: abs(arg.difference_amount), + reverse_dr_or_cr + "_in_account_currency": abs(arg.get("difference_amount")), + reverse_dr_or_cr: abs(arg.get("difference_amount")), } ) From 389cadf15715b1483986297b38ec2dbb268d2b26 Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Tue, 27 Jun 2023 11:16:52 +0530 Subject: [PATCH 14/84] refactor(test): assert Exc journal when reconciling Journa to invoic --- .../test_payment_reconciliation.py | 16 +++++++++++++--- erpnext/controllers/accounts_controller.py | 2 +- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/erpnext/accounts/doctype/payment_reconciliation/test_payment_reconciliation.py b/erpnext/accounts/doctype/payment_reconciliation/test_payment_reconciliation.py index 2ac7df0e39b..1d843abde1d 100644 --- a/erpnext/accounts/doctype/payment_reconciliation/test_payment_reconciliation.py +++ b/erpnext/accounts/doctype/payment_reconciliation/test_payment_reconciliation.py @@ -686,14 +686,24 @@ class TestPaymentReconciliation(FrappeTestCase): # Check if difference journal entry gets generated for difference amount after reconciliation pr.reconcile() - total_debit_amount = frappe.db.get_all( + total_credit_amount = frappe.db.get_all( "Journal Entry Account", {"account": self.debtors_eur, "docstatus": 1, "reference_name": si.name}, - "sum(debit) as amount", + "sum(credit) as amount", group_by="reference_name", )[0].amount - self.assertEqual(flt(total_debit_amount, 2), -500) + # total credit includes the exchange gain/loss amount + self.assertEqual(flt(total_credit_amount, 2), 8500) + + jea_parent = frappe.db.get_all( + "Journal Entry Account", + filters={"account": self.debtors_eur, "docstatus": 1, "reference_name": si.name, "credit": 500}, + fields=["parent"], + )[0] + self.assertEqual( + frappe.db.get_value("Journal Entry", jea_parent.parent, "voucher_type"), "Exchange Gain Or Loss" + ) def test_difference_amount_via_payment_entry(self): # Make Sale Invoice diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index 2548bdc7607..5abff417bf4 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -1038,8 +1038,8 @@ class AccountsController(TransactionBase): # "reference_type": self.doctype, # "reference_name": self.name, # "reference_detail_no": arg.idx, - reverse_dr_or_cr + "_in_account_currency": abs(arg.get("difference_amount")), reverse_dr_or_cr: abs(arg.get("difference_amount")), + reverse_dr_or_cr + "_in_account_currency": 0, } ) From ee2d1fa36e24326aa9f5b11877139857ed3a6f21 Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Tue, 27 Jun 2023 11:41:14 +0530 Subject: [PATCH 15/84] refactor(test): payment will have same exch rate - no gain/loss while making payment entry using reference to sales/purchase invoice, it herits the parent docs exchange rate. so, there will be no exchange gain/loss --- .../accounts/doctype/payment_request/test_payment_request.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/erpnext/accounts/doctype/payment_request/test_payment_request.py b/erpnext/accounts/doctype/payment_request/test_payment_request.py index e17a846dd81..feb2fdffc95 100644 --- a/erpnext/accounts/doctype/payment_request/test_payment_request.py +++ b/erpnext/accounts/doctype/payment_request/test_payment_request.py @@ -144,8 +144,7 @@ class TestPaymentRequest(unittest.TestCase): (d[0], d) for d in [ ["_Test Receivable USD - _TC", 0, 5000, si_usd.name], - [pr.payment_account, 6290.0, 0, None], - ["_Test Exchange Gain/Loss - _TC", 0, 1290, None], + [pr.payment_account, 5000.0, 0, None], ] ) From 78bc712756bc9d8966c22cbbce68e5058daa87db Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Tue, 27 Jun 2023 12:29:02 +0530 Subject: [PATCH 16/84] refactor: only post on base currency for exchange gain/loss --- erpnext/controllers/accounts_controller.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index 5abff417bf4..f72ae81a533 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -1150,7 +1150,7 @@ class AccountsController(TransactionBase): "reference_type": self.doctype, "reference_name": self.name, "reference_detail_no": d.idx, - reverse_dr_or_cr + "_in_account_currency": abs(d.exchange_gain_loss), + reverse_dr_or_cr + "_in_account_currency": 0, reverse_dr_or_cr: abs(d.exchange_gain_loss), } ) From 5b06bd1af4197b0c6ab8714c65d8f7a578499163 Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Tue, 27 Jun 2023 12:42:07 +0530 Subject: [PATCH 17/84] refactor(test): exc gain/loss journal for advance in purchase invoice --- .../purchase_invoice/test_purchase_invoice.py | 63 +++++++++++++++++-- 1 file changed, 59 insertions(+), 4 deletions(-) diff --git a/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py b/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py index 8c964804786..974c8813064 100644 --- a/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py +++ b/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py @@ -1273,10 +1273,11 @@ class TestPurchaseInvoice(unittest.TestCase, StockTestMixin): pi.save() pi.submit() + creditors_account = pi.credit_to + expected_gle = [ ["_Test Account Cost for Goods Sold - _TC", 37500.0], - ["_Test Payable USD - _TC", -35000.0], - ["Exchange Gain/Loss - _TC", -2500.0], + ["_Test Payable USD - _TC", -37500.0], ] gl_entries = frappe.db.sql( @@ -1293,6 +1294,31 @@ class TestPurchaseInvoice(unittest.TestCase, StockTestMixin): self.assertEqual(expected_gle[i][0], gle.account) self.assertEqual(expected_gle[i][1], gle.balance) + pi.reload() + self.assertEqual(pi.outstanding_amount, 0) + + total_debit_amount = frappe.db.get_all( + "Journal Entry Account", + {"account": creditors_account, "docstatus": 1, "reference_name": pi.name}, + "sum(debit) as amount", + group_by="reference_name", + )[0].amount + self.assertEqual(flt(total_debit_amount, 2), 2500) + jea_parent = frappe.db.get_all( + "Journal Entry Account", + filters={ + "account": creditors_account, + "docstatus": 1, + "reference_name": pi.name, + "debit": 2500, + "debit_in_account_currency": 0, + }, + fields=["parent"], + )[0] + self.assertEqual( + frappe.db.get_value("Journal Entry", jea_parent.parent, "voucher_type"), "Exchange Gain Or Loss" + ) + pi_2 = make_purchase_invoice( supplier="_Test Supplier USD", currency="USD", @@ -1317,10 +1343,12 @@ class TestPurchaseInvoice(unittest.TestCase, StockTestMixin): pi_2.save() pi_2.submit() + pi_2.reload() + self.assertEqual(pi_2.outstanding_amount, 0) + expected_gle = [ ["_Test Account Cost for Goods Sold - _TC", 36500.0], - ["_Test Payable USD - _TC", -35000.0], - ["Exchange Gain/Loss - _TC", -1500.0], + ["_Test Payable USD - _TC", -36500.0], ] gl_entries = frappe.db.sql( @@ -1351,12 +1379,39 @@ class TestPurchaseInvoice(unittest.TestCase, StockTestMixin): self.assertEqual(expected_gle[i][0], gle.account) self.assertEqual(expected_gle[i][1], gle.balance) + total_debit_amount = frappe.db.get_all( + "Journal Entry Account", + {"account": creditors_account, "docstatus": 1, "reference_name": pi_2.name}, + "sum(debit) as amount", + group_by="reference_name", + )[0].amount + self.assertEqual(flt(total_debit_amount, 2), 1500) + jea_parent_2 = frappe.db.get_all( + "Journal Entry Account", + filters={ + "account": creditors_account, + "docstatus": 1, + "reference_name": pi_2.name, + "debit": 1500, + "debit_in_account_currency": 0, + }, + fields=["parent"], + )[0] + self.assertEqual( + frappe.db.get_value("Journal Entry", jea_parent_2.parent, "voucher_type"), + "Exchange Gain Or Loss", + ) + pi.reload() pi.cancel() + self.assertEqual(frappe.db.get_value("Journal Entry", jea_parent.parent, "docstatus"), 2) + pi_2.reload() pi_2.cancel() + self.assertEqual(frappe.db.get_value("Journal Entry", jea_parent_2.parent, "docstatus"), 2) + pay.reload() pay.cancel() From 72bc5b3a11528611db8a322d68c0ecc422b570c6 Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Tue, 27 Jun 2023 16:11:03 +0530 Subject: [PATCH 18/84] refactor(test): difference amount no updated for exchange gain/loss --- erpnext/accounts/test/test_utils.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/erpnext/accounts/test/test_utils.py b/erpnext/accounts/test/test_utils.py index 3aca60eae5b..3cb5e42e7ae 100644 --- a/erpnext/accounts/test/test_utils.py +++ b/erpnext/accounts/test/test_utils.py @@ -80,18 +80,27 @@ class TestUtils(unittest.TestCase): item = make_item().name purchase_invoice = make_purchase_invoice( - item=item, supplier="_Test Supplier USD", currency="USD", conversion_rate=82.32 + item=item, supplier="_Test Supplier USD", currency="USD", conversion_rate=82.32, do_not_submit=1 ) + purchase_invoice.credit_to = "_Test Payable USD - _TC" purchase_invoice.submit() payment_entry = get_payment_entry(purchase_invoice.doctype, purchase_invoice.name) - payment_entry.target_exchange_rate = 62.9 payment_entry.paid_amount = 15725 payment_entry.deductions = [] - payment_entry.insert() + payment_entry.save() + + # below is the difference between base_received_amount and base_paid_amount + self.assertEqual(payment_entry.difference_amount, -4855.0) + + payment_entry.target_exchange_rate = 62.9 + payment_entry.save() + + # below is due to change in exchange rate + self.assertEqual(payment_entry.references[0].exchange_gain_loss, -4855.0) - self.assertEqual(payment_entry.difference_amount, -4855.00) payment_entry.references = [] + self.assertEqual(payment_entry.difference_amount, 0.0) payment_entry.submit() payment_reconciliation = frappe.new_doc("Payment Reconciliation") From 1bcb728c850c67f3e479eb402ce1296dc215496b Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Tue, 27 Jun 2023 16:57:38 +0530 Subject: [PATCH 19/84] refactor: remove call for setting deductions in payment entry --- erpnext/accounts/utils.py | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/erpnext/accounts/utils.py b/erpnext/accounts/utils.py index cfd0133700d..49a63677844 100644 --- a/erpnext/accounts/utils.py +++ b/erpnext/accounts/utils.py @@ -639,17 +639,6 @@ def update_reference_in_payment_entry( new_row.docstatus = 1 new_row.update(reference_details) - if d.difference_amount and d.difference_account: - account_details = { - "account": d.difference_account, - "cost_center": payment_entry.cost_center - or frappe.get_cached_value("Company", payment_entry.company, "cost_center"), - } - if d.difference_amount: - account_details["amount"] = d.difference_amount - - payment_entry.set_gain_or_loss(account_details=account_details) - payment_entry.flags.ignore_validate_update_after_submit = True payment_entry.setup_party_account_field() payment_entry.set_missing_values() From cd42b268391113d5d5b10d75a6e2562736e43aae Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Mon, 10 Jul 2023 15:28:10 +0530 Subject: [PATCH 20/84] chore: code cleanup --- .../payment_reconciliation/payment_reconciliation.py | 6 ------ erpnext/controllers/accounts_controller.py | 8 ++++++-- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py b/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py index df777f03be4..d574cd79b88 100644 --- a/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py +++ b/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py @@ -363,12 +363,6 @@ class PaymentReconciliation(Document): payment_details = self.get_payment_details(row, dr_or_cr) reconciled_entry.append(payment_details) - # if payment_details.difference_amount and row.reference_type not in [ - # "Sales Invoice", - # "Purchase Invoice", - # ]: - # self.make_difference_entry(payment_details) - if entry_list: reconcile_against_document(entry_list, skip_ref_details_update_for_pe) diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index f72ae81a533..611eca621eb 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -972,9 +972,12 @@ class AccountsController(TransactionBase): """ Make Exchange Gain/Loss journal for Invoices and Payments """ - # Cancelling existing exchange gain/loss journals is handled in on_cancel event in accounts/utils.py + # Cancelling existing exchange gain/loss journals is handled during the `on_cancel` event. + # see accounts/utils.py:cancel_exchange_gain_loss_journal() if self.docstatus == 1: if self.get("doctype") == "Journal Entry": + # 'args' is populated with exchange gain/loss account and the amount to be booked. + # These are generated by Sales/Purchase Invoice during reconciliation and advance allocation. if args: for arg in args: if arg.get("difference_amount", 0) != 0 and arg.get("difference_account"): @@ -1035,6 +1038,7 @@ class AccountsController(TransactionBase): "exchange_rate": 1, "cost_center": erpnext.get_default_cost_center(self.company), # TODO: figure out a way to pass reference + # throws 'Journal Entry doesn't have {account} or doesn't have matched account' # "reference_type": self.doctype, # "reference_name": self.name, # "reference_detail_no": arg.idx, @@ -1049,7 +1053,7 @@ class AccountsController(TransactionBase): journal_entry.submit() if self.get("doctype") == "Payment Entry": - # For Payment Entry, exchange_gain_loss field in the `reference` table is the trigger for journal creation + # For Payment Entry, exchange_gain_loss field in the `references` table is the trigger for journal creation gain_loss_to_book = [x for x in self.references if x.exchange_gain_loss != 0] booked = [] if gain_loss_to_book: From f119a1e11553a0357f937bd23a397757f3f5b54f Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Tue, 11 Jul 2023 12:04:13 +0530 Subject: [PATCH 21/84] refactor: linkage between journal as payment and gain/loss journal --- erpnext/accounts/doctype/gl_entry/gl_entry.py | 7 +++++++ .../accounts/doctype/journal_entry/journal_entry.py | 11 ++++++----- erpnext/controllers/accounts_controller.py | 6 +++--- 3 files changed, 16 insertions(+), 8 deletions(-) diff --git a/erpnext/accounts/doctype/gl_entry/gl_entry.py b/erpnext/accounts/doctype/gl_entry/gl_entry.py index f07a4fa3bce..7af40c46cb5 100644 --- a/erpnext/accounts/doctype/gl_entry/gl_entry.py +++ b/erpnext/accounts/doctype/gl_entry/gl_entry.py @@ -58,6 +58,13 @@ class GLEntry(Document): validate_balance_type(self.account, adv_adj) validate_frozen_account(self.account, adv_adj) + if ( + self.voucher_type == "Journal Entry" + and frappe.get_cached_value("Journal Entry", self.voucher_no, "voucher_type") + == "Exchange Gain Or Loss" + ): + return + if frappe.get_cached_value("Account", self.account, "account_type") not in [ "Receivable", "Payable", diff --git a/erpnext/accounts/doctype/journal_entry/journal_entry.py b/erpnext/accounts/doctype/journal_entry/journal_entry.py index ea4a2d4b19d..0115fd7f7a3 100644 --- a/erpnext/accounts/doctype/journal_entry/journal_entry.py +++ b/erpnext/accounts/doctype/journal_entry/journal_entry.py @@ -499,11 +499,12 @@ class JournalEntry(AccountsController): ) if not against_entries: - frappe.throw( - _( - "Journal Entry {0} does not have account {1} or already matched against other voucher" - ).format(d.reference_name, d.account) - ) + if self.voucher_type != "Exchange Gain Or Loss": + frappe.throw( + _( + "Journal Entry {0} does not have account {1} or already matched against other voucher" + ).format(d.reference_name, d.account) + ) else: dr_or_cr = "debit" if d.credit > 0 else "credit" valid = False diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index 611eca621eb..0b3b41de9f5 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -1039,9 +1039,9 @@ class AccountsController(TransactionBase): "cost_center": erpnext.get_default_cost_center(self.company), # TODO: figure out a way to pass reference # throws 'Journal Entry doesn't have {account} or doesn't have matched account' - # "reference_type": self.doctype, - # "reference_name": self.name, - # "reference_detail_no": arg.idx, + "reference_type": self.doctype, + "reference_name": self.name, + "reference_detail_no": arg.idx, reverse_dr_or_cr: abs(arg.get("difference_amount")), reverse_dr_or_cr + "_in_account_currency": 0, } From 6e18bb6456b3a7a2cbad89b86dcc124978337e4d Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Tue, 11 Jul 2023 12:21:10 +0530 Subject: [PATCH 22/84] refactor: cancel gain/loss JE on Journal as payment cancellation --- .../doctype/journal_entry/journal_entry.py | 5 ++- erpnext/accounts/utils.py | 2 +- erpnext/controllers/accounts_controller.py | 11 +++--- .../tests/test_accounts_controller.py | 34 ++++++++++++++++--- 4 files changed, 39 insertions(+), 13 deletions(-) diff --git a/erpnext/accounts/doctype/journal_entry/journal_entry.py b/erpnext/accounts/doctype/journal_entry/journal_entry.py index 0115fd7f7a3..e6b8b5d2818 100644 --- a/erpnext/accounts/doctype/journal_entry/journal_entry.py +++ b/erpnext/accounts/doctype/journal_entry/journal_entry.py @@ -87,9 +87,8 @@ class JournalEntry(AccountsController): self.update_invoice_discounting() def on_cancel(self): - from erpnext.accounts.utils import unlink_ref_doc_from_payment_entries - - unlink_ref_doc_from_payment_entries(self) + # References for this Journal are removed on the `on_cancel` event in accounts_controller + super(JournalEntry, self).on_cancel() self.ignore_linked_doctypes = ( "GL Entry", "Stock Ledger Entry", diff --git a/erpnext/accounts/utils.py b/erpnext/accounts/utils.py index 49a63677844..53d9e21c350 100644 --- a/erpnext/accounts/utils.py +++ b/erpnext/accounts/utils.py @@ -655,7 +655,7 @@ def cancel_exchange_gain_loss_journal(parent_doc: dict | object) -> None: """ Cancel Exchange Gain/Loss for Sales/Purchase Invoice, if they have any. """ - if parent_doc.doctype in ["Sales Invoice", "Purchase Invoice", "Payment Entry"]: + if parent_doc.doctype in ["Sales Invoice", "Purchase Invoice", "Payment Entry", "Journal Entry"]: journals = frappe.db.get_all( "Journal Entry Account", filters={ diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index 0b3b41de9f5..a126dfe6b32 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -991,10 +991,11 @@ class AccountsController(TransactionBase): party_account_currency = frappe.get_cached_value( "Account", party_account, "account_currency" ) - dr_or_cr = "debit" if arg.get("difference_amount") > 0 else "credit" - if arg.reference_doctype == "Purchase Invoice": - dr_or_cr = "debit" if dr_or_cr == "credit" else "credit" + dr_or_cr = "debit" if arg.get("party_type") == "Customer" else "credit" + + # if arg.reference_doctype == "Purchase Invoice": + # dr_or_cr = "debit" if dr_or_cr == "credit" else "credit" reverse_dr_or_cr = "debit" if dr_or_cr == "credit" else "credit" @@ -1038,6 +1039,7 @@ class AccountsController(TransactionBase): "exchange_rate": 1, "cost_center": erpnext.get_default_cost_center(self.company), # TODO: figure out a way to pass reference + # TODO: add reference_detail_no field in payment ledger # throws 'Journal Entry doesn't have {account} or doesn't have matched account' "reference_type": self.doctype, "reference_name": self.name, @@ -1163,6 +1165,7 @@ class AccountsController(TransactionBase): journal_entry.save() journal_entry.submit() + # frappe.throw("stopping...") def update_against_document_in_jv(self): """ @@ -1229,7 +1232,7 @@ class AccountsController(TransactionBase): unlink_ref_doc_from_payment_entries, ) - if self.doctype in ["Sales Invoice", "Purchase Invoice", "Payment Entry"]: + if self.doctype in ["Sales Invoice", "Purchase Invoice", "Payment Entry", "Journal Entry"]: # Cancel Exchange Gain/Loss Journal before unlinking cancel_exchange_gain_loss_journal(self) diff --git a/erpnext/controllers/tests/test_accounts_controller.py b/erpnext/controllers/tests/test_accounts_controller.py index 31aa857c8f5..28a569b5246 100644 --- a/erpnext/controllers/tests/test_accounts_controller.py +++ b/erpnext/controllers/tests/test_accounts_controller.py @@ -11,6 +11,7 @@ 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 @@ -20,7 +21,7 @@ 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" + customer.customer_type = "Individual" if currency: customer.default_currency = currency @@ -30,7 +31,22 @@ def make_customer(customer_name, currency=None): return customer_name -class TestAccountsController(FrappeTestCase): +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" + + if currency: + supplier.default_currency = currency + supplier.save() + return supplier.name + else: + return supplier_name + + +# class TestAccountsController(FrappeTestCase): +class TestAccountsController(unittest.TestCase): """ Test Exchange Gain/Loss booking on various scenarios """ @@ -39,11 +55,12 @@ class TestAccountsController(FrappeTestCase): self.create_company() self.create_account() self.create_item() - self.create_customer() + self.create_parties() self.clear_old_entries() def tearDown(self): - frappe.db.rollback() + # frappe.db.rollback() + pass def create_company(self): company_name = "_Test Company MC" @@ -80,9 +97,16 @@ class TestAccountsController(FrappeTestCase): ) 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( @@ -215,7 +239,7 @@ class TestAccountsController(FrappeTestCase): return journals def test_01_payment_against_invoice(self): - # Invoice in Foreign Currency + # Sales 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() From 73cc1ba654f39d81b7e1d9769ef6a0a8ceb689fe Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Tue, 11 Jul 2023 16:34:20 +0530 Subject: [PATCH 23/84] refactor: assert payment ledger outstanding in both currencies --- .../tests/test_accounts_controller.py | 249 +++++++++++------- 1 file changed, 158 insertions(+), 91 deletions(-) diff --git a/erpnext/controllers/tests/test_accounts_controller.py b/erpnext/controllers/tests/test_accounts_controller.py index 28a569b5246..fc30c4b8cde 100644 --- a/erpnext/controllers/tests/test_accounts_controller.py +++ b/erpnext/controllers/tests/test_accounts_controller.py @@ -5,6 +5,7 @@ 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 @@ -48,7 +49,15 @@ def make_supplier(supplier_name, currency=None): # class TestAccountsController(FrappeTestCase): class TestAccountsController(unittest.TestCase): """ - Test Exchange Gain/Loss booking on various scenarios + Test Exchange Gain/Loss booking on various scenarios. + Test Cases are numbered for better readbility + + 10 series - Sales Invoice against Payment Entries + 20 series - Sales Invoice against Journals + 30 series - Sales Invoice against Credit Notes + 40 series - Purchase Invoice against Payment Entries + 50 series - Purchase Invoice against Journals + 60 series - Purchase Invoice against Debit Notes """ def setUp(self): @@ -130,7 +139,13 @@ class TestAccountsController(unittest.TestCase): 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 + 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 @@ -148,7 +163,7 @@ class TestAccountsController(unittest.TestCase): parent_cost_center=self.cost_center, update_stock=0, currency="USD", - conversion_rate=80, + conversion_rate=conversion_rate, is_pos=0, is_return=0, return_against=None, @@ -238,96 +253,140 @@ class TestAccountsController(unittest.TestCase): ) return journals - def test_01_payment_against_invoice(self): - # Sales 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}, + 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 ) - pe = pe.save().submit() - si.reload() - self.assertEqual(si.outstanding_amount, 0) + def test_10_payment_against_sales_invoice(self): + # Sales Invoice in Foreign Currency + rate = 80 + rate_in_account_currency = 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) + si = self.create_sales_invoice(qty=1, rate=rate_in_account_currency) - 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]) + # 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() - # Cancel Payment - pe.cancel() + # 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) - 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]) - # 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) + # Cancel Payment + pe.cancel() - self.assertEqual(exc_je_for_si, []) - self.assertEqual(exc_je_for_pe, []) + # 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) - def test_02_advance_against_invoice(self): + # 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() - # 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() + # 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) + 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) + # 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) + # 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) - 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() - # 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, []) - # 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): + def test_12_partial_advance_and_payment_for_sales_invoice(self): """ - Invoice with partial advance payment, and a normal payment + 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() - # Invoice in Foreign Currency linked with advance - si = self.create_sales_invoice(qty=2, rate=1, do_not_submit=True) + # 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 + ) si.append( "advances", { @@ -343,19 +402,20 @@ class TestAccountsController(unittest.TestCase): si = si.save() si = si.submit() + # Outstanding should be there in both currencies si.reload() - self.assertEqual(si.outstanding_amount, 1) + 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 + # Payment for remaining amount pe = self.create_payment_entry(amount=1, source_exc_rate=75).save() pe.append( "references", @@ -363,13 +423,14 @@ class TestAccountsController(unittest.TestCase): ) 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) @@ -384,21 +445,20 @@ class TestAccountsController(unittest.TestCase): 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): + def test_13_partial_advance_and_payment_for_invoice_with_cancellation(self): """ - Invoice with partial advance payment, and a normal payment. Cancel advance and payment. + 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 in Foreign Currency linked with advance - si = self.create_sales_invoice(qty=2, rate=1, do_not_submit=True) + # invoice with advance(partial amount) + si = self.create_sales_invoice(qty=2, conversion_rate=80, rate=1, do_not_submit=True) si.append( "advances", { @@ -414,19 +474,20 @@ class TestAccountsController(unittest.TestCase): si = si.save() si = si.submit() + # Outstanding should be there in both currencies si.reload() - self.assertEqual(si.outstanding_amount, 1) + 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 + # Payment(remaining amount) pe = self.create_payment_entry(amount=1, source_exc_rate=75).save() pe.append( "references", @@ -434,13 +495,14 @@ class TestAccountsController(unittest.TestCase): ) 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) @@ -450,21 +512,22 @@ class TestAccountsController(unittest.TestCase): adv.reload() adv.cancel() + # Outstanding should be there in both currencies, since advance is cancelled. si.reload() - self.assertEqual(si.outstanding_amount, 1) + 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_05_same_payment_split_against_invoice(self): + def test_14_same_payment_split_against_invoice(self): # Invoice in Foreign Currency - si = self.create_sales_invoice(qty=2, rate=1) + 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( @@ -473,13 +536,14 @@ class TestAccountsController(unittest.TestCase): ) 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) @@ -491,32 +555,35 @@ class TestAccountsController(unittest.TestCase): 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 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) From 056724377206f9bdc3e7ac9894fabb0d93bd3176 Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Wed, 12 Jul 2023 06:14:17 +0530 Subject: [PATCH 24/84] refactor: dr/cr logic for journals as payments --- erpnext/controllers/accounts_controller.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index a126dfe6b32..dfc3114fa02 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -992,15 +992,14 @@ class AccountsController(TransactionBase): "Account", party_account, "account_currency" ) - dr_or_cr = "debit" if arg.get("party_type") == "Customer" else "credit" - - # if arg.reference_doctype == "Purchase Invoice": - # dr_or_cr = "debit" if dr_or_cr == "credit" else "credit" + if arg.get("difference_amount") > 0: + dr_or_cr = "debit" if arg.get("party_type") == "Customer" else "credit" + else: + dr_or_cr = "credit" if arg.get("party_type") == "Customer" else "debit" reverse_dr_or_cr = "debit" if dr_or_cr == "credit" else "credit" gain_loss_account = arg.get("difference_account") - if not gain_loss_account: frappe.throw( _("Please set default Exchange Gain/Loss Account in Company {}").format( From 5695d6a5a62e634536c51a22e045eb8281a29d9d Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Wed, 12 Jul 2023 06:46:59 +0530 Subject: [PATCH 25/84] refactor: unit tests for journals --- .../tests/test_accounts_controller.py | 83 +++++++++++++++++-- 1 file changed, 78 insertions(+), 5 deletions(-) diff --git a/erpnext/controllers/tests/test_accounts_controller.py b/erpnext/controllers/tests/test_accounts_controller.py index fc30c4b8cde..9e857f04c31 100644 --- a/erpnext/controllers/tests/test_accounts_controller.py +++ b/erpnext/controllers/tests/test_accounts_controller.py @@ -216,12 +216,21 @@ class TestAccountsController(unittest.TestCase): return pr def create_journal_entry( - self, acc1=None, acc2=None, amount=0, posting_date=None, cost_center=None + 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( @@ -229,15 +238,21 @@ class TestAccountsController(unittest.TestCase): [ { "account": acc1, + "exchange_rate": acc1_exc_rate or 1, "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, + "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": amount if amount > 0 else 0, - "debit_in_account_currency": abs(amount) if amount < 0 else 0, + "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, }, ], ) @@ -590,3 +605,61 @@ class TestAccountsController(unittest.TestCase): 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_21_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, []) From f4a65cccc48bd15fd732973030451c93629bc84b Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Fri, 14 Jul 2023 16:51:42 +0530 Subject: [PATCH 26/84] refactor: handle diff amount in various names --- erpnext/controllers/accounts_controller.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index dfc3114fa02..2ce1eb8c153 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -980,7 +980,10 @@ class AccountsController(TransactionBase): # These are generated by Sales/Purchase Invoice during reconciliation and advance allocation. if args: for arg in args: - if arg.get("difference_amount", 0) != 0 and arg.get("difference_account"): + # Advance section uses `exchange_gain_loss` and reconciliation uses `difference_amount` + if ( + arg.get("difference_amount", 0) != 0 or arg.get("exchange_gain_loss", 0) != 0 + ) and arg.get("difference_account"): journal_entry = frappe.new_doc("Journal Entry") journal_entry.voucher_type = "Exchange Gain Or Loss" journal_entry.company = self.company @@ -992,7 +995,8 @@ class AccountsController(TransactionBase): "Account", party_account, "account_currency" ) - if arg.get("difference_amount") > 0: + difference_amount = arg.get("difference_amount") or arg.get("exchange_gain_loss") + if difference_amount > 0: dr_or_cr = "debit" if arg.get("party_type") == "Customer" else "credit" else: dr_or_cr = "credit" if arg.get("party_type") == "Customer" else "debit" @@ -1024,7 +1028,7 @@ class AccountsController(TransactionBase): "reference_type": arg.get("against_voucher_type"), "reference_name": arg.get("against_voucher"), "reference_detail_no": arg.get("idx"), - dr_or_cr: abs(arg.difference_amount), + dr_or_cr: abs(difference_amount), dr_or_cr + "_in_account_currency": 0, } ) @@ -1043,7 +1047,7 @@ class AccountsController(TransactionBase): "reference_type": self.doctype, "reference_name": self.name, "reference_detail_no": arg.idx, - reverse_dr_or_cr: abs(arg.get("difference_amount")), + reverse_dr_or_cr: abs(difference_amount), reverse_dr_or_cr + "_in_account_currency": 0, } ) From f3363e813a353363696169602bae5b0a36ae0376 Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Sun, 16 Jul 2023 21:29:19 +0530 Subject: [PATCH 27/84] test: journals against sales invoice --- .../tests/test_accounts_controller.py | 264 +++++++++++++++++- 1 file changed, 263 insertions(+), 1 deletion(-) diff --git a/erpnext/controllers/tests/test_accounts_controller.py b/erpnext/controllers/tests/test_accounts_controller.py index 9e857f04c31..9a7326ea291 100644 --- a/erpnext/controllers/tests/test_accounts_controller.py +++ b/erpnext/controllers/tests/test_accounts_controller.py @@ -606,7 +606,7 @@ class TestAccountsController(unittest.TestCase): self.assertEqual(exc_je_for_si, []) self.assertEqual(exc_je_for_pe, []) - def test_21_journal_against_sales_invoice(self): + 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 @@ -663,3 +663,265 @@ class TestAccountsController(unittest.TestCase): 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, 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) + si.append( + "advances", + { + "doctype": "Sales Invoice Advance", + "reference_type": adv.doctype, + "reference_name": adv.name, + "reference_row": adv.accounts[0].name, + "advance_amount": 1, + "allocated_amount": 1, + "ref_exchange_rate": adv_exc_rate, + "remarks": "Test", + }, + ) + 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) + si.append( + "advances", + { + "doctype": "Sales Invoice Advance", + "reference_type": adv.doctype, + "reference_name": adv.name, + "reference_row": adv.accounts[0].name, + "advance_amount": 1, + "allocated_amount": 1, + "ref_exchange_rate": adv_exc_rate, + "remarks": "Test", + }, + ) + 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, []) From 70dd9d0671e1d77d50c814885c6a6f59508c4f62 Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Mon, 17 Jul 2023 12:29:42 +0530 Subject: [PATCH 28/84] chore(test): fix broken unit test --- .../doctype/sales_invoice/test_sales_invoice.py | 11 +---------- erpnext/controllers/tests/test_accounts_controller.py | 4 +--- 2 files changed, 2 insertions(+), 13 deletions(-) diff --git a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py index 41e55546a83..6ddf3052280 100644 --- a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py @@ -3213,15 +3213,10 @@ class TestSalesInvoice(unittest.TestCase): account.disabled = 0 account.save() + @change_settings("Accounts Settings", {"unlink_payment_on_cancel_of_invoice": 1}) def test_gain_loss_with_advance_entry(self): from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry - unlink_enabled = frappe.db.get_value( - "Accounts Settings", "Accounts Settings", "unlink_payment_on_cancel_of_invoice" - ) - - frappe.db.set_single_value("Accounts Settings", "unlink_payment_on_cancel_of_invoice", 1) - jv = make_journal_entry("_Test Receivable USD - _TC", "_Test Bank - _TC", -7000, save=False) jv.accounts[0].exchange_rate = 70 @@ -3264,10 +3259,6 @@ class TestSalesInvoice(unittest.TestCase): check_gl_entries(self, si.name, expected_gle, nowdate()) - frappe.db.set_single_value( - "Accounts Settings", "unlink_payment_on_cancel_of_invoice", unlink_enabled - ) - def test_batch_expiry_for_sales_invoice_return(self): from erpnext.controllers.sales_and_purchase_return import make_return_doc from erpnext.stock.doctype.item.test_item import make_item diff --git a/erpnext/controllers/tests/test_accounts_controller.py b/erpnext/controllers/tests/test_accounts_controller.py index 9a7326ea291..eefe202e476 100644 --- a/erpnext/controllers/tests/test_accounts_controller.py +++ b/erpnext/controllers/tests/test_accounts_controller.py @@ -37,6 +37,7 @@ def make_supplier(supplier_name, currency=None): 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 @@ -55,9 +56,6 @@ class TestAccountsController(unittest.TestCase): 10 series - Sales Invoice against Payment Entries 20 series - Sales Invoice against Journals 30 series - Sales Invoice against Credit Notes - 40 series - Purchase Invoice against Payment Entries - 50 series - Purchase Invoice against Journals - 60 series - Purchase Invoice against Debit Notes """ def setUp(self): From 37895a361cdf7be4704f376eb6ec749af0ab3c90 Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Mon, 24 Jul 2023 20:41:05 +0530 Subject: [PATCH 29/84] chore(test): fix broken test case --- erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py index 6ddf3052280..2f193972a7c 100644 --- a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py @@ -3213,7 +3213,7 @@ class TestSalesInvoice(unittest.TestCase): account.disabled = 0 account.save() - @change_settings("Accounts Settings", {"unlink_payment_on_cancel_of_invoice": 1}) + @change_settings("Accounts Settings", {"unlink_payment_on_cancellation_of_invoice": 1}) def test_gain_loss_with_advance_entry(self): from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry From 6628632fbb15ddcc80f5af201d15976337141fc6 Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Tue, 25 Jul 2023 10:30:08 +0530 Subject: [PATCH 30/84] chore: type info --- erpnext/controllers/accounts_controller.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index 2ce1eb8c153..40432f70f9a 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -968,7 +968,7 @@ class AccountsController(TransactionBase): d.exchange_gain_loss = difference - def make_exchange_gain_loss_journal(self, args=None) -> None: + def make_exchange_gain_loss_journal(self, args: dict = None) -> None: """ Make Exchange Gain/Loss journal for Invoices and Payments """ From c87332d5da638c43ff6d0560bf3c26dde81e21cf Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Tue, 25 Jul 2023 10:30:49 +0530 Subject: [PATCH 31/84] refactor: cr/dr note will be on single exchange rate --- .../doctype/journal_entry/journal_entry.py | 29 +++++++++++-------- .../payment_reconciliation.py | 7 +++-- 2 files changed, 21 insertions(+), 15 deletions(-) diff --git a/erpnext/accounts/doctype/journal_entry/journal_entry.py b/erpnext/accounts/doctype/journal_entry/journal_entry.py index e6b8b5d2818..0c23d772d5f 100644 --- a/erpnext/accounts/doctype/journal_entry/journal_entry.py +++ b/erpnext/accounts/doctype/journal_entry/journal_entry.py @@ -768,18 +768,23 @@ class JournalEntry(AccountsController): ) ): - # Modified to include the posting date for which to retreive the exchange rate - d.exchange_rate = get_exchange_rate( - self.posting_date, - d.account, - d.account_currency, - self.company, - d.reference_type, - d.reference_name, - d.debit, - d.credit, - d.exchange_rate, - ) + ignore_exchange_rate = False + if self.get("flags") and self.flags.get("ignore_exchange_rate"): + ignore_exchange_rate = True + + if not ignore_exchange_rate: + # Modified to include the posting date for which to retreive the exchange rate + d.exchange_rate = get_exchange_rate( + self.posting_date, + d.account, + d.account_currency, + self.company, + d.reference_type, + d.reference_name, + d.debit, + d.credit, + d.exchange_rate, + ) if not d.exchange_rate: frappe.throw(_("Row {0}: Exchange Rate is mandatory").format(d.idx)) diff --git a/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py b/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py index d574cd79b88..2c11ef51207 100644 --- a/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py +++ b/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py @@ -650,6 +650,7 @@ def reconcile_dr_cr_note(dr_cr_notes, company): "reference_type": inv.against_voucher_type, "reference_name": inv.against_voucher, "cost_center": erpnext.get_default_cost_center(company), + "exchange_rate": inv.exchange_rate, }, { "account": inv.account, @@ -663,13 +664,13 @@ def reconcile_dr_cr_note(dr_cr_notes, company): "reference_type": inv.voucher_type, "reference_name": inv.voucher_no, "cost_center": erpnext.get_default_cost_center(company), + "exchange_rate": inv.exchange_rate, }, ], } ) - if difference_entry := get_difference_row(inv): - jv.append("accounts", difference_entry) - jv.flags.ignore_mandatory = True + jv.flags.ignore_exchange_rate = True jv.submit() + jv.make_exchange_gain_loss_journal(args=[inv]) From c0b3b069b587cff11969112b01fff08c8df7adf0 Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Tue, 25 Jul 2023 10:51:58 +0530 Subject: [PATCH 32/84] refactor: split make_exchage_gain_loss_journal into smaller function --- erpnext/controllers/accounts_controller.py | 219 ++++++++++----------- 1 file changed, 103 insertions(+), 116 deletions(-) diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index 40432f70f9a..6bf9d299d2a 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -968,6 +968,78 @@ class AccountsController(TransactionBase): d.exchange_gain_loss = difference + def create_gain_loss_journal( + self, + party_type, + party, + party_account, + gain_loss_account, + exc_gain_loss, + dr_or_cr, + reverse_dr_or_cr, + ref1_dt, + ref1_dn, + ref1_detail_no, + ref2_dt, + ref2_dn, + ref2_detail_no, + ) -> str: + journal_entry = frappe.new_doc("Journal Entry") + journal_entry.voucher_type = "Exchange Gain Or Loss" + journal_entry.company = self.company + journal_entry.posting_date = nowdate() + journal_entry.multi_currency = 1 + + party_account_currency = frappe.get_cached_value("Account", party_account, "account_currency") + + if not gain_loss_account: + frappe.throw( + _("Please set default Exchange Gain/Loss Account in Company {}").format(self.get("company")) + ) + gain_loss_account_currency = get_account_currency(gain_loss_account) + company_currency = frappe.get_cached_value("Company", self.company, "default_currency") + + if gain_loss_account_currency != self.company_currency: + frappe.throw(_("Currency for {0} must be {1}").format(gain_loss_account, company_currency)) + + journal_account = frappe._dict( + { + "account": party_account, + "party_type": party_type, + "party": party, + "account_currency": party_account_currency, + "exchange_rate": 0, + "cost_center": erpnext.get_default_cost_center(self.company), + "reference_type": ref1_dt, + "reference_name": ref1_dn, + "reference_detail_no": ref1_detail_no, + dr_or_cr: abs(exc_gain_loss), + dr_or_cr + "_in_account_currency": 0, + } + ) + + journal_entry.append("accounts", journal_account) + + journal_account = frappe._dict( + { + "account": gain_loss_account, + "account_currency": gain_loss_account_currency, + "exchange_rate": 1, + "cost_center": erpnext.get_default_cost_center(self.company), + "reference_type": ref2_dt, + "reference_name": ref2_dn, + "reference_detail_no": ref2_detail_no, + reverse_dr_or_cr + "_in_account_currency": 0, + reverse_dr_or_cr: abs(exc_gain_loss), + } + ) + + journal_entry.append("accounts", journal_account) + + journal_entry.save() + journal_entry.submit() + return journal_entry.name + def make_exchange_gain_loss_journal(self, args: dict = None) -> None: """ Make Exchange Gain/Loss journal for Invoices and Payments @@ -978,23 +1050,16 @@ class AccountsController(TransactionBase): if self.get("doctype") == "Journal Entry": # 'args' is populated with exchange gain/loss account and the amount to be booked. # These are generated by Sales/Purchase Invoice during reconciliation and advance allocation. + # and below logic is only for such scenarios if args: for arg in args: # Advance section uses `exchange_gain_loss` and reconciliation uses `difference_amount` if ( arg.get("difference_amount", 0) != 0 or arg.get("exchange_gain_loss", 0) != 0 ) and arg.get("difference_account"): - journal_entry = frappe.new_doc("Journal Entry") - journal_entry.voucher_type = "Exchange Gain Or Loss" - journal_entry.company = self.company - journal_entry.posting_date = nowdate() - journal_entry.multi_currency = 1 party_account = arg.get("account") - party_account_currency = frappe.get_cached_value( - "Account", party_account, "account_currency" - ) - + gain_loss_account = arg.get("difference_account") difference_amount = arg.get("difference_amount") or arg.get("exchange_gain_loss") if difference_amount > 0: dr_or_cr = "debit" if arg.get("party_type") == "Customer" else "credit" @@ -1003,60 +1068,22 @@ class AccountsController(TransactionBase): reverse_dr_or_cr = "debit" if dr_or_cr == "credit" else "credit" - gain_loss_account = arg.get("difference_account") - if not gain_loss_account: - frappe.throw( - _("Please set default Exchange Gain/Loss Account in Company {}").format( - self.get("company") - ) - ) - - gain_loss_account_currency = get_account_currency(gain_loss_account) - if gain_loss_account_currency != self.company_currency: - frappe.throw( - _("Currency for {0} must be {1}").format(gain_loss_account, self.company_currency) - ) - - journal_account = frappe._dict( - { - "account": party_account, - "party_type": arg.get("party_type"), - "party": arg.get("party"), - "account_currency": party_account_currency, - "exchange_rate": 0, - "cost_center": erpnext.get_default_cost_center(self.company), - "reference_type": arg.get("against_voucher_type"), - "reference_name": arg.get("against_voucher"), - "reference_detail_no": arg.get("idx"), - dr_or_cr: abs(difference_amount), - dr_or_cr + "_in_account_currency": 0, - } + self.create_gain_loss_journal( + arg.get("party_type"), + arg.get("party"), + party_account, + gain_loss_account, + difference_amount, + dr_or_cr, + reverse_dr_or_cr, + arg.get("against_voucher_type"), + arg.get("against_voucher"), + arg.get("idx"), + self.doctype, + self.name, + arg.get("idx"), ) - journal_entry.append("accounts", journal_account) - - journal_account = frappe._dict( - { - "account": gain_loss_account, - "account_currency": gain_loss_account_currency, - "exchange_rate": 1, - "cost_center": erpnext.get_default_cost_center(self.company), - # TODO: figure out a way to pass reference - # TODO: add reference_detail_no field in payment ledger - # throws 'Journal Entry doesn't have {account} or doesn't have matched account' - "reference_type": self.doctype, - "reference_name": self.name, - "reference_detail_no": arg.idx, - reverse_dr_or_cr: abs(difference_amount), - reverse_dr_or_cr + "_in_account_currency": 0, - } - ) - - journal_entry.append("accounts", journal_account) - - journal_entry.save() - journal_entry.submit() - if self.get("doctype") == "Payment Entry": # For Payment Entry, exchange_gain_loss field in the `references` table is the trigger for journal creation gain_loss_to_book = [x for x in self.references if x.exchange_gain_loss != 0] @@ -1093,23 +1120,15 @@ class AccountsController(TransactionBase): ) for d in gain_loss_to_book: + # Filter out References for which Gain/Loss is already booked if d.exchange_gain_loss and ( (d.reference_doctype, d.reference_name, str(d.idx)) not in booked ): - journal_entry = frappe.new_doc("Journal Entry") - journal_entry.voucher_type = "Exchange Gain Or Loss" - journal_entry.company = self.company - journal_entry.posting_date = nowdate() - journal_entry.multi_currency = 1 - if self.payment_type == "Receive": party_account = self.paid_from elif self.payment_type == "Pay": party_account = self.paid_to - party_account_currency = frappe.get_cached_value( - "Account", party_account, "account_currency" - ) dr_or_cr = "debit" if d.exchange_gain_loss > 0 else "credit" if d.reference_doctype == "Purchase Invoice": @@ -1120,54 +1139,22 @@ class AccountsController(TransactionBase): gain_loss_account = frappe.get_cached_value( "Company", self.company, "exchange_gain_loss_account" ) - if not gain_loss_account: - frappe.throw( - _("Please set default Exchange Gain/Loss Account in Company {}").format( - self.get("company") - ) - ) - gain_loss_account_currency = get_account_currency(gain_loss_account) - if gain_loss_account_currency != self.company_currency: - frappe.throw( - _("Currency for {0} must be {1}").format(gain_loss_account, self.company_currency) - ) - journal_account = frappe._dict( - { - "account": party_account, - "party_type": self.party_type, - "party": self.party, - "account_currency": party_account_currency, - "exchange_rate": 0, - "cost_center": erpnext.get_default_cost_center(self.company), - "reference_type": d.reference_doctype, - "reference_name": d.reference_name, - "reference_detail_no": d.idx, - dr_or_cr: abs(d.exchange_gain_loss), - dr_or_cr + "_in_account_currency": 0, - } + self.create_gain_loss_journal( + self.party_type, + self.party, + party_account, + gain_loss_account, + d.exchange_gain_loss, + dr_or_cr, + reverse_dr_or_cr, + d.reference_doctype, + d.reference_name, + d.idx, + self.doctype, + self.name, + d.idx, ) - - journal_entry.append("accounts", journal_account) - - journal_account = frappe._dict( - { - "account": gain_loss_account, - "account_currency": gain_loss_account_currency, - "exchange_rate": 1, - "cost_center": erpnext.get_default_cost_center(self.company), - "reference_type": self.doctype, - "reference_name": self.name, - "reference_detail_no": d.idx, - reverse_dr_or_cr + "_in_account_currency": 0, - reverse_dr_or_cr: abs(d.exchange_gain_loss), - } - ) - - journal_entry.append("accounts", journal_account) - - journal_entry.save() - journal_entry.submit() # frappe.throw("stopping...") def update_against_document_in_jv(self): From 1ea1bfebc4a2407961d93a6d0c4c6c9f43202689 Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Wed, 26 Jul 2023 16:19:38 +0530 Subject: [PATCH 33/84] refactor: convert class method to standalone function --- erpnext/accounts/utils.py | 71 ++++++++++++++++++ erpnext/controllers/accounts_controller.py | 85 +++------------------- 2 files changed, 81 insertions(+), 75 deletions(-) diff --git a/erpnext/accounts/utils.py b/erpnext/accounts/utils.py index 53d9e21c350..fa889c0f654 100644 --- a/erpnext/accounts/utils.py +++ b/erpnext/accounts/utils.py @@ -1836,3 +1836,74 @@ class QueryPaymentLedger(object): self.query_for_outstanding() return self.voucher_outstandings + + +def create_gain_loss_journal( + company, + party_type, + party, + party_account, + gain_loss_account, + exc_gain_loss, + dr_or_cr, + reverse_dr_or_cr, + ref1_dt, + ref1_dn, + ref1_detail_no, + ref2_dt, + ref2_dn, + ref2_detail_no, +) -> str: + journal_entry = frappe.new_doc("Journal Entry") + journal_entry.voucher_type = "Exchange Gain Or Loss" + journal_entry.company = company + journal_entry.posting_date = nowdate() + journal_entry.multi_currency = 1 + + party_account_currency = frappe.get_cached_value("Account", party_account, "account_currency") + + if not gain_loss_account: + frappe.throw(_("Please set default Exchange Gain/Loss Account in Company {}").format(company)) + gain_loss_account_currency = get_account_currency(gain_loss_account) + company_currency = frappe.get_cached_value("Company", company, "default_currency") + + if gain_loss_account_currency != company_currency: + frappe.throw(_("Currency for {0} must be {1}").format(gain_loss_account, company_currency)) + + journal_account = frappe._dict( + { + "account": party_account, + "party_type": party_type, + "party": party, + "account_currency": party_account_currency, + "exchange_rate": 0, + "cost_center": erpnext.get_default_cost_center(company), + "reference_type": ref1_dt, + "reference_name": ref1_dn, + "reference_detail_no": ref1_detail_no, + dr_or_cr: abs(exc_gain_loss), + dr_or_cr + "_in_account_currency": 0, + } + ) + + journal_entry.append("accounts", journal_account) + + journal_account = frappe._dict( + { + "account": gain_loss_account, + "account_currency": gain_loss_account_currency, + "exchange_rate": 1, + "cost_center": erpnext.get_default_cost_center(company), + "reference_type": ref2_dt, + "reference_name": ref2_dn, + "reference_detail_no": ref2_detail_no, + reverse_dr_or_cr + "_in_account_currency": 0, + reverse_dr_or_cr: abs(exc_gain_loss), + } + ) + + journal_entry.append("accounts", journal_account) + + journal_entry.save() + journal_entry.submit() + return journal_entry.name diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index 6bf9d299d2a..b9fc0826cc4 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -38,7 +38,12 @@ from erpnext.accounts.party import ( get_party_gle_currency, validate_party_frozen_disabled, ) -from erpnext.accounts.utils import get_account_currency, get_fiscal_years, validate_fiscal_year +from erpnext.accounts.utils import ( + create_gain_loss_journal, + get_account_currency, + get_fiscal_years, + validate_fiscal_year, +) from erpnext.buying.utils import update_last_purchase_rate from erpnext.controllers.print_settings import ( set_print_templates_for_item_table, @@ -968,78 +973,6 @@ class AccountsController(TransactionBase): d.exchange_gain_loss = difference - def create_gain_loss_journal( - self, - party_type, - party, - party_account, - gain_loss_account, - exc_gain_loss, - dr_or_cr, - reverse_dr_or_cr, - ref1_dt, - ref1_dn, - ref1_detail_no, - ref2_dt, - ref2_dn, - ref2_detail_no, - ) -> str: - journal_entry = frappe.new_doc("Journal Entry") - journal_entry.voucher_type = "Exchange Gain Or Loss" - journal_entry.company = self.company - journal_entry.posting_date = nowdate() - journal_entry.multi_currency = 1 - - party_account_currency = frappe.get_cached_value("Account", party_account, "account_currency") - - if not gain_loss_account: - frappe.throw( - _("Please set default Exchange Gain/Loss Account in Company {}").format(self.get("company")) - ) - gain_loss_account_currency = get_account_currency(gain_loss_account) - company_currency = frappe.get_cached_value("Company", self.company, "default_currency") - - if gain_loss_account_currency != self.company_currency: - frappe.throw(_("Currency for {0} must be {1}").format(gain_loss_account, company_currency)) - - journal_account = frappe._dict( - { - "account": party_account, - "party_type": party_type, - "party": party, - "account_currency": party_account_currency, - "exchange_rate": 0, - "cost_center": erpnext.get_default_cost_center(self.company), - "reference_type": ref1_dt, - "reference_name": ref1_dn, - "reference_detail_no": ref1_detail_no, - dr_or_cr: abs(exc_gain_loss), - dr_or_cr + "_in_account_currency": 0, - } - ) - - journal_entry.append("accounts", journal_account) - - journal_account = frappe._dict( - { - "account": gain_loss_account, - "account_currency": gain_loss_account_currency, - "exchange_rate": 1, - "cost_center": erpnext.get_default_cost_center(self.company), - "reference_type": ref2_dt, - "reference_name": ref2_dn, - "reference_detail_no": ref2_detail_no, - reverse_dr_or_cr + "_in_account_currency": 0, - reverse_dr_or_cr: abs(exc_gain_loss), - } - ) - - journal_entry.append("accounts", journal_account) - - journal_entry.save() - journal_entry.submit() - return journal_entry.name - def make_exchange_gain_loss_journal(self, args: dict = None) -> None: """ Make Exchange Gain/Loss journal for Invoices and Payments @@ -1068,7 +1001,8 @@ class AccountsController(TransactionBase): reverse_dr_or_cr = "debit" if dr_or_cr == "credit" else "credit" - self.create_gain_loss_journal( + create_gain_loss_journal( + self.company, arg.get("party_type"), arg.get("party"), party_account, @@ -1140,7 +1074,8 @@ class AccountsController(TransactionBase): "Company", self.company, "exchange_gain_loss_account" ) - self.create_gain_loss_journal( + create_gain_loss_journal( + self.company, self.party_type, self.party, party_account, From ba1f065765db6fc36358281fb4e4d775f1c1dcb1 Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Wed, 26 Jul 2023 16:46:50 +0530 Subject: [PATCH 34/84] refactor: create gain/loss on Cr/Dr notes with different exc rates --- .../doctype/journal_entry/journal_entry.py | 4 ++- .../payment_reconciliation.py | 27 ++++++++++++++++++- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/erpnext/accounts/doctype/journal_entry/journal_entry.py b/erpnext/accounts/doctype/journal_entry/journal_entry.py index 0c23d772d5f..daa6355a445 100644 --- a/erpnext/accounts/doctype/journal_entry/journal_entry.py +++ b/erpnext/accounts/doctype/journal_entry/journal_entry.py @@ -586,7 +586,9 @@ class JournalEntry(AccountsController): else: party_account = against_voucher[1] - if against_voucher[0] != cstr(d.party) or party_account != d.account: + if ( + against_voucher[0] != cstr(d.party) or party_account != d.account + ) and self.voucher_type != "Exchange Gain Or Loss": frappe.throw( _("Row {0}: Party / Account does not match with {1} / {2} in {3} {4}").format( d.idx, diff --git a/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py b/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py index 2c11ef51207..5937a29ff57 100644 --- a/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py +++ b/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py @@ -14,6 +14,7 @@ from erpnext.accounts.doctype.process_payment_reconciliation.process_payment_rec ) from erpnext.accounts.utils import ( QueryPaymentLedger, + create_gain_loss_journal, get_outstanding_invoices, reconcile_against_document, ) @@ -673,4 +674,28 @@ def reconcile_dr_cr_note(dr_cr_notes, company): jv.flags.ignore_mandatory = True jv.flags.ignore_exchange_rate = True jv.submit() - jv.make_exchange_gain_loss_journal(args=[inv]) + + # make gain/loss journal + if inv.party_type == "Customer": + dr_or_cr = "credit" if inv.difference_amount < 0 else "debit" + else: + dr_or_cr = "debit" if inv.difference_amount < 0 else "credit" + + reverse_dr_or_cr = "debit" if dr_or_cr == "credit" else "credit" + + create_gain_loss_journal( + company, + inv.party_type, + inv.party, + inv.account, + inv.difference_account, + inv.difference_amount, + dr_or_cr, + reverse_dr_or_cr, + inv.against_voucher_type, + inv.against_voucher, + None, + inv.voucher_type, + inv.voucher_no, + None, + ) From 506a5775f9937fc893ae02b287ecd7303487363c Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Wed, 26 Jul 2023 20:53:07 +0530 Subject: [PATCH 35/84] fix: incorrect gain/loss on allocation change on reconciliation tool --- .../doctype/payment_reconciliation/payment_reconciliation.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py b/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py index 5937a29ff57..36f362210d2 100644 --- a/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py +++ b/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py @@ -277,6 +277,11 @@ class PaymentReconciliation(Document): def calculate_difference_on_allocation_change(self, payment_entry, invoice, allocated_amount): invoice_exchange_map = self.get_invoice_exchange_map(invoice, payment_entry) invoice[0]["exchange_rate"] = invoice_exchange_map.get(invoice[0].get("invoice_number")) + if payment_entry[0].get("reference_type") in ["Sales Invoice", "Purchase Invoice"]: + payment_entry[0]["exchange_rate"] = invoice_exchange_map.get( + payment_entry[0].get("reference_name") + ) + new_difference_amount = self.get_difference_amount( payment_entry[0], invoice[0], allocated_amount ) From e3d2a2c5bdd94364f22828acc40854f6834b66ce Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Wed, 26 Jul 2023 21:12:14 +0530 Subject: [PATCH 36/84] test: cr notes against invoice --- .../tests/test_accounts_controller.py | 43 ++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) diff --git a/erpnext/controllers/tests/test_accounts_controller.py b/erpnext/controllers/tests/test_accounts_controller.py index eefe202e476..fc4fb9fe9bf 100644 --- a/erpnext/controllers/tests/test_accounts_controller.py +++ b/erpnext/controllers/tests/test_accounts_controller.py @@ -51,7 +51,7 @@ def make_supplier(supplier_name, currency=None): class TestAccountsController(unittest.TestCase): """ Test Exchange Gain/Loss booking on various scenarios. - Test Cases are numbered for better readbility + Test Cases are numbered for better organization 10 series - Sales Invoice against Payment Entries 20 series - Sales Invoice against Journals @@ -923,3 +923,44 @@ class TestAccountsController(unittest.TestCase): 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_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) From 95543225cf402e9f17e05efee80f2dfb199aa4d9 Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Wed, 26 Jul 2023 21:15:48 +0530 Subject: [PATCH 37/84] fix: cr/dr note should be posted for exc gain/loss --- .../payment_reconciliation/payment_reconciliation.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py b/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py index 36f362210d2..59abecd0b94 100644 --- a/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py +++ b/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py @@ -697,10 +697,10 @@ def reconcile_dr_cr_note(dr_cr_notes, company): inv.difference_amount, dr_or_cr, reverse_dr_or_cr, - inv.against_voucher_type, - inv.against_voucher, - None, inv.voucher_type, inv.voucher_no, None, + inv.against_voucher_type, + inv.against_voucher, + None, ) From ae424fdfedb49e6018d957eebb11eb4e03d9d410 Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Wed, 26 Jul 2023 21:24:08 +0530 Subject: [PATCH 38/84] test: assert ledger after cr note cancellation --- .../tests/test_accounts_controller.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/erpnext/controllers/tests/test_accounts_controller.py b/erpnext/controllers/tests/test_accounts_controller.py index fc4fb9fe9bf..415e1734a93 100644 --- a/erpnext/controllers/tests/test_accounts_controller.py +++ b/erpnext/controllers/tests/test_accounts_controller.py @@ -964,3 +964,19 @@ class TestAccountsController(unittest.TestCase): 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) From bfa54d533572f33ea5bc83794489293d36949e5d Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Wed, 26 Jul 2023 21:54:23 +0530 Subject: [PATCH 39/84] fix(test): test case breakage in Github Actions --- erpnext/accounts/doctype/journal_entry/test_journal_entry.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/erpnext/accounts/doctype/journal_entry/test_journal_entry.py b/erpnext/accounts/doctype/journal_entry/test_journal_entry.py index e7aca79d08b..a6e920b7ef6 100644 --- a/erpnext/accounts/doctype/journal_entry/test_journal_entry.py +++ b/erpnext/accounts/doctype/journal_entry/test_journal_entry.py @@ -5,6 +5,7 @@ import unittest import frappe +from frappe.tests.utils import change_settings from frappe.utils import flt, nowdate from erpnext.accounts.doctype.account.test_account import get_inventory_account @@ -13,6 +14,7 @@ from erpnext.exceptions import InvalidAccountCurrency class TestJournalEntry(unittest.TestCase): + @change_settings("Accounts Settings", {"unlink_payment_on_cancellation_of_invoice": 1}) def test_journal_entry_with_against_jv(self): jv_invoice = frappe.copy_doc(test_records[2]) base_jv = frappe.copy_doc(test_records[0]) From 025091161e47bd2ad77beec068d5263567605425 Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Wed, 26 Jul 2023 22:32:59 +0530 Subject: [PATCH 40/84] refactor(test): assert ledger outstanding --- .../sales_invoice/test_sales_invoice.py | 22 +++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py index 2f193972a7c..0de43bab2d8 100644 --- a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py @@ -3249,16 +3249,30 @@ class TestSalesInvoice(unittest.TestCase): ) si.save() si.submit() - expected_gle = [ - ["_Test Exchange Gain/Loss - _TC", 500.0, 0.0, nowdate()], ["_Test Receivable USD - _TC", 7500.0, 0.0, nowdate()], - ["_Test Receivable USD - _TC", 0.0, 500.0, nowdate()], ["Sales - _TC", 0.0, 7500.0, nowdate()], ] - check_gl_entries(self, si.name, expected_gle, nowdate()) + si.reload() + self.assertEqual(si.outstanding_amount, 0) + journals = frappe.db.get_all( + "Journal Entry Account", + filters={"reference_type": "Sales Invoice", "reference_name": si.name, "docstatus": 1}, + pluck="parent", + ) + journals = [x for x in journals if x != jv.name] + self.assertEqual(len(journals), 1) + je_type = frappe.get_cached_value("Journal Entry", journals[0], "voucher_type") + self.assertEqual(je_type, "Exchange Gain Or Loss") + ledger_outstanding = frappe.db.get_all( + "Payment Ledger Entry", + filters={"against_voucher_no": si.name, "delinked": 0}, + fields=["sum(amount), sum(amount_in_account_currency)"], + as_list=1, + ) + def test_batch_expiry_for_sales_invoice_return(self): from erpnext.controllers.sales_and_purchase_return import make_return_doc from erpnext.stock.doctype.item.test_item import make_item From 47bbb37291eba1e2bb8217837417a072f73f5634 Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Thu, 27 Jul 2023 05:54:13 +0530 Subject: [PATCH 41/84] chore: use frappetestcase --- erpnext/controllers/tests/test_accounts_controller.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/erpnext/controllers/tests/test_accounts_controller.py b/erpnext/controllers/tests/test_accounts_controller.py index 415e1734a93..acda12bf595 100644 --- a/erpnext/controllers/tests/test_accounts_controller.py +++ b/erpnext/controllers/tests/test_accounts_controller.py @@ -47,8 +47,7 @@ def make_supplier(supplier_name, currency=None): return supplier_name -# class TestAccountsController(FrappeTestCase): -class TestAccountsController(unittest.TestCase): +class TestAccountsController(FrappeTestCase): """ Test Exchange Gain/Loss booking on various scenarios. Test Cases are numbered for better organization @@ -66,8 +65,7 @@ class TestAccountsController(unittest.TestCase): self.clear_old_entries() def tearDown(self): - # frappe.db.rollback() - pass + frappe.db.rollback() def create_company(self): company_name = "_Test Company MC" From acc7322874b97830a838d066925aec99b01af129 Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Thu, 27 Jul 2023 07:52:01 +0530 Subject: [PATCH 42/84] chore: add msgprint for exc JE --- erpnext/controllers/accounts_controller.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index b9fc0826cc4..37a18d80e94 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -1001,7 +1001,7 @@ class AccountsController(TransactionBase): reverse_dr_or_cr = "debit" if dr_or_cr == "credit" else "credit" - create_gain_loss_journal( + je = create_gain_loss_journal( self.company, arg.get("party_type"), arg.get("party"), @@ -1017,6 +1017,11 @@ class AccountsController(TransactionBase): self.name, arg.get("idx"), ) + frappe.msgprint( + _("Exchange Gain/Loss amount has been booked through {0}").format( + get_link_to_form("Journal Entry", je) + ) + ) if self.get("doctype") == "Payment Entry": # For Payment Entry, exchange_gain_loss field in the `references` table is the trigger for journal creation @@ -1074,7 +1079,7 @@ class AccountsController(TransactionBase): "Company", self.company, "exchange_gain_loss_account" ) - create_gain_loss_journal( + je = create_gain_loss_journal( self.company, self.party_type, self.party, @@ -1090,7 +1095,11 @@ class AccountsController(TransactionBase): self.name, d.idx, ) - # frappe.throw("stopping...") + frappe.msgprint( + _("Exchange Gain/Loss amount has been booked through {0}").format( + get_link_to_form("Journal Entry", je) + ) + ) def update_against_document_in_jv(self): """ From d9d685615335778cd36734b1d1bd0c2b4189b690 Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Thu, 27 Jul 2023 08:02:46 +0530 Subject: [PATCH 43/84] chore: rename some internal variables --- erpnext/accounts/utils.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/erpnext/accounts/utils.py b/erpnext/accounts/utils.py index fa889c0f654..61359a66711 100644 --- a/erpnext/accounts/utils.py +++ b/erpnext/accounts/utils.py @@ -666,8 +666,9 @@ def cancel_exchange_gain_loss_journal(parent_doc: dict | object) -> None: fields=["parent"], as_list=1, ) + if journals: - exchange_journals = frappe.db.get_all( + gain_loss_journals = frappe.db.get_all( "Journal Entry", filters={ "name": ["in", [x[0] for x in journals]], @@ -676,7 +677,7 @@ def cancel_exchange_gain_loss_journal(parent_doc: dict | object) -> None: }, as_list=1, ) - for doc in exchange_journals: + for doc in gain_loss_journals: frappe.get_doc("Journal Entry", doc[0]).cancel() From 804afaa647b5727c37206fc4207c203652c10d53 Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Thu, 27 Jul 2023 09:30:38 +0530 Subject: [PATCH 44/84] chore(test): use existing company for unit test --- erpnext/controllers/tests/test_accounts_controller.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/erpnext/controllers/tests/test_accounts_controller.py b/erpnext/controllers/tests/test_accounts_controller.py index acda12bf595..8e5f813d97d 100644 --- a/erpnext/controllers/tests/test_accounts_controller.py +++ b/erpnext/controllers/tests/test_accounts_controller.py @@ -68,8 +68,8 @@ class TestAccountsController(FrappeTestCase): frappe.db.rollback() def create_company(self): - company_name = "_Test Company MC" - self.company_abbr = abbr = "_CM" + company_name = "_Test Company" + self.company_abbr = abbr = "_TC" if frappe.db.exists("Company", company_name): company = frappe.get_doc("Company", company_name) else: From 567c0ce1e85a42056d76cfc399b3468df32a576a Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Fri, 28 Jul 2023 08:12:44 +0530 Subject: [PATCH 45/84] chore: don't make gain/loss journal for base currency transactions --- .../payment_reconciliation.py | 45 ++++++++++--------- 1 file changed, 23 insertions(+), 22 deletions(-) diff --git a/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py b/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py index 59abecd0b94..ea06e0ec9ae 100644 --- a/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py +++ b/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py @@ -680,27 +680,28 @@ def reconcile_dr_cr_note(dr_cr_notes, company): jv.flags.ignore_exchange_rate = True jv.submit() - # make gain/loss journal - if inv.party_type == "Customer": - dr_or_cr = "credit" if inv.difference_amount < 0 else "debit" - else: - dr_or_cr = "debit" if inv.difference_amount < 0 else "credit" + if inv.difference_amount != 0: + # make gain/loss journal + if inv.party_type == "Customer": + dr_or_cr = "credit" if inv.difference_amount < 0 else "debit" + else: + dr_or_cr = "debit" if inv.difference_amount < 0 else "credit" - reverse_dr_or_cr = "debit" if dr_or_cr == "credit" else "credit" + reverse_dr_or_cr = "debit" if dr_or_cr == "credit" else "credit" - create_gain_loss_journal( - company, - inv.party_type, - inv.party, - inv.account, - inv.difference_account, - inv.difference_amount, - dr_or_cr, - reverse_dr_or_cr, - inv.voucher_type, - inv.voucher_no, - None, - inv.against_voucher_type, - inv.against_voucher, - None, - ) + create_gain_loss_journal( + company, + inv.party_type, + inv.party, + inv.account, + inv.difference_account, + inv.difference_amount, + dr_or_cr, + reverse_dr_or_cr, + inv.voucher_type, + inv.voucher_no, + None, + inv.against_voucher_type, + inv.against_voucher, + None, + ) From 46ea81440066af74a3b98f4ab9d5006839a17a4b Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Fri, 28 Jul 2023 08:29:19 +0530 Subject: [PATCH 46/84] chore: cancel gain/loss je while posting reverse gl --- .../accounts/doctype/journal_entry/journal_entry.py | 3 +++ .../accounts/doctype/payment_entry/payment_entry.py | 12 ++++++++++-- .../accounts/doctype/sales_invoice/sales_invoice.py | 3 ++- erpnext/controllers/stock_controller.py | 3 ++- 4 files changed, 17 insertions(+), 4 deletions(-) diff --git a/erpnext/accounts/doctype/journal_entry/journal_entry.py b/erpnext/accounts/doctype/journal_entry/journal_entry.py index daa6355a445..1e1b3ba642a 100644 --- a/erpnext/accounts/doctype/journal_entry/journal_entry.py +++ b/erpnext/accounts/doctype/journal_entry/journal_entry.py @@ -18,6 +18,7 @@ from erpnext.accounts.doctype.tax_withholding_category.tax_withholding_category ) from erpnext.accounts.party import get_party_account from erpnext.accounts.utils import ( + cancel_exchange_gain_loss_journal, get_account_currency, get_balance_on, get_stock_accounts, @@ -942,6 +943,8 @@ class JournalEntry(AccountsController): merge_entries=merge_entries, update_outstanding=update_outstanding, ) + if cancel: + cancel_exchange_gain_loss_journal(frappe._dict(doctype=self.doctype, name=self.name)) @frappe.whitelist() def get_balance(self, difference_account=None): diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.py b/erpnext/accounts/doctype/payment_entry/payment_entry.py index 89241ebfe0d..dec7f2b777d 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry.py +++ b/erpnext/accounts/doctype/payment_entry/payment_entry.py @@ -28,7 +28,12 @@ from erpnext.accounts.general_ledger import ( process_gl_map, ) from erpnext.accounts.party import get_party_account -from erpnext.accounts.utils import get_account_currency, get_balance_on, get_outstanding_invoices +from erpnext.accounts.utils import ( + cancel_exchange_gain_loss_journal, + get_account_currency, + get_balance_on, + get_outstanding_invoices, +) from erpnext.controllers.accounts_controller import ( AccountsController, get_supplier_block_status, @@ -1018,7 +1023,10 @@ class PaymentEntry(AccountsController): gl_entries = self.build_gl_map() gl_entries = process_gl_map(gl_entries) make_gl_entries(gl_entries, cancel=cancel, adv_adj=adv_adj) - self.make_exchange_gain_loss_journal() + if cancel: + cancel_exchange_gain_loss_journal(frappe._dict(doctype=self.doctype, name=self.name)) + else: + self.make_exchange_gain_loss_journal() def add_party_gl_entries(self, gl_entries): if self.party_account: diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py index fa18d8fc0ef..e6bec930503 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py @@ -23,7 +23,7 @@ from erpnext.accounts.doctype.tax_withholding_category.tax_withholding_category ) from erpnext.accounts.general_ledger import get_round_off_account_and_cost_center from erpnext.accounts.party import get_due_date, get_party_account, get_party_details -from erpnext.accounts.utils import get_account_currency +from erpnext.accounts.utils import cancel_exchange_gain_loss_journal, get_account_currency from erpnext.assets.doctype.asset.depreciation import ( depreciate_asset, get_disposal_account_and_cost_center, @@ -1032,6 +1032,7 @@ class SalesInvoice(SellingController): self.make_exchange_gain_loss_journal() elif self.docstatus == 2: + cancel_exchange_gain_loss_journal(frappe._dict(doctype=self.doctype, name=self.name)) make_reverse_gl_entries(voucher_type=self.doctype, voucher_no=self.name) if update_outstanding == "No": diff --git a/erpnext/controllers/stock_controller.py b/erpnext/controllers/stock_controller.py index caf4b6f18bc..d669abe910d 100644 --- a/erpnext/controllers/stock_controller.py +++ b/erpnext/controllers/stock_controller.py @@ -15,7 +15,7 @@ from erpnext.accounts.general_ledger import ( make_reverse_gl_entries, process_gl_map, ) -from erpnext.accounts.utils import get_fiscal_year +from erpnext.accounts.utils import cancel_exchange_gain_loss_journal, get_fiscal_year from erpnext.controllers.accounts_controller import AccountsController from erpnext.stock import get_warehouse_account_map from erpnext.stock.doctype.inventory_dimension.inventory_dimension import ( @@ -534,6 +534,7 @@ class StockController(AccountsController): make_sl_entries(sl_entries, allow_negative_stock, via_landed_cost_voucher) def make_gl_entries_on_cancel(self): + cancel_exchange_gain_loss_journal(frappe._dict(doctype=self.doctype, name=self.name)) if frappe.db.sql( """select name from `tabGL Entry` where voucher_type=%s and voucher_no=%s""", From fd5c4e0a64a4a8972bf70fd1358767ab1fb86785 Mon Sep 17 00:00:00 2001 From: Gursheen Anand Date: Fri, 28 Jul 2023 11:41:03 +0530 Subject: [PATCH 47/84] fix: fetch ple for all party types --- .../accounts_payable/accounts_payable.py | 2 +- .../accounts_receivable.py | 98 +++++++++++-------- 2 files changed, 56 insertions(+), 44 deletions(-) diff --git a/erpnext/accounts/report/accounts_payable/accounts_payable.py b/erpnext/accounts/report/accounts_payable/accounts_payable.py index 7b199949113..8279afbc2bc 100644 --- a/erpnext/accounts/report/accounts_payable/accounts_payable.py +++ b/erpnext/accounts/report/accounts_payable/accounts_payable.py @@ -7,7 +7,7 @@ from erpnext.accounts.report.accounts_receivable.accounts_receivable import Rece def execute(filters=None): args = { - "party_type": "Supplier", + "account_type": "Payable", "naming_by": ["Buying Settings", "supp_master_name"], } return ReceivablePayableReport(filters).run(args) diff --git a/erpnext/accounts/report/accounts_receivable/accounts_receivable.py b/erpnext/accounts/report/accounts_receivable/accounts_receivable.py index 93c3fb33403..5b92dcd717f 100755 --- a/erpnext/accounts/report/accounts_receivable/accounts_receivable.py +++ b/erpnext/accounts/report/accounts_receivable/accounts_receivable.py @@ -34,7 +34,7 @@ from erpnext.accounts.utils import get_currency_precision def execute(filters=None): args = { - "party_type": "Customer", + "account_type": "Receivable", "naming_by": ["Selling Settings", "cust_master_name"], } return ReceivablePayableReport(filters).run(args) @@ -70,8 +70,11 @@ class ReceivablePayableReport(object): "Company", self.filters.get("company"), "default_currency" ) self.currency_precision = get_currency_precision() or 2 - self.dr_or_cr = "debit" if self.filters.party_type == "Customer" else "credit" - self.party_type = self.filters.party_type + self.dr_or_cr = "debit" if self.filters.account_type == "Receivable" else "credit" + self.account_type = self.filters.account_type + self.party_type = frappe.db.get_all( + "Party Type", {"account_type": self.account_type}, pluck="name" + ) self.party_details = {} self.invoices = set() self.skip_total_row = 0 @@ -197,6 +200,7 @@ class ReceivablePayableReport(object): # no invoice, this is an invoice / stand-alone payment / credit note row = self.voucher_balance.get((ple.voucher_type, ple.voucher_no, ple.party)) + row.party_type = ple.party_type return row def update_voucher_balance(self, ple): @@ -207,8 +211,9 @@ class ReceivablePayableReport(object): return # amount in "Party Currency", if its supplied. If not, amount in company currency - if self.filters.get(scrub(self.party_type)): - amount = ple.amount_in_account_currency + for party_type in self.party_type: + if self.filters.get(scrub(party_type)): + amount = ple.amount_in_account_currency else: amount = ple.amount amount_in_account_currency = ple.amount_in_account_currency @@ -362,7 +367,7 @@ class ReceivablePayableReport(object): def get_invoice_details(self): self.invoice_details = frappe._dict() - if self.party_type == "Customer": + if self.account_type == "Receivable": si_list = frappe.db.sql( """ select name, due_date, po_no @@ -390,7 +395,7 @@ class ReceivablePayableReport(object): d.sales_person ) - if self.party_type == "Supplier": + if self.account_type == "Payable": for pi in frappe.db.sql( """ select name, due_date, bill_no, bill_date @@ -421,12 +426,10 @@ class ReceivablePayableReport(object): # customer / supplier name party_details = self.get_party_details(row.party) or {} row.update(party_details) - if row.voucher_type == "Expense Claim": - row.party_type = "Employee" - else: - row.party_type = self.party_type - if self.filters.get(scrub(self.filters.party_type)): - row.currency = row.account_currency + for party_type in self.party_type: + if self.filters.get(scrub(party_type)): + row.currency = row.account_currency + break else: row.currency = self.company_currency @@ -552,7 +555,7 @@ class ReceivablePayableReport(object): where payment_entry.docstatus < 2 and payment_entry.posting_date > %s - and payment_entry.party_type = %s + and payment_entry.party_type in %s """, (self.filters.report_date, self.party_type), as_dict=1, @@ -562,11 +565,11 @@ class ReceivablePayableReport(object): if self.filters.get("party"): amount_field = ( "jea.debit_in_account_currency - jea.credit_in_account_currency" - if self.party_type == "Supplier" + if self.account_type == "Payable" else "jea.credit_in_account_currency - jea.debit_in_account_currency" ) else: - amount_field = "jea.debit - " if self.party_type == "Supplier" else "jea.credit" + amount_field = "jea.debit - " if self.account_type == "Payable" else "jea.credit" return frappe.db.sql( """ @@ -584,7 +587,7 @@ class ReceivablePayableReport(object): where je.docstatus < 2 and je.posting_date > %s - and jea.party_type = %s + and jea.party_type in %s and jea.reference_name is not null and jea.reference_name != '' group by je.name, jea.reference_name having future_amount > 0 @@ -623,13 +626,17 @@ class ReceivablePayableReport(object): row.future_ref = ", ".join(row.future_ref) def get_return_entries(self): - doctype = "Sales Invoice" if self.party_type == "Customer" else "Purchase Invoice" + doctype = "Sales Invoice" if self.account_type == "Receivable" else "Purchase Invoice" filters = {"is_return": 1, "docstatus": 1, "company": self.filters.company} - party_field = scrub(self.filters.party_type) - if self.filters.get(party_field): - filters.update({party_field: self.filters.get(party_field)}) + or_filters = {} + for party_type in self.party_type: + party_field = scrub(party_type) + if self.filters.get(party_field): + or_filters.update({party_field: self.filters.get(party_field)}) self.return_entries = frappe._dict( - frappe.get_all(doctype, filters, ["name", "return_against"], as_list=1) + frappe.get_all( + doctype, filters=filters, or_filters=or_filters, fields=["name", "return_against"], as_list=1 + ) ) def set_ageing(self, row): @@ -720,6 +727,7 @@ class ReceivablePayableReport(object): ) .where(ple.delinked == 0) .where(Criterion.all(self.qb_selection_filter)) + .where(Criterion.any(self.or_filters)) ) if self.filters.get("group_by_party"): @@ -750,19 +758,18 @@ class ReceivablePayableReport(object): def prepare_conditions(self): self.qb_selection_filter = [] - party_type_field = scrub(self.party_type) - if self.party_type == "Supplier": - self.qb_selection_filter.append(self.ple.party_type.isin([self.party_type, "Employee"])) - else: - self.qb_selection_filter.append(self.ple.party_type == self.party_type) + self.or_filters = [] + for party_type in self.party_type: + party_type_field = scrub(party_type) + self.or_filters.append(self.ple.party_type == party_type) - self.add_common_filters(party_type_field=party_type_field) + self.add_common_filters(party_type_field=party_type_field) - if party_type_field == "customer": - self.add_customer_filters() + if party_type_field == "customer": + self.add_customer_filters() - elif party_type_field == "supplier": - self.add_supplier_filters() + elif party_type_field == "supplier": + self.add_supplier_filters() if self.filters.cost_center: self.get_cost_center_conditions() @@ -791,11 +798,10 @@ class ReceivablePayableReport(object): self.qb_selection_filter.append(self.ple.account == self.filters.party_account) else: # get GL with "receivable" or "payable" account_type - account_type = "Receivable" if self.party_type == "Customer" else "Payable" accounts = [ d.name for d in frappe.get_all( - "Account", filters={"account_type": account_type, "company": self.filters.company} + "Account", filters={"account_type": self.account_type, "company": self.filters.company} ) ] @@ -885,7 +891,7 @@ class ReceivablePayableReport(object): def get_party_details(self, party): if not party in self.party_details: - if self.party_type == "Customer": + if self.account_type == "Receivable": fields = ["customer_name", "territory", "customer_group", "customer_primary_contact"] if self.filters.get("sales_partner"): @@ -921,7 +927,7 @@ class ReceivablePayableReport(object): width=180, ) self.add_column( - label="Receivable Account" if self.party_type == "Customer" else "Payable Account", + label=self.account_type + " Account", fieldname="party_account", fieldtype="Link", options="Account", @@ -929,13 +935,19 @@ class ReceivablePayableReport(object): ) if self.party_naming_by == "Naming Series": + if self.account_type == "Payable": + label = "Supplier Name" + fieldname = "supplier_name" + else: + label = "Customer Name" + fieldname = "customer_name" self.add_column( - _("{0} Name").format(self.party_type), - fieldname=scrub(self.party_type) + "_name", + label=label, + fieldname=fieldname, fieldtype="Data", ) - if self.party_type == "Customer": + if self.account_type == "Receivable": self.add_column( _("Customer Contact"), fieldname="customer_primary_contact", @@ -955,7 +967,7 @@ class ReceivablePayableReport(object): self.add_column(label="Due Date", fieldtype="Date") - if self.party_type == "Supplier": + if self.account_type == "Payable": self.add_column(label=_("Bill No"), fieldname="bill_no", fieldtype="Data") self.add_column(label=_("Bill Date"), fieldname="bill_date", fieldtype="Date") @@ -965,7 +977,7 @@ class ReceivablePayableReport(object): self.add_column(_("Invoiced Amount"), fieldname="invoiced") self.add_column(_("Paid Amount"), fieldname="paid") - if self.party_type == "Customer": + if self.account_type == "Receivable": self.add_column(_("Credit Note"), fieldname="credit_note") else: # note: fieldname is still `credit_note` @@ -983,7 +995,7 @@ class ReceivablePayableReport(object): self.add_column(label=_("Future Payment Amount"), fieldname="future_amount") self.add_column(label=_("Remaining Balance"), fieldname="remaining_balance") - if self.filters.party_type == "Customer": + if self.filters.account_type == "Receivable": self.add_column(label=_("Customer LPO"), fieldname="po_no", fieldtype="Data") # comma separated list of linked delivery notes @@ -1004,7 +1016,7 @@ class ReceivablePayableReport(object): if self.filters.sales_partner: self.add_column(label=_("Sales Partner"), fieldname="default_sales_partner", fieldtype="Data") - if self.filters.party_type == "Supplier": + if self.filters.account_type == "Payable": self.add_column( label=_("Supplier Group"), fieldname="supplier_group", From e355dea4b550fcf64450876652f852f6a6c529fd Mon Sep 17 00:00:00 2001 From: Gursheen Anand Date: Fri, 28 Jul 2023 14:51:28 +0530 Subject: [PATCH 48/84] fix: AP and AR summary --- erpnext/accounts/party.py | 38 ++++++++------- .../accounts_payable_summary.py | 2 +- .../accounts_receivable_summary.py | 48 ++++++++++++++----- 3 files changed, 58 insertions(+), 30 deletions(-) diff --git a/erpnext/accounts/party.py b/erpnext/accounts/party.py index 49962036351..d5f8634a7e9 100644 --- a/erpnext/accounts/party.py +++ b/erpnext/accounts/party.py @@ -14,6 +14,7 @@ from frappe.contacts.doctype.address.address import ( from frappe.contacts.doctype.contact.contact import get_contact_details from frappe.core.doctype.user_permission.user_permission import get_permitted_documents from frappe.model.utils import get_fetch_values +from frappe.query_builder.functions import Date, Sum from frappe.utils import ( add_days, add_months, @@ -920,32 +921,35 @@ def get_party_shipping_address(doctype: str, name: str) -> Optional[str]: def get_partywise_advanced_payment_amount( - party_type, posting_date=None, future_payment=0, company=None, party=None + party_type, posting_date=None, future_payment=0, company=None, party=None, account_type=None ): - cond = "1=1" + gle = frappe.qb.DocType("GL Entry") + query = ( + frappe.qb.from_(gle) + .select(gle.party) + .where( + (gle.party_type.isin(party_type)) & (gle.against_voucher == None) & (gle.is_cancelled == 0) + ) + .groupby(gle.party) + ) + if account_type == "Receivable": + query = query.select(Sum(gle.credit).as_("amount")) + else: + query = query.select(Sum(gle.debit).as_("amount")) + if posting_date: if future_payment: - cond = "(posting_date <= '{0}' OR DATE(creation) <= '{0}')" "".format(posting_date) + query = query.where((gle.posting_date <= posting_date) | (Date(gle.creation) <= posting_date)) else: - cond = "posting_date <= '{0}'".format(posting_date) + query = query.where(gle.posting_date <= posting_date) if company: - cond += "and company = {0}".format(frappe.db.escape(company)) + query = query.where(gle.company == company) if party: - cond += "and party = {0}".format(frappe.db.escape(party)) + query = query.where(gle.party == party) - data = frappe.db.sql( - """ SELECT party, sum({0}) as amount - FROM `tabGL Entry` - WHERE - party_type = %s and against_voucher is null - and is_cancelled = 0 - and {1} GROUP BY party""".format( - ("credit") if party_type == "Customer" else "debit", cond - ), - party_type, - ) + data = query.run(as_dict=True) if data: return frappe._dict(data) diff --git a/erpnext/accounts/report/accounts_payable_summary/accounts_payable_summary.py b/erpnext/accounts/report/accounts_payable_summary/accounts_payable_summary.py index 65fe1de5689..834c83c38e9 100644 --- a/erpnext/accounts/report/accounts_payable_summary/accounts_payable_summary.py +++ b/erpnext/accounts/report/accounts_payable_summary/accounts_payable_summary.py @@ -9,7 +9,7 @@ from erpnext.accounts.report.accounts_receivable_summary.accounts_receivable_sum def execute(filters=None): args = { - "party_type": "Supplier", + "account_type": "Payable", "naming_by": ["Buying Settings", "supp_master_name"], } return AccountsReceivableSummary(filters).run(args) diff --git a/erpnext/accounts/report/accounts_receivable_summary/accounts_receivable_summary.py b/erpnext/accounts/report/accounts_receivable_summary/accounts_receivable_summary.py index 9c01b1a4980..3aa1ae71045 100644 --- a/erpnext/accounts/report/accounts_receivable_summary/accounts_receivable_summary.py +++ b/erpnext/accounts/report/accounts_receivable_summary/accounts_receivable_summary.py @@ -12,7 +12,7 @@ from erpnext.accounts.report.accounts_receivable.accounts_receivable import Rece def execute(filters=None): args = { - "party_type": "Customer", + "account_type": "Receivable", "naming_by": ["Selling Settings", "cust_master_name"], } @@ -21,7 +21,10 @@ def execute(filters=None): class AccountsReceivableSummary(ReceivablePayableReport): def run(self, args): - self.party_type = args.get("party_type") + self.account_type = args.get("account_type") + self.party_type = frappe.db.get_all( + "Party Type", {"account_type": self.account_type}, pluck="name" + ) self.party_naming_by = frappe.db.get_value( args.get("naming_by")[0], None, args.get("naming_by")[1] ) @@ -35,13 +38,19 @@ class AccountsReceivableSummary(ReceivablePayableReport): self.get_party_total(args) + party = None + for party_type in self.party_type: + if self.filters.get(scrub(party_type)): + party = self.filters.get(scrub(party_type)) + party_advance_amount = ( get_partywise_advanced_payment_amount( self.party_type, self.filters.report_date, self.filters.show_future_payments, self.filters.company, - party=self.filters.get(scrub(self.party_type)), + party=party, + account_type=self.account_type, ) or {} ) @@ -57,9 +66,13 @@ class AccountsReceivableSummary(ReceivablePayableReport): row.party = party if self.party_naming_by == "Naming Series": - row.party_name = frappe.get_cached_value( - self.party_type, party, scrub(self.party_type) + "_name" - ) + if self.account_type == "Payable": + doctype = "Supplier" + fieldname = "supplier_name" + else: + doctype = "Customer" + fieldname = "customer_name" + row.party_name = frappe.get_cached_value(doctype, party, fieldname) row.update(party_dict) @@ -93,6 +106,7 @@ class AccountsReceivableSummary(ReceivablePayableReport): # set territory, customer_group, sales person etc self.set_party_details(d) + self.party_total[d.party].update({"party_type": d.party_type}) def init_party_total(self, row): self.party_total.setdefault( @@ -131,17 +145,27 @@ class AccountsReceivableSummary(ReceivablePayableReport): def get_columns(self): self.columns = [] self.add_column( - label=_(self.party_type), + label="Party Type", + fieldname="party_type", + fieldtype="Data", + width=100, + ) + self.add_column( + label="Party", fieldname="party", - fieldtype="Link", - options=self.party_type, + fieldtype="Dynamic Link", + options="party_type", width=180, ) if self.party_naming_by == "Naming Series": - self.add_column(_("{0} Name").format(self.party_type), fieldname="party_name", fieldtype="Data") + self.add_column( + label="Supplier Name" if self.account_type == "Payable" else "Customer Name", + fieldname="party_name", + fieldtype="Data", + ) - credit_debit_label = "Credit Note" if self.party_type == "Customer" else "Debit Note" + credit_debit_label = "Credit Note" if self.account_type == "Receivable" else "Debit Note" self.add_column(_("Advance Amount"), fieldname="advance") self.add_column(_("Invoiced Amount"), fieldname="invoiced") @@ -159,7 +183,7 @@ class AccountsReceivableSummary(ReceivablePayableReport): self.add_column(label=_("Future Payment Amount"), fieldname="future_amount") self.add_column(label=_("Remaining Balance"), fieldname="remaining_balance") - if self.party_type == "Customer": + if self.account_type == "Receivable": self.add_column( label=_("Territory"), fieldname="territory", fieldtype="Link", options="Territory" ) From f5761e79657f744aff6b1ed964f9ffdf6e7d5a9f Mon Sep 17 00:00:00 2001 From: Gursheen Anand Date: Fri, 28 Jul 2023 16:01:30 +0530 Subject: [PATCH 49/84] refactor: future payments query --- erpnext/accounts/party.py | 2 +- .../accounts_receivable.py | 110 +++++++++--------- 2 files changed, 57 insertions(+), 55 deletions(-) diff --git a/erpnext/accounts/party.py b/erpnext/accounts/party.py index d5f8634a7e9..895c314510a 100644 --- a/erpnext/accounts/party.py +++ b/erpnext/accounts/party.py @@ -928,7 +928,7 @@ def get_partywise_advanced_payment_amount( frappe.qb.from_(gle) .select(gle.party) .where( - (gle.party_type.isin(party_type)) & (gle.against_voucher == None) & (gle.is_cancelled == 0) + (gle.party_type.isin(party_type)) & (gle.against_voucher.isnull()) & (gle.is_cancelled == 0) ) .groupby(gle.party) ) diff --git a/erpnext/accounts/report/accounts_receivable/accounts_receivable.py b/erpnext/accounts/report/accounts_receivable/accounts_receivable.py index 5b92dcd717f..11bbb6f1e43 100755 --- a/erpnext/accounts/report/accounts_receivable/accounts_receivable.py +++ b/erpnext/accounts/report/accounts_receivable/accounts_receivable.py @@ -7,7 +7,7 @@ from collections import OrderedDict import frappe from frappe import _, qb, scrub from frappe.query_builder import Criterion -from frappe.query_builder.functions import Date +from frappe.query_builder.functions import Date, Sum from frappe.utils import cint, cstr, flt, getdate, nowdate from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import ( @@ -539,65 +539,67 @@ class ReceivablePayableReport(object): self.future_payments.setdefault((d.invoice_no, d.party), []).append(d) def get_future_payments_from_payment_entry(self): - return frappe.db.sql( - """ - select - ref.reference_name as invoice_no, - payment_entry.party, - payment_entry.party_type, - payment_entry.posting_date as future_date, - ref.allocated_amount as future_amount, - payment_entry.reference_no as future_ref - from - `tabPayment Entry` as payment_entry inner join `tabPayment Entry Reference` as ref - on - (ref.parent = payment_entry.name) - where - payment_entry.docstatus < 2 - and payment_entry.posting_date > %s - and payment_entry.party_type in %s - """, - (self.filters.report_date, self.party_type), - as_dict=1, - ) + pe = frappe.qb.DocType("Payment Entry") + pe_ref = frappe.qb.DocType("Payment Entry Reference") + return ( + frappe.qb.from_(pe) + .inner_join(pe_ref) + .on(pe_ref.parent == pe.name) + .select( + (pe_ref.reference_name).as_("invoice_no"), + pe.party, + pe.party_type, + (pe.posting_date).as_("future_date"), + (pe_ref.allocated_amount).as_("future_amount"), + (pe.reference_no).as_("future_ref"), + ) + .where( + (pe.docstatus < 2) + & (pe.posting_date > self.filters.report_date) + & (pe.party_type.isin(self.party_type)) + ) + ).run(as_dict=True) def get_future_payments_from_journal_entry(self): - if self.filters.get("party"): - amount_field = ( - "jea.debit_in_account_currency - jea.credit_in_account_currency" - if self.account_type == "Payable" - else "jea.credit_in_account_currency - jea.debit_in_account_currency" - ) - else: - amount_field = "jea.debit - " if self.account_type == "Payable" else "jea.credit" - - return frappe.db.sql( - """ - select - jea.reference_name as invoice_no, + je = frappe.qb.DocType("Journal Entry") + jea = frappe.qb.DocType("Journal Entry Account") + query = ( + frappe.qb.from_(je) + .inner_join(jea) + .on(jea.parent == je.name) + .select( + jea.reference_name.as_("invoice_no"), jea.party, jea.party_type, - je.posting_date as future_date, - sum('{0}') as future_amount, - je.cheque_no as future_ref - from - `tabJournal Entry` as je inner join `tabJournal Entry Account` as jea - on - (jea.parent = je.name) - where - je.docstatus < 2 - and je.posting_date > %s - and jea.party_type in %s - and jea.reference_name is not null and jea.reference_name != '' - group by je.name, jea.reference_name - having future_amount > 0 - """.format( - amount_field - ), - (self.filters.report_date, self.party_type), - as_dict=1, + je.posting_date.as_("future_date"), + je.cheque_no.as_("future_ref"), + ) + .where( + (je.docstatus < 2) + & (je.posting_date > self.filters.report_date) + & (jea.party_type.isin(self.party_type)) + & (jea.reference_name.isnotnull()) + & (jea.reference_name != "") + ) ) + if self.filters.get("party"): + if self.account_type == "Payable": + query = query.select( + Sum(jea.debit_in_account_currency - jea.credit_in_account_currency).as_("future_amount") + ) + else: + query = query.select( + Sum(jea.credit_in_account_currency - jea.debit_in_account_currency).as_("future_amount") + ) + else: + query = query.select( + Sum(jea.debit if self.account_type == "Payable" else jea.credit).as_("future_amount") + ) + + query = query.having(qb.Field("future_amount") > 0) + return query.run(as_dict=True) + def allocate_future_payments(self, row): # future payments are captured in additional columns # this method allocates pending future payments against a voucher to From bc8d05da0fb856f055e43162747749c7df0825cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bernd=20Oliver=20S=C3=BCnderhauf?= Date: Sun, 30 Jul 2023 19:04:03 +0200 Subject: [PATCH 50/84] feat: Reallow customizing company abbreviation on setup. --- erpnext/public/js/setup_wizard.js | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/erpnext/public/js/setup_wizard.js b/erpnext/public/js/setup_wizard.js index a913844e186..934fd1f88ae 100644 --- a/erpnext/public/js/setup_wizard.js +++ b/erpnext/public/js/setup_wizard.js @@ -24,12 +24,14 @@ erpnext.setup.slides_settings = [ fieldtype: 'Data', reqd: 1 }, + { fieldtype: "Column Break" }, { fieldname: 'company_abbr', label: __('Company Abbreviation'), fieldtype: 'Data', - hidden: 1 + reqd: 1 }, + { fieldtype: "Section Break" }, { fieldname: 'chart_of_accounts', label: __('Chart of Accounts'), options: "", fieldtype: 'Select' @@ -134,18 +136,20 @@ erpnext.setup.slides_settings = [ me.charts_modal(slide, chart_template); }); - slide.get_input("company_name").on("change", function () { + slide.get_input("company_name").on("input", function () { let parts = slide.get_input("company_name").val().split(" "); let abbr = $.map(parts, function (p) { return p ? p.substr(0, 1) : null }).join(""); slide.get_field("company_abbr").set_value(abbr.slice(0, 10).toUpperCase()); }).val(frappe.boot.sysdefaults.company_name || "").trigger("change"); slide.get_input("company_abbr").on("change", function () { - if (slide.get_input("company_abbr").val().length > 10) { + let abbr = slide.get_input("company_abbr").val(); + if (abbr.length > 10) { frappe.msgprint(__("Company Abbreviation cannot have more than 5 characters")); - slide.get_field("company_abbr").set_value(""); + abbr = abbr.slice(0, 10); } - }); + slide.get_field("company_abbr").set_value(abbr); + }).val(frappe.boot.sysdefaults.company_abbr || "").trigger("change"); }, charts_modal: function(slide, chart_template) { From bd3fc7c4342195ce22cd860cba83e287aaac15b5 Mon Sep 17 00:00:00 2001 From: Corentin Flr <10946971+cogk@users.noreply.github.com> Date: Tue, 1 Aug 2023 14:35:11 +0200 Subject: [PATCH 51/84] fix: Fix query for financial statement report --- .../consolidated_financial_statement.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/accounts/report/consolidated_financial_statement/consolidated_financial_statement.py b/erpnext/accounts/report/consolidated_financial_statement/consolidated_financial_statement.py index 7c2ebe1d20c..f1e665a68f7 100644 --- a/erpnext/accounts/report/consolidated_financial_statement/consolidated_financial_statement.py +++ b/erpnext/accounts/report/consolidated_financial_statement/consolidated_financial_statement.py @@ -654,7 +654,7 @@ def set_gl_entries_by_account( & (gle.posting_date <= to_date) & (account.lft >= root_lft) & (account.rgt <= root_rgt) - & (account.root_type <= root_type) + & (account.root_type == root_type) ) .orderby(gle.account, gle.posting_date) ) From cd98be6088acc0a9d6170c88da7bdf440306f425 Mon Sep 17 00:00:00 2001 From: Gursheen Anand Date: Tue, 1 Aug 2023 23:22:49 +0530 Subject: [PATCH 52/84] fix: check root type only when not none --- .../consolidated_financial_statement.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/erpnext/accounts/report/consolidated_financial_statement/consolidated_financial_statement.py b/erpnext/accounts/report/consolidated_financial_statement/consolidated_financial_statement.py index f1e665a68f7..080e45a7987 100644 --- a/erpnext/accounts/report/consolidated_financial_statement/consolidated_financial_statement.py +++ b/erpnext/accounts/report/consolidated_financial_statement/consolidated_financial_statement.py @@ -654,11 +654,12 @@ def set_gl_entries_by_account( & (gle.posting_date <= to_date) & (account.lft >= root_lft) & (account.rgt <= root_rgt) - & (account.root_type == root_type) ) .orderby(gle.account, gle.posting_date) ) + if root_type: + query = query.where(account.root_type == root_type) additional_conditions = get_additional_conditions(from_date, ignore_closing_entries, filters, d) if additional_conditions: query = query.where(Criterion.all(additional_conditions)) From 002bf77314a71c02ad164e328a3a9cc9ec9714e4 Mon Sep 17 00:00:00 2001 From: Gursheen Anand Date: Tue, 1 Aug 2023 23:24:18 +0530 Subject: [PATCH 53/84] test: balance sheet report --- .../balance_sheet/test_balance_sheet.py | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 erpnext/accounts/report/balance_sheet/test_balance_sheet.py diff --git a/erpnext/accounts/report/balance_sheet/test_balance_sheet.py b/erpnext/accounts/report/balance_sheet/test_balance_sheet.py new file mode 100644 index 00000000000..3cb6efebee3 --- /dev/null +++ b/erpnext/accounts/report/balance_sheet/test_balance_sheet.py @@ -0,0 +1,51 @@ +# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors +# MIT License. See license.txt + +import frappe +from frappe.tests.utils import FrappeTestCase +from frappe.utils import today + +from erpnext.accounts.report.balance_sheet.balance_sheet import execute + + +class TestBalanceSheet(FrappeTestCase): + def test_balance_sheet(self): + 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, + make_sales_invoice, + ) + from erpnext.accounts.utils import get_fiscal_year + + frappe.db.sql("delete from `tabPurchase Invoice` where company='_Test Company 6'") + frappe.db.sql("delete from `tabSales Invoice` where company='_Test Company 6'") + frappe.db.sql("delete from `tabGL Entry` where company='_Test Company 6'") + + pi = make_purchase_invoice( + company="_Test Company 6", + warehouse="Finished Goods - _TC6", + expense_account="Cost of Goods Sold - _TC6", + cost_center="Main - _TC6", + qty=10, + rate=100, + ) + si = create_sales_invoice( + company="_Test Company 6", + debit_to="Debtors - _TC6", + income_account="Sales - _TC6", + cost_center="Main - _TC6", + qty=5, + rate=110, + ) + filters = frappe._dict( + company="_Test Company 6", + period_start_date=today(), + period_end_date=today(), + periodicity="Yearly", + ) + result = execute(filters)[1] + for account_dict in result: + if account_dict.get("account") == "Current Liabilities - _TC6": + self.assertEqual(account_dict.total, 1000) + if account_dict.get("account") == "Current Assets - _TC6": + self.assertEqual(account_dict.total, 550) From dedf24b86db824f84dd48cf2b470272aa90ab636 Mon Sep 17 00:00:00 2001 From: Devin Slauenwhite Date: Wed, 2 Aug 2023 06:56:55 -0400 Subject: [PATCH 54/84] fix: don't allow negative rates (#36027) * fix: don't allow negative rate * test: don't allow negative rate * fix: only check for -rate on items child table --- .../accounts/doctype/sales_invoice/test_sales_invoice.py | 7 +++++++ erpnext/controllers/status_updater.py | 3 +++ 2 files changed, 10 insertions(+) diff --git a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py index 41e55546a83..e8445aa82a5 100644 --- a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py @@ -3371,6 +3371,13 @@ class TestSalesInvoice(unittest.TestCase): set_advance_flag(company="_Test Company", flag=0, default_account="") + def test_sales_return_negative_rate(self): + si = create_sales_invoice(is_return=1, qty=-2, rate=-10, do_not_save=True) + self.assertRaises(frappe.ValidationError, si.save) + + si.items[0].rate = 10 + si.save() + def set_advance_flag(company, flag, default_account): frappe.db.set_value( diff --git a/erpnext/controllers/status_updater.py b/erpnext/controllers/status_updater.py index 58cab147a47..a4bc4a9c69e 100644 --- a/erpnext/controllers/status_updater.py +++ b/erpnext/controllers/status_updater.py @@ -233,6 +233,9 @@ class StatusUpdater(Document): if hasattr(d, "qty") and d.qty > 0 and self.get("is_return"): frappe.throw(_("For an item {0}, quantity must be negative number").format(d.item_code)) + if hasattr(d, "item_code") and hasattr(d, "rate") and d.rate < 0: + frappe.throw(_("For an item {0}, rate must be a positive number").format(d.item_code)) + if d.doctype == args["source_dt"] and d.get(args["join_field"]): args["name"] = d.get(args["join_field"]) From 27ebf14f9d516ec555ed702a2edd78c6e65d517f Mon Sep 17 00:00:00 2001 From: Husam Hammad <85282854+husamhammad@users.noreply.github.com> Date: Wed, 2 Aug 2023 13:58:05 +0300 Subject: [PATCH 55/84] fix: handle None value in payment_term_outstanding * Fix payment entry bug: Handle None value in payment_term_outstanding * fix: Handle None value in payment_term_outstanding V2 fix linting issue --- erpnext/accounts/doctype/payment_entry/payment_entry.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.py b/erpnext/accounts/doctype/payment_entry/payment_entry.py index 29b52729cd3..c3018cdfd4c 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry.py +++ b/erpnext/accounts/doctype/payment_entry/payment_entry.py @@ -281,7 +281,8 @@ class PaymentEntry(AccountsController): d.payment_term and ( (flt(d.allocated_amount)) > 0 - and flt(d.allocated_amount) > flt(latest.payment_term_outstanding) + 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) ): From 38a612c62e2943a4a84f678bfd2804671e966b46 Mon Sep 17 00:00:00 2001 From: Anand Baburajan Date: Thu, 3 Aug 2023 16:37:05 +0530 Subject: [PATCH 56/84] chore: better cost center validation for assets (#36477) --- erpnext/assets/doctype/asset/asset.py | 36 +++++++++++++++++++-------- 1 file changed, 26 insertions(+), 10 deletions(-) diff --git a/erpnext/assets/doctype/asset/asset.py b/erpnext/assets/doctype/asset/asset.py index 252a3dd63fd..04ec7be3cd8 100644 --- a/erpnext/assets/doctype/asset/asset.py +++ b/erpnext/assets/doctype/asset/asset.py @@ -148,17 +148,33 @@ class Asset(AccountsController): frappe.throw(_("Item {0} must be a non-stock item").format(self.item_code)) def validate_cost_center(self): - if not self.cost_center: - return - - cost_center_company = frappe.db.get_value("Cost Center", self.cost_center, "company") - if cost_center_company != self.company: - frappe.throw( - _("Selected Cost Center {} doesn't belongs to {}").format( - frappe.bold(self.cost_center), frappe.bold(self.company) - ), - title=_("Invalid Cost Center"), + if self.cost_center: + cost_center_company, cost_center_is_group = frappe.db.get_value( + "Cost Center", self.cost_center, ["company", "is_group"] ) + if cost_center_company != self.company: + frappe.throw( + _("Cost Center {} doesn't belong to Company {}").format( + frappe.bold(self.cost_center), frappe.bold(self.company) + ), + title=_("Invalid Cost Center"), + ) + if cost_center_is_group: + frappe.throw( + _( + "Cost Center {} is a group cost center and group cost centers cannot be used in transactions" + ).format(frappe.bold(self.cost_center)), + title=_("Invalid Cost Center"), + ) + + else: + if not frappe.get_cached_value("Company", self.company, "depreciation_cost_center"): + frappe.throw( + _( + "Please set a Cost Center for the Asset or set an Asset Depreciation Cost Center for the Company {}" + ).format(frappe.bold(self.company)), + title=_("Missing Cost Center"), + ) def validate_in_use_date(self): if not self.available_for_use_date: From 49be7407369f33419513475f00b1ca8da9efea17 Mon Sep 17 00:00:00 2001 From: Sumit Jain <59503001+sumitjain236@users.noreply.github.com> Date: Fri, 4 Aug 2023 17:45:16 +0530 Subject: [PATCH 57/84] fix: Contact Doctype doesn't have any field called `job_title` fix: Contact Doctype doesn't have any field called `job_title` --- erpnext/crm/doctype/lead/lead.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/crm/doctype/lead/lead.py b/erpnext/crm/doctype/lead/lead.py index a98886c6481..105c58d110f 100644 --- a/erpnext/crm/doctype/lead/lead.py +++ b/erpnext/crm/doctype/lead/lead.py @@ -182,7 +182,7 @@ class Lead(SellingController, CRMNote): "last_name": self.last_name, "salutation": self.salutation, "gender": self.gender, - "job_title": self.job_title, + "designation": self.job_title, "company_name": self.company_name, } ) From edbefee10ca779f1d81153110c6085dd04d9c769 Mon Sep 17 00:00:00 2001 From: Gursheen Kaur Anand <40693548+GursheenK@users.noreply.github.com> Date: Fri, 4 Aug 2023 17:49:17 +0530 Subject: [PATCH 58/84] fix: payment allocation in invoice payment schedule (#36440) * fix: payment allocation in invoice payment schedule * test: payment allocation for payment terms * chore: linting issues --- .../purchase_invoice/test_purchase_invoice.py | 46 +++++++++++++++++++ erpnext/controllers/accounts_controller.py | 5 ++ 2 files changed, 51 insertions(+) diff --git a/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py b/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py index 55d0203c7ab..ce7ada3b097 100644 --- a/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py +++ b/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py @@ -1791,6 +1791,52 @@ class TestPurchaseInvoice(unittest.TestCase, StockTestMixin): rate = flt(sle.stock_value_difference) / flt(sle.actual_qty) self.assertAlmostEqual(returned_inv.items[0].rate, rate) + def test_payment_allocation_for_payment_terms(self): + from erpnext.buying.doctype.purchase_order.test_purchase_order import ( + create_pr_against_po, + create_purchase_order, + ) + from erpnext.selling.doctype.sales_order.test_sales_order import ( + automatically_fetch_payment_terms, + ) + from erpnext.stock.doctype.purchase_receipt.purchase_receipt import ( + make_purchase_invoice as make_pi_from_pr, + ) + + automatically_fetch_payment_terms() + frappe.db.set_value( + "Payment Terms Template", + "_Test Payment Term Template", + "allocate_payment_based_on_payment_terms", + 0, + ) + + po = create_purchase_order(do_not_save=1) + po.payment_terms_template = "_Test Payment Term Template" + po.save() + po.submit() + + pr = create_pr_against_po(po.name, received_qty=4) + pi = make_pi_from_pr(pr.name) + self.assertEqual(pi.payment_schedule[0].payment_amount, 1000) + + frappe.db.set_value( + "Payment Terms Template", + "_Test Payment Term Template", + "allocate_payment_based_on_payment_terms", + 1, + ) + pi = make_pi_from_pr(pr.name) + self.assertEqual(pi.payment_schedule[0].payment_amount, 2500) + + automatically_fetch_payment_terms(enable=0) + frappe.db.set_value( + "Payment Terms Template", + "_Test Payment Term Template", + "allocate_payment_based_on_payment_terms", + 0, + ) + def test_offsetting_entries_for_accounting_dimensions(self): from erpnext.accounts.doctype.account.test_account import create_account from erpnext.accounts.report.trial_balance.test_trial_balance import ( diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index 37a18d80e94..b2cfc39be9e 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -1756,8 +1756,13 @@ class AccountsController(TransactionBase): ) self.append("payment_schedule", data) + allocate_payment_based_on_payment_terms = frappe.db.get_value( + "Payment Terms Template", self.payment_terms_template, "allocate_payment_based_on_payment_terms" + ) + if not ( automatically_fetch_payment_terms + and allocate_payment_based_on_payment_terms and self.linked_order_has_payment_terms(po_or_so, fieldname, doctype) ): for d in self.get("payment_schedule"): From b86afb2964d404322ce4541f1e60d650be37a1a0 Mon Sep 17 00:00:00 2001 From: RitvikSardana <65544983+RitvikSardana@users.noreply.github.com> Date: Fri, 4 Aug 2023 22:05:30 +0530 Subject: [PATCH 59/84] feat: Financial Ratio Report (#36130) * feat: Financial Ratio report added * fix: Made columns dynamic * fix: Changed fieldtype of year column * fix: Added Financial Ratios for all Fiscal Years * fix: Added Validation of only Parent Having account_type of Direct Income, Indirect Income, Current Asset and Current Liability * fix: Added 4 more ratios * fix: added a function for repeated code * fix: added account_type in accounts utils and cleaned report code * fix: created function for avg_ratio_values * fix: cleaning code * fix: basic ratios completed * fix: cleaned the code * chore: code cleanup * chore: remove comments * chore: code cleanup * chore: cleanup account query * chore: Remove unused variables --------- Co-authored-by: Ritvik Sardana Co-authored-by: Deepesh Garg --- erpnext/accounts/doctype/account/account.js | 185 ++++++----- erpnext/accounts/doctype/account/account.json | 5 +- erpnext/accounts/doctype/account/account.py | 15 + .../report/balance_sheet/balance_sheet.js | 26 +- .../report/financial_ratios/__init__.py | 0 .../financial_ratios/financial_ratios.js | 72 +++++ .../financial_ratios/financial_ratios.json | 37 +++ .../financial_ratios/financial_ratios.py | 296 ++++++++++++++++++ .../profit_and_loss_statement.js | 27 +- erpnext/accounts/utils.py | 20 +- 10 files changed, 575 insertions(+), 108 deletions(-) create mode 100644 erpnext/accounts/report/financial_ratios/__init__.py create mode 100644 erpnext/accounts/report/financial_ratios/financial_ratios.js create mode 100644 erpnext/accounts/report/financial_ratios/financial_ratios.json create mode 100644 erpnext/accounts/report/financial_ratios/financial_ratios.py diff --git a/erpnext/accounts/doctype/account/account.js b/erpnext/accounts/doctype/account/account.js index f033b54dd0d..3c0eb857018 100644 --- a/erpnext/accounts/doctype/account/account.js +++ b/erpnext/accounts/doctype/account/account.js @@ -1,67 +1,83 @@ // Copyright (c) 2017, Frappe Technologies Pvt. Ltd. and Contributors // License: GNU General Public License v3. See license.txt -frappe.ui.form.on('Account', { - setup: function(frm) { - frm.add_fetch('parent_account', 'report_type', 'report_type'); - frm.add_fetch('parent_account', 'root_type', 'root_type'); +frappe.ui.form.on("Account", { + setup: function (frm) { + frm.add_fetch("parent_account", "report_type", "report_type"); + frm.add_fetch("parent_account", "root_type", "root_type"); }, - onload: function(frm) { - frm.set_query('parent_account', function(doc) { + onload: function (frm) { + frm.set_query("parent_account", function (doc) { return { filters: { - "is_group": 1, - "company": doc.company - } + is_group: 1, + company: doc.company, + }, }; }); }, - refresh: function(frm) { - frm.toggle_display('account_name', frm.is_new()); + refresh: function (frm) { + frm.toggle_display("account_name", frm.is_new()); // hide fields if group - frm.toggle_display(['account_type', 'tax_rate'], cint(frm.doc.is_group) == 0); + frm.toggle_display(["tax_rate"], cint(frm.doc.is_group) == 0); // disable fields - frm.toggle_enable(['is_group', 'company'], false); + frm.toggle_enable(["is_group", "company"], false); if (cint(frm.doc.is_group) == 0) { - frm.toggle_display('freeze_account', frm.doc.__onload - && frm.doc.__onload.can_freeze_account); + frm.toggle_display( + "freeze_account", + frm.doc.__onload && frm.doc.__onload.can_freeze_account + ); } // read-only for root accounts if (!frm.is_new()) { if (!frm.doc.parent_account) { frm.set_read_only(); - frm.set_intro(__("This is a root account and cannot be edited.")); + frm.set_intro( + __("This is a root account and cannot be edited.") + ); } else { // credit days and type if customer or supplier frm.set_intro(null); - frm.trigger('account_type'); + frm.trigger("account_type"); // show / hide convert buttons - frm.trigger('add_toolbar_buttons'); + frm.trigger("add_toolbar_buttons"); } - if (frm.has_perm('write')) { - frm.add_custom_button(__('Merge Account'), function () { - frm.trigger("merge_account"); - }, __('Actions')); - frm.add_custom_button(__('Update Account Name / Number'), function () { - frm.trigger("update_account_number"); - }, __('Actions')); + if (frm.has_perm("write")) { + frm.add_custom_button( + __("Merge Account"), + function () { + frm.trigger("merge_account"); + }, + __("Actions") + ); + frm.add_custom_button( + __("Update Account Name / Number"), + function () { + frm.trigger("update_account_number"); + }, + __("Actions") + ); } } }, account_type: function (frm) { if (frm.doc.is_group == 0) { - frm.toggle_display(['tax_rate'], frm.doc.account_type == 'Tax'); - frm.toggle_display('warehouse', frm.doc.account_type == 'Stock'); + frm.toggle_display(["tax_rate"], frm.doc.account_type == "Tax"); + frm.toggle_display("warehouse", frm.doc.account_type == "Stock"); } }, - add_toolbar_buttons: function(frm) { - frm.add_custom_button(__('Chart of Accounts'), () => { - frappe.set_route("Tree", "Account"); - }, __('View')); + add_toolbar_buttons: function (frm) { + frm.add_custom_button( + __("Chart of Accounts"), + () => { + frappe.set_route("Tree", "Account"); + }, + __("View") + ); if (frm.doc.is_group == 1) { frm.add_custom_button(__('Convert to Non-Group'), function () { @@ -86,31 +102,35 @@ frappe.ui.form.on('Account', { frappe.set_route("query-report", "General Ledger"); }, __('View')); - frm.add_custom_button(__('Convert to Group'), function () { - return frappe.call({ - doc: frm.doc, - method: 'convert_ledger_to_group', - callback: function() { - frm.refresh(); - } - }); - }, __('Actions')); + frm.add_custom_button( + __("Convert to Group"), + function () { + return frappe.call({ + doc: frm.doc, + method: "convert_ledger_to_group", + callback: function () { + frm.refresh(); + }, + }); + }, + __("Actions") + ); } }, - merge_account: function(frm) { + merge_account: function (frm) { var d = new frappe.ui.Dialog({ - title: __('Merge with Existing Account'), + title: __("Merge with Existing Account"), fields: [ { - "label" : "Name", - "fieldname": "name", - "fieldtype": "Data", - "reqd": 1, - "default": frm.doc.name - } + label: "Name", + fieldname: "name", + fieldtype: "Data", + reqd: 1, + default: frm.doc.name, + }, ], - primary_action: function() { + primary_action: function () { var data = d.get_values(); frappe.call({ method: "erpnext.accounts.doctype.account.account.merge_account", @@ -119,44 +139,47 @@ frappe.ui.form.on('Account', { new: data.name, is_group: frm.doc.is_group, root_type: frm.doc.root_type, - company: frm.doc.company + company: frm.doc.company, }, - callback: function(r) { - if(!r.exc) { - if(r.message) { + callback: function (r) { + if (!r.exc) { + if (r.message) { frappe.set_route("Form", "Account", r.message); } d.hide(); } - } + }, }); }, - primary_action_label: __('Merge') + primary_action_label: __("Merge"), }); d.show(); }, - update_account_number: function(frm) { + update_account_number: function (frm) { var d = new frappe.ui.Dialog({ - title: __('Update Account Number / Name'), + title: __("Update Account Number / Name"), fields: [ { - "label": "Account Name", - "fieldname": "account_name", - "fieldtype": "Data", - "reqd": 1, - "default": frm.doc.account_name + label: "Account Name", + fieldname: "account_name", + fieldtype: "Data", + reqd: 1, + default: frm.doc.account_name, }, { - "label": "Account Number", - "fieldname": "account_number", - "fieldtype": "Data", - "default": frm.doc.account_number - } + label: "Account Number", + fieldname: "account_number", + fieldtype: "Data", + default: frm.doc.account_number, + }, ], - primary_action: function() { + primary_action: function () { var data = d.get_values(); - if(data.account_number === frm.doc.account_number && data.account_name === frm.doc.account_name) { + if ( + data.account_number === frm.doc.account_number && + data.account_name === frm.doc.account_name + ) { d.hide(); return; } @@ -166,23 +189,29 @@ frappe.ui.form.on('Account', { args: { account_number: data.account_number, account_name: data.account_name, - name: frm.doc.name + name: frm.doc.name, }, - callback: function(r) { - if(!r.exc) { - if(r.message) { + callback: function (r) { + if (!r.exc) { + if (r.message) { frappe.set_route("Form", "Account", r.message); } else { - frm.set_value("account_number", data.account_number); - frm.set_value("account_name", data.account_name); + frm.set_value( + "account_number", + data.account_number + ); + frm.set_value( + "account_name", + data.account_name + ); } d.hide(); } - } + }, }); }, - primary_action_label: __('Update') + primary_action_label: __("Update"), }); d.show(); - } + }, }); diff --git a/erpnext/accounts/doctype/account/account.json b/erpnext/accounts/doctype/account/account.json index e79fb660625..78f73efff11 100644 --- a/erpnext/accounts/doctype/account/account.json +++ b/erpnext/accounts/doctype/account/account.json @@ -123,7 +123,7 @@ "label": "Account Type", "oldfieldname": "account_type", "oldfieldtype": "Select", - "options": "\nAccumulated Depreciation\nAsset Received But Not Billed\nBank\nCash\nChargeable\nCapital Work in Progress\nCost of Goods Sold\nDepreciation\nEquity\nExpense Account\nExpenses Included In Asset Valuation\nExpenses Included In Valuation\nFixed Asset\nIncome Account\nPayable\nReceivable\nRound Off\nStock\nStock Adjustment\nStock Received But Not Billed\nService Received But Not Billed\nTax\nTemporary" + "options": "\nAccumulated Depreciation\nAsset Received But Not Billed\nBank\nCash\nChargeable\nCapital Work in Progress\nCost of Goods Sold\nCurrent Asset\nCurrent Liability\nDepreciation\nDirect Expense\nDirect Income\nEquity\nExpense Account\nExpenses Included In Asset Valuation\nExpenses Included In Valuation\nFixed Asset\nIncome Account\nIndirect Expense\nIndirect Income\nLiability\nPayable\nReceivable\nRound Off\nStock\nStock Adjustment\nStock Received But Not Billed\nService Received But Not Billed\nTax\nTemporary" }, { "description": "Rate at which this tax is applied", @@ -192,7 +192,7 @@ "idx": 1, "is_tree": 1, "links": [], - "modified": "2023-04-11 16:08:46.983677", + "modified": "2023-07-20 18:18:44.405723", "modified_by": "Administrator", "module": "Accounts", "name": "Account", @@ -243,7 +243,6 @@ "read": 1, "report": 1, "role": "Accounts Manager", - "set_user_permissions": 1, "share": 1, "write": 1 } diff --git a/erpnext/accounts/doctype/account/account.py b/erpnext/accounts/doctype/account/account.py index e94b7cf4c26..c1eca721b6f 100644 --- a/erpnext/accounts/doctype/account/account.py +++ b/erpnext/accounts/doctype/account/account.py @@ -45,6 +45,7 @@ class Account(NestedSet): if frappe.local.flags.allow_unverified_charts: return self.validate_parent() + self.validate_parent_child_account_type() self.validate_root_details() validate_field_number("Account", self.name, self.account_number, self.company, "account_number") self.validate_group_or_ledger() @@ -55,6 +56,20 @@ class Account(NestedSet): self.validate_account_currency() self.validate_root_company_and_sync_account_to_children() + def validate_parent_child_account_type(self): + if self.parent_account: + if self.account_type in [ + "Direct Income", + "Indirect Income", + "Current Asset", + "Current Liability", + "Direct Expense", + "Indirect Expense", + ]: + parent_account_type = frappe.db.get_value("Account", self.parent_account, ["account_type"]) + if parent_account_type == self.account_type: + throw(_("Only Parent can be of type {0}").format(self.account_type)) + def validate_parent(self): """Fetch Parent Details and validate parent account""" if self.parent_account: diff --git a/erpnext/accounts/report/balance_sheet/balance_sheet.js b/erpnext/accounts/report/balance_sheet/balance_sheet.js index 4a4ad4d71cb..c65b9e8ccc7 100644 --- a/erpnext/accounts/report/balance_sheet/balance_sheet.js +++ b/erpnext/accounts/report/balance_sheet/balance_sheet.js @@ -1,22 +1,26 @@ // Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors // License: GNU General Public License v3. See license.txt -frappe.require("assets/erpnext/js/financial_statements.js", function() { - frappe.query_reports["Balance Sheet"] = $.extend({}, erpnext.financial_statements); +frappe.require("assets/erpnext/js/financial_statements.js", function () { + frappe.query_reports["Balance Sheet"] = $.extend( + {}, + erpnext.financial_statements + ); - erpnext.utils.add_dimensions('Balance Sheet', 10); + erpnext.utils.add_dimensions("Balance Sheet", 10); frappe.query_reports["Balance Sheet"]["filters"].push({ - "fieldname": "accumulated_values", - "label": __("Accumulated Values"), - "fieldtype": "Check", - "default": 1 + fieldname: "accumulated_values", + label: __("Accumulated Values"), + fieldtype: "Check", + default: 1, }); + console.log(frappe.query_reports["Balance Sheet"]["filters"]); frappe.query_reports["Balance Sheet"]["filters"].push({ - "fieldname": "include_default_book_entries", - "label": __("Include Default Book Entries"), - "fieldtype": "Check", - "default": 1 + fieldname: "include_default_book_entries", + label: __("Include Default Book Entries"), + fieldtype: "Check", + default: 1, }); }); diff --git a/erpnext/accounts/report/financial_ratios/__init__.py b/erpnext/accounts/report/financial_ratios/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/accounts/report/financial_ratios/financial_ratios.js b/erpnext/accounts/report/financial_ratios/financial_ratios.js new file mode 100644 index 00000000000..643423d865c --- /dev/null +++ b/erpnext/accounts/report/financial_ratios/financial_ratios.js @@ -0,0 +1,72 @@ +// Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt +/* eslint-disable */ + +frappe.query_reports["Financial Ratios"] = { + filters: [ + { + fieldname: "company", + label: __("Company"), + fieldtype: "Link", + options: "Company", + default: frappe.defaults.get_user_default("Company"), + reqd: 1, + }, + { + fieldname: "from_fiscal_year", + label: __("Start Year"), + fieldtype: "Link", + options: "Fiscal Year", + default: erpnext.utils.get_fiscal_year(frappe.datetime.get_today()), + reqd: 1, + }, + { + fieldname: "to_fiscal_year", + label: __("End Year"), + fieldtype: "Link", + options: "Fiscal Year", + default: erpnext.utils.get_fiscal_year(frappe.datetime.get_today()), + reqd: 1, + }, + { + fieldname: "periodicity", + label: __("Periodicity"), + fieldtype: "Data", + default: "Yearly", + reqd: 1, + hidden: 1, + }, + { + fieldname: "period_start_date", + label: __("From Date"), + fieldtype: "Date", + default: erpnext.utils.get_fiscal_year(frappe.datetime.get_today(), true)[1], + hidden: 1, + }, + { + fieldname: "period_end_date", + label: __("To Date"), + fieldtype: "Date", + default: erpnext.utils.get_fiscal_year(frappe.datetime.get_today(), true)[2], + hidden: 1, + }, + ], + "formatter": function(value, row, column, data, default_formatter) { + + let heading_ratios = ["Liquidity Ratios", "Solvency Ratios","Turnover Ratios"] + + if (heading_ratios.includes(value)) { + value = $(`${value}`); + let $value = $(value).css("font-weight", "bold"); + value = $value.wrap("

").parent().html(); + } + + if (heading_ratios.includes(row[1].content) && column.fieldtype == "Float") { + column.fieldtype = "Data"; + } + + value = default_formatter(value, row, column, data); + + return value; + }, +}; diff --git a/erpnext/accounts/report/financial_ratios/financial_ratios.json b/erpnext/accounts/report/financial_ratios/financial_ratios.json new file mode 100644 index 00000000000..1a2e56bad19 --- /dev/null +++ b/erpnext/accounts/report/financial_ratios/financial_ratios.json @@ -0,0 +1,37 @@ +{ + "add_total_row": 0, + "columns": [], + "creation": "2023-07-13 16:11:11.925096", + "disabled": 0, + "docstatus": 0, + "doctype": "Report", + "filters": [], + "idx": 0, + "is_standard": "Yes", + "modified": "2023-07-13 16:11:11.925096", + "modified_by": "Administrator", + "module": "Accounts", + "name": "Financial Ratios", + "owner": "Administrator", + "prepared_report": 0, + "ref_doctype": "Account", + "report_name": "Financial Ratios", + "report_type": "Script Report", + "roles": [ + { + "role": "Accounts User" + }, + { + "role": "Auditor" + }, + { + "role": "Sales User" + }, + { + "role": "Purchase User" + }, + { + "role": "Accounts Manager" + } + ] +} \ No newline at end of file diff --git a/erpnext/accounts/report/financial_ratios/financial_ratios.py b/erpnext/accounts/report/financial_ratios/financial_ratios.py new file mode 100644 index 00000000000..57421ebcb01 --- /dev/null +++ b/erpnext/accounts/report/financial_ratios/financial_ratios.py @@ -0,0 +1,296 @@ +# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +import frappe +from frappe import _ +from frappe.utils import add_days, flt + +from erpnext.accounts.report.financial_statements import get_data, get_period_list +from erpnext.accounts.utils import get_balance_on, get_fiscal_year + + +def execute(filters=None): + filters["filter_based_on"] = "Fiscal Year" + columns, data = [], [] + + setup_filters(filters) + + period_list = get_period_list( + filters.from_fiscal_year, + filters.to_fiscal_year, + filters.period_start_date, + filters.period_end_date, + filters.filter_based_on, + filters.periodicity, + company=filters.company, + ) + + columns, years = get_columns(period_list) + data = get_ratios_data(filters, period_list, years) + + return columns, data + + +def setup_filters(filters): + if not filters.get("period_start_date"): + period_start_date = get_fiscal_year(fiscal_year=filters.from_fiscal_year)[1] + filters["period_start_date"] = period_start_date + + if not filters.get("period_end_date"): + period_end_date = get_fiscal_year(fiscal_year=filters.to_fiscal_year)[2] + filters["period_end_date"] = period_end_date + + +def get_columns(period_list): + years = [] + columns = [ + { + "label": _("Ratios"), + "fieldname": "ratio", + "fieldtype": "Data", + "width": 200, + }, + ] + + for period in period_list: + columns.append( + { + "fieldname": period.key, + "label": period.label, + "fieldtype": "Float", + "width": 150, + } + ) + years.append(period.key) + + return columns, years + + +def get_ratios_data(filters, period_list, years): + + data = [] + assets, liabilities, income, expense = get_gl_data(filters, period_list, years) + + current_asset, total_asset = {}, {} + current_liability, total_liability = {}, {} + net_sales, total_income = {}, {} + cogs, total_expense = {}, {} + quick_asset = {} + direct_expense = {} + + for year in years: + total_quick_asset = 0 + total_net_sales = 0 + total_cogs = 0 + + for d in [ + [ + current_asset, + total_asset, + "Current Asset", + year, + assets, + "Asset", + quick_asset, + total_quick_asset, + ], + [ + current_liability, + total_liability, + "Current Liability", + year, + liabilities, + "Liability", + {}, + 0, + ], + [cogs, total_expense, "Cost of Goods Sold", year, expense, "Expense", {}, total_cogs], + [direct_expense, direct_expense, "Direct Expense", year, expense, "Expense", {}, 0], + [net_sales, total_income, "Direct Income", year, income, "Income", {}, total_net_sales], + ]: + update_balances(d[0], d[1], d[2], d[3], d[4], d[5], d[6], d[7]) + add_liquidity_ratios(data, years, current_asset, current_liability, quick_asset) + add_solvency_ratios( + data, years, total_asset, total_liability, net_sales, cogs, total_income, total_expense + ) + add_turnover_ratios( + data, years, period_list, filters, total_asset, net_sales, cogs, direct_expense + ) + + return data + + +def get_gl_data(filters, period_list, years): + data = {} + + for d in [ + ["Asset", "Debit"], + ["Liability", "Credit"], + ["Income", "Credit"], + ["Expense", "Debit"], + ]: + data[frappe.scrub(d[0])] = get_data( + filters.company, + d[0], + d[1], + period_list, + only_current_fiscal_year=False, + filters=filters, + ) + + assets, liabilities, income, expense = ( + data.get("asset"), + data.get("liability"), + data.get("income"), + data.get("expense"), + ) + + return assets, liabilities, income, expense + + +def add_liquidity_ratios(data, years, current_asset, current_liability, quick_asset): + precision = frappe.db.get_single_value("System Settings", "float_precision") + data.append({"ratio": "Liquidity Ratios"}) + + ratio_data = [["Current Ratio", current_asset], ["Quick Ratio", quick_asset]] + + for d in ratio_data: + row = { + "ratio": d[0], + } + for year in years: + row[year] = calculate_ratio(d[1].get(year, 0), current_liability.get(year, 0), precision) + + data.append(row) + + +def add_solvency_ratios( + data, years, total_asset, total_liability, net_sales, cogs, total_income, total_expense +): + precision = frappe.db.get_single_value("System Settings", "float_precision") + data.append({"ratio": "Solvency Ratios"}) + + debt_equity_ratio = {"ratio": "Debt Equity Ratio"} + gross_profit_ratio = {"ratio": "Gross Profit Ratio"} + net_profit_ratio = {"ratio": "Net Profit Ratio"} + return_on_asset_ratio = {"ratio": "Return on Asset Ratio"} + return_on_equity_ratio = {"ratio": "Return on Equity Ratio"} + + for year in years: + profit_after_tax = total_income[year] + total_expense[year] + share_holder_fund = total_asset[year] - total_liability[year] + + debt_equity_ratio[year] = calculate_ratio( + total_liability.get(year), share_holder_fund, precision + ) + return_on_equity_ratio[year] = calculate_ratio(profit_after_tax, share_holder_fund, precision) + + net_profit_ratio[year] = calculate_ratio(profit_after_tax, net_sales.get(year), precision) + gross_profit_ratio[year] = calculate_ratio( + net_sales.get(year, 0) - cogs.get(year, 0), net_sales.get(year), precision + ) + return_on_asset_ratio[year] = calculate_ratio(profit_after_tax, total_asset.get(year), precision) + + data.append(debt_equity_ratio) + data.append(gross_profit_ratio) + data.append(net_profit_ratio) + data.append(return_on_asset_ratio) + data.append(return_on_equity_ratio) + + +def add_turnover_ratios( + data, years, period_list, filters, total_asset, net_sales, cogs, direct_expense +): + precision = frappe.db.get_single_value("System Settings", "float_precision") + data.append({"ratio": "Turnover Ratios"}) + + avg_data = {} + for d in ["Receivable", "Payable", "Stock"]: + avg_data[frappe.scrub(d)] = avg_ratio_balance("Receivable", period_list, precision, filters) + + avg_debtors, avg_creditors, avg_stock = ( + avg_data.get("receivable"), + avg_data.get("payable"), + avg_data.get("stock"), + ) + + ratio_data = [ + ["Fixed Asset Turnover Ratio", net_sales, total_asset], + ["Debtor Turnover Ratio", net_sales, avg_debtors], + ["Creditor Turnover Ratio", direct_expense, avg_creditors], + ["Inventory Turnover Ratio", cogs, avg_stock], + ] + for ratio in ratio_data: + row = { + "ratio": ratio[0], + } + for year in years: + row[year] = calculate_ratio(ratio[1].get(year, 0), ratio[2].get(year, 0), precision) + + data.append(row) + + +def update_balances( + ratio_dict, + total_dict, + account_type, + year, + root_type_data, + root_type, + net_dict=None, + total_net=0, +): + + for entry in root_type_data: + if not entry.get("parent_account") and entry.get("is_group"): + total_dict[year] = entry[year] + if account_type == "Direct Expense": + total_dict[year] = entry[year] * -1 + + if root_type in ("Asset", "Liability"): + if entry.get("account_type") == account_type and entry.get("is_group"): + ratio_dict[year] = entry.get(year) + if entry.get("account_type") in ["Bank", "Cash", "Receivable"] and not entry.get("is_group"): + total_net += entry.get(year) + net_dict[year] = total_net + + elif root_type == "Income": + if entry.get("account_type") == account_type and entry.get("is_group"): + total_net += entry.get(year) + ratio_dict[year] = total_net + elif root_type == "Expense" and account_type == "Cost of Goods Sold": + if entry.get("account_type") == account_type: + total_net += entry.get(year) + ratio_dict[year] = total_net + else: + if entry.get("account_type") == account_type and entry.get("is_group"): + ratio_dict[year] = entry.get(year) + + +def avg_ratio_balance(account_type, period_list, precision, filters): + avg_ratio = {} + for period in period_list: + opening_date = add_days(period["from_date"], -1) + closing_date = period["to_date"] + + closing_balance = get_balance_on( + date=closing_date, + company=filters.company, + account_type=account_type, + ) + opening_balance = get_balance_on( + date=opening_date, + company=filters.company, + account_type=account_type, + ) + avg_ratio[period["key"]] = flt( + (flt(closing_balance) + flt(opening_balance)) / 2, precision=precision + ) + + return avg_ratio + + +def calculate_ratio(value, denominator, precision): + if flt(denominator): + return flt(flt(value) / denominator, precision) + return 0 diff --git a/erpnext/accounts/report/profit_and_loss_statement/profit_and_loss_statement.js b/erpnext/accounts/report/profit_and_loss_statement/profit_and_loss_statement.js index e794f270c2b..9fe93b9772f 100644 --- a/erpnext/accounts/report/profit_and_loss_statement/profit_and_loss_statement.js +++ b/erpnext/accounts/report/profit_and_loss_statement/profit_and_loss_statement.js @@ -1,19 +1,18 @@ // Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors // License: GNU General Public License v3. See license.txt - -frappe.require("assets/erpnext/js/financial_statements.js", function() { - frappe.query_reports["Profit and Loss Statement"] = $.extend({}, - erpnext.financial_statements); - - erpnext.utils.add_dimensions('Profit and Loss Statement', 10); - - frappe.query_reports["Profit and Loss Statement"]["filters"].push( - { - "fieldname": "include_default_book_entries", - "label": __("Include Default Book Entries"), - "fieldtype": "Check", - "default": 1 - } +frappe.require("assets/erpnext/js/financial_statements.js", function () { + frappe.query_reports["Profit and Loss Statement"] = $.extend( + {}, + erpnext.financial_statements ); + + erpnext.utils.add_dimensions("Profit and Loss Statement", 10); + + frappe.query_reports["Profit and Loss Statement"]["filters"].push({ + fieldname: "accumulated_values", + label: __("Accumulated Values"), + fieldtype: "Check", + default: 1, + }); }); diff --git a/erpnext/accounts/utils.py b/erpnext/accounts/utils.py index 961f41ccef6..c24442e2c3a 100644 --- a/erpnext/accounts/utils.py +++ b/erpnext/accounts/utils.py @@ -179,6 +179,7 @@ def get_balance_on( in_account_currency=True, cost_center=None, ignore_account_permission=False, + account_type=None, ): if not account and frappe.form_dict.get("account"): account = frappe.form_dict.get("account") @@ -254,6 +255,21 @@ def get_balance_on( else: cond.append("""gle.account = %s """ % (frappe.db.escape(account, percent=False),)) + if account_type: + accounts = frappe.db.get_all( + "Account", + filters={"company": company, "account_type": account_type, "is_group": 0}, + pluck="name", + order_by="lft", + ) + + cond.append( + """ + gle.account in (%s) + """ + % (", ".join([frappe.db.escape(account) for account in accounts])) + ) + if party_type and party: cond.append( """gle.party_type = %s and gle.party = %s """ @@ -263,7 +279,8 @@ def get_balance_on( if company: cond.append("""gle.company = %s """ % (frappe.db.escape(company, percent=False))) - if account or (party_type and party): + if account or (party_type and party) or account_type: + if in_account_currency: select_field = "sum(debit_in_account_currency) - sum(credit_in_account_currency)" else: @@ -276,7 +293,6 @@ def get_balance_on( select_field, " and ".join(cond) ) )[0][0] - # if bal is None, return 0 return flt(bal) From b65ee6c2db5c495bcadece2538915002312d83f6 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Sat, 5 Aug 2023 10:57:23 +0530 Subject: [PATCH 60/84] fix: cross connect delivery note and sales invoice (backport #36183) (#36457) fix: cross connect delivery note and sales invoice (#36183) * fix: cross connect delivery note and sales invoice * chore: remove unnecessary non_standard_fieldname (cherry picked from commit 8501a1182ae8323d91438da30ddc8d93cf8c2789) Co-authored-by: Anand Baburajan --- .../accounts/doctype/sales_invoice/sales_invoice_dashboard.py | 1 + erpnext/stock/doctype/delivery_note/delivery_note_dashboard.py | 1 + 2 files changed, 2 insertions(+) diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice_dashboard.py b/erpnext/accounts/doctype/sales_invoice/sales_invoice_dashboard.py index 0a765f3f46f..6fdcf263a55 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice_dashboard.py +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice_dashboard.py @@ -15,6 +15,7 @@ def get_data(): }, "internal_links": { "Sales Order": ["items", "sales_order"], + "Delivery Note": ["items", "delivery_note"], "Timesheet": ["timesheets", "time_sheet"], }, "transactions": [ diff --git a/erpnext/stock/doctype/delivery_note/delivery_note_dashboard.py b/erpnext/stock/doctype/delivery_note/delivery_note_dashboard.py index b6b5ff4296f..e66c23324da 100644 --- a/erpnext/stock/doctype/delivery_note/delivery_note_dashboard.py +++ b/erpnext/stock/doctype/delivery_note/delivery_note_dashboard.py @@ -11,6 +11,7 @@ def get_data(): }, "internal_links": { "Sales Order": ["items", "against_sales_order"], + "Sales Invoice": ["items", "against_sales_invoice"], "Material Request": ["items", "material_request"], "Purchase Order": ["items", "purchase_order"], }, From 466734fb4b59fccfadf2ae6f9eec7d195388f605 Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Sat, 5 Aug 2023 14:11:57 +0530 Subject: [PATCH 61/84] fix(test): replace hardcoded reference to adv with dynamic one --- .../tests/test_accounts_controller.py | 65 ++++++++++++------- 1 file changed, 42 insertions(+), 23 deletions(-) diff --git a/erpnext/controllers/tests/test_accounts_controller.py b/erpnext/controllers/tests/test_accounts_controller.py index 8e5f813d97d..0f8e133e0fd 100644 --- a/erpnext/controllers/tests/test_accounts_controller.py +++ b/erpnext/controllers/tests/test_accounts_controller.py @@ -347,18 +347,23 @@ class TestAccountsController(FrappeTestCase): 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": adv.doctype, - "reference_name": adv.name, + "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": 85, - "remarks": "Test", + "ref_exchange_rate": advances[0].exchange_rate, + "remarks": advances[0].remarks, }, ) + si = si.save() si = si.submit() @@ -398,16 +403,19 @@ class TestAccountsController(FrappeTestCase): 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": adv.doctype, - "reference_name": adv.name, + "reference_type": advances[0].reference_type, + "reference_name": advances[0].reference_name, "advance_amount": 1, "allocated_amount": 1, - "ref_exchange_rate": 85, - "remarks": "Test", + "ref_exchange_rate": advances[0].exchange_rate, + "remarks": advances[0].remarks, }, ) si = si.save() @@ -470,16 +478,19 @@ class TestAccountsController(FrappeTestCase): # 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": adv.doctype, - "reference_name": adv.name, + "reference_type": advances[0].reference_type, + "reference_name": advances[0].reference_name, "advance_amount": 1, "allocated_amount": 1, - "ref_exchange_rate": 85, - "remarks": "Test", + "ref_exchange_rate": advances[0].exchange_rate, + "remarks": advances[0].remarks, }, ) si = si.save() @@ -678,22 +689,26 @@ class TestAccountsController(FrappeTestCase): adv.reload() # Sales Invoices in different exchange rates - for exc_rate in [75.9, 83.1, 80.01]: + 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": adv.doctype, - "reference_name": adv.name, - "reference_row": adv.accounts[0].name, + "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": adv_exc_rate, - "remarks": "Test", + "ref_exchange_rate": advances[0].exchange_rate, + "remarks": advances[0].remarks, }, ) + si = si.save() si = si.submit() @@ -741,19 +756,23 @@ class TestAccountsController(FrappeTestCase): # 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": adv.doctype, - "reference_name": adv.name, - "reference_row": adv.accounts[0].name, + "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": adv_exc_rate, - "remarks": "Test", + "ref_exchange_rate": advances[0].exchange_rate, + "remarks": advances[0].remarks, }, ) + si = si.save() si = si.submit() From 16bc1e228fd388aa6cb8438212be20fb178fe0a0 Mon Sep 17 00:00:00 2001 From: Michelle Alva <50285544+michellealva@users.noreply.github.com> Date: Sat, 5 Aug 2023 18:30:24 +0530 Subject: [PATCH 62/84] chore: typo in onboarding (#36504) * fix: typo in onboarding * fix: typo --- .../setup/onboarding_step/create_an_item/create_an_item.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/erpnext/setup/onboarding_step/create_an_item/create_an_item.json b/erpnext/setup/onboarding_step/create_an_item/create_an_item.json index 4115196ffa1..66c5bfb961d 100644 --- a/erpnext/setup/onboarding_step/create_an_item/create_an_item.json +++ b/erpnext/setup/onboarding_step/create_an_item/create_an_item.json @@ -2,7 +2,7 @@ "action": "Create Entry", "action_label": "Create a new Item", "creation": "2021-05-17 13:47:18.515052", - "description": "# Create an Item\n\nItem is a product, of a or service offered by your company, or something you buy as a part of your supplies or raw materials.\n\nItems are integral to everything you do in ERPNext - from billing, purchasing to managing inventory. Everything you buy or sell, whether it is a physical product or a service is an Item. Items can be stock, non-stock, variants, serialized, batched, assets etc.\n", + "description": "# Create an Item\n\nItem is a product or a service offered by your company, or something you buy as a part of your supplies or raw materials.\n\nItems are integral to everything you do in ERPNext - from billing, purchasing to managing inventory. Everything you buy or sell, whether it is a physical product or a service is an Item. Items can be stock, non-stock, variants, serialized, batched, assets, etc.\n", "docstatus": 0, "doctype": "Onboarding Step", "form_tour": "Item General", @@ -20,4 +20,4 @@ "show_full_form": 0, "title": "Create an Item", "validate_action": 1 -} \ No newline at end of file +} From e17949976442f921335ab1961928ddec37aeffef Mon Sep 17 00:00:00 2001 From: s-aga-r Date: Sat, 5 Aug 2023 19:26:11 +0530 Subject: [PATCH 63/84] fix(ux): add `Ordered Qty` column in Get Items From > MR (#36486) --- erpnext/buying/doctype/purchase_order/purchase_order.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/buying/doctype/purchase_order/purchase_order.js b/erpnext/buying/doctype/purchase_order/purchase_order.js index a7f0304a3a1..7c33056a91f 100644 --- a/erpnext/buying/doctype/purchase_order/purchase_order.js +++ b/erpnext/buying/doctype/purchase_order/purchase_order.js @@ -366,7 +366,7 @@ erpnext.buying.PurchaseOrderController = class PurchaseOrderController extends e }, allow_child_item_selection: true, child_fieldname: "items", - child_columns: ["item_code", "qty"] + child_columns: ["item_code", "qty", "ordered_qty"] }) }, __("Get Items From")); From 758b31d895f77ae04b075b65dce3bb427b70975a Mon Sep 17 00:00:00 2001 From: s-aga-r Date: Sat, 5 Aug 2023 19:28:38 +0530 Subject: [PATCH 64/84] fix: get incoming rate instead of BOM rate (#36496) * fix: get incoming rate instead of BOM rate * test: add test case for SCR rm rate --- .../controllers/subcontracting_controller.py | 2 +- .../test_subcontracting_receipt.py | 61 +++++++++++++++++++ 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/erpnext/controllers/subcontracting_controller.py b/erpnext/controllers/subcontracting_controller.py index 57339bf4caf..6633f4f6eba 100644 --- a/erpnext/controllers/subcontracting_controller.py +++ b/erpnext/controllers/subcontracting_controller.py @@ -550,7 +550,7 @@ class SubcontractingController(StockController): if rm_obj.serial_and_batch_bundle: args["serial_and_batch_bundle"] = rm_obj.serial_and_batch_bundle - rm_obj.rate = bom_item.rate if self.backflush_based_on == "BOM" else get_incoming_rate(args) + rm_obj.rate = get_incoming_rate(args) def __get_qty_based_on_material_transfer(self, item_row, transfer_item): key = (item_row.item_code, item_row.get(self.subcontract_data.order_field)) diff --git a/erpnext/subcontracting/doctype/subcontracting_receipt/test_subcontracting_receipt.py b/erpnext/subcontracting/doctype/subcontracting_receipt/test_subcontracting_receipt.py index 46632092ffe..887cba5b250 100644 --- a/erpnext/subcontracting/doctype/subcontracting_receipt/test_subcontracting_receipt.py +++ b/erpnext/subcontracting/doctype/subcontracting_receipt/test_subcontracting_receipt.py @@ -506,6 +506,67 @@ class TestSubcontractingReceipt(FrappeTestCase): self.assertNotEqual(scr.supplied_items[0].rate, prev_cost) self.assertEqual(scr.supplied_items[0].rate, sr.items[0].valuation_rate) + def test_subcontracting_receipt_raw_material_rate(self): + from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom + + # Step - 1: Set Backflush Based On as "BOM" + set_backflush_based_on("BOM") + + # Step - 2: Create FG and RM Items + fg_item = make_item(properties={"is_stock_item": 1, "is_sub_contracted_item": 1}).name + rm_item1 = make_item(properties={"is_stock_item": 1}).name + rm_item2 = make_item(properties={"is_stock_item": 1}).name + + # Step - 3: Create BOM for FG Item + bom = make_bom(item=fg_item, raw_materials=[rm_item1, rm_item2]) + for rm_item in bom.items: + self.assertEqual(rm_item.rate, 0) + self.assertEqual(rm_item.amount, 0) + bom = bom.name + + # Step - 4: Create PO and SCO + service_items = [ + { + "warehouse": "_Test Warehouse - _TC", + "item_code": "Subcontracted Service Item 1", + "qty": 100, + "rate": 100, + "fg_item": fg_item, + "fg_item_qty": 100, + }, + ] + sco = get_subcontracting_order(service_items=service_items) + for rm_item in sco.supplied_items: + self.assertEqual(rm_item.rate, 0) + self.assertEqual(rm_item.amount, 0) + + # Step - 5: Inward Raw Materials + rm_items = get_rm_items(sco.supplied_items) + for rm_item in rm_items: + rm_item["rate"] = 100 + itemwise_details = make_stock_in_entry(rm_items=rm_items) + + # Step - 6: Transfer RM's to Subcontractor + se = make_stock_transfer_entry( + sco_no=sco.name, + rm_items=rm_items, + itemwise_details=copy.deepcopy(itemwise_details), + ) + for item in se.items: + self.assertEqual(item.qty, 100) + self.assertEqual(item.basic_rate, 100) + self.assertEqual(item.amount, item.qty * item.basic_rate) + + # Step - 7: Create Subcontracting Receipt + scr = make_subcontracting_receipt(sco.name) + scr.save() + scr.submit() + scr.load_from_db() + for rm_item in scr.supplied_items: + self.assertEqual(rm_item.consumed_qty, 100) + self.assertEqual(rm_item.rate, 100) + self.assertEqual(rm_item.amount, rm_item.consumed_qty * rm_item.rate) + def make_return_subcontracting_receipt(**args): args = frappe._dict(args) From 559d914c0bffb615b9b53083f6c9ca9fd2ca9a3d Mon Sep 17 00:00:00 2001 From: Corentin Flr <10946971+cogk@users.noreply.github.com> Date: Sat, 5 Aug 2023 18:48:36 +0200 Subject: [PATCH 65/84] fix(accounts): Translate columns in AP/AR report (#36503) --- .../accounts_receivable_summary.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/erpnext/accounts/report/accounts_receivable_summary/accounts_receivable_summary.py b/erpnext/accounts/report/accounts_receivable_summary/accounts_receivable_summary.py index 3aa1ae71045..da4c9dabbf6 100644 --- a/erpnext/accounts/report/accounts_receivable_summary/accounts_receivable_summary.py +++ b/erpnext/accounts/report/accounts_receivable_summary/accounts_receivable_summary.py @@ -145,13 +145,13 @@ class AccountsReceivableSummary(ReceivablePayableReport): def get_columns(self): self.columns = [] self.add_column( - label="Party Type", + label=_("Party Type"), fieldname="party_type", fieldtype="Data", width=100, ) self.add_column( - label="Party", + label=_("Party"), fieldname="party", fieldtype="Dynamic Link", options="party_type", @@ -160,7 +160,7 @@ class AccountsReceivableSummary(ReceivablePayableReport): if self.party_naming_by == "Naming Series": self.add_column( - label="Supplier Name" if self.account_type == "Payable" else "Customer Name", + label=_("Supplier Name") if self.account_type == "Payable" else _("Customer Name"), fieldname="party_name", fieldtype="Data", ) From 96035b87d566948ac56bec2a49357fed199a59ba Mon Sep 17 00:00:00 2001 From: Deepesh Garg Date: Sat, 5 Aug 2023 22:21:06 +0530 Subject: [PATCH 66/84] fix: Lower deduction certificate for multi-company (#36491) --- .../lower_deduction_certificate/lower_deduction_certificate.py | 1 + 1 file changed, 1 insertion(+) diff --git a/erpnext/regional/doctype/lower_deduction_certificate/lower_deduction_certificate.py b/erpnext/regional/doctype/lower_deduction_certificate/lower_deduction_certificate.py index cc223e91bc8..6ae04c165c4 100644 --- a/erpnext/regional/doctype/lower_deduction_certificate/lower_deduction_certificate.py +++ b/erpnext/regional/doctype/lower_deduction_certificate/lower_deduction_certificate.py @@ -34,6 +34,7 @@ class LowerDeductionCertificate(Document): "supplier": self.supplier, "tax_withholding_category": self.tax_withholding_category, "name": ("!=", self.name), + "company": self.company, }, ["name", "valid_from", "valid_upto"], as_dict=True, From 93767eb7fcaadd5d7e921c3e255b495ea02b094c Mon Sep 17 00:00:00 2001 From: Deepesh Garg Date: Sat, 5 Aug 2023 22:22:03 +0530 Subject: [PATCH 67/84] fix: Tax withholding against order via Payment Entry (#36493) * fix: Tax withholding against order via Payment Entry * test: Add test case * fix: Nonetype exceptions --- .../doctype/payment_entry/payment_entry.py | 18 ++++++++- .../test_tax_withholding_category.py | 37 +++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.py b/erpnext/accounts/doctype/payment_entry/payment_entry.py index 69ce19c96a6..cc42f9faec1 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry.py +++ b/erpnext/accounts/doctype/payment_entry/payment_entry.py @@ -688,7 +688,9 @@ class PaymentEntry(AccountsController): if not self.apply_tax_withholding_amount: return - net_total = self.paid_amount + order_amount = self.get_order_net_total() + + net_total = flt(order_amount) + flt(self.unallocated_amount) # Adding args as purchase invoice to get TDS amount args = frappe._dict( @@ -733,6 +735,20 @@ class PaymentEntry(AccountsController): for d in to_remove: self.remove(d) + def get_order_net_total(self): + if self.party_type == "Supplier": + doctype = "Purchase Order" + else: + doctype = "Sales Order" + + docnames = [d.reference_name for d in self.references if d.reference_doctype == doctype] + + tax_withholding_net_total = frappe.db.get_value( + doctype, {"name": ["in", docnames]}, ["sum(base_tax_withholding_net_total)"] + ) + + return tax_withholding_net_total + def apply_taxes(self): self.initialize_taxes() self.determine_exclusive_rate() diff --git a/erpnext/accounts/doctype/tax_withholding_category/test_tax_withholding_category.py b/erpnext/accounts/doctype/tax_withholding_category/test_tax_withholding_category.py index 4580b13613c..80220e4e541 100644 --- a/erpnext/accounts/doctype/tax_withholding_category/test_tax_withholding_category.py +++ b/erpnext/accounts/doctype/tax_withholding_category/test_tax_withholding_category.py @@ -316,6 +316,42 @@ class TestTaxWithholdingCategory(unittest.TestCase): for d in reversed(orders): d.cancel() + def test_tds_deduction_for_po_via_payment_entry(self): + from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry + + frappe.db.set_value( + "Supplier", "Test TDS Supplier8", "tax_withholding_category", "Cumulative Threshold TDS" + ) + order = create_purchase_order(supplier="Test TDS Supplier8", rate=40000, do_not_save=True) + + # Add some tax on the order + order.append( + "taxes", + { + "category": "Total", + "charge_type": "Actual", + "account_head": "_Test Account VAT - _TC", + "cost_center": "Main - _TC", + "tax_amount": 8000, + "description": "Test", + "add_deduct_tax": "Add", + }, + ) + + order.save() + + order.apply_tds = 1 + order.tax_withholding_category = "Cumulative Threshold TDS" + order.submit() + + self.assertEqual(order.taxes[0].tax_amount, 4000) + + payment = get_payment_entry(order.doctype, order.name) + payment.apply_tax_withholding_amount = 1 + payment.tax_withholding_category = "Cumulative Threshold TDS" + payment.submit() + self.assertEqual(payment.taxes[0].tax_amount, 4000) + def test_multi_category_single_supplier(self): frappe.db.set_value( "Supplier", "Test TDS Supplier5", "tax_withholding_category", "Test Service Category" @@ -573,6 +609,7 @@ def create_records(): "Test TDS Supplier5", "Test TDS Supplier6", "Test TDS Supplier7", + "Test TDS Supplier8", ]: if frappe.db.exists("Supplier", name): continue From 5435c641a2916546768340dd1ad977ae89b79d66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bernd=20Oliver=20S=C3=BCnderhauf?= <46800703+pancho-s@users.noreply.github.com> Date: Sat, 5 Aug 2023 19:43:14 +0200 Subject: [PATCH 68/84] fix: Refine supplier scorecard standings. (#36414) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Bernd Oliver Sünderhauf --- .../doctype/supplier_scorecard/supplier_scorecard.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/erpnext/buying/doctype/supplier_scorecard/supplier_scorecard.py b/erpnext/buying/doctype/supplier_scorecard/supplier_scorecard.py index 58da8512951..6e22acf01a5 100644 --- a/erpnext/buying/doctype/supplier_scorecard/supplier_scorecard.py +++ b/erpnext/buying/doctype/supplier_scorecard/supplier_scorecard.py @@ -339,29 +339,35 @@ def make_default_records(): { "min_grade": 0.0, "prevent_rfqs": 1, + "warn_rfqs": 0, "notify_supplier": 0, "max_grade": 30.0, "prevent_pos": 1, + "warn_pos": 0, "standing_color": "Red", "notify_employee": 0, "standing_name": "Very Poor", }, { "min_grade": 30.0, - "prevent_rfqs": 1, + "prevent_rfqs": 0, + "warn_rfqs": 1, "notify_supplier": 0, "max_grade": 50.0, "prevent_pos": 0, - "standing_color": "Red", + "warn_pos": 1, + "standing_color": "Yellow", "notify_employee": 0, "standing_name": "Poor", }, { "min_grade": 50.0, "prevent_rfqs": 0, + "warn_rfqs": 0, "notify_supplier": 0, "max_grade": 80.0, "prevent_pos": 0, + "warn_pos": 0, "standing_color": "Green", "notify_employee": 0, "standing_name": "Average", @@ -369,9 +375,11 @@ def make_default_records(): { "min_grade": 80.0, "prevent_rfqs": 0, + "warn_rfqs": 0, "notify_supplier": 0, "max_grade": 100.0, "prevent_pos": 0, + "warn_pos": 0, "standing_color": "Blue", "notify_employee": 0, "standing_name": "Excellent", From b717e2b5bf9a7ddcb47b4b496cb1d815fc2909a4 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Sun, 6 Aug 2023 23:44:22 +0530 Subject: [PATCH 69/84] chore: don't merge asset capitalization gl entries (copy #36514) (#36515) chore: don't merge asset capitalization gl entries (cherry picked from commit 3c8f292ac3943ddad52a7041d8910552d47abf0a) Co-authored-by: anandbaburajan --- .../doctype/asset_capitalization/asset_capitalization.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/erpnext/assets/doctype/asset_capitalization/asset_capitalization.py b/erpnext/assets/doctype/asset_capitalization/asset_capitalization.py index 858c1db43c0..324b7392a8b 100644 --- a/erpnext/assets/doctype/asset_capitalization/asset_capitalization.py +++ b/erpnext/assets/doctype/asset_capitalization/asset_capitalization.py @@ -330,7 +330,7 @@ class AssetCapitalization(StockController): gl_entries = self.get_gl_entries() if gl_entries: - make_gl_entries(gl_entries, from_repost=from_repost) + make_gl_entries(gl_entries, merge_entries=False, from_repost=from_repost) elif self.docstatus == 2: make_reverse_gl_entries(voucher_type=self.doctype, voucher_no=self.name) @@ -360,9 +360,6 @@ class AssetCapitalization(StockController): gl_entries, target_account, target_against, precision ) - if not self.stock_items and not self.service_items and self.are_all_asset_items_non_depreciable: - return [] - self.get_gl_entries_for_target_item(gl_entries, target_against, precision) return gl_entries From 38805603dbdf4662ca0a97cbf376fb62a771580f Mon Sep 17 00:00:00 2001 From: Himanshu Date: Mon, 7 Aug 2023 08:33:47 +0530 Subject: [PATCH 70/84] feat: subscription refactor (#30963) * feat: subscription refactor * fix: linter changes * chore: linter changes * chore: linter changes * chore: Update tests * chore: Remove commits --------- Co-authored-by: Deepesh Garg --- .../purchase_invoice/purchase_invoice.json | 9 +- .../doctype/sales_invoice/sales_invoice.json | 9 +- .../doctype/subscription/subscription.js | 96 ++- .../doctype/subscription/subscription.json | 43 +- .../doctype/subscription/subscription.py | 577 ++++++++---------- .../doctype/subscription/test_subscription.py | 152 +++-- erpnext/patches.txt | 1 + .../v14_0/update_subscription_details.py | 17 + 8 files changed, 438 insertions(+), 466 deletions(-) create mode 100644 erpnext/patches/v14_0/update_subscription_details.py diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.json b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.json index d8759e95b87..0599e19d9bf 100644 --- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.json +++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.json @@ -167,6 +167,7 @@ "column_break_63", "unrealized_profit_loss_account", "subscription_section", + "subscription", "auto_repeat", "update_auto_repeat_reference", "column_break_114", @@ -1423,6 +1424,12 @@ "options": "Advance Tax", "read_only": 1 }, + { + "fieldname": "subscription", + "fieldtype": "Link", + "label": "Subscription", + "options": "Subscription" + }, { "default": "0", "fieldname": "is_old_subcontracting_flow", @@ -1577,7 +1584,7 @@ "idx": 204, "is_submittable": 1, "links": [], - "modified": "2023-07-04 17:22:59.145031", + "modified": "2023-07-25 17:22:59.145031", "modified_by": "Administrator", "module": "Accounts", "name": "Purchase Invoice", diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.json b/erpnext/accounts/doctype/sales_invoice/sales_invoice.json index f0d3f720948..7581366bc08 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.json +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.json @@ -194,6 +194,7 @@ "select_print_heading", "language", "subscription_section", + "subscription", "from_date", "auto_repeat", "column_break_140", @@ -2017,6 +2018,12 @@ "label": "Amount Eligible for Commission", "read_only": 1 }, + { + "fieldname": "subscription", + "fieldtype": "Link", + "label": "Subscription", + "options": "Subscription" + }, { "default": "0", "depends_on": "eval: doc.apply_discount_on == \"Grand Total\"", @@ -2157,7 +2164,7 @@ "link_fieldname": "consolidated_invoice" } ], - "modified": "2023-06-21 16:02:18.988799", + "modified": "2023-07-25 16:02:18.988799", "modified_by": "Administrator", "module": "Accounts", "name": "Sales Invoice", diff --git a/erpnext/accounts/doctype/subscription/subscription.js b/erpnext/accounts/doctype/subscription/subscription.js index 1a9066470a5..ae789b54247 100644 --- a/erpnext/accounts/doctype/subscription/subscription.js +++ b/erpnext/accounts/doctype/subscription/subscription.js @@ -2,16 +2,16 @@ // For license information, please see license.txt frappe.ui.form.on('Subscription', { - setup: function(frm) { - frm.set_query('party_type', function() { + setup: function (frm) { + frm.set_query('party_type', function () { return { - filters : { + filters: { name: ['in', ['Customer', 'Supplier']] } } }); - frm.set_query('cost_center', function() { + frm.set_query('cost_center', function () { return { filters: { company: frm.doc.company @@ -20,76 +20,60 @@ frappe.ui.form.on('Subscription', { }); }, - refresh: function(frm) { - if(!frm.is_new()){ - if(frm.doc.status !== 'Cancelled'){ - frm.add_custom_button( - __('Cancel Subscription'), - () => frm.events.cancel_this_subscription(frm) - ); - frm.add_custom_button( - __('Fetch Subscription Updates'), - () => frm.events.get_subscription_updates(frm) - ); - } - else if(frm.doc.status === 'Cancelled'){ - frm.add_custom_button( - __('Restart Subscription'), - () => frm.events.renew_this_subscription(frm) - ); - } + refresh: function (frm) { + if (frm.is_new()) return; + + if (frm.doc.status !== 'Cancelled') { + frm.add_custom_button( + __('Fetch Subscription Updates'), + () => frm.trigger('get_subscription_updates'), + __('Actions') + ); + + frm.add_custom_button( + __('Cancel Subscription'), + () => frm.trigger('cancel_this_subscription'), + __('Actions') + ); + } else if (frm.doc.status === 'Cancelled') { + frm.add_custom_button( + __('Restart Subscription'), + () => frm.trigger('renew_this_subscription'), + __('Actions') + ); } }, - cancel_this_subscription: function(frm) { - const doc = frm.doc; + cancel_this_subscription: function (frm) { frappe.confirm( __('This action will stop future billing. Are you sure you want to cancel this subscription?'), - function() { - frappe.call({ - method: - "erpnext.accounts.doctype.subscription.subscription.cancel_subscription", - args: {name: doc.name}, - callback: function(data){ - if(!data.exc){ - frm.reload_doc(); - } + () => { + frm.call('cancel_subscription').then(r => { + if (!r.exec) { + frm.reload_doc(); } }); } ); }, - renew_this_subscription: function(frm) { - const doc = frm.doc; + renew_this_subscription: function (frm) { frappe.confirm( - __('You will lose records of previously generated invoices. Are you sure you want to restart this subscription?'), - function() { - frappe.call({ - method: - "erpnext.accounts.doctype.subscription.subscription.restart_subscription", - args: {name: doc.name}, - callback: function(data){ - if(!data.exc){ - frm.reload_doc(); - } + __('Are you sure you want to restart this subscription?'), + () => { + frm.call('restart_subscription').then(r => { + if (!r.exec) { + frm.reload_doc(); } }); } ); }, - get_subscription_updates: function(frm) { - const doc = frm.doc; - frappe.call({ - method: - "erpnext.accounts.doctype.subscription.subscription.get_subscription_updates", - args: {name: doc.name}, - freeze: true, - callback: function(data){ - if(!data.exc){ - frm.reload_doc(); - } + get_subscription_updates: function (frm) { + frm.call('process').then(r => { + if (!r.exec) { + frm.reload_doc(); } }); } diff --git a/erpnext/accounts/doctype/subscription/subscription.json b/erpnext/accounts/doctype/subscription/subscription.json index c4e4be7f781..c15aa1e05a7 100644 --- a/erpnext/accounts/doctype/subscription/subscription.json +++ b/erpnext/accounts/doctype/subscription/subscription.json @@ -19,6 +19,7 @@ "trial_period_end", "follow_calendar_months", "generate_new_invoices_past_due_date", + "submit_invoice", "column_break_11", "current_invoice_start", "current_invoice_end", @@ -35,12 +36,8 @@ "cb_2", "additional_discount_percentage", "additional_discount_amount", - "sb_3", - "submit_invoice", - "invoices", "accounting_dimensions_section", - "cost_center", - "dimension_col_break" + "cost_center" ], "fields": [ { @@ -162,29 +159,12 @@ "fieldtype": "Currency", "label": "Additional DIscount Amount" }, - { - "depends_on": "eval:doc.invoices", - "fieldname": "sb_3", - "fieldtype": "Section Break", - "label": "Invoices" - }, - { - "collapsible": 1, - "fieldname": "invoices", - "fieldtype": "Table", - "label": "Invoices", - "options": "Subscription Invoice" - }, { "collapsible": 1, "fieldname": "accounting_dimensions_section", "fieldtype": "Section Break", "label": "Accounting Dimensions" }, - { - "fieldname": "dimension_col_break", - "fieldtype": "Column Break" - }, { "fieldname": "party_type", "fieldtype": "Link", @@ -259,15 +239,27 @@ "default": "1", "fieldname": "submit_invoice", "fieldtype": "Check", - "label": "Submit Invoice Automatically" + "label": "Submit Generated Invoices" } ], "index_web_pages_for_search": 1, - "links": [], - "modified": "2021-04-19 15:24:27.550797", + "links": [ + { + "group": "Buying", + "link_doctype": "Purchase Invoice", + "link_fieldname": "subscription" + }, + { + "group": "Selling", + "link_doctype": "Sales Invoice", + "link_fieldname": "subscription" + } + ], + "modified": "2022-02-18 23:24:57.185054", "modified_by": "Administrator", "module": "Accounts", "name": "Subscription", + "naming_rule": "Expression (old style)", "owner": "Administrator", "permissions": [ { @@ -309,5 +301,6 @@ ], "sort_field": "modified", "sort_order": "DESC", + "states": [], "track_changes": 1 } \ No newline at end of file diff --git a/erpnext/accounts/doctype/subscription/subscription.py b/erpnext/accounts/doctype/subscription/subscription.py index 8708342b118..bbcade17589 100644 --- a/erpnext/accounts/doctype/subscription/subscription.py +++ b/erpnext/accounts/doctype/subscription/subscription.py @@ -2,14 +2,17 @@ # For license information, please see license.txt +from datetime import datetime +from typing import Dict, List, Optional, Union + import frappe from frappe import _ from frappe.model.document import Document from frappe.utils.data import ( add_days, + add_months, add_to_date, cint, - cstr, date_diff, flt, get_last_day, @@ -17,8 +20,7 @@ from frappe.utils.data import ( nowdate, ) -import erpnext -from erpnext import get_default_company +from erpnext import get_default_company, get_default_cost_center from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import ( get_accounting_dimensions, ) @@ -26,33 +28,39 @@ from erpnext.accounts.doctype.subscription_plan.subscription_plan import get_pla from erpnext.accounts.party import get_party_account_currency +class InvoiceCancelled(frappe.ValidationError): + pass + + +class InvoiceNotCancelled(frappe.ValidationError): + pass + + class Subscription(Document): def before_insert(self): # update start just before the subscription doc is created self.update_subscription_period(self.start_date) - def update_subscription_period(self, date=None, return_date=False): + def update_subscription_period(self, date: Optional[Union[datetime.date, str]] = None): """ Subscription period is the period to be billed. This method updates the beginning of the billing period and end of the billing period. - The beginning of the billing period is represented in the doctype as `current_invoice_start` and the end of the billing period is represented as `current_invoice_end`. - - If return_date is True, it wont update the start and end dates. - This is implemented to get the dates to check if is_current_invoice_generated """ + self.current_invoice_start = self.get_current_invoice_start(date) + self.current_invoice_end = self.get_current_invoice_end(self.current_invoice_start) + + def _get_subscription_period(self, date: Optional[Union[datetime.date, str]] = None): _current_invoice_start = self.get_current_invoice_start(date) _current_invoice_end = self.get_current_invoice_end(_current_invoice_start) - if return_date: - return _current_invoice_start, _current_invoice_end + return _current_invoice_start, _current_invoice_end - self.current_invoice_start = _current_invoice_start - self.current_invoice_end = _current_invoice_end - - def get_current_invoice_start(self, date=None): + def get_current_invoice_start( + self, date: Optional[Union[datetime.date, str]] = None + ) -> Union[datetime.date, str]: """ This returns the date of the beginning of the current billing period. If the `date` parameter is not given , it will be automatically set as today's @@ -75,13 +83,13 @@ class Subscription(Document): return _current_invoice_start - def get_current_invoice_end(self, date=None): + def get_current_invoice_end( + self, date: Optional[Union[datetime.date, str]] = None + ) -> Union[datetime.date, str]: """ This returns the date of the end of the current billing period. - If the subscription is in trial period, it will be set as the end of the trial period. - If is not in a trial period, it will be `x` days from the beginning of the current billing period where `x` is the billing interval from the `Subscription Plan` in the `Subscription`. @@ -105,24 +113,13 @@ class Subscription(Document): _current_invoice_end = get_last_day(date) if self.follow_calendar_months: + # Sets the end date + # eg if date is 17-Feb-2022, the invoice will be generated per month ie + # the invoice will be created from 17 Feb to 28 Feb billing_info = self.get_billing_cycle_and_interval() billing_interval_count = billing_info[0]["billing_interval_count"] - calendar_months = get_calendar_months(billing_interval_count) - calendar_month = 0 - current_invoice_end_month = getdate(_current_invoice_end).month - current_invoice_end_year = getdate(_current_invoice_end).year - - for month in calendar_months: - if month <= current_invoice_end_month: - calendar_month = month - - if cint(calendar_month - billing_interval_count) <= 0 and getdate(date).month != 1: - calendar_month = 12 - current_invoice_end_year -= 1 - - _current_invoice_end = get_last_day( - cstr(current_invoice_end_year) + "-" + cstr(calendar_month) + "-01" - ) + _end = add_months(getdate(date), billing_interval_count - 1) + _current_invoice_end = get_last_day(_end) if self.end_date and getdate(_current_invoice_end) > getdate(self.end_date): _current_invoice_end = self.end_date @@ -130,7 +127,7 @@ class Subscription(Document): return _current_invoice_end @staticmethod - def validate_plans_billing_cycle(billing_cycle_data): + def validate_plans_billing_cycle(billing_cycle_data: List[Dict[str, str]]) -> None: """ Makes sure that all `Subscription Plan` in the `Subscription` have the same billing interval @@ -138,10 +135,9 @@ class Subscription(Document): if billing_cycle_data and len(billing_cycle_data) != 1: frappe.throw(_("You can only have Plans with the same billing cycle in a Subscription")) - def get_billing_cycle_and_interval(self): + def get_billing_cycle_and_interval(self) -> List[Dict[str, str]]: """ Returns a dict representing the billing interval and cycle for this `Subscription`. - You shouldn't need to call this directly. Use `get_billing_cycle` instead. """ plan_names = [plan.plan for plan in self.plans] @@ -156,72 +152,65 @@ class Subscription(Document): return billing_info - def get_billing_cycle_data(self): + def get_billing_cycle_data(self) -> Dict[str, int]: """ Returns dict contain the billing cycle data. - You shouldn't need to call this directly. Use `get_billing_cycle` instead. """ billing_info = self.get_billing_cycle_and_interval() + if not billing_info: + return None - self.validate_plans_billing_cycle(billing_info) + data = dict() + interval = billing_info[0]["billing_interval"] + interval_count = billing_info[0]["billing_interval_count"] - if billing_info: - data = dict() - interval = billing_info[0]["billing_interval"] - interval_count = billing_info[0]["billing_interval_count"] - if interval not in ["Day", "Week"]: - data["days"] = -1 - if interval == "Day": - data["days"] = interval_count - 1 - elif interval == "Month": - data["months"] = interval_count - elif interval == "Year": - data["years"] = interval_count - # todo: test week - elif interval == "Week": - data["days"] = interval_count * 7 - 1 + if interval not in ["Day", "Week"]: + data["days"] = -1 - return data + if interval == "Day": + data["days"] = interval_count - 1 + elif interval == "Week": + data["days"] = interval_count * 7 - 1 + elif interval == "Month": + data["months"] = interval_count + elif interval == "Year": + data["years"] = interval_count - def set_status_grace_period(self): - """ - Sets the `Subscription` `status` based on the preference set in `Subscription Settings`. + return data - Used when the `Subscription` needs to decide what to do after the current generated - invoice is past it's due date and grace period. - """ - subscription_settings = frappe.get_single("Subscription Settings") - if self.status == "Past Due Date" and self.is_past_grace_period(): - self.status = "Cancelled" if cint(subscription_settings.cancel_after_grace) else "Unpaid" - - def set_subscription_status(self): + def set_subscription_status(self) -> None: """ Sets the status of the `Subscription` """ if self.is_trialling(): self.status = "Trialling" - elif self.status == "Active" and self.end_date and getdate() > getdate(self.end_date): + elif ( + self.status == "Active" + and self.end_date + and getdate(frappe.flags.current_date) > getdate(self.end_date) + ): self.status = "Completed" elif self.is_past_grace_period(): - subscription_settings = frappe.get_single("Subscription Settings") - self.status = "Cancelled" if cint(subscription_settings.cancel_after_grace) else "Unpaid" + self.status = self.get_status_for_past_grace_period() + self.cancelation_date = ( + getdate(frappe.flags.current_date) if self.status == "Cancelled" else None + ) elif self.current_invoice_is_past_due() and not self.is_past_grace_period(): self.status = "Past Due Date" - elif not self.has_outstanding_invoice(): - self.status = "Active" - elif self.is_new_subscription(): + elif not self.has_outstanding_invoice() or self.is_new_subscription(): self.status = "Active" + self.save() - def is_trialling(self): + def is_trialling(self) -> bool: """ Returns `True` if the `Subscription` is in trial period. """ return not self.period_has_passed(self.trial_period_end) and self.is_new_subscription() @staticmethod - def period_has_passed(end_date): + def period_has_passed(end_date: Union[str, datetime.date]) -> bool: """ Returns true if the given `end_date` has passed """ @@ -229,61 +218,59 @@ class Subscription(Document): if not end_date: return True - end_date = getdate(end_date) - return getdate() > getdate(end_date) + return getdate(frappe.flags.current_date) > getdate(end_date) - def is_past_grace_period(self): + def get_status_for_past_grace_period(self) -> str: + cancel_after_grace = cint(frappe.get_value("Subscription Settings", None, "cancel_after_grace")) + status = "Unpaid" + + if cancel_after_grace: + status = "Cancelled" + + return status + + def is_past_grace_period(self) -> bool: """ Returns `True` if the grace period for the `Subscription` has passed """ - current_invoice = self.get_current_invoice() - if self.current_invoice_is_past_due(current_invoice): - subscription_settings = frappe.get_single("Subscription Settings") - grace_period = cint(subscription_settings.grace_period) + if not self.current_invoice_is_past_due(): + return - return getdate() > add_days(current_invoice.due_date, grace_period) + grace_period = cint(frappe.get_value("Subscription Settings", None, "grace_period")) + return getdate(frappe.flags.current_date) >= getdate( + add_days(self.current_invoice.due_date, grace_period) + ) - def current_invoice_is_past_due(self, current_invoice=None): + def current_invoice_is_past_due(self) -> bool: """ Returns `True` if the current generated invoice is overdue """ - if not current_invoice: - current_invoice = self.get_current_invoice() - - if not current_invoice or self.is_paid(current_invoice): + if not self.current_invoice or self.is_paid(self.current_invoice): return False - else: - return getdate() > getdate(current_invoice.due_date) - def get_current_invoice(self): - """ - Returns the most recent generated invoice. - """ - doctype = "Sales Invoice" if self.party_type == "Customer" else "Purchase Invoice" + return getdate(frappe.flags.current_date) >= getdate(self.current_invoice.due_date) - if len(self.invoices): - current = self.invoices[-1] - if frappe.db.exists(doctype, current.get("invoice")): - doc = frappe.get_doc(doctype, current.get("invoice")) - return doc - else: - frappe.throw(_("Invoice {0} no longer exists").format(current.get("invoice"))) + @property + def invoice_document_type(self) -> str: + return "Sales Invoice" if self.party_type == "Customer" else "Purchase Invoice" - def is_new_subscription(self): + def is_new_subscription(self) -> bool: """ Returns `True` if `Subscription` has never generated an invoice """ - return len(self.invoices) == 0 + return self.is_new() or not frappe.db.exists( + {"doctype": self.invoice_document_type, "subscription": self.name} + ) - def validate(self): + def validate(self) -> None: self.validate_trial_period() self.validate_plans_billing_cycle(self.get_billing_cycle_and_interval()) self.validate_end_date() self.validate_to_follow_calendar_months() if not self.cost_center: - self.cost_center = erpnext.get_default_cost_center(self.get("company")) + self.cost_center = get_default_cost_center(self.get("company")) - def validate_trial_period(self): + def validate_trial_period(self) -> None: """ Runs sanity checks on trial period dates for the `Subscription` """ @@ -297,7 +284,7 @@ class Subscription(Document): if self.trial_period_start and getdate(self.trial_period_start) > getdate(self.start_date): frappe.throw(_("Trial Period Start date cannot be after Subscription Start Date")) - def validate_end_date(self): + def validate_end_date(self) -> None: billing_cycle_info = self.get_billing_cycle_data() end_date = add_to_date(self.start_date, **billing_cycle_info) @@ -306,53 +293,53 @@ class Subscription(Document): _("Subscription End Date must be after {0} as per the subscription plan").format(end_date) ) - def validate_to_follow_calendar_months(self): - if self.follow_calendar_months: - billing_info = self.get_billing_cycle_and_interval() + def validate_to_follow_calendar_months(self) -> None: + if not self.follow_calendar_months: + return - if not self.end_date: - frappe.throw(_("Subscription End Date is mandatory to follow calendar months")) + billing_info = self.get_billing_cycle_and_interval() - if billing_info[0]["billing_interval"] != "Month": - frappe.throw( - _("Billing Interval in Subscription Plan must be Month to follow calendar months") - ) + if not self.end_date: + frappe.throw(_("Subscription End Date is mandatory to follow calendar months")) - def after_insert(self): + if billing_info[0]["billing_interval"] != "Month": + frappe.throw(_("Billing Interval in Subscription Plan must be Month to follow calendar months")) + + def after_insert(self) -> None: # todo: deal with users who collect prepayments. Maybe a new Subscription Invoice doctype? self.set_subscription_status() - def generate_invoice(self, prorate=0): + def generate_invoice( + self, + from_date: Optional[Union[str, datetime.date]] = None, + to_date: Optional[Union[str, datetime.date]] = None, + ) -> Document: """ Creates a `Invoice` for the `Subscription`, updates `self.invoices` and saves the `Subscription`. + Backwards compatibility """ + return self.create_invoice(from_date=from_date, to_date=to_date) - doctype = "Sales Invoice" if self.party_type == "Customer" else "Purchase Invoice" - - invoice = self.create_invoice(prorate) - self.append("invoices", {"document_type": doctype, "invoice": invoice.name}) - - self.save() - - return invoice - - def create_invoice(self, prorate): + def create_invoice( + self, + from_date: Optional[Union[str, datetime.date]] = None, + to_date: Optional[Union[str, datetime.date]] = None, + ) -> Document: """ Creates a `Invoice`, submits it and returns it """ - doctype = "Sales Invoice" if self.party_type == "Customer" else "Purchase Invoice" - - invoice = frappe.new_doc(doctype) - # For backward compatibility # Earlier subscription didn't had any company field company = self.get("company") or get_default_company() if not company: + # fmt: off frappe.throw( - _("Company is mandatory was generating invoice. Please set default company in Global Defaults") + _("Company is mandatory was generating invoice. Please set default company in Global Defaults.") ) + # fmt: on + invoice = frappe.new_doc(self.invoice_document_type) invoice.company = company invoice.set_posting_time = 1 invoice.posting_date = ( @@ -363,17 +350,17 @@ class Subscription(Document): invoice.cost_center = self.cost_center - if doctype == "Sales Invoice": + if self.invoice_document_type == "Sales Invoice": invoice.customer = self.party else: invoice.supplier = self.party if frappe.db.get_value("Supplier", self.party, "tax_withholding_category"): invoice.apply_tds = 1 - ### Add party currency to invoice + # Add party currency to invoice invoice.currency = get_party_account_currency(self.party_type, self.party, self.company) - ## Add dimensions in invoice for subscription: + # Add dimensions in invoice for subscription: accounting_dimensions = get_accounting_dimensions() for dimension in accounting_dimensions: @@ -382,7 +369,7 @@ class Subscription(Document): # Subscription is better suited for service items. I won't update `update_stock` # for that reason - items_list = self.get_items_from_plans(self.plans, prorate) + items_list = self.get_items_from_plans(self.plans, is_prorate()) for item in items_list: item["cost_center"] = self.cost_center invoice.append("items", item) @@ -390,9 +377,9 @@ class Subscription(Document): # Taxes tax_template = "" - if doctype == "Sales Invoice" and self.sales_tax_template: + if self.invoice_document_type == "Sales Invoice" and self.sales_tax_template: tax_template = self.sales_tax_template - if doctype == "Purchase Invoice" and self.purchase_tax_template: + if self.invoice_document_type == "Purchase Invoice" and self.purchase_tax_template: tax_template = self.purchase_tax_template if tax_template: @@ -424,8 +411,9 @@ class Subscription(Document): invoice.apply_discount_on = discount_on if discount_on else "Grand Total" # Subscription period - invoice.from_date = self.current_invoice_start - invoice.to_date = self.current_invoice_end + invoice.subscription = self.name + invoice.from_date = from_date or self.current_invoice_start + invoice.to_date = to_date or self.current_invoice_end invoice.flags.ignore_mandatory = True @@ -437,13 +425,20 @@ class Subscription(Document): return invoice - def get_items_from_plans(self, plans, prorate=0): + def get_items_from_plans( + self, plans: List[Dict[str, str]], prorate: Optional[bool] = None + ) -> List[Dict]: """ Returns the `Item`s linked to `Subscription Plan` """ + if prorate is None: + prorate = False + if prorate: prorate_factor = get_prorata_factor( - self.current_invoice_end, self.current_invoice_start, self.generate_invoice_at_period_start + self.current_invoice_end, + self.current_invoice_start, + cint(self.generate_invoice_at_period_start), ) items = [] @@ -465,7 +460,11 @@ class Subscription(Document): "item_code": item_code, "qty": plan.qty, "rate": get_plan_rate( - plan.plan, plan.qty, party, self.current_invoice_start, self.current_invoice_end + plan.plan, + plan.qty, + party, + self.current_invoice_start, + self.current_invoice_end, ), "cost_center": plan_doc.cost_center, } @@ -503,254 +502,184 @@ class Subscription(Document): return items - def process(self): + @frappe.whitelist() + def process(self) -> bool: """ To be called by task periodically. It checks the subscription and takes appropriate action as need be. It calls either of these methods depending the `Subscription` status: 1. `process_for_active` 2. `process_for_past_due` """ - if self.status == "Active": - self.process_for_active() - elif self.status in ["Past Due Date", "Unpaid"]: - self.process_for_past_due_date() + if ( + not self.is_current_invoice_generated(self.current_invoice_start, self.current_invoice_end) + and self.can_generate_new_invoice() + ): + self.generate_invoice() + self.update_subscription_period(add_days(self.current_invoice_end, 1)) + + if self.cancel_at_period_end and ( + getdate(frappe.flags.current_date) >= getdate(self.current_invoice_end) + or getdate(frappe.flags.current_date) >= getdate(self.end_date) + ): + self.cancel_subscription() self.set_subscription_status() self.save() - def is_postpaid_to_invoice(self): - return getdate() > getdate(self.current_invoice_end) or ( - getdate() >= getdate(self.current_invoice_end) - and getdate(self.current_invoice_end) == getdate(self.current_invoice_start) - ) + def can_generate_new_invoice(self) -> bool: + if self.cancelation_date: + return False + elif self.generate_invoice_at_period_start and ( + getdate(frappe.flags.current_date) == getdate(self.current_invoice_start) + or self.is_new_subscription() + ): + return True + elif getdate(frappe.flags.current_date) == getdate(self.current_invoice_end): + if self.has_outstanding_invoice() and not self.generate_new_invoices_past_due_date: + return False - def is_prepaid_to_invoice(self): - if not self.generate_invoice_at_period_start: + return True + else: return False - if self.is_new_subscription() and getdate() >= getdate(self.current_invoice_start): - return True - - # Check invoice dates and make sure it doesn't have outstanding invoices - return getdate() >= getdate(self.current_invoice_start) - - def is_current_invoice_generated(self, _current_start_date=None, _current_end_date=None): - invoice = self.get_current_invoice() - + def is_current_invoice_generated( + self, + _current_start_date: Union[datetime.date, str] = None, + _current_end_date: Union[datetime.date, str] = None, + ) -> bool: if not (_current_start_date and _current_end_date): - _current_start_date, _current_end_date = self.update_subscription_period( - date=add_days(self.current_invoice_end, 1), return_date=True + _current_start_date, _current_end_date = self._get_subscription_period( + date=add_days(self.current_invoice_end, 1) ) - if invoice and getdate(_current_start_date) <= getdate(invoice.posting_date) <= getdate( - _current_end_date - ): + if self.current_invoice and getdate(_current_start_date) <= getdate( + self.current_invoice.posting_date + ) <= getdate(_current_end_date): return True return False - def process_for_active(self): + @property + def current_invoice(self) -> Union[Document, None]: """ - Called by `process` if the status of the `Subscription` is 'Active'. - - The possible outcomes of this method are: - 1. Generate a new invoice - 2. Change the `Subscription` status to 'Past Due Date' - 3. Change the `Subscription` status to 'Cancelled' + Adds property for accessing the current_invoice """ + return self.get_current_invoice() - if not self.is_current_invoice_generated( - self.current_invoice_start, self.current_invoice_end - ) and (self.is_postpaid_to_invoice() or self.is_prepaid_to_invoice()): + def get_current_invoice(self) -> Union[Document, None]: + """ + Returns the most recent generated invoice. + """ + invoice = frappe.get_all( + self.invoice_document_type, + { + "subscription": self.name, + }, + limit=1, + order_by="to_date desc", + pluck="name", + ) - prorate = frappe.db.get_single_value("Subscription Settings", "prorate") - self.generate_invoice(prorate) + if invoice: + return frappe.get_doc(self.invoice_document_type, invoice[0]) - if getdate() > getdate(self.current_invoice_end) and self.is_prepaid_to_invoice(): - self.update_subscription_period(add_days(self.current_invoice_end, 1)) - - if self.cancel_at_period_end and getdate() > getdate(self.current_invoice_end): - self.cancel_subscription_at_period_end() - - def cancel_subscription_at_period_end(self): + def cancel_subscription_at_period_end(self) -> None: """ Called when `Subscription.cancel_at_period_end` is truthy """ - if self.end_date and getdate() < getdate(self.end_date): - return - self.status = "Cancelled" - if not self.cancelation_date: - self.cancelation_date = nowdate() + self.cancelation_date = nowdate() - def process_for_past_due_date(self): - """ - Called by `process` if the status of the `Subscription` is 'Past Due Date'. - - The possible outcomes of this method are: - 1. Change the `Subscription` status to 'Active' - 2. Change the `Subscription` status to 'Cancelled' - 3. Change the `Subscription` status to 'Unpaid' - """ - current_invoice = self.get_current_invoice() - if not current_invoice: - frappe.throw(_("Current invoice {0} is missing").format(current_invoice.invoice)) - else: - if not self.has_outstanding_invoice(): - self.status = "Active" - else: - self.set_status_grace_period() - - if getdate() > getdate(self.current_invoice_end): - self.update_subscription_period(add_days(self.current_invoice_end, 1)) - - # Generate invoices periodically even if current invoice are unpaid - if ( - self.generate_new_invoices_past_due_date - and not self.is_current_invoice_generated(self.current_invoice_start, self.current_invoice_end) - and (self.is_postpaid_to_invoice() or self.is_prepaid_to_invoice()) - ): - - prorate = frappe.db.get_single_value("Subscription Settings", "prorate") - self.generate_invoice(prorate) + @property + def invoices(self) -> List[Dict]: + return frappe.get_all( + self.invoice_document_type, + filters={"subscription": self.name}, + order_by="from_date asc", + ) @staticmethod - def is_paid(invoice): + def is_paid(invoice: Document) -> bool: """ Return `True` if the given invoice is paid """ return invoice.status == "Paid" - def has_outstanding_invoice(self): + def has_outstanding_invoice(self) -> int: """ Returns `True` if the most recent invoice for the `Subscription` is not paid """ - doctype = "Sales Invoice" if self.party_type == "Customer" else "Purchase Invoice" - current_invoice = self.get_current_invoice() - invoice_list = [d.invoice for d in self.invoices] - - outstanding_invoices = frappe.get_all( - doctype, fields=["name"], filters={"status": ("!=", "Paid"), "name": ("in", invoice_list)} + return frappe.db.count( + self.invoice_document_type, + { + "subscription": self.name, + "status": ["!=", "Paid"], + }, ) - if outstanding_invoices: - return True - else: - False - - def cancel_subscription(self): + @frappe.whitelist() + def cancel_subscription(self) -> None: """ This sets the subscription as cancelled. It will stop invoices from being generated but it will not affect already created invoices. """ - if self.status != "Cancelled": - to_generate_invoice = ( - True if self.status == "Active" and not self.generate_invoice_at_period_start else False - ) - to_prorate = frappe.db.get_single_value("Subscription Settings", "prorate") - self.status = "Cancelled" - self.cancelation_date = nowdate() - if to_generate_invoice: - self.generate_invoice(prorate=to_prorate) - self.save() + if self.status == "Cancelled": + frappe.throw(_("subscription is already cancelled."), InvoiceCancelled) - def restart_subscription(self): + to_generate_invoice = ( + True if self.status == "Active" and not self.generate_invoice_at_period_start else False + ) + self.status = "Cancelled" + self.cancelation_date = nowdate() + + if to_generate_invoice: + self.generate_invoice(self.current_invoice_start, self.cancelation_date) + + self.save() + + @frappe.whitelist() + def restart_subscription(self) -> None: """ This sets the subscription as active. The subscription will be made to be like a new subscription and the `Subscription` will lose all the history of generated invoices it has. """ - if self.status == "Cancelled": - self.status = "Active" - self.db_set("start_date", nowdate()) - self.update_subscription_period(nowdate()) - self.invoices = [] - self.save() - else: - frappe.throw(_("You cannot restart a Subscription that is not cancelled.")) + if not self.status == "Cancelled": + frappe.throw(_("You cannot restart a Subscription that is not cancelled."), InvoiceNotCancelled) - def get_precision(self): - invoice = self.get_current_invoice() - if invoice: - return invoice.precision("grand_total") + self.status = "Active" + self.cancelation_date = None + self.update_subscription_period(frappe.flags.current_date or nowdate()) + self.save() -def get_calendar_months(billing_interval): - calendar_months = [] - start = 0 - while start < 12: - start += billing_interval - calendar_months.append(start) - - return calendar_months +def is_prorate() -> int: + return cint(frappe.db.get_single_value("Subscription Settings", "prorate")) -def get_prorata_factor(period_end, period_start, is_prepaid): +def get_prorata_factor( + period_end: Union[datetime.date, str], + period_start: Union[datetime.date, str], + is_prepaid: Optional[int] = None, +) -> Union[int, float]: if is_prepaid: - prorate_factor = 1 - else: - diff = flt(date_diff(nowdate(), period_start) + 1) - plan_days = flt(date_diff(period_end, period_start) + 1) - prorate_factor = diff / plan_days + return 1 - return prorate_factor + diff = flt(date_diff(nowdate(), period_start) + 1) + plan_days = flt(date_diff(period_end, period_start) + 1) + return diff / plan_days -def process_all(): +def process_all() -> None: """ Task to updates the status of all `Subscription` apart from those that are cancelled """ - subscriptions = get_all_subscriptions() - for subscription in subscriptions: - process(subscription) - - -def get_all_subscriptions(): - """ - Returns all `Subscription` documents - """ - return frappe.db.get_all("Subscription", {"status": ("!=", "Cancelled")}) - - -def process(data): - """ - Checks a `Subscription` and updates it status as necessary - """ - if data: + for subscription in frappe.get_all("Subscription", {"status": ("!=", "Cancelled")}, pluck="name"): try: - subscription = frappe.get_doc("Subscription", data["name"]) + subscription = frappe.get_doc("Subscription", subscription) subscription.process() frappe.db.commit() except frappe.ValidationError: frappe.db.rollback() subscription.log_error("Subscription failed") - - -@frappe.whitelist() -def cancel_subscription(name): - """ - Cancels a `Subscription`. This will stop the `Subscription` from further invoicing the - `Subscriber` but all already outstanding invoices will not be affected. - """ - subscription = frappe.get_doc("Subscription", name) - subscription.cancel_subscription() - - -@frappe.whitelist() -def restart_subscription(name): - """ - Restarts a cancelled `Subscription`. The `Subscription` will 'forget' the history of - all invoices it has generated - """ - subscription = frappe.get_doc("Subscription", name) - subscription.restart_subscription() - - -@frappe.whitelist() -def get_subscription_updates(name): - """ - Use this to get the latest state of the given `Subscription` - """ - subscription = frappe.get_doc("Subscription", name) - subscription.process() diff --git a/erpnext/accounts/doctype/subscription/test_subscription.py b/erpnext/accounts/doctype/subscription/test_subscription.py index eb17daa282f..0bb171f464e 100644 --- a/erpnext/accounts/doctype/subscription/test_subscription.py +++ b/erpnext/accounts/doctype/subscription/test_subscription.py @@ -11,6 +11,7 @@ from frappe.utils.data import ( date_diff, flt, get_date_str, + getdate, nowdate, ) @@ -90,10 +91,18 @@ def create_parties(): customer.insert() +def reset_settings(): + settings = frappe.get_single("Subscription Settings") + settings.grace_period = 0 + settings.cancel_after_grace = 0 + settings.save() + + class TestSubscription(unittest.TestCase): def setUp(self): create_plan() create_parties() + reset_settings() def test_create_subscription_with_trial_with_correct_period(self): subscription = frappe.new_doc("Subscription") @@ -116,8 +125,6 @@ class TestSubscription(unittest.TestCase): self.assertEqual(subscription.invoices, []) self.assertEqual(subscription.status, "Trialling") - subscription.delete() - def test_create_subscription_without_trial_with_correct_period(self): subscription = frappe.new_doc("Subscription") subscription.party_type = "Customer" @@ -133,8 +140,6 @@ class TestSubscription(unittest.TestCase): self.assertEqual(len(subscription.invoices), 0) self.assertEqual(subscription.status, "Active") - subscription.delete() - def test_create_subscription_trial_with_wrong_dates(self): subscription = frappe.new_doc("Subscription") subscription.party_type = "Customer" @@ -144,7 +149,6 @@ class TestSubscription(unittest.TestCase): subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1}) self.assertRaises(frappe.ValidationError, subscription.save) - subscription.delete() def test_create_subscription_multi_with_different_billing_fails(self): subscription = frappe.new_doc("Subscription") @@ -156,7 +160,6 @@ class TestSubscription(unittest.TestCase): subscription.append("plans", {"plan": "_Test Plan Name 3", "qty": 1}) self.assertRaises(frappe.ValidationError, subscription.save) - subscription.delete() def test_invoice_is_generated_at_end_of_billing_period(self): subscription = frappe.new_doc("Subscription") @@ -169,13 +172,13 @@ class TestSubscription(unittest.TestCase): self.assertEqual(subscription.status, "Active") self.assertEqual(subscription.current_invoice_start, "2018-01-01") self.assertEqual(subscription.current_invoice_end, "2018-01-31") + frappe.flags.current_date = "2018-01-31" subscription.process() self.assertEqual(len(subscription.invoices), 1) - self.assertEqual(subscription.current_invoice_start, "2018-01-01") - subscription.process() + self.assertEqual(subscription.current_invoice_start, "2018-02-01") + self.assertEqual(subscription.current_invoice_end, "2018-02-28") self.assertEqual(subscription.status, "Unpaid") - subscription.delete() def test_status_goes_back_to_active_after_invoice_is_paid(self): subscription = frappe.new_doc("Subscription") @@ -183,7 +186,9 @@ class TestSubscription(unittest.TestCase): subscription.party = "_Test Customer" subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1}) subscription.start_date = "2018-01-01" + subscription.generate_invoice_at_period_start = True subscription.insert() + frappe.flags.current_date = "2018-01-01" subscription.process() # generate first invoice self.assertEqual(len(subscription.invoices), 1) @@ -203,11 +208,8 @@ class TestSubscription(unittest.TestCase): self.assertEqual(subscription.current_invoice_start, add_months(subscription.start_date, 1)) self.assertEqual(len(subscription.invoices), 1) - subscription.delete() - def test_subscription_cancel_after_grace_period(self): settings = frappe.get_single("Subscription Settings") - default_grace_period_action = settings.cancel_after_grace settings.cancel_after_grace = 1 settings.save() @@ -215,20 +217,18 @@ class TestSubscription(unittest.TestCase): subscription.party_type = "Customer" subscription.party = "_Test Customer" subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1}) + # subscription.generate_invoice_at_period_start = True subscription.start_date = "2018-01-01" subscription.insert() self.assertEqual(subscription.status, "Active") + frappe.flags.current_date = "2018-01-31" subscription.process() # generate first invoice # This should change status to Cancelled since grace period is 0 # And is backdated subscription so subscription will be cancelled after processing self.assertEqual(subscription.status, "Cancelled") - settings.cancel_after_grace = default_grace_period_action - settings.save() - subscription.delete() - def test_subscription_unpaid_after_grace_period(self): settings = frappe.get_single("Subscription Settings") default_grace_period_action = settings.cancel_after_grace @@ -248,21 +248,26 @@ class TestSubscription(unittest.TestCase): settings.cancel_after_grace = default_grace_period_action settings.save() - subscription.delete() def test_subscription_invoice_days_until_due(self): + _date = add_months(nowdate(), -1) subscription = frappe.new_doc("Subscription") subscription.party_type = "Customer" subscription.party = "_Test Customer" - subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1}) subscription.days_until_due = 10 - subscription.start_date = add_months(nowdate(), -1) + subscription.start_date = _date + subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1}) subscription.insert() + + frappe.flags.current_date = subscription.current_invoice_end + subscription.process() # generate first invoice self.assertEqual(len(subscription.invoices), 1) self.assertEqual(subscription.status, "Active") - subscription.delete() + frappe.flags.current_date = add_days(subscription.current_invoice_end, 3) + self.assertEqual(len(subscription.invoices), 1) + self.assertEqual(subscription.status, "Active") def test_subscription_is_past_due_doesnt_change_within_grace_period(self): settings = frappe.get_single("Subscription Settings") @@ -276,6 +281,8 @@ class TestSubscription(unittest.TestCase): subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1}) subscription.start_date = add_days(nowdate(), -1000) subscription.insert() + + frappe.flags.current_date = subscription.current_invoice_end subscription.process() # generate first invoice self.assertEqual(subscription.status, "Past Due Date") @@ -292,7 +299,6 @@ class TestSubscription(unittest.TestCase): settings.grace_period = grace_period settings.save() - subscription.delete() def test_subscription_remains_active_during_invoice_period(self): subscription = frappe.new_doc("Subscription") @@ -319,8 +325,6 @@ class TestSubscription(unittest.TestCase): self.assertEqual(subscription.current_invoice_end, add_to_date(nowdate(), months=1, days=-1)) self.assertEqual(len(subscription.invoices), 0) - subscription.delete() - def test_subscription_cancelation(self): subscription = frappe.new_doc("Subscription") subscription.party_type = "Customer" @@ -331,8 +335,6 @@ class TestSubscription(unittest.TestCase): self.assertEqual(subscription.status, "Cancelled") - subscription.delete() - def test_subscription_cancellation_invoices(self): settings = frappe.get_single("Subscription Settings") to_prorate = settings.prorate @@ -372,7 +374,6 @@ class TestSubscription(unittest.TestCase): self.assertEqual(flt(invoice.grand_total, 2), flt(prorate_factor * 900, 2)) self.assertEqual(subscription.status, "Cancelled") - subscription.delete() settings.prorate = to_prorate settings.save() @@ -395,8 +396,6 @@ class TestSubscription(unittest.TestCase): settings.prorate = to_prorate settings.save() - subscription.delete() - def test_subscription_cancellation_invoices_with_prorata_true(self): settings = frappe.get_single("Subscription Settings") to_prorate = settings.prorate @@ -422,8 +421,6 @@ class TestSubscription(unittest.TestCase): settings.prorate = to_prorate settings.save() - subscription.delete() - def test_subcription_cancellation_and_process(self): settings = frappe.get_single("Subscription Settings") default_grace_period_action = settings.cancel_after_grace @@ -437,23 +434,22 @@ class TestSubscription(unittest.TestCase): subscription.start_date = "2018-01-01" subscription.insert() subscription.process() # generate first invoice - invoices = len(subscription.invoices) + # Generate an invoice for the cancelled period subscription.cancel_subscription() self.assertEqual(subscription.status, "Cancelled") - self.assertEqual(len(subscription.invoices), invoices) + self.assertEqual(len(subscription.invoices), 1) subscription.process() self.assertEqual(subscription.status, "Cancelled") - self.assertEqual(len(subscription.invoices), invoices) + self.assertEqual(len(subscription.invoices), 1) subscription.process() self.assertEqual(subscription.status, "Cancelled") - self.assertEqual(len(subscription.invoices), invoices) + self.assertEqual(len(subscription.invoices), 1) settings.cancel_after_grace = default_grace_period_action settings.save() - subscription.delete() def test_subscription_restart_and_process(self): settings = frappe.get_single("Subscription Settings") @@ -468,6 +464,7 @@ class TestSubscription(unittest.TestCase): subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1}) subscription.start_date = "2018-01-01" subscription.insert() + frappe.flags.current_date = "2018-01-31" subscription.process() # generate first invoice # Status is unpaid as Days until Due is zero and grace period is Zero @@ -478,19 +475,18 @@ class TestSubscription(unittest.TestCase): subscription.restart_subscription() self.assertEqual(subscription.status, "Active") - self.assertEqual(len(subscription.invoices), 0) + self.assertEqual(len(subscription.invoices), 1) subscription.process() - self.assertEqual(subscription.status, "Active") - self.assertEqual(len(subscription.invoices), 0) + self.assertEqual(subscription.status, "Unpaid") + self.assertEqual(len(subscription.invoices), 1) subscription.process() - self.assertEqual(subscription.status, "Active") - self.assertEqual(len(subscription.invoices), 0) + self.assertEqual(subscription.status, "Unpaid") + self.assertEqual(len(subscription.invoices), 1) settings.cancel_after_grace = default_grace_period_action settings.save() - subscription.delete() def test_subscription_unpaid_back_to_active(self): settings = frappe.get_single("Subscription Settings") @@ -503,8 +499,11 @@ class TestSubscription(unittest.TestCase): subscription.party = "_Test Customer" subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1}) subscription.start_date = "2018-01-01" + subscription.generate_invoice_at_period_start = True subscription.insert() + frappe.flags.current_date = subscription.current_invoice_start + subscription.process() # generate first invoice # This should change status to Unpaid since grace period is 0 self.assertEqual(subscription.status, "Unpaid") @@ -517,12 +516,12 @@ class TestSubscription(unittest.TestCase): self.assertEqual(subscription.status, "Active") # A new invoice is generated + frappe.flags.current_date = subscription.current_invoice_start subscription.process() self.assertEqual(subscription.status, "Unpaid") settings.cancel_after_grace = default_grace_period_action settings.save() - subscription.delete() def test_restart_active_subscription(self): subscription = frappe.new_doc("Subscription") @@ -533,8 +532,6 @@ class TestSubscription(unittest.TestCase): self.assertRaises(frappe.ValidationError, subscription.restart_subscription) - subscription.delete() - def test_subscription_invoice_discount_percentage(self): subscription = frappe.new_doc("Subscription") subscription.party_type = "Customer" @@ -549,8 +546,6 @@ class TestSubscription(unittest.TestCase): self.assertEqual(invoice.additional_discount_percentage, 10) self.assertEqual(invoice.apply_discount_on, "Grand Total") - subscription.delete() - def test_subscription_invoice_discount_amount(self): subscription = frappe.new_doc("Subscription") subscription.party_type = "Customer" @@ -565,8 +560,6 @@ class TestSubscription(unittest.TestCase): self.assertEqual(invoice.discount_amount, 11) self.assertEqual(invoice.apply_discount_on, "Grand Total") - subscription.delete() - def test_prepaid_subscriptions(self): # Create a non pre-billed subscription, processing should not create # invoices. @@ -614,8 +607,6 @@ class TestSubscription(unittest.TestCase): settings.prorate = to_prorate settings.save() - subscription.delete() - def test_subscription_with_follow_calendar_months(self): subscription = frappe.new_doc("Subscription") subscription.party_type = "Supplier" @@ -623,14 +614,14 @@ class TestSubscription(unittest.TestCase): subscription.generate_invoice_at_period_start = 1 subscription.follow_calendar_months = 1 - # select subscription start date as '2018-01-15' + # select subscription start date as "2018-01-15" subscription.start_date = "2018-01-15" subscription.end_date = "2018-07-15" subscription.append("plans", {"plan": "_Test Plan Name 4", "qty": 1}) subscription.save() - # even though subscription starts at '2018-01-15' and Billing interval is Month and count 3 - # First invoice will end at '2018-03-31' instead of '2018-04-14' + # even though subscription starts at "2018-01-15" and Billing interval is Month and count 3 + # First invoice will end at "2018-03-31" instead of "2018-04-14" self.assertEqual(get_date_str(subscription.current_invoice_end), "2018-03-31") def test_subscription_generate_invoice_past_due(self): @@ -639,11 +630,12 @@ class TestSubscription(unittest.TestCase): subscription.party = "_Test Supplier" subscription.generate_invoice_at_period_start = 1 subscription.generate_new_invoices_past_due_date = 1 - # select subscription start date as '2018-01-15' + # select subscription start date as "2018-01-15" subscription.start_date = "2018-01-01" subscription.append("plans", {"plan": "_Test Plan Name 4", "qty": 1}) subscription.save() + frappe.flags.current_date = "2018-01-01" # Process subscription and create first invoice # Subscription status will be unpaid since due date has already passed subscription.process() @@ -652,8 +644,8 @@ class TestSubscription(unittest.TestCase): # Now the Subscription is unpaid # Even then new invoice should be created as we have enabled `generate_new_invoices_past_due_date` in - # subscription - + # subscription and the interval between the subscriptions is 3 months + frappe.flags.current_date = "2018-04-01" subscription.process() self.assertEqual(len(subscription.invoices), 2) @@ -662,7 +654,7 @@ class TestSubscription(unittest.TestCase): subscription.party_type = "Supplier" subscription.party = "_Test Supplier" subscription.generate_invoice_at_period_start = 1 - # select subscription start date as '2018-01-15' + # select subscription start date as "2018-01-15" subscription.start_date = "2018-01-01" subscription.append("plans", {"plan": "_Test Plan Name 4", "qty": 1}) subscription.save() @@ -682,7 +674,7 @@ class TestSubscription(unittest.TestCase): subscription.party = "_Test Subscription Customer" subscription.generate_invoice_at_period_start = 1 subscription.company = "_Test Company" - # select subscription start date as '2018-01-15' + # select subscription start date as "2018-01-15" subscription.start_date = "2018-01-01" subscription.append("plans", {"plan": "_Test Plan Multicurrency", "qty": 1}) subscription.save() @@ -692,5 +684,47 @@ class TestSubscription(unittest.TestCase): self.assertEqual(subscription.status, "Unpaid") # Check the currency of the created invoice - currency = frappe.db.get_value("Sales Invoice", subscription.invoices[0].invoice, "currency") + currency = frappe.db.get_value("Sales Invoice", subscription.invoices[0].name, "currency") self.assertEqual(currency, "USD") + + def test_subscription_recovery(self): + """Test if Subscription recovers when start/end date run out of sync with created invoices.""" + subscription = frappe.new_doc("Subscription") + subscription.party_type = "Customer" + subscription.party = "_Test Subscription Customer" + subscription.company = "_Test Company" + subscription.start_date = "2021-12-01" + subscription.generate_new_invoices_past_due_date = 1 + subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1}) + subscription.submit_invoice = 0 + subscription.save() + + # create invoices for the first two moths + frappe.flags.current_date = "2021-12-31" + subscription.process() + + frappe.flags.current_date = "2022-01-31" + subscription.process() + + self.assertEqual(len(subscription.invoices), 2) + self.assertEqual( + getdate(frappe.db.get_value("Sales Invoice", subscription.invoices[0].name, "from_date")), + getdate("2021-12-01"), + ) + self.assertEqual( + getdate(frappe.db.get_value("Sales Invoice", subscription.invoices[1].name, "from_date")), + getdate("2022-01-01"), + ) + + # recreate most recent invoice + subscription.process() + + self.assertEqual(len(subscription.invoices), 2) + self.assertEqual( + getdate(frappe.db.get_value("Sales Invoice", subscription.invoices[0].name, "from_date")), + getdate("2021-12-01"), + ) + self.assertEqual( + getdate(frappe.db.get_value("Sales Invoice", subscription.invoices[1].name, "from_date")), + getdate("2022-01-01"), + ) diff --git a/erpnext/patches.txt b/erpnext/patches.txt index 641d7550e3e..d53bacea64d 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -262,6 +262,7 @@ erpnext.patches.v14_0.update_reference_due_date_in_journal_entry erpnext.patches.v15_0.saudi_depreciation_warning erpnext.patches.v15_0.delete_saudi_doctypes erpnext.patches.v14_0.show_loan_management_deprecation_warning +erpnext.patches.v14_0.update_subscription_details execute:frappe.rename_doc("Report", "TDS Payable Monthly", "Tax Withholding Details", force=True) [post_model_sync] diff --git a/erpnext/patches/v14_0/update_subscription_details.py b/erpnext/patches/v14_0/update_subscription_details.py new file mode 100644 index 00000000000..729ac1895a1 --- /dev/null +++ b/erpnext/patches/v14_0/update_subscription_details.py @@ -0,0 +1,17 @@ +import frappe + + +def execute(): + subscription_invoices = frappe.get_all( + "Subscription Invoice", fields=["document_type", "invoice", "parent"] + ) + + for subscription_invoice in subscription_invoices: + frappe.db.set_value( + subscription_invoice.document_type, + subscription_invoice.invoice, + "Subscription", + subscription_invoice.parent, + ) + + frappe.delete_doc_if_exists("DocType", "Subscription Invoice") From 0218ca538f8dd4342d1c9a989607441dc89fed22 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Mon, 7 Aug 2023 09:55:46 +0530 Subject: [PATCH 71/84] fix: use correct lang separator for frappe --- erpnext/setup/doctype/holiday_list/holiday_list.py | 2 +- .../setup/doctype/holiday_list/test_holiday_list.py | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/erpnext/setup/doctype/holiday_list/holiday_list.py b/erpnext/setup/doctype/holiday_list/holiday_list.py index 2ef4e655b2d..acf421536dd 100644 --- a/erpnext/setup/doctype/holiday_list/holiday_list.py +++ b/erpnext/setup/doctype/holiday_list/holiday_list.py @@ -169,4 +169,4 @@ def is_holiday(holiday_list, date=None): def local_country_name(country_code: str) -> str: """Return the localized country name for the given country code.""" - return Locale.parse(frappe.local.lang).territories.get(country_code, country_code) + return Locale.parse(frappe.local.lang, sep="-").territories.get(country_code, country_code) diff --git a/erpnext/setup/doctype/holiday_list/test_holiday_list.py b/erpnext/setup/doctype/holiday_list/test_holiday_list.py index 23b08fd1170..7eeb27d864e 100644 --- a/erpnext/setup/doctype/holiday_list/test_holiday_list.py +++ b/erpnext/setup/doctype/holiday_list/test_holiday_list.py @@ -8,6 +8,8 @@ from datetime import date, timedelta import frappe from frappe.utils import getdate +from erpnext.setup.doctype.holiday_list.holiday_list import local_country_name + class TestHolidayList(unittest.TestCase): def test_holiday_list(self): @@ -58,6 +60,16 @@ class TestHolidayList(unittest.TestCase): self.assertIn(date(2023, 4, 10), holidays) self.assertNotIn(date(2023, 5, 1), holidays) + def test_localized_country_names(self): + lang = frappe.local.lang + frappe.local.lang = "en-gb" + self.assertEqual(local_country_name("IN"), "India") + self.assertEqual(local_country_name("DE"), "Germany") + + frappe.local.lang = "de" + self.assertEqual(local_country_name("DE"), "Deutschland") + frappe.local.lang = lang + def make_holiday_list( name, from_date=getdate() - timedelta(days=10), to_date=getdate(), holiday_dates=None From f574ac11ea8ee2f7f46916d93c6f5877350bc069 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Mon, 7 Aug 2023 10:03:40 +0530 Subject: [PATCH 72/84] perf: defer babel import Only required when configuring but will get loaded everywhere --- erpnext/setup/doctype/holiday_list/holiday_list.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/erpnext/setup/doctype/holiday_list/holiday_list.py b/erpnext/setup/doctype/holiday_list/holiday_list.py index acf421536dd..6b6c8ba5be8 100644 --- a/erpnext/setup/doctype/holiday_list/holiday_list.py +++ b/erpnext/setup/doctype/holiday_list/holiday_list.py @@ -6,7 +6,6 @@ import json from datetime import date import frappe -from babel import Locale from frappe import _, throw from frappe.model.document import Document from frappe.utils import formatdate, getdate, today @@ -169,4 +168,6 @@ def is_holiday(holiday_list, date=None): def local_country_name(country_code: str) -> str: """Return the localized country name for the given country code.""" + from babel import Locale + return Locale.parse(frappe.local.lang, sep="-").territories.get(country_code, country_code) From 2eea90a873ca48a351dfda4bcc52a4e1fd57bff6 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Mon, 7 Aug 2023 10:08:03 +0530 Subject: [PATCH 73/84] perf: defer holiday list imports Only used for configuring but loaded whenever get_doc("holiday list", ...) is done --- erpnext/setup/doctype/holiday_list/holiday_list.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/erpnext/setup/doctype/holiday_list/holiday_list.py b/erpnext/setup/doctype/holiday_list/holiday_list.py index 6b6c8ba5be8..526bc2ba4ac 100644 --- a/erpnext/setup/doctype/holiday_list/holiday_list.py +++ b/erpnext/setup/doctype/holiday_list/holiday_list.py @@ -9,8 +9,6 @@ import frappe from frappe import _, throw from frappe.model.document import Document from frappe.utils import formatdate, getdate, today -from holidays import country_holidays -from holidays.utils import list_supported_countries class OverlapError(frappe.ValidationError): @@ -39,6 +37,8 @@ class HolidayList(Document): @frappe.whitelist() def get_supported_countries(self): + from holidays.utils import list_supported_countries + subdivisions_by_country = list_supported_countries() countries = [ {"value": country, "label": local_country_name(country)} @@ -51,6 +51,8 @@ class HolidayList(Document): @frappe.whitelist() def get_local_holidays(self): + from holidays import country_holidays + if not self.country: throw(_("Please select a country")) From b86747c9d4595d37ffeca44c0915a117730ed078 Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Mon, 7 Aug 2023 11:28:07 +0530 Subject: [PATCH 74/84] feat: ledger comparison report (#36485) * feat: Accounting Ledger comparison report * chore: barebones methods * chore: working state * chore: refactor internal logic * chore: working multi select filter on Account * chore: working voucher no filter * chore: remove debugging statements * chore: report with currency symbol * chore: working start and end date filter * test: basic report function * refactor(test): test all filters --- .../__init__.py | 0 .../general_and_payment_ledger_comparison.js | 52 +++++ ...general_and_payment_ledger_comparison.json | 32 +++ .../general_and_payment_ledger_comparison.py | 221 ++++++++++++++++++ ...t_general_and_payment_ledger_comparison.py | 100 ++++++++ 5 files changed, 405 insertions(+) create mode 100644 erpnext/accounts/report/general_and_payment_ledger_comparison/__init__.py create mode 100644 erpnext/accounts/report/general_and_payment_ledger_comparison/general_and_payment_ledger_comparison.js create mode 100644 erpnext/accounts/report/general_and_payment_ledger_comparison/general_and_payment_ledger_comparison.json create mode 100644 erpnext/accounts/report/general_and_payment_ledger_comparison/general_and_payment_ledger_comparison.py create mode 100644 erpnext/accounts/report/general_and_payment_ledger_comparison/test_general_and_payment_ledger_comparison.py diff --git a/erpnext/accounts/report/general_and_payment_ledger_comparison/__init__.py b/erpnext/accounts/report/general_and_payment_ledger_comparison/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/accounts/report/general_and_payment_ledger_comparison/general_and_payment_ledger_comparison.js b/erpnext/accounts/report/general_and_payment_ledger_comparison/general_and_payment_ledger_comparison.js new file mode 100644 index 00000000000..7e6b0537e87 --- /dev/null +++ b/erpnext/accounts/report/general_and_payment_ledger_comparison/general_and_payment_ledger_comparison.js @@ -0,0 +1,52 @@ +// Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +function get_filters() { + let filters = [ + { + "fieldname":"company", + "label": __("Company"), + "fieldtype": "Link", + "options": "Company", + "default": frappe.defaults.get_user_default("Company"), + "reqd": 1 + }, + { + "fieldname":"period_start_date", + "label": __("Start Date"), + "fieldtype": "Date", + "reqd": 1, + "default": frappe.datetime.add_months(frappe.datetime.get_today(), -1) + }, + { + "fieldname":"period_end_date", + "label": __("End Date"), + "fieldtype": "Date", + "reqd": 1, + "default": frappe.datetime.get_today() + }, + { + "fieldname":"account", + "label": __("Account"), + "fieldtype": "MultiSelectList", + "options": "Account", + get_data: function(txt) { + return frappe.db.get_link_options('Account', txt, { + company: frappe.query_report.get_filter_value("company"), + account_type: ['in', ["Receivable", "Payable"]] + }); + } + }, + { + "fieldname":"voucher_no", + "label": __("Voucher No"), + "fieldtype": "Data", + "width": 100, + }, + ] + return filters; +} + +frappe.query_reports["General and Payment Ledger Comparison"] = { + "filters": get_filters() +}; diff --git a/erpnext/accounts/report/general_and_payment_ledger_comparison/general_and_payment_ledger_comparison.json b/erpnext/accounts/report/general_and_payment_ledger_comparison/general_and_payment_ledger_comparison.json new file mode 100644 index 00000000000..1d0d9d134da --- /dev/null +++ b/erpnext/accounts/report/general_and_payment_ledger_comparison/general_and_payment_ledger_comparison.json @@ -0,0 +1,32 @@ +{ + "add_total_row": 0, + "columns": [], + "creation": "2023-08-02 17:30:29.494907", + "disabled": 0, + "docstatus": 0, + "doctype": "Report", + "filters": [], + "idx": 0, + "is_standard": "Yes", + "letterhead": null, + "modified": "2023-08-02 17:30:29.494907", + "modified_by": "Administrator", + "module": "Accounts", + "name": "General and Payment Ledger Comparison", + "owner": "Administrator", + "prepared_report": 0, + "ref_doctype": "GL Entry", + "report_name": "General and Payment Ledger Comparison", + "report_type": "Script Report", + "roles": [ + { + "role": "Accounts User" + }, + { + "role": "Accounts Manager" + }, + { + "role": "Auditor" + } + ] +} \ No newline at end of file diff --git a/erpnext/accounts/report/general_and_payment_ledger_comparison/general_and_payment_ledger_comparison.py b/erpnext/accounts/report/general_and_payment_ledger_comparison/general_and_payment_ledger_comparison.py new file mode 100644 index 00000000000..553c137f024 --- /dev/null +++ b/erpnext/accounts/report/general_and_payment_ledger_comparison/general_and_payment_ledger_comparison.py @@ -0,0 +1,221 @@ +# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +import frappe +from frappe import _, qb +from frappe.query_builder import Criterion +from frappe.query_builder.functions import Sum + + +class General_Payment_Ledger_Comparison(object): + """ + A Utility report to compare Voucher-wise balance between General and Payment Ledger + """ + + def __init__(self, filters=None): + self.filters = filters + self.gle = [] + self.ple = [] + + def get_accounts(self): + receivable_accounts = [ + x[0] + for x in frappe.db.get_all( + "Account", + filters={"company": self.filters.company, "account_type": "Receivable"}, + as_list=True, + ) + ] + payable_accounts = [ + x[0] + for x in frappe.db.get_all( + "Account", filters={"company": self.filters.company, "account_type": "Payable"}, as_list=True + ) + ] + + self.account_types = frappe._dict( + { + "receivable": frappe._dict({"accounts": receivable_accounts, "gle": [], "ple": []}), + "payable": frappe._dict({"accounts": payable_accounts, "gle": [], "ple": []}), + } + ) + + def generate_filters(self): + if self.filters.account: + self.account_types.receivable.accounts = [] + self.account_types.payable.accounts = [] + + for acc in frappe.db.get_all( + "Account", filters={"name": ["in", self.filters.account]}, fields=["name", "account_type"] + ): + if acc.account_type == "Receivable": + self.account_types.receivable.accounts.append(acc.name) + else: + self.account_types.payable.accounts.append(acc.name) + + def get_gle(self): + gle = qb.DocType("GL Entry") + + for acc_type, val in self.account_types.items(): + if val.accounts: + + filter_criterion = [] + if self.filters.voucher_no: + filter_criterion.append((gle.voucher_no == self.filters.voucher_no)) + + if self.filters.period_start_date: + filter_criterion.append(gle.posting_date.gte(self.filters.period_start_date)) + + if self.filters.period_end_date: + filter_criterion.append(gle.posting_date.lte(self.filters.period_end_date)) + + if acc_type == "receivable": + outstanding = (Sum(gle.debit) - Sum(gle.credit)).as_("outstanding") + else: + outstanding = (Sum(gle.credit) - Sum(gle.debit)).as_("outstanding") + + self.account_types[acc_type].gle = ( + qb.from_(gle) + .select( + gle.company, + gle.account, + gle.voucher_no, + gle.party, + outstanding, + ) + .where( + (gle.company == self.filters.company) + & (gle.is_cancelled == 0) + & (gle.account.isin(val.accounts)) + ) + .where(Criterion.all(filter_criterion)) + .groupby(gle.company, gle.account, gle.voucher_no, gle.party) + .run() + ) + + def get_ple(self): + ple = qb.DocType("Payment Ledger Entry") + + for acc_type, val in self.account_types.items(): + if val.accounts: + + filter_criterion = [] + if self.filters.voucher_no: + filter_criterion.append((ple.voucher_no == self.filters.voucher_no)) + + if self.filters.period_start_date: + filter_criterion.append(ple.posting_date.gte(self.filters.period_start_date)) + + if self.filters.period_end_date: + filter_criterion.append(ple.posting_date.lte(self.filters.period_end_date)) + + self.account_types[acc_type].ple = ( + qb.from_(ple) + .select( + ple.company, ple.account, ple.voucher_no, ple.party, Sum(ple.amount).as_("outstanding") + ) + .where( + (ple.company == self.filters.company) + & (ple.delinked == 0) + & (ple.account.isin(val.accounts)) + ) + .where(Criterion.all(filter_criterion)) + .groupby(ple.company, ple.account, ple.voucher_no, ple.party) + .run() + ) + + def compare(self): + self.gle_balances = set() + self.ple_balances = set() + + # consolidate both receivable and payable balances in one set + for acc_type, val in self.account_types.items(): + self.gle_balances = set(val.gle) | self.gle_balances + self.ple_balances = set(val.ple) | self.ple_balances + + self.diff1 = self.gle_balances.difference(self.ple_balances) + self.diff2 = self.ple_balances.difference(self.gle_balances) + self.diff = frappe._dict({}) + + for x in self.diff1: + self.diff[(x[0], x[1], x[2], x[3])] = frappe._dict({"gl_balance": x[4]}) + + for x in self.diff2: + self.diff[(x[0], x[1], x[2], x[3])].update(frappe._dict({"pl_balance": x[4]})) + + def generate_data(self): + self.data = [] + for key, val in self.diff.items(): + self.data.append( + frappe._dict( + { + "voucher_no": key[2], + "party": key[3], + "gl_balance": val.gl_balance, + "pl_balance": val.pl_balance, + } + ) + ) + + def get_columns(self): + self.columns = [] + options = None + self.columns.append( + dict( + label=_("Voucher No"), + fieldname="voucher_no", + fieldtype="Data", + options=options, + width="100", + ) + ) + + self.columns.append( + dict( + label=_("Party"), + fieldname="party", + fieldtype="Data", + options=options, + width="100", + ) + ) + + self.columns.append( + dict( + label=_("GL Balance"), + fieldname="gl_balance", + fieldtype="Currency", + options="Company:company:default_currency", + width="100", + ) + ) + + self.columns.append( + dict( + label=_("Payment Ledger Balance"), + fieldname="pl_balance", + fieldtype="Currency", + options="Company:company:default_currency", + width="100", + ) + ) + + def run(self): + self.get_accounts() + self.generate_filters() + self.get_gle() + self.get_ple() + self.compare() + self.generate_data() + self.get_columns() + + return self.columns, self.data + + +def execute(filters=None): + columns, data = [], [] + + rpt = General_Payment_Ledger_Comparison(filters) + columns, data = rpt.run() + + return columns, data diff --git a/erpnext/accounts/report/general_and_payment_ledger_comparison/test_general_and_payment_ledger_comparison.py b/erpnext/accounts/report/general_and_payment_ledger_comparison/test_general_and_payment_ledger_comparison.py new file mode 100644 index 00000000000..4b0e99d7125 --- /dev/null +++ b/erpnext/accounts/report/general_and_payment_ledger_comparison/test_general_and_payment_ledger_comparison.py @@ -0,0 +1,100 @@ +import unittest + +import frappe +from frappe import qb +from frappe.tests.utils import FrappeTestCase +from frappe.utils import add_days + +from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice +from erpnext.accounts.report.general_and_payment_ledger_comparison.general_and_payment_ledger_comparison import ( + execute, +) +from erpnext.accounts.test.accounts_mixin import AccountsTestMixin + + +class TestGeneralAndPaymentLedger(FrappeTestCase, AccountsTestMixin): + def setUp(self): + self.create_company() + self.cleanup() + + def tearDown(self): + frappe.db.rollback() + + def cleanup(self): + doctypes = [] + doctypes.append(qb.DocType("GL Entry")) + doctypes.append(qb.DocType("Payment Ledger Entry")) + doctypes.append(qb.DocType("Sales Invoice")) + + for doctype in doctypes: + qb.from_(doctype).delete().where(doctype.company == self.company).run() + + def test_01_basic_report_functionality(self): + sinv = create_sales_invoice( + company=self.company, + debit_to=self.debit_to, + expense_account=self.expense_account, + cost_center=self.cost_center, + income_account=self.income_account, + warehouse=self.warehouse, + ) + + # manually edit the payment ledger entry + ple = frappe.db.get_all( + "Payment Ledger Entry", filters={"voucher_no": sinv.name, "delinked": 0} + )[0] + frappe.db.set_value("Payment Ledger Entry", ple.name, "amount", sinv.grand_total - 1) + + filters = frappe._dict({"company": self.company}) + columns, data = execute(filters=filters) + self.assertEqual(len(data), 1) + + expected = { + "voucher_no": sinv.name, + "party": sinv.customer, + "gl_balance": sinv.grand_total, + "pl_balance": sinv.grand_total - 1, + } + self.assertEqual(expected, data[0]) + + # account filter + filters = frappe._dict({"company": self.company, "account": self.debit_to}) + columns, data = execute(filters=filters) + self.assertEqual(len(data), 1) + self.assertEqual(expected, data[0]) + + filters = frappe._dict({"company": self.company, "account": self.creditors}) + columns, data = execute(filters=filters) + self.assertEqual([], data) + + # voucher_no filter + filters = frappe._dict({"company": self.company, "voucher_no": sinv.name}) + columns, data = execute(filters=filters) + self.assertEqual(len(data), 1) + self.assertEqual(expected, data[0]) + + filters = frappe._dict({"company": self.company, "voucher_no": sinv.name + "-1"}) + columns, data = execute(filters=filters) + self.assertEqual([], data) + + # date range filter + filters = frappe._dict( + { + "company": self.company, + "period_start_date": sinv.posting_date, + "period_end_date": sinv.posting_date, + } + ) + columns, data = execute(filters=filters) + self.assertEqual(len(data), 1) + self.assertEqual(expected, data[0]) + + filters = frappe._dict( + { + "company": self.company, + "period_start_date": add_days(sinv.posting_date, -1), + "period_end_date": add_days(sinv.posting_date, -1), + } + ) + columns, data = execute(filters=filters) + self.assertEqual([], data) From 21080afd92d42c12d302f8f1499c0697e571790e Mon Sep 17 00:00:00 2001 From: Raffael Meyer <14891507+barredterra@users.noreply.github.com> Date: Mon, 7 Aug 2023 13:42:31 +0200 Subject: [PATCH 75/84] feat(RFQ): make email message fully configurable (#36353) feat: make RFQ message fully configurable --- .../request_for_quotation.js | 16 ++++---- .../request_for_quotation.json | 30 +++++---------- .../request_for_quotation.py | 37 ++++++++----------- .../emails/request_for_quotation.html | 29 --------------- 4 files changed, 33 insertions(+), 79 deletions(-) delete mode 100644 erpnext/templates/emails/request_for_quotation.html diff --git a/erpnext/buying/doctype/request_for_quotation/request_for_quotation.js b/erpnext/buying/doctype/request_for_quotation/request_for_quotation.js index 0cdb915cdce..31a06cf95e3 100644 --- a/erpnext/buying/doctype/request_for_quotation/request_for_quotation.js +++ b/erpnext/buying/doctype/request_for_quotation/request_for_quotation.js @@ -244,19 +244,21 @@ frappe.ui.form.on("Request for Quotation",{ ] }); - dialog.fields_dict['supplier'].df.onchange = () => { - var supplier = dialog.get_value('supplier'); - frm.call('get_supplier_email_preview', {supplier: supplier}).then(result => { + dialog.fields_dict["supplier"].df.onchange = () => { + frm.call("get_supplier_email_preview", { + supplier: dialog.get_value("supplier"), + }).then(({ message }) => { dialog.fields_dict.email_preview.$wrapper.empty(); - dialog.fields_dict.email_preview.$wrapper.append(result.message); + dialog.fields_dict.email_preview.$wrapper.append( + message.message + ); + dialog.set_value("subject", message.subject); }); - - } + }; dialog.fields_dict.note.$wrapper.append(`

This is a preview of the email to be sent. A PDF of the document will automatically be attached with the email.

`); - dialog.set_value("subject", frm.doc.subject); dialog.show(); } }) diff --git a/erpnext/buying/doctype/request_for_quotation/request_for_quotation.json b/erpnext/buying/doctype/request_for_quotation/request_for_quotation.json index bd65b0c805e..394a5c75141 100644 --- a/erpnext/buying/doctype/request_for_quotation/request_for_quotation.json +++ b/erpnext/buying/doctype/request_for_quotation/request_for_quotation.json @@ -20,11 +20,10 @@ "items_section", "items", "supplier_response_section", - "salutation", - "subject", - "col_break_email_1", "email_template", "preview", + "col_break_email_1", + "html_llwp", "sec_break_email_2", "message_for_supplier", "terms_section_break", @@ -236,23 +235,6 @@ "print_hide": 1, "read_only": 1 }, - { - "fetch_from": "email_template.subject", - "fetch_if_empty": 1, - "fieldname": "subject", - "fieldtype": "Data", - "label": "Subject", - "print_hide": 1 - }, - { - "description": "Select a greeting for the receiver. E.g. Mr., Ms., etc.", - "fieldname": "salutation", - "fieldtype": "Link", - "label": "Salutation", - "no_copy": 1, - "options": "Salutation", - "print_hide": 1 - }, { "fieldname": "col_break_email_1", "fieldtype": "Column Break" @@ -285,13 +267,19 @@ "fieldname": "named_place", "fieldtype": "Data", "label": "Named Place" + }, + { + "fieldname": "html_llwp", + "fieldtype": "HTML", + "options": "

In your Email Template, you can use the following special variables:\n

\n
    \n
  • \n {{ update_password_link }}: A link where your supplier can set a new password to log into your portal.\n
  • \n
  • \n {{ portal_link }}: A link to this RFQ in your supplier portal.\n
  • \n
  • \n {{ supplier_name }}: The company name of your supplier.\n
  • \n
  • \n {{ contact.salutation }} {{ contact.last_name }}: The contact person of your supplier.\n
  • \n {{ user_fullname }}: Your full name.\n
  • \n
\n

\n

Apart from these, you can access all values in this RFQ, like {{ message_for_supplier }} or {{ terms }}.

", + "read_only": 1 } ], "icon": "fa fa-shopping-cart", "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2023-01-31 23:22:06.684694", + "modified": "2023-07-27 14:01:14.534594", "modified_by": "Administrator", "module": "Buying", "name": "Request for Quotation", diff --git a/erpnext/buying/doctype/request_for_quotation/request_for_quotation.py b/erpnext/buying/doctype/request_for_quotation/request_for_quotation.py index 4590f8c3d93..1c17233ab7c 100644 --- a/erpnext/buying/doctype/request_for_quotation/request_for_quotation.py +++ b/erpnext/buying/doctype/request_for_quotation/request_for_quotation.py @@ -179,35 +179,28 @@ class RequestforQuotation(BuyingController): if full_name == "Guest": full_name = "Administrator" - # send document dict and some important data from suppliers row - # to render message_for_supplier from any template doc_args = self.as_dict() - doc_args.update({"supplier": data.get("supplier"), "supplier_name": data.get("supplier_name")}) - # Get Contact Full Name - supplier_name = None if data.get("contact"): - contact_name = frappe.db.get_value( - "Contact", data.get("contact"), ["first_name", "middle_name", "last_name"] - ) - supplier_name = (" ").join(x for x in contact_name if x) # remove any blank values + contact = frappe.get_doc("Contact", data.get("contact")) + doc_args["contact"] = contact.as_dict() - args = { - "update_password_link": update_password_link, - "message": frappe.render_template(self.message_for_supplier, doc_args), - "rfq_link": rfq_link, - "user_fullname": full_name, - "supplier_name": supplier_name or data.get("supplier_name"), - "supplier_salutation": self.salutation or "Dear Mx.", - } - - subject = self.subject or _("Request for Quotation") - template = "templates/emails/request_for_quotation.html" + doc_args.update( + { + "supplier": data.get("supplier"), + "supplier_name": data.get("supplier_name"), + "update_password_link": f'{_("Set Password")}', + "portal_link": f' {_("Submit your Quotation")} ', + "user_fullname": full_name, + } + ) + email_template = frappe.get_doc("Email Template", self.email_template) + message = frappe.render_template(email_template.response_, doc_args) + subject = frappe.render_template(email_template.subject, doc_args) sender = frappe.session.user not in STANDARD_USERS and frappe.session.user or None - message = frappe.get_template(template).render(args) if preview: - return message + return {"message": message, "subject": subject} attachments = self.get_attachments() diff --git a/erpnext/templates/emails/request_for_quotation.html b/erpnext/templates/emails/request_for_quotation.html deleted file mode 100644 index 5b073e604ff..00000000000 --- a/erpnext/templates/emails/request_for_quotation.html +++ /dev/null @@ -1,29 +0,0 @@ -

{{_("Request for Quotation")}}

-

{{ supplier_salutation if supplier_salutation else ''}} {{ supplier_name }},

-

{{ message }}

-

{{_("The Request for Quotation can be accessed by clicking on the following button")}}:

-
- - {{ _("Submit your Quotation") }} - -
-
-{% if update_password_link %} -
-

{{_("Please click on the following button to set your new password")}}:

- - {{_("Set Password") }} - -
-
-{% endif %} -

- {{_("Regards")}},
- {{ user_fullname }} -

From 28dfc88789e71edac74556e01e8d49d46e42a35f Mon Sep 17 00:00:00 2001 From: rohitwaghchaure Date: Mon, 7 Aug 2023 17:31:38 +0530 Subject: [PATCH 76/84] fix: stock entry decimal issue (#36530) --- erpnext/stock/doctype/material_request/material_request.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/erpnext/stock/doctype/material_request/material_request.py b/erpnext/stock/doctype/material_request/material_request.py index 00b1b20f3f8..1139c4b83a5 100644 --- a/erpnext/stock/doctype/material_request/material_request.py +++ b/erpnext/stock/doctype/material_request/material_request.py @@ -225,7 +225,8 @@ class MaterialRequest(BuyingController): d.ordered_qty = flt(mr_items_ordered_qty.get(d.name)) if mr_qty_allowance: - allowed_qty = d.qty + (d.qty * (mr_qty_allowance / 100)) + allowed_qty = flt((d.qty + (d.qty * (mr_qty_allowance / 100))), d.precision("ordered_qty")) + if d.ordered_qty and d.ordered_qty > allowed_qty: frappe.throw( _( From 8cc3df7c2c77d55ee6cea85b67b045f8c35c9668 Mon Sep 17 00:00:00 2001 From: Raffael Meyer <14891507+barredterra@users.noreply.github.com> Date: Mon, 7 Aug 2023 15:41:01 +0200 Subject: [PATCH 77/84] feat(RFQ): make sending attachments configurable (#36359) --- .../request_for_quotation/request_for_quotation.json | 10 +++++++++- .../request_for_quotation/request_for_quotation.py | 4 +++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/erpnext/buying/doctype/request_for_quotation/request_for_quotation.json b/erpnext/buying/doctype/request_for_quotation/request_for_quotation.json index 394a5c75141..158334f9632 100644 --- a/erpnext/buying/doctype/request_for_quotation/request_for_quotation.json +++ b/erpnext/buying/doctype/request_for_quotation/request_for_quotation.json @@ -24,6 +24,7 @@ "preview", "col_break_email_1", "html_llwp", + "send_attached_files", "sec_break_email_2", "message_for_supplier", "terms_section_break", @@ -273,13 +274,20 @@ "fieldtype": "HTML", "options": "

In your Email Template, you can use the following special variables:\n

\n
    \n
  • \n {{ update_password_link }}: A link where your supplier can set a new password to log into your portal.\n
  • \n
  • \n {{ portal_link }}: A link to this RFQ in your supplier portal.\n
  • \n
  • \n {{ supplier_name }}: The company name of your supplier.\n
  • \n
  • \n {{ contact.salutation }} {{ contact.last_name }}: The contact person of your supplier.\n
  • \n {{ user_fullname }}: Your full name.\n
  • \n
\n

\n

Apart from these, you can access all values in this RFQ, like {{ message_for_supplier }} or {{ terms }}.

", "read_only": 1 + }, + { + "default": "1", + "description": "If enabled, all files attached to this document will be attached to each email", + "fieldname": "send_attached_files", + "fieldtype": "Check", + "label": "Send Attached Files" } ], "icon": "fa fa-shopping-cart", "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2023-07-27 14:01:14.534594", + "modified": "2023-07-27 16:41:48.468873", "modified_by": "Administrator", "module": "Buying", "name": "Request for Quotation", diff --git a/erpnext/buying/doctype/request_for_quotation/request_for_quotation.py b/erpnext/buying/doctype/request_for_quotation/request_for_quotation.py index 1c17233ab7c..cae3c719361 100644 --- a/erpnext/buying/doctype/request_for_quotation/request_for_quotation.py +++ b/erpnext/buying/doctype/request_for_quotation/request_for_quotation.py @@ -202,7 +202,9 @@ class RequestforQuotation(BuyingController): if preview: return {"message": message, "subject": subject} - attachments = self.get_attachments() + attachments = None + if self.send_attached_files: + attachments = self.get_attachments() self.send_email(data, sender, subject, message, attachments) From ecba6ee1833343d57de15ca546da87e140ab8a55 Mon Sep 17 00:00:00 2001 From: s-aga-r Date: Mon, 7 Aug 2023 19:36:19 +0530 Subject: [PATCH 78/84] fix: enqueue submit/cancel action for stock entry having more than 50 line items (#36532) --- erpnext/stock/doctype/stock_entry/stock_entry.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index 0059a3f4327..a2cae7ff8d9 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -187,7 +187,7 @@ class StockEntry(StockController): return False # If line items are more than 100 or record is older than 6 months - if len(self.items) > 100 or month_diff(nowdate(), self.posting_date) > 6: + if len(self.items) > 50 or month_diff(nowdate(), self.posting_date) > 6: return True return False From 492ea3bcc820fe14dfde3a0026397d3b5e9e4179 Mon Sep 17 00:00:00 2001 From: Deepesh Garg Date: Tue, 8 Aug 2023 11:39:44 +0530 Subject: [PATCH 79/84] fix: Debit credit difference while submitting Sales Invoice (#36523) * fix: Debit credit difference while submitting Sales Invoice * test(fix): Update gl entry comparison * test(fix): Update gl entry comparison --- .../purchase_invoice/purchase_invoice.py | 24 -------------- .../doctype/sales_invoice/sales_invoice.py | 1 + .../sales_invoice/test_sales_invoice.py | 32 +++++++++---------- erpnext/controllers/accounts_controller.py | 28 ++++++++++++++++ 4 files changed, 45 insertions(+), 40 deletions(-) diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py index d175df579ee..7329cecf8e6 100644 --- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py +++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py @@ -969,30 +969,6 @@ class PurchaseInvoice(BuyingController): item.item_tax_amount, item.precision("item_tax_amount") ) - def make_precision_loss_gl_entry(self, gl_entries): - round_off_account, round_off_cost_center = get_round_off_account_and_cost_center( - self.company, "Purchase Invoice", self.name, self.use_company_roundoff_cost_center - ) - - precision_loss = self.get("base_net_total") - flt( - self.get("net_total") * self.conversion_rate, self.precision("net_total") - ) - - if precision_loss: - gl_entries.append( - self.get_gl_dict( - { - "account": round_off_account, - "against": self.supplier, - "credit": precision_loss, - "cost_center": round_off_cost_center - if self.use_company_roundoff_cost_center - else self.cost_center or round_off_cost_center, - "remarks": _("Net total calculation precision loss"), - } - ) - ) - def get_asset_gl_entry(self, gl_entries): arbnb_account = self.get_company_default("asset_received_but_not_billed") eiiav_account = self.get_company_default("expenses_included_in_asset_valuation") diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py index f08bf18cfc9..e331c0b1bd2 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py @@ -1061,6 +1061,7 @@ class SalesInvoice(SellingController): self.make_internal_transfer_gl_entries(gl_entries) self.make_item_gl_entries(gl_entries) + self.make_precision_loss_gl_entry(gl_entries) self.make_discount_gl_entries(gl_entries) # merge gl entries before adding pos entries diff --git a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py index 8816a8c38c6..63c0c453045 100644 --- a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py @@ -2049,28 +2049,27 @@ class TestSalesInvoice(unittest.TestCase): self.assertEqual(si.total_taxes_and_charges, 228.82) self.assertEqual(si.rounding_adjustment, -0.01) - expected_values = dict( - (d[0], d) - for d in [ - [si.debit_to, 1500, 0.0], - ["_Test Account Service Tax - _TC", 0.0, 114.41], - ["_Test Account VAT - _TC", 0.0, 114.41], - ["Sales - _TC", 0.0, 1271.18], - ] - ) + expected_values = [ + ["_Test Account Service Tax - _TC", 0.0, 114.41], + ["_Test Account VAT - _TC", 0.0, 114.41], + [si.debit_to, 1500, 0.0], + ["Round Off - _TC", 0.01, 0.01], + ["Sales - _TC", 0.0, 1271.18], + ] gl_entries = frappe.db.sql( - """select account, debit, credit + """select account, sum(debit) as debit, sum(credit) as credit from `tabGL Entry` where voucher_type='Sales Invoice' and voucher_no=%s + group by account order by account asc""", si.name, as_dict=1, ) - for gle in gl_entries: - self.assertEqual(expected_values[gle.account][0], gle.account) - self.assertEqual(expected_values[gle.account][1], gle.debit) - self.assertEqual(expected_values[gle.account][2], gle.credit) + for i, gle in enumerate(gl_entries): + self.assertEqual(expected_values[i][0], gle.account) + self.assertEqual(expected_values[i][1], gle.debit) + self.assertEqual(expected_values[i][2], gle.credit) def test_rounding_adjustment_3(self): from erpnext.accounts.doctype.accounting_dimension.test_accounting_dimension import ( @@ -2125,13 +2124,14 @@ class TestSalesInvoice(unittest.TestCase): ["_Test Account Service Tax - _TC", 0.0, 240.43], ["_Test Account VAT - _TC", 0.0, 240.43], ["Sales - _TC", 0.0, 4007.15], - ["Round Off - _TC", 0.01, 0], + ["Round Off - _TC", 0.02, 0.01], ] ) gl_entries = frappe.db.sql( - """select account, debit, credit + """select account, sum(debit) as debit, sum(credit) as credit from `tabGL Entry` where voucher_type='Sales Invoice' and voucher_no=%s + group by account order by account asc""", si.name, as_dict=1, diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index b2cfc39be9e..fbf97aab594 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -32,6 +32,7 @@ from erpnext.accounts.doctype.pricing_rule.utils import ( apply_pricing_rule_on_transaction, get_applied_pricing_rules, ) +from erpnext.accounts.general_ledger import get_round_off_account_and_cost_center from erpnext.accounts.party import ( get_party_account, get_party_account_currency, @@ -973,6 +974,33 @@ class AccountsController(TransactionBase): d.exchange_gain_loss = difference + def make_precision_loss_gl_entry(self, gl_entries): + round_off_account, round_off_cost_center = get_round_off_account_and_cost_center( + self.company, "Purchase Invoice", self.name, self.use_company_roundoff_cost_center + ) + + precision_loss = self.get("base_net_total") - flt( + self.get("net_total") * self.conversion_rate, self.precision("net_total") + ) + + credit_or_debit = "credit" if self.doctype == "Purchase Invoice" else "debit" + against = self.supplier if self.doctype == "Purchase Invoice" else self.customer + + if precision_loss: + gl_entries.append( + self.get_gl_dict( + { + "account": round_off_account, + "against": against, + credit_or_debit: precision_loss, + "cost_center": round_off_cost_center + if self.use_company_roundoff_cost_center + else self.cost_center or round_off_cost_center, + "remarks": _("Net total calculation precision loss"), + } + ) + ) + def make_exchange_gain_loss_journal(self, args: dict = None) -> None: """ Make Exchange Gain/Loss journal for Invoices and Payments From 11d5327d1b5fc1b9198bad77365d5ca269593e76 Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Tue, 8 Aug 2023 14:14:30 +0530 Subject: [PATCH 80/84] refactor: use base_tax_withholding_net_total for treshold validation (#36528) * refactor: use base_tax_withholding_net_total for treshold validation * fix: only for non payment entry doctypes --- .../tax_withholding_category/tax_withholding_category.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/erpnext/accounts/doctype/tax_withholding_category/tax_withholding_category.py b/erpnext/accounts/doctype/tax_withholding_category/tax_withholding_category.py index 58792d1d8ad..e66a886bf9a 100644 --- a/erpnext/accounts/doctype/tax_withholding_category/tax_withholding_category.py +++ b/erpnext/accounts/doctype/tax_withholding_category/tax_withholding_category.py @@ -476,7 +476,12 @@ def get_tds_amount(ldc, parties, inv, tax_details, tax_deducted, vouchers): threshold = tax_details.get("threshold", 0) cumulative_threshold = tax_details.get("cumulative_threshold", 0) - if (threshold and inv.tax_withholding_net_total >= threshold) or ( + if inv.doctype != "Payment Entry": + tax_withholding_net_total = inv.base_tax_withholding_net_total + else: + tax_withholding_net_total = inv.tax_withholding_net_total + + if (threshold and tax_withholding_net_total >= threshold) or ( cumulative_threshold and supp_credit_amt >= cumulative_threshold ): if (cumulative_threshold and supp_credit_amt >= cumulative_threshold) and cint( From 0b36e7d10ec42a4baac2af8f41a2817ed06b711d Mon Sep 17 00:00:00 2001 From: rohitwaghchaure Date: Tue, 8 Aug 2023 15:18:27 +0530 Subject: [PATCH 81/84] fix: stock reconciliation negative stock error (#36544) fix: stock reco negative stock error --- .../stock/doctype/stock_reconciliation/stock_reconciliation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py index 6ea27edc45b..f009bd42e4e 100644 --- a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py +++ b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py @@ -696,7 +696,7 @@ class StockReconciliation(StockController): ) if sl_entries: - self.make_sl_entries(sl_entries) + self.make_sl_entries(sl_entries, allow_negative_stock=True) def recalculate_qty_for_serial_and_batch_bundle(self, row): doc = frappe.get_doc("Serial and Batch Bundle", row.current_serial_and_batch_bundle) From fd91f2c2e02c3eaa4365baca83c299ee8c6c02eb Mon Sep 17 00:00:00 2001 From: barredterra <14891507+barredterra@users.noreply.github.com> Date: Tue, 8 Aug 2023 12:31:01 +0200 Subject: [PATCH 82/84] fix(RFQ): link to supplier portal --- .../doctype/request_for_quotation/request_for_quotation.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/erpnext/buying/doctype/request_for_quotation/request_for_quotation.py b/erpnext/buying/doctype/request_for_quotation/request_for_quotation.py index cae3c719361..e9385777e5d 100644 --- a/erpnext/buying/doctype/request_for_quotation/request_for_quotation.py +++ b/erpnext/buying/doctype/request_for_quotation/request_for_quotation.py @@ -116,7 +116,10 @@ class RequestforQuotation(BuyingController): route = frappe.db.get_value( "Portal Menu Item", {"reference_doctype": "Request for Quotation"}, ["route"] ) - return get_url("/app/{0}/".format(route) + self.name) + if not route: + frappe.throw(_("Please add Request for Quotation to the sidebar in Portal Settings.")) + + return get_url(f"{route}/{self.name}") def update_supplier_part_no(self, supplier): self.vendor = supplier From 68ad62f7d082d9c50e558d6834f623dc70516a1e Mon Sep 17 00:00:00 2001 From: barredterra <14891507+barredterra@users.noreply.github.com> Date: Tue, 8 Aug 2023 12:31:37 +0200 Subject: [PATCH 83/84] test(RFQ): get_link --- .../test_request_for_quotation.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/erpnext/buying/doctype/request_for_quotation/test_request_for_quotation.py b/erpnext/buying/doctype/request_for_quotation/test_request_for_quotation.py index d250e6f18a9..42fa1d923e1 100644 --- a/erpnext/buying/doctype/request_for_quotation/test_request_for_quotation.py +++ b/erpnext/buying/doctype/request_for_quotation/test_request_for_quotation.py @@ -2,11 +2,14 @@ # See license.txt +from urllib.parse import urlparse + import frappe from frappe.tests.utils import FrappeTestCase from frappe.utils import nowdate from erpnext.buying.doctype.request_for_quotation.request_for_quotation import ( + RequestforQuotation, create_supplier_quotation, get_pdf, make_supplier_quotation_from_rfq, @@ -125,13 +128,18 @@ class TestRequestforQuotation(FrappeTestCase): rfq.status = "Draft" rfq.submit() + def test_get_link(self): + rfq = make_request_for_quotation() + parsed_link = urlparse(rfq.get_link()) + self.assertEqual(parsed_link.path, f"/rfq/{rfq.name}") + def test_get_pdf(self): rfq = make_request_for_quotation() get_pdf(rfq.name, rfq.get("suppliers")[0].supplier) self.assertEqual(frappe.local.response.type, "pdf") -def make_request_for_quotation(**args): +def make_request_for_quotation(**args) -> "RequestforQuotation": """ :param supplier_data: List containing supplier data """ From e64b004eca86255159c799d9b47f36a75fe70ba0 Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Tue, 8 Aug 2023 20:27:12 +0530 Subject: [PATCH 84/84] feat: utility to repost accounting ledgers without cancellation (#36469) * feat: introduce doctypes for repost * refactor: basic filters and validations * chore: basic validations * chore: added barebones function to generate ledger entries * chore: repost on submit * chore: repost in background * chore: include payment entry and journal entry * chore: ignore repost doc on cancel * chore: preview method * chore: rudimentary form of preview * refactor: preview template * refactor: basic background colors to differentiate old and new * chore: remove commented code * test: basic functionality * chore: fix conflict * chore: prevent repost on invoices with deferred accounting * refactor(test): rename and test basic validations and methods * refactor(test): test all validations * fix(test): use proper name account name * refactor(test): fix failing test case * refactor(test): clear old entries * refactor(test): simpler logic to clear old records * refactor(test): make use of deletion flag * refactor(test): split into multiple test cases --- .../doctype/journal_entry/journal_entry.js | 2 +- .../doctype/journal_entry/journal_entry.py | 2 + .../doctype/payment_entry/payment_entry.js | 2 +- .../doctype/payment_entry/payment_entry.py | 2 + .../purchase_invoice/purchase_invoice.js | 2 +- .../purchase_invoice/purchase_invoice.py | 2 + .../repost_accounting_ledger/__init__.py | 0 .../repost_accounting_ledger.html | 44 ++++ .../repost_accounting_ledger.js | 50 +++++ .../repost_accounting_ledger.json | 81 +++++++ .../repost_accounting_ledger.py | 183 ++++++++++++++++ .../test_repost_accounting_ledger.py | 202 ++++++++++++++++++ .../__init__.py | 0 .../repost_accounting_ledger_items.json | 40 ++++ .../repost_accounting_ledger_items.py | 9 + .../doctype/sales_invoice/sales_invoice.js | 2 +- .../doctype/sales_invoice/sales_invoice.py | 2 + erpnext/accounts/test/accounts_mixin.py | 53 +++-- 18 files changed, 660 insertions(+), 18 deletions(-) create mode 100644 erpnext/accounts/doctype/repost_accounting_ledger/__init__.py create mode 100644 erpnext/accounts/doctype/repost_accounting_ledger/repost_accounting_ledger.html create mode 100644 erpnext/accounts/doctype/repost_accounting_ledger/repost_accounting_ledger.js create mode 100644 erpnext/accounts/doctype/repost_accounting_ledger/repost_accounting_ledger.json create mode 100644 erpnext/accounts/doctype/repost_accounting_ledger/repost_accounting_ledger.py create mode 100644 erpnext/accounts/doctype/repost_accounting_ledger/test_repost_accounting_ledger.py create mode 100644 erpnext/accounts/doctype/repost_accounting_ledger_items/__init__.py create mode 100644 erpnext/accounts/doctype/repost_accounting_ledger_items/repost_accounting_ledger_items.json create mode 100644 erpnext/accounts/doctype/repost_accounting_ledger_items/repost_accounting_ledger_items.py diff --git a/erpnext/accounts/doctype/journal_entry/journal_entry.js b/erpnext/accounts/doctype/journal_entry/journal_entry.js index 8d8cbefa71b..35a378856b0 100644 --- a/erpnext/accounts/doctype/journal_entry/journal_entry.js +++ b/erpnext/accounts/doctype/journal_entry/journal_entry.js @@ -8,7 +8,7 @@ frappe.provide("erpnext.journal_entry"); frappe.ui.form.on("Journal Entry", { setup: function(frm) { frm.add_fetch("bank_account", "account", "account"); - frm.ignore_doctypes_on_cancel_all = ['Sales Invoice', 'Purchase Invoice', 'Journal Entry', "Repost Payment Ledger", 'Asset', 'Asset Movement', 'Asset Depreciation Schedule']; + frm.ignore_doctypes_on_cancel_all = ['Sales Invoice', 'Purchase Invoice', 'Journal Entry', "Repost Payment Ledger", 'Asset', 'Asset Movement', 'Asset Depreciation Schedule', "Repost Accounting Ledger"]; }, refresh: function(frm) { diff --git a/erpnext/accounts/doctype/journal_entry/journal_entry.py b/erpnext/accounts/doctype/journal_entry/journal_entry.py index 1e1b3ba642a..22e092c0d04 100644 --- a/erpnext/accounts/doctype/journal_entry/journal_entry.py +++ b/erpnext/accounts/doctype/journal_entry/journal_entry.py @@ -96,6 +96,8 @@ class JournalEntry(AccountsController): "Payment Ledger Entry", "Repost Payment Ledger", "Repost Payment Ledger Items", + "Repost Accounting Ledger", + "Repost Accounting Ledger Items", ) self.make_gl_entries(1) self.update_advance_paid() diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.js b/erpnext/accounts/doctype/payment_entry/payment_entry.js index 33f263433b7..f131be2dfed 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry.js +++ b/erpnext/accounts/doctype/payment_entry/payment_entry.js @@ -9,7 +9,7 @@ erpnext.accounts.taxes.setup_tax_filters("Advance Taxes and Charges"); frappe.ui.form.on('Payment Entry', { onload: function(frm) { - frm.ignore_doctypes_on_cancel_all = ['Sales Invoice', 'Purchase Invoice', "Journal Entry", "Repost Payment Ledger"]; + frm.ignore_doctypes_on_cancel_all = ['Sales Invoice', 'Purchase Invoice', 'Journal Entry', 'Repost Payment Ledger','Repost Accounting Ledger']; if(frm.doc.__islocal) { if (!frm.doc.paid_from) frm.set_value("paid_from_account_currency", null); diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.py b/erpnext/accounts/doctype/payment_entry/payment_entry.py index cc42f9faec1..64b4d167082 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry.py +++ b/erpnext/accounts/doctype/payment_entry/payment_entry.py @@ -147,6 +147,8 @@ class PaymentEntry(AccountsController): "Payment Ledger Entry", "Repost Payment Ledger", "Repost Payment Ledger Items", + "Repost Accounting Ledger", + "Repost Accounting Ledger Items", ) super(PaymentEntry, self).on_cancel() self.make_gl_entries(cancel=1) diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js index 89d62078ccf..66438a7efab 100644 --- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js +++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js @@ -35,7 +35,7 @@ erpnext.accounts.PurchaseInvoice = class PurchaseInvoice extends erpnext.buying. super.onload(); // Ignore linked advances - this.frm.ignore_doctypes_on_cancel_all = ['Journal Entry', 'Payment Entry', 'Purchase Invoice', "Repost Payment Ledger"]; + this.frm.ignore_doctypes_on_cancel_all = ['Journal Entry', 'Payment Entry', 'Purchase Invoice', "Repost Payment Ledger", "Repost Accounting Ledger"]; if(!this.frm.doc.__islocal) { // show credit_to in print format diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py index 7329cecf8e6..f33439989a8 100644 --- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py +++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py @@ -1415,6 +1415,8 @@ class PurchaseInvoice(BuyingController): "Repost Item Valuation", "Repost Payment Ledger", "Repost Payment Ledger Items", + "Repost Accounting Ledger", + "Repost Accounting Ledger Items", "Payment Ledger Entry", "Tax Withheld Vouchers", "Serial and Batch Bundle", diff --git a/erpnext/accounts/doctype/repost_accounting_ledger/__init__.py b/erpnext/accounts/doctype/repost_accounting_ledger/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/accounts/doctype/repost_accounting_ledger/repost_accounting_ledger.html b/erpnext/accounts/doctype/repost_accounting_ledger/repost_accounting_ledger.html new file mode 100644 index 00000000000..2dec8f753f2 --- /dev/null +++ b/erpnext/accounts/doctype/repost_accounting_ledger/repost_accounting_ledger.html @@ -0,0 +1,44 @@ + + + + + + {% for col in gl_columns%} + + {% endfor %} + + + + {% for col in gl_columns%} + + {% endfor %} + + +{% for gl in gl_data%} +{% if gl["old"]%} + +{% else %} + +{% endif %} + {% for col in gl_columns %} + + {% endfor %} + +{% endfor %} +
{{ col.label }}
+ {{ gl[col.fieldname] }} +
diff --git a/erpnext/accounts/doctype/repost_accounting_ledger/repost_accounting_ledger.js b/erpnext/accounts/doctype/repost_accounting_ledger/repost_accounting_ledger.js new file mode 100644 index 00000000000..3a87a380d19 --- /dev/null +++ b/erpnext/accounts/doctype/repost_accounting_ledger/repost_accounting_ledger.js @@ -0,0 +1,50 @@ +// Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +frappe.ui.form.on("Repost Accounting Ledger", { + setup: function(frm) { + frm.fields_dict['vouchers'].grid.get_field('voucher_type').get_query = function(doc) { + return { + filters: { + name: ['in', ['Purchase Invoice', 'Sales Invoice', 'Payment Entry', 'Journal Entry']], + } + } + } + + frm.fields_dict['vouchers'].grid.get_field('voucher_no').get_query = function(doc) { + if (doc.company) { + return { + filters: { + company: doc.company, + docstatus: 1 + } + } + } + } + }, + + refresh: function(frm) { + frm.add_custom_button(__('Show Preview'), () => { + frm.call({ + method: 'generate_preview', + doc: frm.doc, + freeze: true, + freeze_message: __('Generating Preview'), + callback: function(r) { + if (r && r.message) { + let content = r.message; + let opts = { + title: "Preview", + subtitle: "preview", + content: content, + print_settings: {orientation: "landscape"}, + columns: [], + data: [], + } + frappe.render_grid(opts); + } + } + }); + }); + } +}); diff --git a/erpnext/accounts/doctype/repost_accounting_ledger/repost_accounting_ledger.json b/erpnext/accounts/doctype/repost_accounting_ledger/repost_accounting_ledger.json new file mode 100644 index 00000000000..8d56c9bb11d --- /dev/null +++ b/erpnext/accounts/doctype/repost_accounting_ledger/repost_accounting_ledger.json @@ -0,0 +1,81 @@ +{ + "actions": [], + "allow_rename": 1, + "autoname": "format:ACC-REPOST-{#####}", + "creation": "2023-07-04 13:07:32.923675", + "default_view": "List", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "company", + "column_break_vpup", + "delete_cancelled_entries", + "section_break_metl", + "vouchers", + "amended_from" + ], + "fields": [ + { + "fieldname": "company", + "fieldtype": "Link", + "label": "Company", + "options": "Company" + }, + { + "fieldname": "amended_from", + "fieldtype": "Link", + "label": "Amended From", + "no_copy": 1, + "options": "Repost Accounting Ledger", + "print_hide": 1, + "read_only": 1 + }, + { + "fieldname": "vouchers", + "fieldtype": "Table", + "label": "Vouchers", + "options": "Repost Accounting Ledger Items" + }, + { + "fieldname": "column_break_vpup", + "fieldtype": "Column Break" + }, + { + "fieldname": "section_break_metl", + "fieldtype": "Section Break" + }, + { + "default": "0", + "fieldname": "delete_cancelled_entries", + "fieldtype": "Check", + "label": "Delete Cancelled Ledger Entries" + } + ], + "index_web_pages_for_search": 1, + "is_submittable": 1, + "links": [], + "modified": "2023-07-27 15:47:58.975034", + "modified_by": "Administrator", + "module": "Accounts", + "name": "Repost Accounting Ledger", + "naming_rule": "Expression", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC", + "states": [] +} \ No newline at end of file diff --git a/erpnext/accounts/doctype/repost_accounting_ledger/repost_accounting_ledger.py b/erpnext/accounts/doctype/repost_accounting_ledger/repost_accounting_ledger.py new file mode 100644 index 00000000000..4cf2ed2f46c --- /dev/null +++ b/erpnext/accounts/doctype/repost_accounting_ledger/repost_accounting_ledger.py @@ -0,0 +1,183 @@ +# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +import frappe +from frappe import _, qb +from frappe.model.document import Document +from frappe.utils.data import comma_and + + +class RepostAccountingLedger(Document): + def __init__(self, *args, **kwargs): + super(RepostAccountingLedger, self).__init__(*args, **kwargs) + self._allowed_types = set( + ["Purchase Invoice", "Sales Invoice", "Payment Entry", "Journal Entry"] + ) + + def validate(self): + self.validate_vouchers() + self.validate_for_closed_fiscal_year() + self.validate_for_deferred_accounting() + + def validate_for_deferred_accounting(self): + sales_docs = [x.voucher_no for x in self.vouchers if x.voucher_type == "Sales Invoice"] + docs_with_deferred_revenue = frappe.db.get_all( + "Sales Invoice Item", + filters={"parent": ["in", sales_docs], "docstatus": 1, "enable_deferred_revenue": True}, + fields=["parent"], + as_list=1, + ) + + purchase_docs = [x.voucher_no for x in self.vouchers if x.voucher_type == "Purchase Invoice"] + docs_with_deferred_expense = frappe.db.get_all( + "Purchase Invoice Item", + filters={"parent": ["in", purchase_docs], "docstatus": 1, "enable_deferred_expense": 1}, + fields=["parent"], + as_list=1, + ) + + if docs_with_deferred_revenue or docs_with_deferred_expense: + frappe.throw( + _("Documents: {0} have deferred revenue/expense enabled for them. Cannot repost.").format( + frappe.bold( + comma_and([x[0] for x in docs_with_deferred_expense + docs_with_deferred_revenue]) + ) + ) + ) + + def validate_for_closed_fiscal_year(self): + if self.vouchers: + latest_pcv = ( + frappe.db.get_all( + "Period Closing Voucher", + filters={"company": self.company}, + order_by="posting_date desc", + pluck="posting_date", + limit=1, + ) + or None + ) + if not latest_pcv: + return + + for vtype in self._allowed_types: + if names := [x.voucher_no for x in self.vouchers if x.voucher_type == vtype]: + latest_voucher = frappe.db.get_all( + vtype, + filters={"name": ["in", names]}, + pluck="posting_date", + order_by="posting_date desc", + limit=1, + )[0] + if latest_voucher and latest_pcv[0] >= latest_voucher: + frappe.throw(_("Cannot Resubmit Ledger entries for vouchers in Closed fiscal year.")) + + def validate_vouchers(self): + if self.vouchers: + # Validate voucher types + voucher_types = set([x.voucher_type for x in self.vouchers]) + if disallowed_types := voucher_types.difference(self._allowed_types): + frappe.throw( + _("{0} types are not allowed. Only {1} are.").format( + frappe.bold(comma_and(list(disallowed_types))), + frappe.bold(comma_and(list(self._allowed_types))), + ) + ) + + def get_existing_ledger_entries(self): + vouchers = [x.voucher_no for x in self.vouchers] + gl = qb.DocType("GL Entry") + existing_gles = ( + qb.from_(gl) + .select(gl.star) + .where((gl.voucher_no.isin(vouchers)) & (gl.is_cancelled == 0)) + .run(as_dict=True) + ) + self.gles = frappe._dict({}) + + for gle in existing_gles: + self.gles.setdefault((gle.voucher_type, gle.voucher_no), frappe._dict({})).setdefault( + "existing", [] + ).append(gle.update({"old": True})) + + def generate_preview_data(self): + self.gl_entries = [] + self.get_existing_ledger_entries() + for x in self.vouchers: + doc = frappe.get_doc(x.voucher_type, x.voucher_no) + if doc.doctype in ["Payment Entry", "Journal Entry"]: + gle_map = doc.build_gl_map() + else: + gle_map = doc.get_gl_entries() + + old_entries = self.gles.get((x.voucher_type, x.voucher_no)) + if old_entries: + self.gl_entries.extend(old_entries.existing) + self.gl_entries.extend(gle_map) + + @frappe.whitelist() + def generate_preview(self): + from erpnext.accounts.report.general_ledger.general_ledger import get_columns as get_gl_columns + + gl_columns = [] + gl_data = [] + + self.generate_preview_data() + if self.gl_entries: + filters = {"company": self.company, "include_dimensions": 1} + for x in get_gl_columns(filters): + if x["fieldname"] == "gl_entry": + x["fieldname"] = "name" + gl_columns.append(x) + + gl_data = self.gl_entries + rendered_page = frappe.render_template( + "erpnext/accounts/doctype/repost_accounting_ledger/repost_accounting_ledger.html", + {"gl_columns": gl_columns, "gl_data": gl_data}, + ) + + return rendered_page + + def on_submit(self): + job_name = "repost_accounting_ledger_" + self.name + frappe.enqueue( + method="erpnext.accounts.doctype.repost_accounting_ledger.repost_accounting_ledger.start_repost", + account_repost_doc=self.name, + is_async=True, + job_name=job_name, + ) + frappe.msgprint(_("Repost has started in the background")) + + +@frappe.whitelist() +def start_repost(account_repost_doc=str) -> None: + if account_repost_doc: + repost_doc = frappe.get_doc("Repost Accounting Ledger", account_repost_doc) + + if repost_doc.docstatus == 1: + # Prevent repost on invoices with deferred accounting + repost_doc.validate_for_deferred_accounting() + + for x in repost_doc.vouchers: + doc = frappe.get_doc(x.voucher_type, x.voucher_no) + + if repost_doc.delete_cancelled_entries: + frappe.db.delete("GL Entry", filters={"voucher_type": doc.doctype, "voucher_no": doc.name}) + frappe.db.delete( + "Payment Ledger Entry", filters={"voucher_type": doc.doctype, "voucher_no": doc.name} + ) + + if doc.doctype in ["Sales Invoice", "Purchase Invoice"]: + if not repost_doc.delete_cancelled_entries: + doc.docstatus = 2 + doc.make_gl_entries_on_cancel() + + doc.docstatus = 1 + doc.make_gl_entries() + + elif doc.doctype in ["Payment Entry", "Journal Entry"]: + if not repost_doc.delete_cancelled_entries: + doc.make_gl_entries(1) + doc.make_gl_entries() + + frappe.db.commit() diff --git a/erpnext/accounts/doctype/repost_accounting_ledger/test_repost_accounting_ledger.py b/erpnext/accounts/doctype/repost_accounting_ledger/test_repost_accounting_ledger.py new file mode 100644 index 00000000000..0e75dd2e3e1 --- /dev/null +++ b/erpnext/accounts/doctype/repost_accounting_ledger/test_repost_accounting_ledger.py @@ -0,0 +1,202 @@ +# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt + +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, nowdate, today + +from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry +from erpnext.accounts.doctype.payment_request.payment_request import make_payment_request +from erpnext.accounts.doctype.repost_accounting_ledger.repost_accounting_ledger import start_repost +from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice +from erpnext.accounts.test.accounts_mixin import AccountsTestMixin +from erpnext.accounts.utils import get_fiscal_year + + +class TestRepostAccountingLedger(AccountsTestMixin, FrappeTestCase): + def setUp(self): + self.create_company() + self.create_customer() + self.create_item() + + def teadDown(self): + frappe.db.rollback() + + def test_01_basic_functions(self): + si = create_sales_invoice( + item=self.item, + company=self.company, + customer=self.customer, + debit_to=self.debit_to, + parent_cost_center=self.cost_center, + cost_center=self.cost_center, + rate=100, + ) + + preq = frappe.get_doc( + make_payment_request( + dt=si.doctype, + dn=si.name, + payment_request_type="Inward", + party_type="Customer", + party=si.customer, + ) + ) + preq.save().submit() + + # Test Validation Error + ral = frappe.new_doc("Repost Accounting Ledger") + ral.company = self.company + ral.delete_cancelled_entries = True + ral.append("vouchers", {"voucher_type": si.doctype, "voucher_no": si.name}) + ral.append( + "vouchers", {"voucher_type": preq.doctype, "voucher_no": preq.name} + ) # this should throw validation error + self.assertRaises(frappe.ValidationError, ral.save) + ral.vouchers.pop() + preq.cancel() + preq.delete() + + pe = get_payment_entry(si.doctype, si.name) + pe.save().submit() + ral.append("vouchers", {"voucher_type": pe.doctype, "voucher_no": pe.name}) + ral.save() + + # manually set an incorrect debit amount in DB + gle = frappe.db.get_all("GL Entry", filters={"voucher_no": si.name, "account": self.debit_to}) + frappe.db.set_value("GL Entry", gle[0], "debit", 90) + + gl = qb.DocType("GL Entry") + res = ( + qb.from_(gl) + .select(gl.voucher_no, Sum(gl.debit).as_("debit"), Sum(gl.credit).as_("credit")) + .where((gl.voucher_no == si.name) & (gl.is_cancelled == 0)) + .run() + ) + + # Assert incorrect ledger balance + self.assertNotEqual(res[0], (si.name, 100, 100)) + + # Submit repost document + ral.save().submit() + + # background jobs don't run on test cases. Manually triggering repost function. + start_repost(ral.name) + + res = ( + qb.from_(gl) + .select(gl.voucher_no, Sum(gl.debit).as_("debit"), Sum(gl.credit).as_("credit")) + .where((gl.voucher_no == si.name) & (gl.is_cancelled == 0)) + .run() + ) + + # Ledger should reflect correct amount post repost + self.assertEqual(res[0], (si.name, 100, 100)) + + def test_02_deferred_accounting_valiations(self): + si = create_sales_invoice( + item=self.item, + company=self.company, + customer=self.customer, + debit_to=self.debit_to, + parent_cost_center=self.cost_center, + cost_center=self.cost_center, + rate=100, + do_not_submit=True, + ) + si.items[0].enable_deferred_revenue = True + si.items[0].deferred_revenue_account = self.deferred_revenue + si.items[0].service_start_date = nowdate() + si.items[0].service_end_date = add_days(nowdate(), 90) + si.save().submit() + + ral = frappe.new_doc("Repost Accounting Ledger") + ral.company = self.company + ral.append("vouchers", {"voucher_type": si.doctype, "voucher_no": si.name}) + self.assertRaises(frappe.ValidationError, ral.save) + + @change_settings("Accounts Settings", {"delete_linked_ledger_entries": 1}) + def test_04_pcv_validation(self): + # Clear old GL entries so PCV can be submitted. + gl = frappe.qb.DocType("GL Entry") + qb.from_(gl).delete().where(gl.company == self.company).run() + + si = create_sales_invoice( + item=self.item, + company=self.company, + customer=self.customer, + debit_to=self.debit_to, + parent_cost_center=self.cost_center, + cost_center=self.cost_center, + rate=100, + ) + pcv = frappe.get_doc( + { + "doctype": "Period Closing Voucher", + "transaction_date": today(), + "posting_date": today(), + "company": self.company, + "fiscal_year": get_fiscal_year(today(), company=self.company)[0], + "cost_center": self.cost_center, + "closing_account_head": self.retained_earnings, + "remarks": "test", + } + ) + pcv.save().submit() + + ral = frappe.new_doc("Repost Accounting Ledger") + ral.company = self.company + ral.append("vouchers", {"voucher_type": si.doctype, "voucher_no": si.name}) + self.assertRaises(frappe.ValidationError, ral.save) + + pcv.reload() + pcv.cancel() + pcv.delete() + + def test_03_deletion_flag_and_preview_function(self): + si = create_sales_invoice( + item=self.item, + company=self.company, + customer=self.customer, + debit_to=self.debit_to, + parent_cost_center=self.cost_center, + cost_center=self.cost_center, + rate=100, + ) + + pe = get_payment_entry(si.doctype, si.name) + pe.save().submit() + + # without deletion flag set + ral = frappe.new_doc("Repost Accounting Ledger") + ral.company = self.company + ral.delete_cancelled_entries = False + ral.append("vouchers", {"voucher_type": si.doctype, "voucher_no": si.name}) + ral.append("vouchers", {"voucher_type": pe.doctype, "voucher_no": pe.name}) + ral.save() + + # assert preview data is generated + preview = ral.generate_preview() + self.assertIsNotNone(preview) + + ral.save().submit() + + # background jobs don't run on test cases. Manually triggering repost function. + start_repost(ral.name) + + self.assertIsNotNone(frappe.db.exists("GL Entry", {"voucher_no": si.name, "is_cancelled": 1})) + self.assertIsNotNone(frappe.db.exists("GL Entry", {"voucher_no": pe.name, "is_cancelled": 1})) + + # with deletion flag set + ral = frappe.new_doc("Repost Accounting Ledger") + ral.company = self.company + ral.delete_cancelled_entries = True + ral.append("vouchers", {"voucher_type": si.doctype, "voucher_no": si.name}) + ral.append("vouchers", {"voucher_type": pe.doctype, "voucher_no": pe.name}) + ral.save().submit() + + start_repost(ral.name) + self.assertIsNone(frappe.db.exists("GL Entry", {"voucher_no": si.name, "is_cancelled": 1})) + self.assertIsNone(frappe.db.exists("GL Entry", {"voucher_no": pe.name, "is_cancelled": 1})) diff --git a/erpnext/accounts/doctype/repost_accounting_ledger_items/__init__.py b/erpnext/accounts/doctype/repost_accounting_ledger_items/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/accounts/doctype/repost_accounting_ledger_items/repost_accounting_ledger_items.json b/erpnext/accounts/doctype/repost_accounting_ledger_items/repost_accounting_ledger_items.json new file mode 100644 index 00000000000..4a2041f88c6 --- /dev/null +++ b/erpnext/accounts/doctype/repost_accounting_ledger_items/repost_accounting_ledger_items.json @@ -0,0 +1,40 @@ +{ + "actions": [], + "allow_rename": 1, + "creation": "2023-07-04 14:14:01.243848", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "voucher_type", + "voucher_no" + ], + "fields": [ + { + "fieldname": "voucher_type", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Voucher Type", + "options": "DocType" + }, + { + "fieldname": "voucher_no", + "fieldtype": "Dynamic Link", + "in_list_view": 1, + "label": "Voucher No", + "options": "voucher_type" + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2023-07-04 14:15:51.165584", + "modified_by": "Administrator", + "module": "Accounts", + "name": "Repost Accounting Ledger Items", + "owner": "Administrator", + "permissions": [], + "sort_field": "modified", + "sort_order": "DESC", + "states": [] +} \ No newline at end of file diff --git a/erpnext/accounts/doctype/repost_accounting_ledger_items/repost_accounting_ledger_items.py b/erpnext/accounts/doctype/repost_accounting_ledger_items/repost_accounting_ledger_items.py new file mode 100644 index 00000000000..9221f447355 --- /dev/null +++ b/erpnext/accounts/doctype/repost_accounting_ledger_items/repost_accounting_ledger_items.py @@ -0,0 +1,9 @@ +# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class RepostAccountingLedgerItems(Document): + pass diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.js b/erpnext/accounts/doctype/sales_invoice/sales_invoice.js index b45bc41e96f..a4bcdb41db5 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.js +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.js @@ -37,7 +37,7 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends e super.onload(); this.frm.ignore_doctypes_on_cancel_all = ['POS Invoice', 'Timesheet', 'POS Invoice Merge Log', - 'POS Closing Entry', 'Journal Entry', 'Payment Entry', "Repost Payment Ledger"]; + 'POS Closing Entry', 'Journal Entry', 'Payment Entry', "Repost Payment Ledger", "Repost Accounting Ledger"]; if(!this.frm.doc.__islocal && !this.frm.doc.customer && this.frm.doc.debit_to) { // show debit_to in print format diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py index e331c0b1bd2..db120740dcc 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py @@ -386,6 +386,8 @@ class SalesInvoice(SellingController): "Repost Item Valuation", "Repost Payment Ledger", "Repost Payment Ledger Items", + "Repost Accounting Ledger", + "Repost Accounting Ledger Items", "Payment Ledger Entry", "Serial and Batch Bundle", ) diff --git a/erpnext/accounts/test/accounts_mixin.py b/erpnext/accounts/test/accounts_mixin.py index c82164ef644..70bbf7e694d 100644 --- a/erpnext/accounts/test/accounts_mixin.py +++ b/erpnext/accounts/test/accounts_mixin.py @@ -4,7 +4,7 @@ from erpnext.stock.doctype.item.test_item import create_item class AccountsTestMixin: - def create_customer(self, customer_name, currency=None): + def create_customer(self, customer_name="_Test Customer", currency=None): if not frappe.db.exists("Customer", customer_name): customer = frappe.new_doc("Customer") customer.customer_name = customer_name @@ -17,7 +17,7 @@ class AccountsTestMixin: else: self.customer = customer_name - def create_supplier(self, supplier_name, currency=None): + def create_supplier(self, supplier_name="_Test Supplier", currency=None): if not frappe.db.exists("Supplier", supplier_name): supplier = frappe.new_doc("Supplier") supplier.supplier_name = supplier_name @@ -31,7 +31,7 @@ class AccountsTestMixin: else: self.supplier = supplier_name - def create_item(self, item_name, is_stock=0, warehouse=None, company=None): + def create_item(self, item_name="_Test Item", is_stock=0, warehouse=None, company=None): item = create_item(item_name, is_stock_item=is_stock, warehouse=warehouse, company=company) self.item = item.name @@ -62,19 +62,44 @@ class AccountsTestMixin: self.debit_usd = "Debtors USD - " + abbr self.cash = "Cash - " + abbr self.creditors = "Creditors - " + abbr + self.retained_earnings = "Retained Earnings - " + abbr - # create bank account - bank_account = "HDFC - " + abbr - if frappe.db.exists("Account", bank_account): - self.bank = bank_account - else: - bank_acc = frappe.get_doc( + # Deferred revenue, expense and bank accounts + other_accounts = [ + frappe._dict( { - "doctype": "Account", + "attribute_name": "deferred_revenue", + "account_name": "Deferred Revenue", + "parent_account": "Current Liabilities - " + abbr, + } + ), + frappe._dict( + { + "attribute_name": "deferred_expense", + "account_name": "Deferred Expense", + "parent_account": "Current Assets - " + abbr, + } + ), + frappe._dict( + { + "attribute_name": "bank", "account_name": "HDFC", "parent_account": "Bank Accounts - " + abbr, - "company": self.company, } - ) - bank_acc.save() - self.bank = bank_acc.name + ), + ] + for acc in other_accounts: + acc_name = acc.account_name + " - " + abbr + if frappe.db.exists("Account", acc_name): + setattr(self, acc.attribute_name, acc_name) + else: + new_acc = frappe.get_doc( + { + "doctype": "Account", + "account_name": acc.account_name, + "parent_account": acc.parent_account, + "company": self.company, + } + ) + new_acc.save() + setattr(self, acc.attribute_name, new_acc.name)