From 7b383880c6ab802ca1d3b593bfa12e1fd3f6c5fa Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Wed, 25 May 2022 15:51:16 +0530 Subject: [PATCH 1/8] feat: helper class for quering Payment Ledger --- erpnext/accounts/utils.py | 193 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 193 insertions(+) diff --git a/erpnext/accounts/utils.py b/erpnext/accounts/utils.py index 41f3223b541..7ab4f4334fd 100644 --- a/erpnext/accounts/utils.py +++ b/erpnext/accounts/utils.py @@ -1486,3 +1486,196 @@ def delink_original_entry(pl_entry): ) ) query.run() + + +class QueryPaymentLedger(object): + """ + Helper Class for Querying Payment Ledger Entry + """ + + def __init__(self): + self.ple = qb.DocType("Payment Ledger Entry") + + # query result + self.voucher_outstandings = [] + + # query filters + self.vouchers = [] + self.common_filter = [] + self.min_outstanding = None + self.max_outstanding = None + + def reset(self): + # clear filters + self.vouchers.clear() + self.common_filter.clear() + self.min_outstanding = self.max_outstanding = None + + # clear result + self.voucher_outstandings.clear() + + def query_for_outstanding(self): + """ + Database query to fetch voucher amount and voucher outstanding using Common Table Expression + """ + + ple = self.ple + + filter_on_voucher_no = [] + filter_on_against_voucher_no = [] + if self.vouchers: + voucher_types = set([x.voucher_type for x in self.vouchers]) + voucher_nos = set([x.voucher_no for x in self.vouchers]) + + filter_on_voucher_no.append(ple.voucher_type.isin(voucher_types)) + filter_on_voucher_no.append(ple.voucher_no.isin(voucher_nos)) + + filter_on_against_voucher_no.append(ple.against_voucher_type.isin(voucher_types)) + filter_on_against_voucher_no.append(ple.against_voucher_no.isin(voucher_nos)) + + # build outstanding amount filter + filter_on_outstanding_amount = [] + if self.min_outstanding: + if self.min_outstanding > 0: + filter_on_outstanding_amount.append( + Table("outstanding").amount_in_account_currency >= self.min_outstanding + ) + else: + filter_on_outstanding_amount.append( + Table("outstanding").amount_in_account_currency <= self.min_outstanding + ) + if self.max_outstanding: + if self.max_outstanding > 0: + filter_on_outstanding_amount.append( + Table("outstanding").amount_in_account_currency <= self.max_outstanding + ) + else: + filter_on_outstanding_amount.append( + Table("outstanding").amount_in_account_currency >= self.max_outstanding + ) + + # build query for voucher amount + query_voucher_amount = ( + qb.from_(ple) + .select( + ple.account, + ple.voucher_type, + ple.voucher_no, + ple.party_type, + ple.party, + ple.posting_date, + ple.due_date, + ple.account_currency.as_("currency"), + Sum(ple.amount).as_("amount"), + Sum(ple.amount_in_account_currency).as_("amount_in_account_currency"), + ) + .where(ple.delinked == 0) + .where(Criterion.all(filter_on_voucher_no)) + .where(Criterion.all(self.common_filter)) + .groupby(ple.voucher_type, ple.voucher_no, ple.party_type, ple.party) + ) + + # build query for voucher outstanding + query_voucher_outstanding = ( + qb.from_(ple) + .select( + ple.account, + ple.against_voucher_type.as_("voucher_type"), + ple.against_voucher_no.as_("voucher_no"), + ple.party_type, + ple.party, + ple.posting_date, + ple.due_date, + ple.account_currency.as_("currency"), + Sum(ple.amount).as_("amount"), + Sum(ple.amount_in_account_currency).as_("amount_in_account_currency"), + ) + .where(ple.delinked == 0) + .where(Criterion.all(filter_on_against_voucher_no)) + .where(Criterion.all(self.common_filter)) + .groupby(ple.against_voucher_type, ple.against_voucher_no, ple.party_type, ple.party) + ) + + # build CTE for combining voucher amount and outstanding + self.cte_query_voucher_amount_and_outstanding = ( + qb.with_(query_voucher_amount, "vouchers") + .with_(query_voucher_outstanding, "outstanding") + .from_(AliasedQuery("vouchers")) + .left_join(AliasedQuery("outstanding")) + .on( + (AliasedQuery("vouchers").account == AliasedQuery("outstanding").account) + & (AliasedQuery("vouchers").voucher_type == AliasedQuery("outstanding").voucher_type) + & (AliasedQuery("vouchers").voucher_no == AliasedQuery("outstanding").voucher_no) + & (AliasedQuery("vouchers").party_type == AliasedQuery("outstanding").party_type) + & (AliasedQuery("vouchers").party == AliasedQuery("outstanding").party) + ) + .select( + Table("vouchers").account, + Table("vouchers").voucher_type, + Table("vouchers").voucher_no, + Table("vouchers").party_type, + Table("vouchers").party, + Table("vouchers").posting_date, + Table("vouchers").amount.as_("invoice_amount"), + Table("vouchers").amount_in_account_currency.as_("invoice_amount_in_account_currency"), + Table("outstanding").amount.as_("outstanding"), + Table("outstanding").amount_in_account_currency.as_("outstanding_in_account_currency"), + (Table("vouchers").amount - Table("outstanding").amount).as_("paid_amount"), + ( + Table("vouchers").amount_in_account_currency - Table("outstanding").amount_in_account_currency + ).as_("paid_amount_in_account_currency"), + Table("vouchers").due_date, + Table("vouchers").currency, + ) + .where(Criterion.all(filter_on_outstanding_amount)) + ) + + # build CTE filter + # only fetch invoices + if self.get_invoices: + self.cte_query_voucher_amount_and_outstanding = ( + self.cte_query_voucher_amount_and_outstanding.having( + qb.Field("outstanding_in_account_currency") > 0 + ) + ) + # only fetch payments + elif self.get_payments: + self.cte_query_voucher_amount_and_outstanding = ( + self.cte_query_voucher_amount_and_outstanding.having( + qb.Field("outstanding_in_account_currency") < 0 + ) + ) + + # execute SQL + self.voucher_outstandings = self.cte_query_voucher_amount_and_outstanding.run(as_dict=True) + + def get_voucher_outstandings( + self, + vouchers=None, + common_filter=None, + min_outstanding=None, + max_outstanding=None, + get_payments=False, + get_invoices=False, + ): + """ + Fetch voucher amount and outstanding amount from Payment Ledger using Database CTE + + vouchers - dict of vouchers to get + common_filter - array of criterions + min_outstanding - filter on minimum total outstanding amount + max_outstanding - filter on maximum total outstanding amount + get_invoices - only fetch vouchers(ledger entries with +ve outstanding) + get_payments - only fetch payments(ledger entries with -ve outstanding) + """ + + self.reset() + self.vouchers = vouchers + self.common_filter = common_filter or [] + self.min_outstanding = min_outstanding + self.max_outstanding = max_outstanding + self.get_payments = get_payments + self.get_invoices = get_invoices + self.query_for_outstanding() + + return self.voucher_outstandings From 65f47bca31611683f2aec89136ae8bb10fa63e49 Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Tue, 24 May 2022 14:21:33 +0530 Subject: [PATCH 2/8] refactor: payment reconciliation tool PR uses payment ledger for outstanding invoice and unreconcilied cr/dr notes. --- .../payment_reconciliation.py | 228 ++++++++---------- 1 file changed, 107 insertions(+), 121 deletions(-) diff --git a/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py b/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py index e5b942fb6ef..5b2b526e591 100644 --- a/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py +++ b/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py @@ -3,16 +3,26 @@ import frappe -from frappe import _, msgprint +from frappe import _, msgprint, qb from frappe.model.document import Document +from frappe.query_builder.custom import ConstantColumn +from frappe.query_builder.functions import IfNull from frappe.utils import flt, getdate, nowdate, today import erpnext -from erpnext.accounts.utils import get_outstanding_invoices, reconcile_against_document +from erpnext.accounts.utils import ( + QueryPaymentLedger, + get_outstanding_invoices, + reconcile_against_document, +) from erpnext.controllers.accounts_controller import get_advance_payment_entries class PaymentReconciliation(Document): + def __init__(self, *args, **kwargs): + super(PaymentReconciliation, self).__init__(*args, **kwargs) + self.common_filter_conditions = [] + @frappe.whitelist() def get_unreconciled_entries(self): self.get_nonreconciled_payment_entries() @@ -108,54 +118,58 @@ class PaymentReconciliation(Document): return list(journal_entries) def get_dr_or_cr_notes(self): - condition = self.get_conditions(get_return_invoices=True) - dr_or_cr = ( - "credit_in_account_currency" - if erpnext.get_party_account_type(self.party_type) == "Receivable" - else "debit_in_account_currency" - ) - reconciled_dr_or_cr = ( - "debit_in_account_currency" - if dr_or_cr == "credit_in_account_currency" - else "credit_in_account_currency" - ) + self.build_qb_filter_conditions(get_return_invoices=True) + ple = qb.DocType("Payment Ledger Entry") voucher_type = "Sales Invoice" if self.party_type == "Customer" else "Purchase Invoice" - return frappe.db.sql( - """ SELECT doc.name as reference_name, %(voucher_type)s as reference_type, - (sum(gl.{dr_or_cr}) - sum(gl.{reconciled_dr_or_cr})) as amount, doc.posting_date, - account_currency as currency - FROM `tab{doc}` doc, `tabGL Entry` gl - WHERE - (doc.name = gl.against_voucher or doc.name = gl.voucher_no) - and doc.{party_type_field} = %(party)s - and doc.is_return = 1 and ifnull(doc.return_against, "") = "" - and gl.against_voucher_type = %(voucher_type)s - and doc.docstatus = 1 and gl.party = %(party)s - and gl.party_type = %(party_type)s and gl.account = %(account)s - and gl.is_cancelled = 0 {condition} - GROUP BY doc.name - Having - amount > 0 - ORDER BY doc.posting_date - """.format( - doc=voucher_type, - dr_or_cr=dr_or_cr, - reconciled_dr_or_cr=reconciled_dr_or_cr, - party_type_field=frappe.scrub(self.party_type), - condition=condition or "", - ), - { - "party": self.party, - "party_type": self.party_type, - "voucher_type": voucher_type, - "account": self.receivable_payable_account, - }, - as_dict=1, + if erpnext.get_party_account_type(self.party_type) == "Receivable": + self.common_filter_conditions.append(ple.account_type == "Receivable") + else: + self.common_filter_conditions.append(ple.account_type == "Payable") + self.common_filter_conditions.append(ple.account == self.receivable_payable_account) + + # get return invoices + doc = qb.DocType(voucher_type) + return_invoices = ( + qb.from_(doc) + .select(ConstantColumn(voucher_type).as_("voucher_type"), doc.name.as_("voucher_no")) + .where( + (doc.docstatus == 1) + & (doc[frappe.scrub(self.party_type)] == self.party) + & (doc.is_return == 1) + & (IfNull(doc.return_against, "") == "") + ) + .run(as_dict=True) ) + outstanding_dr_or_cr = [] + if return_invoices: + ple_query = QueryPaymentLedger() + return_outstanding = ple_query.get_voucher_outstandings( + vouchers=return_invoices, + common_filter=self.common_filter_conditions, + min_outstanding=-(self.minimum_payment_amount) if self.minimum_payment_amount else None, + max_outstanding=-(self.maximum_payment_amount) if self.maximum_payment_amount else None, + get_payments=True, + ) + + for inv in return_outstanding: + if inv.outstanding != 0: + outstanding_dr_or_cr.append( + frappe._dict( + { + "reference_type": inv.voucher_type, + "reference_name": inv.voucher_no, + "amount": -(inv.outstanding), + "posting_date": inv.posting_date, + "currency": inv.currency, + } + ) + ) + return outstanding_dr_or_cr + def add_payment_entries(self, non_reconciled_payments): self.set("payments", []) @@ -166,10 +180,15 @@ class PaymentReconciliation(Document): def get_invoice_entries(self): # Fetch JVs, Sales and Purchase Invoices for 'invoices' to reconcile against - condition = self.get_conditions(get_invoices=True) + self.build_qb_filter_conditions(get_invoices=True) non_reconciled_invoices = get_outstanding_invoices( - self.party_type, self.party, self.receivable_payable_account, condition=condition + self.party_type, + self.party, + self.receivable_payable_account, + common_filter=self.common_filter_conditions, + min_outstanding=self.minimum_invoice_amount if self.minimum_invoice_amount else None, + max_outstanding=self.maximum_invoice_amount if self.maximum_invoice_amount else None, ) if self.invoice_limit: @@ -329,89 +348,56 @@ class PaymentReconciliation(Document): if not invoices_to_reconcile: frappe.throw(_("No records found in Allocation table")) - def get_conditions(self, get_invoices=False, get_payments=False, get_return_invoices=False): - condition = " and company = '{0}' ".format(self.company) + def build_qb_filter_conditions(self, get_invoices=False, get_return_invoices=False): + self.common_filter_conditions.clear() + ple = qb.DocType("Payment Ledger Entry") - if self.get("cost_center") and (get_invoices or get_payments or get_return_invoices): - condition = " and cost_center = '{0}' ".format(self.cost_center) + self.common_filter_conditions.append(ple.company == self.company) + + if self.get("cost_center") and (get_invoices or get_return_invoices): + self.common_filter_conditions.append(ple.cost_center == self.cost_center) if get_invoices: - condition += ( - " and posting_date >= {0}".format(frappe.db.escape(self.from_invoice_date)) - if self.from_invoice_date - else "" - ) - condition += ( - " and posting_date <= {0}".format(frappe.db.escape(self.to_invoice_date)) - if self.to_invoice_date - else "" - ) - dr_or_cr = ( - "debit_in_account_currency" - if erpnext.get_party_account_type(self.party_type) == "Receivable" - else "credit_in_account_currency" - ) - - if self.minimum_invoice_amount: - condition += " and {dr_or_cr} >= {amount}".format( - dr_or_cr=dr_or_cr, amount=flt(self.minimum_invoice_amount) - ) - if self.maximum_invoice_amount: - condition += " and {dr_or_cr} <= {amount}".format( - dr_or_cr=dr_or_cr, amount=flt(self.maximum_invoice_amount) - ) + if self.from_invoice_date: + self.common_filter_conditions.append(ple.posting_date.gte(self.from_invoice_date)) + if self.to_invoice_date: + self.common_filter_conditions.append(ple.posting_date.lte(self.to_invoice_date)) elif get_return_invoices: - condition = " and doc.company = '{0}' ".format(self.company) - condition += ( - " and doc.posting_date >= {0}".format(frappe.db.escape(self.from_payment_date)) - if self.from_payment_date - else "" - ) - condition += ( - " and doc.posting_date <= {0}".format(frappe.db.escape(self.to_payment_date)) - if self.to_payment_date - else "" - ) - dr_or_cr = ( - "debit_in_account_currency" - if erpnext.get_party_account_type(self.party_type) == "Receivable" - else "credit_in_account_currency" - ) + if self.from_payment_date: + self.common_filter_conditions.append(ple.posting_date.gte(self.from_payment_date)) + if self.to_payment_date: + self.common_filter_conditions.append(ple.posting_date.lte(self.to_payment_date)) - if self.minimum_invoice_amount: - condition += " and gl.{dr_or_cr} >= {amount}".format( - dr_or_cr=dr_or_cr, amount=flt(self.minimum_payment_amount) - ) - if self.maximum_invoice_amount: - condition += " and gl.{dr_or_cr} <= {amount}".format( - dr_or_cr=dr_or_cr, amount=flt(self.maximum_payment_amount) - ) + def get_conditions(self, get_payments=False): + condition = " and company = '{0}' ".format(self.company) - else: - condition += ( - " and posting_date >= {0}".format(frappe.db.escape(self.from_payment_date)) - if self.from_payment_date - else "" - ) - condition += ( - " and posting_date <= {0}".format(frappe.db.escape(self.to_payment_date)) - if self.to_payment_date - else "" - ) + if self.get("cost_center") and get_payments: + condition = " and cost_center = '{0}' ".format(self.cost_center) - if self.minimum_payment_amount: - condition += ( - " and unallocated_amount >= {0}".format(flt(self.minimum_payment_amount)) - if get_payments - else " and total_debit >= {0}".format(flt(self.minimum_payment_amount)) - ) - if self.maximum_payment_amount: - condition += ( - " and unallocated_amount <= {0}".format(flt(self.maximum_payment_amount)) - if get_payments - else " and total_debit <= {0}".format(flt(self.maximum_payment_amount)) - ) + condition += ( + " and posting_date >= {0}".format(frappe.db.escape(self.from_payment_date)) + if self.from_payment_date + else "" + ) + condition += ( + " and posting_date <= {0}".format(frappe.db.escape(self.to_payment_date)) + if self.to_payment_date + else "" + ) + + if self.minimum_payment_amount: + condition += ( + " and unallocated_amount >= {0}".format(flt(self.minimum_payment_amount)) + if get_payments + else " and total_debit >= {0}".format(flt(self.minimum_payment_amount)) + ) + if self.maximum_payment_amount: + condition += ( + " and unallocated_amount <= {0}".format(flt(self.maximum_payment_amount)) + if get_payments + else " and total_debit <= {0}".format(flt(self.maximum_payment_amount)) + ) return condition From 9cdc388c977ac5f58c109dcc46b136b438f3769a Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Wed, 25 May 2022 15:42:02 +0530 Subject: [PATCH 3/8] test: payment reconciliation tool unit test cases for partial reconciliation, return invoice against invoice, invoice against journals and journal against journal have been added --- .../test_payment_reconciliation.py | 510 +++++++++++++++--- 1 file changed, 435 insertions(+), 75 deletions(-) diff --git a/erpnext/accounts/doctype/payment_reconciliation/test_payment_reconciliation.py b/erpnext/accounts/doctype/payment_reconciliation/test_payment_reconciliation.py index d2374b77a63..575ac74a4eb 100644 --- a/erpnext/accounts/doctype/payment_reconciliation/test_payment_reconciliation.py +++ b/erpnext/accounts/doctype/payment_reconciliation/test_payment_reconciliation.py @@ -4,93 +4,453 @@ import unittest import frappe -from frappe.utils import add_days, getdate +from frappe import qb +from frappe.tests.utils import FrappeTestCase +from frappe.utils import add_days, nowdate +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 -class TestPaymentReconciliation(unittest.TestCase): - @classmethod - def setUpClass(cls): - make_customer() - make_invoice_and_payment() +class TestPaymentReconciliation(FrappeTestCase): + def setUp(self): + self.create_company() + self.create_item() + self.create_customer() + self.clear_old_entries() - def test_payment_reconciliation(self): - payment_reco = frappe.get_doc("Payment Reconciliation") - payment_reco.company = "_Test Company" - payment_reco.party_type = "Customer" - payment_reco.party = "_Test Payment Reco Customer" - payment_reco.receivable_payable_account = "Debtors - _TC" - payment_reco.from_invoice_date = add_days(getdate(), -1) - payment_reco.to_invoice_date = getdate() - payment_reco.from_payment_date = add_days(getdate(), -1) - payment_reco.to_payment_date = getdate() - payment_reco.maximum_invoice_amount = 1000 - payment_reco.maximum_payment_amount = 1000 - payment_reco.invoice_limit = 10 - payment_reco.payment_limit = 10 - payment_reco.bank_cash_account = "_Test Bank - _TC" - payment_reco.cost_center = "_Test Cost Center - _TC" - payment_reco.get_unreconciled_entries() + def tearDown(self): + frappe.db.rollback() - self.assertEqual(len(payment_reco.get("invoices")), 1) - self.assertEqual(len(payment_reco.get("payments")), 1) + def create_company(self): + company = None + if frappe.db.exists("Company", "_Test Payment Reconciliation"): + company = frappe.get_doc("Company", "_Test Payment Reconciliation") + else: + company = frappe.get_doc( + { + "doctype": "Company", + "company_name": "_Test Payment Reconciliation", + "country": "India", + "default_currency": "INR", + "create_chart_of_accounts_based_on": "Standard Template", + "chart_of_accounts": "Standard", + } + ) + company = company.save() - payment_entry = payment_reco.get("payments")[0].reference_name - invoice = payment_reco.get("invoices")[0].invoice_number + self.company = company.name + self.cost_center = company.cost_center + self.warehouse = "All Warehouses - _PR" + self.income_account = "Sales - _PR" + self.expense_account = "Cost of Goods Sold - _PR" + self.debit_to = "Debtors - _PR" + self.creditors = "Creditors - _PR" - payment_reco.allocate_entries( - { - "payments": [payment_reco.get("payments")[0].as_dict()], - "invoices": [payment_reco.get("invoices")[0].as_dict()], - } + # create bank account + if frappe.db.exists("Account", "HDFC - _PR"): + self.bank = "HDFC - _PR" + else: + bank_acc = frappe.get_doc( + { + "doctype": "Account", + "account_name": "HDFC", + "parent_account": "Bank Accounts - _PR", + "company": self.company, + } + ) + bank_acc.save() + self.bank = bank_acc.name + + def create_item(self): + item = create_item( + item_code="_Test PR Item", is_stock_item=0, company=self.company, warehouse=self.warehouse ) - payment_reco.reconcile() + self.item = item if isinstance(item, str) else item.item_code - payment_entry_doc = frappe.get_doc("Payment Entry", payment_entry) - self.assertEqual(payment_entry_doc.get("references")[0].reference_name, invoice) + def create_customer(self): + if frappe.db.exists("Customer", "_Test PR Customer"): + self.customer = "_Test PR Customer" + else: + customer = frappe.new_doc("Customer") + customer.customer_name = "_Test PR Customer" + customer.type = "Individual" + customer.save() + self.customer = customer.name + if frappe.db.exists("Customer", "_Test PR Customer 2"): + self.customer2 = "_Test PR Customer 2" + else: + customer = frappe.new_doc("Customer") + customer.customer_name = "_Test PR Customer 2" + customer.type = "Individual" + customer.save() + self.customer2 = customer.name -def make_customer(): - if not frappe.db.get_value("Customer", "_Test Payment Reco Customer"): - frappe.get_doc( - { - "doctype": "Customer", - "customer_name": "_Test Payment Reco Customer", - "customer_type": "Individual", - "customer_group": "_Test Customer Group", - "territory": "_Test Territory", - } - ).insert() + def create_sales_invoice( + self, qty=1, rate=100, 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_to, + parent_cost_center=self.cost_center, + update_stock=0, + currency="INR", + 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=100, posting_date=nowdate()): + """ + Helper function to populate default values in payment entry + """ + payment = create_payment_entry( + company=self.company, + payment_type="Receive", + party_type="Customer", + party=self.customer, + paid_from=self.debit_to, + paid_to=self.bank, + paid_amount=amount, + ) + payment.posting_date = posting_date + return payment -def make_invoice_and_payment(): - si = create_sales_invoice( - customer="_Test Payment Reco Customer", qty=1, rate=690, do_not_save=True - ) - si.cost_center = "_Test Cost Center - _TC" - si.save() - si.submit() + 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() - pe = frappe.get_doc( - { - "doctype": "Payment Entry", - "payment_type": "Receive", - "party_type": "Customer", - "party": "_Test Payment Reco Customer", - "company": "_Test Company", - "paid_from_account_currency": "INR", - "paid_to_account_currency": "INR", - "source_exchange_rate": 1, - "target_exchange_rate": 1, - "reference_no": "1", - "reference_date": getdate(), - "received_amount": 690, - "paid_amount": 690, - "paid_from": "Debtors - _TC", - "paid_to": "_Test Bank - _TC", - "cost_center": "_Test Cost Center - _TC", - } - ) - pe.insert() - pe.submit() + 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 test_filter_min_max(self): + # check filter condition minimum and maximum amount + self.create_sales_invoice(qty=1, rate=300) + self.create_sales_invoice(qty=1, rate=400) + self.create_sales_invoice(qty=1, rate=500) + self.create_payment_entry(amount=300).save().submit() + self.create_payment_entry(amount=400).save().submit() + self.create_payment_entry(amount=500).save().submit() + + pr = self.create_payment_reconciliation() + pr.minimum_invoice_amount = 400 + pr.maximum_invoice_amount = 500 + pr.minimum_payment_amount = 300 + pr.maximum_payment_amount = 600 + pr.get_unreconciled_entries() + self.assertEqual(len(pr.get("invoices")), 2) + self.assertEqual(len(pr.get("payments")), 3) + + pr.minimum_invoice_amount = 300 + pr.maximum_invoice_amount = 600 + pr.minimum_payment_amount = 400 + pr.maximum_payment_amount = 500 + pr.get_unreconciled_entries() + self.assertEqual(len(pr.get("invoices")), 3) + self.assertEqual(len(pr.get("payments")), 2) + + pr.minimum_invoice_amount = ( + pr.maximum_invoice_amount + ) = pr.minimum_payment_amount = pr.maximum_payment_amount = 0 + pr.get_unreconciled_entries() + self.assertEqual(len(pr.get("invoices")), 3) + self.assertEqual(len(pr.get("payments")), 3) + + def test_filter_posting_date(self): + # check filter condition using transaction date + date1 = nowdate() + date2 = add_days(nowdate(), -1) + amount = 100 + self.create_sales_invoice(qty=1, rate=amount, posting_date=date1) + si2 = self.create_sales_invoice( + qty=1, rate=amount, posting_date=date2, do_not_save=True, do_not_submit=True + ) + si2.set_posting_time = 1 + si2.posting_date = date2 + si2.save().submit() + self.create_payment_entry(amount=amount, posting_date=date1).save().submit() + self.create_payment_entry(amount=amount, posting_date=date2).save().submit() + + pr = self.create_payment_reconciliation() + pr.from_invoice_date = pr.to_invoice_date = date1 + pr.from_payment_date = pr.to_payment_date = date1 + + pr.get_unreconciled_entries() + # assert only si and pe are fetched + self.assertEqual(len(pr.get("invoices")), 1) + self.assertEqual(len(pr.get("payments")), 1) + + pr.from_invoice_date = date2 + pr.to_invoice_date = date1 + pr.from_payment_date = date2 + pr.to_payment_date = date1 + + pr.get_unreconciled_entries() + # assert only si and pe are fetched + self.assertEqual(len(pr.get("invoices")), 2) + self.assertEqual(len(pr.get("payments")), 2) + + def test_filter_invoice_limit(self): + # check filter condition - invoice limit + transaction_date = nowdate() + rate = 100 + invoices = [] + payments = [] + for i in range(5): + invoices.append(self.create_sales_invoice(qty=1, rate=rate, posting_date=transaction_date)) + pe = self.create_payment_entry(amount=rate, posting_date=transaction_date).save().submit() + payments.append(pe) + + pr = self.create_payment_reconciliation() + pr.from_invoice_date = pr.to_invoice_date = transaction_date + pr.from_payment_date = pr.to_payment_date = transaction_date + pr.invoice_limit = 2 + pr.payment_limit = 3 + pr.get_unreconciled_entries() + + self.assertEqual(len(pr.get("invoices")), 2) + self.assertEqual(len(pr.get("payments")), 3) + + def test_payment_against_invoice(self): + si = self.create_sales_invoice(qty=1, rate=200) + pe = self.create_payment_entry(amount=55).save().submit() + # second payment entry + self.create_payment_entry(amount=35).save().submit() + + pr = self.create_payment_reconciliation() + + # reconcile multiple payments against invoice + pr.get_unreconciled_entries() + invoices = [x.as_dict() for x in pr.get("invoices")] + payments = [x.as_dict() for x in pr.get("payments")] + pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments})) + pr.reconcile() + + si.reload() + self.assertEqual(si.status, "Partly Paid") + # check PR tool output post reconciliation + self.assertEqual(len(pr.get("invoices")), 1) + self.assertEqual(pr.get("invoices")[0].get("outstanding_amount"), 110) + self.assertEqual(pr.get("payments"), []) + + # cancel one PE + pe.reload() + pe.cancel() + pr.get_unreconciled_entries() + # check PR tool output + self.assertEqual(len(pr.get("invoices")), 1) + self.assertEqual(len(pr.get("payments")), 0) + self.assertEqual(pr.get("invoices")[0].get("outstanding_amount"), 165) + + def test_payment_against_journal(self): + transaction_date = nowdate() + + sales = "Sales - _PR" + amount = 921 + # debit debtors account to record an invoice + je = self.create_journal_entry(self.debit_to, sales, amount, transaction_date) + je.accounts[0].party_type = "Customer" + je.accounts[0].party = self.customer + je.save() + je.submit() + + self.create_payment_entry(amount=amount, posting_date=transaction_date).save().submit() + + pr = self.create_payment_reconciliation() + pr.minimum_invoice_amount = pr.maximum_invoice_amount = amount + pr.from_invoice_date = pr.to_invoice_date = transaction_date + pr.from_payment_date = pr.to_payment_date = transaction_date + + pr.get_unreconciled_entries() + invoices = [x.as_dict() for x in pr.get("invoices")] + payments = [x.as_dict() for x in pr.get("payments")] + pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments})) + pr.reconcile() + + # check PR tool output + self.assertEqual(len(pr.get("invoices")), 0) + self.assertEqual(len(pr.get("payments")), 0) + + def test_journal_against_invoice(self): + transaction_date = nowdate() + amount = 100 + si = self.create_sales_invoice(qty=1, rate=amount, posting_date=transaction_date) + + # credit debtors account to record a payment + je = self.create_journal_entry(self.bank, self.debit_to, amount, transaction_date) + je.accounts[1].party_type = "Customer" + je.accounts[1].party = self.customer + je.save() + je.submit() + + pr = self.create_payment_reconciliation() + + pr.get_unreconciled_entries() + invoices = [x.as_dict() for x in pr.get("invoices")] + payments = [x.as_dict() for x in pr.get("payments")] + pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments})) + pr.reconcile() + + # assert outstanding + si.reload() + self.assertEqual(si.status, "Paid") + self.assertEqual(si.outstanding_amount, 0) + + # check PR tool output + self.assertEqual(len(pr.get("invoices")), 0) + self.assertEqual(len(pr.get("payments")), 0) + + def test_journal_against_journal(self): + transaction_date = nowdate() + sales = "Sales - _PR" + amount = 100 + + # debit debtors account to simulate a invoice + je1 = self.create_journal_entry(self.debit_to, sales, amount, transaction_date) + je1.accounts[0].party_type = "Customer" + je1.accounts[0].party = self.customer + je1.save() + je1.submit() + + # credit debtors account to simulate a payment + je2 = self.create_journal_entry(self.bank, self.debit_to, amount, transaction_date) + je2.accounts[1].party_type = "Customer" + je2.accounts[1].party = self.customer + je2.save() + je2.submit() + + pr = self.create_payment_reconciliation() + + pr.get_unreconciled_entries() + invoices = [x.as_dict() for x in pr.get("invoices")] + payments = [x.as_dict() for x in pr.get("payments")] + pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments})) + pr.reconcile() + + self.assertEqual(pr.get("invoices"), []) + self.assertEqual(pr.get("payments"), []) + + def test_cr_note_against_invoice(self): + transaction_date = nowdate() + amount = 100 + + si = self.create_sales_invoice(qty=1, rate=amount, posting_date=transaction_date) + + cr_note = self.create_sales_invoice( + qty=-1, rate=amount, posting_date=transaction_date, do_not_save=True, do_not_submit=True + ) + cr_note.is_return = 1 + cr_note = cr_note.save().submit() + + pr = self.create_payment_reconciliation() + + pr.get_unreconciled_entries() + invoices = [x.as_dict() for x in pr.get("invoices")] + payments = [x.as_dict() for x in pr.get("payments")] + pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments})) + pr.reconcile() + + pr.get_unreconciled_entries() + # check reconciliation tool output + # reconciled invoice and credit note shouldn't show up in selection + self.assertEqual(pr.get("invoices"), []) + self.assertEqual(pr.get("payments"), []) + + # assert outstanding + si.reload() + self.assertEqual(si.status, "Paid") + self.assertEqual(si.outstanding_amount, 0) + + def test_cr_note_partial_against_invoice(self): + transaction_date = nowdate() + amount = 100 + allocated_amount = 80 + + si = self.create_sales_invoice(qty=1, rate=amount, posting_date=transaction_date) + + cr_note = self.create_sales_invoice( + qty=-1, rate=amount, posting_date=transaction_date, do_not_save=True, do_not_submit=True + ) + cr_note.is_return = 1 + cr_note = cr_note.save().submit() + + pr = self.create_payment_reconciliation() + + pr.get_unreconciled_entries() + invoices = [x.as_dict() for x in pr.get("invoices")] + payments = [x.as_dict() for x in pr.get("payments")] + pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments})) + pr.allocation[0].allocated_amount = allocated_amount + pr.reconcile() + + # assert outstanding + si.reload() + self.assertEqual(si.status, "Partly Paid") + self.assertEqual(si.outstanding_amount, 20) + + pr.get_unreconciled_entries() + # check reconciliation tool output + self.assertEqual(len(pr.get("invoices")), 1) + self.assertEqual(len(pr.get("payments")), 1) + self.assertEqual(pr.get("invoices")[0].outstanding_amount, 20) + self.assertEqual(pr.get("payments")[0].amount, 20) From 8c87674c62ea1511516ee10ef1297a2280f79ca6 Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Tue, 14 Jun 2022 17:46:04 +0530 Subject: [PATCH 4/8] refactor: outstanding_invoice function and helper class outstanding invoice function has been refactored to use payment ledger --- erpnext/accounts/utils.py | 86 +++++++++++---------------------------- 1 file changed, 23 insertions(+), 63 deletions(-) diff --git a/erpnext/accounts/utils.py b/erpnext/accounts/utils.py index 7ab4f4334fd..a18391a1eb9 100644 --- a/erpnext/accounts/utils.py +++ b/erpnext/accounts/utils.py @@ -9,6 +9,8 @@ import frappe import frappe.defaults from frappe import _, qb, throw from frappe.model.meta import get_field_precision +from frappe.query_builder import AliasedQuery, Criterion, Table +from frappe.query_builder.functions import Sum from frappe.query_builder.utils import DocType from frappe.utils import ( cint, @@ -816,7 +818,11 @@ def get_held_invoices(party_type, party): return held_invoices -def get_outstanding_invoices(party_type, party, account, condition=None, filters=None): +def get_outstanding_invoices( + party_type, party, account, common_filter=None, min_outstanding=None, max_outstanding=None +): + + ple = qb.DocType("Payment Ledger Entry") outstanding_invoices = [] precision = frappe.get_precision("Sales Invoice", "outstanding_amount") or 2 @@ -829,76 +835,30 @@ def get_outstanding_invoices(party_type, party, account, condition=None, filters else: party_account_type = erpnext.get_party_account_type(party_type) - if party_account_type == "Receivable": - dr_or_cr = "debit_in_account_currency - credit_in_account_currency" - payment_dr_or_cr = "credit_in_account_currency - debit_in_account_currency" - else: - dr_or_cr = "credit_in_account_currency - debit_in_account_currency" - payment_dr_or_cr = "debit_in_account_currency - credit_in_account_currency" - held_invoices = get_held_invoices(party_type, party) - invoice_list = frappe.db.sql( - """ - select - voucher_no, voucher_type, posting_date, due_date, - ifnull(sum({dr_or_cr}), 0) as invoice_amount, - account_currency as currency - from - `tabGL Entry` - where - party_type = %(party_type)s and party = %(party)s - and account = %(account)s and {dr_or_cr} > 0 - and is_cancelled=0 - {condition} - and ((voucher_type = 'Journal Entry' - and (against_voucher = '' or against_voucher is null)) - or (voucher_type not in ('Journal Entry', 'Payment Entry'))) - group by voucher_type, voucher_no - order by posting_date, name""".format( - dr_or_cr=dr_or_cr, condition=condition or "" - ), - { - "party_type": party_type, - "party": party, - "account": account, - }, - as_dict=True, - ) + common_filter = common_filter or [] + common_filter.append(ple.account_type == party_account_type) + common_filter.append(ple.account == account) + common_filter.append(ple.party_type == party_type) + common_filter.append(ple.party == party) - payment_entries = frappe.db.sql( - """ - select against_voucher_type, against_voucher, - ifnull(sum({payment_dr_or_cr}), 0) as payment_amount - from `tabGL Entry` - where party_type = %(party_type)s and party = %(party)s - and account = %(account)s - and {payment_dr_or_cr} > 0 - and against_voucher is not null and against_voucher != '' - and is_cancelled=0 - group by against_voucher_type, against_voucher - """.format( - payment_dr_or_cr=payment_dr_or_cr - ), - {"party_type": party_type, "party": party, "account": account}, - as_dict=True, + ple_query = QueryPaymentLedger() + invoice_list = ple_query.get_voucher_outstandings( + common_filter=common_filter, + min_outstanding=min_outstanding, + max_outstanding=max_outstanding, + get_invoices=True, ) - pe_map = frappe._dict() - for d in payment_entries: - pe_map.setdefault((d.against_voucher_type, d.against_voucher), d.payment_amount) - for d in invoice_list: - payment_amount = pe_map.get((d.voucher_type, d.voucher_no), 0) - outstanding_amount = flt(d.invoice_amount - payment_amount, precision) + payment_amount = d.invoice_amount - d.outstanding + outstanding_amount = d.outstanding if outstanding_amount > 0.5 / (10**precision): if ( - filters - and filters.get("outstanding_amt_greater_than") - and not ( - outstanding_amount >= filters.get("outstanding_amt_greater_than") - and outstanding_amount <= filters.get("outstanding_amt_less_than") - ) + min_outstanding + and max_outstanding + and not (outstanding_amount >= min_outstanding and outstanding_amount <= max_outstanding) ): continue From ae8aa8f3e75236a59efecc1cc6aaeb1004a0a7b8 Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Wed, 25 May 2022 16:54:40 +0530 Subject: [PATCH 5/8] refactor: 'get outstanding invoices' popup in payment entry Payment entry has option to select outstanding invoices using a popup form. This change refactors the pop to use payment ledger to fetch +ve outstanding invoices. --- .../doctype/payment_entry/payment_entry.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.py b/erpnext/accounts/doctype/payment_entry/payment_entry.py index f7a57bb96e8..3f1d7618275 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry.py +++ b/erpnext/accounts/doctype/payment_entry/payment_entry.py @@ -6,7 +6,7 @@ import json from functools import reduce import frappe -from frappe import ValidationError, _, scrub, throw +from frappe import ValidationError, _, qb, scrub, throw from frappe.utils import cint, comma_or, flt, getdate, nowdate import erpnext @@ -1195,6 +1195,9 @@ def get_outstanding_reference_documents(args): if args.get("party_type") == "Member": return + ple = qb.DocType("Payment Ledger Entry") + common_filter = [] + # confirm that Supplier is not blocked if args.get("party_type") == "Supplier": supplier_status = get_supplier_block_status(args["party"]) @@ -1216,10 +1219,13 @@ def get_outstanding_reference_documents(args): condition = " and voucher_type={0} and voucher_no={1}".format( frappe.db.escape(args["voucher_type"]), frappe.db.escape(args["voucher_no"]) ) + common_filter.append(ple.voucher_type == args["voucher_type"]) + common_filter.append(ple.voucher_no == args["voucher_no"]) # Add cost center condition if args.get("cost_center"): condition += " and cost_center='%s'" % args.get("cost_center") + common_filter.append(ple.cost_center == args.get("cost_center")) date_fields_dict = { "posting_date": ["from_posting_date", "to_posting_date"], @@ -1231,16 +1237,19 @@ def get_outstanding_reference_documents(args): condition += " and {0} between '{1}' and '{2}'".format( fieldname, args.get(date_fields[0]), args.get(date_fields[1]) ) + common_filter.append(ple[fieldname][args.get(date_fields[0]) : args.get(date_fields[1])]) if args.get("company"): condition += " and company = {0}".format(frappe.db.escape(args.get("company"))) + common_filter.append(ple.company == args.get("company")) outstanding_invoices = get_outstanding_invoices( args.get("party_type"), args.get("party"), args.get("party_account"), - filters=args, - condition=condition, + common_filter=common_filter, + min_outstanding=args.get("outstanding_amt_greater_than"), + max_outstanding=args.get("outstanding_amt_less_than"), ) outstanding_invoices = split_invoices_based_on_payment_terms(outstanding_invoices) From 524c175cf062912183a9de5da8af0a10a7bb6d1c Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Thu, 26 May 2022 16:00:40 +0530 Subject: [PATCH 6/8] refactor: delink gl entry from reconciliation --- .../doctype/journal_entry/journal_entry.py | 9 ++++++--- .../doctype/payment_entry/payment_entry.py | 5 ++++- erpnext/accounts/utils.py | 14 +++++++++++--- 3 files changed, 21 insertions(+), 7 deletions(-) diff --git a/erpnext/accounts/doctype/journal_entry/journal_entry.py b/erpnext/accounts/doctype/journal_entry/journal_entry.py index 2c16ca32750..787efd2a426 100644 --- a/erpnext/accounts/doctype/journal_entry/journal_entry.py +++ b/erpnext/accounts/doctype/journal_entry/journal_entry.py @@ -800,9 +800,7 @@ class JournalEntry(AccountsController): self.total_amount_in_words = money_in_words(amt, currency) - def make_gl_entries(self, cancel=0, adv_adj=0): - from erpnext.accounts.general_ledger import make_gl_entries - + def build_gl_map(self): gl_map = [] for d in self.get("accounts"): if d.debit or d.credit: @@ -838,7 +836,12 @@ class JournalEntry(AccountsController): item=d, ) ) + return gl_map + def make_gl_entries(self, cancel=0, adv_adj=0): + from erpnext.accounts.general_ledger import make_gl_entries + + gl_map = self.build_gl_map() if self.voucher_type in ("Deferred Revenue", "Deferred Expense"): update_outstanding = "No" else: diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.py b/erpnext/accounts/doctype/payment_entry/payment_entry.py index 3f1d7618275..d8af9db077a 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry.py +++ b/erpnext/accounts/doctype/payment_entry/payment_entry.py @@ -785,7 +785,7 @@ class PaymentEntry(AccountsController): self.set("remarks", "\n".join(remarks)) - def make_gl_entries(self, cancel=0, adv_adj=0): + def build_gl_map(self): if self.payment_type in ("Receive", "Pay") and not self.get("party_account_field"): self.setup_party_account_field() @@ -794,7 +794,10 @@ class PaymentEntry(AccountsController): self.add_bank_gl_entries(gl_entries) self.add_deductions_gl_entries(gl_entries) self.add_tax_gl_entries(gl_entries) + return gl_entries + def make_gl_entries(self, cancel=0, adv_adj=0): + gl_entries = self.build_gl_map() gl_entries = process_gl_map(gl_entries) make_gl_entries(gl_entries, cancel=cancel, adv_adj=adv_adj) diff --git a/erpnext/accounts/utils.py b/erpnext/accounts/utils.py index a18391a1eb9..42a748e1aa1 100644 --- a/erpnext/accounts/utils.py +++ b/erpnext/accounts/utils.py @@ -439,7 +439,8 @@ def reconcile_against_document(args): # cancel advance entry doc = frappe.get_doc(voucher_type, voucher_no) frappe.flags.ignore_party_validation = True - doc.make_gl_entries(cancel=1, adv_adj=1) + gl_map = doc.build_gl_map() + create_payment_ledger_entry(gl_map, cancel=1, adv_adj=1) for entry in entries: check_if_advance_entry_modified(entry) @@ -454,7 +455,9 @@ def reconcile_against_document(args): doc.save(ignore_permissions=True) # re-submit advance entry doc = frappe.get_doc(entry.voucher_type, entry.voucher_no) - doc.make_gl_entries(cancel=0, adv_adj=1) + gl_map = doc.build_gl_map() + create_payment_ledger_entry(gl_map, cancel=0, adv_adj=1) + frappe.flags.ignore_party_validation = False if entry.voucher_type in ("Payment Entry", "Journal Entry"): @@ -1349,7 +1352,9 @@ def check_and_delete_linked_reports(report): frappe.delete_doc("Desktop Icon", icon) -def create_payment_ledger_entry(gl_entries, cancel=0): +def create_payment_ledger_entry( + gl_entries, cancel=0, adv_adj=0, update_outstanding="Yes", from_repost=0 +): if gl_entries: ple = None @@ -1422,6 +1427,9 @@ def create_payment_ledger_entry(gl_entries, cancel=0): if cancel: delink_original_entry(ple) ple.flags.ignore_permissions = 1 + ple.flags.adv_adj = adv_adj + ple.flags.from_repost = from_repost + ple.flags.update_outstanding = update_outstanding ple.submit() From 7312f22f35c66e51587120aa827ad0f144a927a9 Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Sun, 29 May 2022 21:33:08 +0530 Subject: [PATCH 7/8] refactor: update voucher outstanding from payment ledger Outstanding amount is updated from payment ledger, only for receivable/payable accounts. For remaining account types, update happens from GL Entry. --- erpnext/accounts/doctype/gl_entry/gl_entry.py | 24 ++-- .../payment_ledger_entry.py | 127 ++++++++++++++++++ erpnext/accounts/general_ledger.py | 11 +- erpnext/accounts/utils.py | 30 +++++ 4 files changed, 181 insertions(+), 11 deletions(-) diff --git a/erpnext/accounts/doctype/gl_entry/gl_entry.py b/erpnext/accounts/doctype/gl_entry/gl_entry.py index e5fa57df7fd..9f716568cc0 100644 --- a/erpnext/accounts/doctype/gl_entry/gl_entry.py +++ b/erpnext/accounts/doctype/gl_entry/gl_entry.py @@ -58,16 +58,20 @@ class GLEntry(Document): validate_balance_type(self.account, adv_adj) validate_frozen_account(self.account, adv_adj) - # Update outstanding amt on against voucher - if ( - self.against_voucher_type in ["Journal Entry", "Sales Invoice", "Purchase Invoice", "Fees"] - and self.against_voucher - and self.flags.update_outstanding == "Yes" - and not frappe.flags.is_reverse_depr_entry - ): - update_outstanding_amt( - self.account, self.party_type, self.party, self.against_voucher_type, self.against_voucher - ) + if frappe.db.get_value("Account", self.account, "account_type") not in [ + "Receivable", + "Payable", + ]: + # Update outstanding amt on against voucher + if ( + self.against_voucher_type in ["Journal Entry", "Sales Invoice", "Purchase Invoice", "Fees"] + and self.against_voucher + and self.flags.update_outstanding == "Yes" + and not frappe.flags.is_reverse_depr_entry + ): + update_outstanding_amt( + self.account, self.party_type, self.party, self.against_voucher_type, self.against_voucher + ) def check_mandatory(self): mandatory = ["account", "voucher_type", "voucher_no", "company"] diff --git a/erpnext/accounts/doctype/payment_ledger_entry/payment_ledger_entry.py b/erpnext/accounts/doctype/payment_ledger_entry/payment_ledger_entry.py index 43e19f4ae7d..52df9234e27 100644 --- a/erpnext/accounts/doctype/payment_ledger_entry/payment_ledger_entry.py +++ b/erpnext/accounts/doctype/payment_ledger_entry/payment_ledger_entry.py @@ -6,6 +6,19 @@ import frappe from frappe import _ from frappe.model.document import Document +from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import ( + get_checks_for_pl_and_bs_accounts, +) +from erpnext.accounts.doctype.accounting_dimension_filter.accounting_dimension_filter import ( + get_dimension_filter_map, +) +from erpnext.accounts.doctype.gl_entry.gl_entry import ( + validate_balance_type, + validate_frozen_account, +) +from erpnext.accounts.utils import update_voucher_outstanding +from erpnext.exceptions import InvalidAccountDimensionError, MandatoryAccountDimensionError + class PaymentLedgerEntry(Document): def validate_account(self): @@ -18,5 +31,119 @@ class PaymentLedgerEntry(Document): if not valid_account: frappe.throw(_("{0} account is not of type {1}").format(self.account, self.account_type)) + def validate_account_details(self): + """Account must be ledger, active and not freezed""" + + ret = frappe.db.sql( + """select is_group, docstatus, company + from tabAccount where name=%s""", + self.account, + as_dict=1, + )[0] + + if ret.is_group == 1: + frappe.throw( + _( + """{0} {1}: Account {2} is a Group Account and group accounts cannot be used in transactions""" + ).format(self.voucher_type, self.voucher_no, self.account) + ) + + if ret.docstatus == 2: + frappe.throw( + _("{0} {1}: Account {2} is inactive").format(self.voucher_type, self.voucher_no, self.account) + ) + + if ret.company != self.company: + frappe.throw( + _("{0} {1}: Account {2} does not belong to Company {3}").format( + self.voucher_type, self.voucher_no, self.account, self.company + ) + ) + + def validate_allowed_dimensions(self): + dimension_filter_map = get_dimension_filter_map() + for key, value in dimension_filter_map.items(): + dimension = key[0] + account = key[1] + + if self.account == account: + if value["is_mandatory"] and not self.get(dimension): + frappe.throw( + _("{0} is mandatory for account {1}").format( + frappe.bold(frappe.unscrub(dimension)), frappe.bold(self.account) + ), + MandatoryAccountDimensionError, + ) + + if value["allow_or_restrict"] == "Allow": + if self.get(dimension) and self.get(dimension) not in value["allowed_dimensions"]: + frappe.throw( + _("Invalid value {0} for {1} against account {2}").format( + frappe.bold(self.get(dimension)), + frappe.bold(frappe.unscrub(dimension)), + frappe.bold(self.account), + ), + InvalidAccountDimensionError, + ) + else: + if self.get(dimension) and self.get(dimension) in value["allowed_dimensions"]: + frappe.throw( + _("Invalid value {0} for {1} against account {2}").format( + frappe.bold(self.get(dimension)), + frappe.bold(frappe.unscrub(dimension)), + frappe.bold(self.account), + ), + InvalidAccountDimensionError, + ) + + def validate_dimensions_for_pl_and_bs(self): + account_type = frappe.db.get_value("Account", self.account, "report_type") + + for dimension in get_checks_for_pl_and_bs_accounts(): + if ( + account_type == "Profit and Loss" + and self.company == dimension.company + and dimension.mandatory_for_pl + and not dimension.disabled + ): + if not self.get(dimension.fieldname): + frappe.throw( + _("Accounting Dimension {0} is required for 'Profit and Loss' account {1}.").format( + dimension.label, self.account + ) + ) + + if ( + account_type == "Balance Sheet" + and self.company == dimension.company + and dimension.mandatory_for_bs + and not dimension.disabled + ): + if not self.get(dimension.fieldname): + frappe.throw( + _("Accounting Dimension {0} is required for 'Balance Sheet' account {1}.").format( + dimension.label, self.account + ) + ) + def validate(self): self.validate_account() + + def on_update(self): + adv_adj = self.flags.adv_adj + if not self.flags.from_repost: + self.validate_account_details() + self.validate_dimensions_for_pl_and_bs() + self.validate_allowed_dimensions() + validate_balance_type(self.account, adv_adj) + validate_frozen_account(self.account, adv_adj) + + # update outstanding amount + if ( + self.against_voucher_type in ["Journal Entry", "Sales Invoice", "Purchase Invoice", "Fees"] + and self.flags.update_outstanding == "Yes" + and not frappe.flags.is_reverse_depr_entry + ): + update_voucher_outstanding( + self.against_voucher_type, self.against_voucher_no, self.account, self.party_type, self.party + ) diff --git a/erpnext/accounts/general_ledger.py b/erpnext/accounts/general_ledger.py index b0513f16a59..81468047058 100644 --- a/erpnext/accounts/general_ledger.py +++ b/erpnext/accounts/general_ledger.py @@ -35,7 +35,13 @@ def make_gl_entries( validate_disabled_accounts(gl_map) gl_map = process_gl_map(gl_map, merge_entries) if gl_map and len(gl_map) > 1: - create_payment_ledger_entry(gl_map) + create_payment_ledger_entry( + gl_map, + cancel=0, + adv_adj=adv_adj, + update_outstanding=update_outstanding, + from_repost=from_repost, + ) save_entries(gl_map, adv_adj, update_outstanding, from_repost) # Post GL Map proccess there may no be any GL Entries elif gl_map: @@ -482,6 +488,9 @@ def make_reverse_gl_entries( if gl_entries: create_payment_ledger_entry(gl_entries, cancel=1) + create_payment_ledger_entry( + gl_entries, cancel=1, adv_adj=adv_adj, update_outstanding=update_outstanding + ) validate_accounting_period(gl_entries) check_freezing_date(gl_entries[0]["posting_date"], adv_adj) set_as_cancel(gl_entries[0]["voucher_type"], gl_entries[0]["voucher_no"]) diff --git a/erpnext/accounts/utils.py b/erpnext/accounts/utils.py index 42a748e1aa1..8daff9d1936 100644 --- a/erpnext/accounts/utils.py +++ b/erpnext/accounts/utils.py @@ -1433,6 +1433,36 @@ def create_payment_ledger_entry( ple.submit() +def update_voucher_outstanding(voucher_type, voucher_no, account, party_type, party): + ple = frappe.qb.DocType("Payment Ledger Entry") + vouchers = [frappe._dict({"voucher_type": voucher_type, "voucher_no": voucher_no})] + common_filter = [] + if account: + common_filter.append(ple.account == account) + + if party_type: + common_filter.append(ple.party_type == party_type) + + if party: + common_filter.append(ple.party == party) + + ple_query = QueryPaymentLedger() + + # on cancellation outstanding can be an empty list + voucher_outstanding = ple_query.get_voucher_outstandings(vouchers, common_filter=common_filter) + if voucher_type in ["Sales Invoice", "Purchase Invoice", "Fees"] and voucher_outstanding: + outstanding = voucher_outstanding[0] + ref_doc = frappe.get_doc(voucher_type, voucher_no) + + # Didn't use db_set for optimisation purpose + ref_doc.outstanding_amount = outstanding["outstanding_in_account_currency"] + frappe.db.set_value( + voucher_type, voucher_no, "outstanding_amount", outstanding["outstanding_in_account_currency"] + ) + + ref_doc.set_status(update=True) + + def delink_original_entry(pl_entry): if pl_entry: ple = qb.DocType("Payment Ledger Entry") From 3a238b4daa0441679bf5df43275ba770e651f14e Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Fri, 10 Jun 2022 06:55:02 +0530 Subject: [PATCH 8/8] docs: specification of payment ledger --- erpnext/accounts/README.md | 40 +++++++++++++++++++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/erpnext/accounts/README.md b/erpnext/accounts/README.md index da1f2015706..15f70392079 100644 --- a/erpnext/accounts/README.md +++ b/erpnext/accounts/README.md @@ -10,4 +10,42 @@ Entries are: - Sales Invoice (Itemised) - Purchase Invoice (Itemised) -All accounting entries are stored in the `General Ledger` \ No newline at end of file +All accounting entries are stored in the `General Ledger` + +## Payment Ledger +Transactions on Receivable and Payable Account types will also be stored in `Payment Ledger`. This is so that payment reconciliation process only requires update on this ledger. + +### Key Fields +| Field | Description | +|----------------------|----------------------------------| +| `account_type` | Receivable/Payable | +| `account` | Accounting head | +| `party` | Party Name | +| `voucher_no` | Voucher No | +| `against_voucher_no` | Linked voucher(secondary effect) | +| `amount` | can be +ve/-ve | + +### Design +`debit` and `credit` have been replaced with `account_type` and `amount`. `against_voucher_no` is populated for all entries. So, outstanding amount can be calculated by summing up amount only using `against_voucher_no`. + +Ex: +1. Consider an invoice for ₹100 and a partial payment of ₹80 against that invoice. Payment Ledger will have following entries. + +| voucher_no | against_voucher_no | amount | +|------------|--------------------|--------| +| SINV-01 | SINV-01 | 100 | +| PAY-01 | SINV-01 | -80 | + + +2. Reconcile a Credit Note against an invoice using a Journal Entry + +An invoice for ₹100 partially reconciled against a credit of ₹70 using a Journal Entry. Payment Ledger will have the following entries. + +| voucher_no | against_voucher_no | amount | +|------------|--------------------|--------| +| SINV-01 | SINV-01 | 100 | +| | | | +| CR-NOTE-01 | CR-NOTE-01 | -70 | +| | | | +| JE-01 | CR-NOTE-01 | +70 | +| JE-01 | SINV-01 | -70 |