From 768c3a49278e35abc31a04a0b87d2dcd2e8794d8 Mon Sep 17 00:00:00 2001 From: marination Date: Fri, 3 Mar 2023 13:21:38 +0530 Subject: [PATCH 01/20] fix: Taxes aren't discounted on early payment discount - Deductions in payment entry must be split into income loss and tax loss - Compute total discount in percentage, makes discounting different amounts proportionately easier --- .../doctype/payment_entry/payment_entry.py | 92 ++++++++++++++++++- 1 file changed, 89 insertions(+), 3 deletions(-) diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.py b/erpnext/accounts/doctype/payment_entry/payment_entry.py index cd5b6d5ce2..91d31ab0ec 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry.py +++ b/erpnext/accounts/doctype/payment_entry/payment_entry.py @@ -1669,7 +1669,7 @@ def get_payment_entry( dt, party_account_currency, bank, outstanding_amount, payment_type, bank_amount, doc ) - paid_amount, received_amount, discount_amount = apply_early_payment_discount( + paid_amount, received_amount, discount_amount, valid_discounts = apply_early_payment_discount( paid_amount, received_amount, doc ) @@ -1769,7 +1769,9 @@ def get_payment_entry( if party_account and bank: pe.set_exchange_rate(ref_doc=reference_doc) pe.set_amounts() - if discount_amount: + + discount_amount = set_early_payment_discount_loss(pe, doc, valid_discounts, discount_amount) + if discount_amount > 0: pe.set_gain_or_loss( account_details={ "account": frappe.get_cached_value("Company", pe.company, "default_discount_account"), @@ -1891,6 +1893,7 @@ def set_paid_amount_and_received_amount( def apply_early_payment_discount(paid_amount, received_amount, doc): total_discount = 0 + valid_discounts = [] eligible_for_payments = ["Sales Order", "Sales Invoice", "Purchase Order", "Purchase Invoice"] has_payment_schedule = hasattr(doc, "payment_schedule") and doc.payment_schedule @@ -1911,13 +1914,96 @@ def apply_early_payment_discount(paid_amount, received_amount, doc): received_amount -= discount_amount paid_amount -= discount_amount_in_foreign_currency + valid_discounts.append({"type": term.discount_type, "discount": term.discount}) total_discount += discount_amount if total_discount: money = frappe.utils.fmt_money(total_discount, currency=doc.get("currency")) frappe.msgprint(_("Discount of {} applied as per Payment Term").format(money), alert=1) - return paid_amount, received_amount, total_discount + return paid_amount, received_amount, total_discount, valid_discounts + + +def set_early_payment_discount_loss(pe, doc, valid_discounts, discount_amount): + """Split early bird discount deductions into Income Loss & Tax Loss.""" + if not (discount_amount and valid_discounts): + return discount_amount + + total_discount_percent = get_total_discount_percent(doc, valid_discounts) + + if not total_discount_percent: + return discount_amount + + loss_on_income = add_income_discount_loss(pe, doc, total_discount_percent) + loss_on_taxes = add_tax_discount_loss(pe, doc, total_discount_percent) + + return flt(discount_amount - (loss_on_income + loss_on_taxes)) + + +def get_total_discount_percent(doc, valid_discounts) -> float: + """Get total percentage and amount discount applied as a percentage.""" + total_discount_percent = ( + sum( + discount.get("discount") for discount in valid_discounts if discount.get("type") == "Percentage" + ) + or 0.0 + ) + + # Operate in percentages only as it makes the income & tax split easier + total_discount_amount = ( + sum(discount.get("discount") for discount in valid_discounts if discount.get("type") == "Amount") + or 0.0 + ) + + if total_discount_amount: + discount_percentage = (total_discount_amount / doc.get("grand_total")) * 100 + total_discount_percent += discount_percentage + return total_discount_percent + + return total_discount_percent + + +def add_income_discount_loss(pe, doc, total_discount_percent) -> float: + loss_on_income = flt(doc.get("total") * (total_discount_percent / 100), doc.precision("total")) + pe.append( + "deductions", + { + "account": frappe.get_cached_value("Company", pe.company, "default_discount_account"), + "cost_center": pe.cost_center or frappe.get_cached_value("Company", pe.company, "cost_center"), + "amount": loss_on_income, + }, + ) + return loss_on_income + + +def add_tax_discount_loss(pe, doc, total_discount_percenatage) -> float: + tax_discount_loss = {} + total_tax_loss = 0 + precision = doc.precision("tax_amount_after_discount_amount", "taxes") + + # The same account head could be used more than once + for tax in doc.get("taxes", []): + tax_loss = flt( + tax.get("tax_amount_after_discount_amount") * (total_discount_percenatage / 100), precision + ) + account = tax.get("account_head") + if not tax_discount_loss.get(account): + tax_discount_loss[account] = tax_loss + else: + tax_discount_loss[account] += tax_loss + + for account, loss in tax_discount_loss.items(): + total_tax_loss += loss + pe.append( + "deductions", + { + "account": account, + "cost_center": pe.cost_center or frappe.get_cached_value("Company", pe.company, "cost_center"), + "amount": loss, + }, + ) + + return total_tax_loss def get_reference_as_per_payment_terms( From 75ec0a0a85a010415765518f5a9e36bb13d08b22 Mon Sep 17 00:00:00 2001 From: marination Date: Fri, 3 Mar 2023 14:13:27 +0530 Subject: [PATCH 02/20] fix: Recalculate difference amount after setting deductions --- erpnext/accounts/doctype/payment_entry/payment_entry.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.py b/erpnext/accounts/doctype/payment_entry/payment_entry.py index 91d31ab0ec..cf1cc0a839 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry.py +++ b/erpnext/accounts/doctype/payment_entry/payment_entry.py @@ -1772,6 +1772,7 @@ def get_payment_entry( discount_amount = set_early_payment_discount_loss(pe, doc, valid_discounts, discount_amount) if discount_amount > 0: + # Set pending discount amount in deductions pe.set_gain_or_loss( account_details={ "account": frappe.get_cached_value("Company", pe.company, "default_discount_account"), @@ -1780,7 +1781,8 @@ def get_payment_entry( "amount": discount_amount * (-1 if payment_type == "Pay" else 1), } ) - pe.set_difference_amount() + + pe.set_difference_amount() return pe From dc2998f5442613e3c3624493896686fc75f3c388 Mon Sep 17 00:00:00 2001 From: marination Date: Fri, 3 Mar 2023 17:24:43 +0530 Subject: [PATCH 03/20] fix: Set deductions in base currency - Use field precision to get more accurate values --- .../doctype/payment_entry/payment_entry.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.py b/erpnext/accounts/doctype/payment_entry/payment_entry.py index cf1cc0a839..05399d0cec 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry.py +++ b/erpnext/accounts/doctype/payment_entry/payment_entry.py @@ -1772,13 +1772,14 @@ def get_payment_entry( discount_amount = set_early_payment_discount_loss(pe, doc, valid_discounts, discount_amount) if discount_amount > 0: - # Set pending discount amount in deductions + # Set pending base discount amount in deductions + positive_negative = -1 if payment_type == "Pay" else 1 pe.set_gain_or_loss( account_details={ "account": frappe.get_cached_value("Company", pe.company, "default_discount_account"), "cost_center": pe.cost_center or frappe.get_cached_value("Company", pe.company, "cost_center"), - "amount": discount_amount * (-1 if payment_type == "Pay" else 1), + "amount": discount_amount * positive_negative * doc.get("conversion_rate", 1), } ) @@ -1966,19 +1967,22 @@ def get_total_discount_percent(doc, valid_discounts) -> float: def add_income_discount_loss(pe, doc, total_discount_percent) -> float: - loss_on_income = flt(doc.get("total") * (total_discount_percent / 100), doc.precision("total")) + """Add loss on income discount in base currency.""" + precision = doc.precision("total") + loss_on_income = flt(doc.get("total") * (total_discount_percent / 100), precision) pe.append( "deductions", { "account": frappe.get_cached_value("Company", pe.company, "default_discount_account"), "cost_center": pe.cost_center or frappe.get_cached_value("Company", pe.company, "cost_center"), - "amount": loss_on_income, + "amount": flt(loss_on_income * doc.get("conversion_rate", 1), precision), }, ) return loss_on_income def add_tax_discount_loss(pe, doc, total_discount_percenatage) -> float: + """Add loss on tax discount in base currency.""" tax_discount_loss = {} total_tax_loss = 0 precision = doc.precision("tax_amount_after_discount_amount", "taxes") @@ -2001,7 +2005,7 @@ def add_tax_discount_loss(pe, doc, total_discount_percenatage) -> float: { "account": account, "cost_center": pe.cost_center or frappe.get_cached_value("Company", pe.company, "cost_center"), - "amount": loss, + "amount": flt(loss * doc.get("conversion_rate", 1), precision), }, ) From 2ae58342907c0cfb9ae7658176e1549fb51d1cb3 Mon Sep 17 00:00:00 2001 From: marination Date: Mon, 6 Mar 2023 14:54:46 +0530 Subject: [PATCH 04/20] fix: Back update discounted amount in Invoice based on discount type - Discount value was always trated as a percentage on back updation --- .../doctype/payment_entry/payment_entry.py | 21 +++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.py b/erpnext/accounts/doctype/payment_entry/payment_entry.py index 05399d0cec..6e612eeee7 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry.py +++ b/erpnext/accounts/doctype/payment_entry/payment_entry.py @@ -424,15 +424,28 @@ class PaymentEntry(AccountsController): payment_schedule = frappe.get_all( "Payment Schedule", filters={"parent": ref.reference_name}, - fields=["paid_amount", "payment_amount", "payment_term", "discount", "outstanding"], + fields=[ + "paid_amount", + "payment_amount", + "payment_term", + "discount", + "outstanding", + "discount_type", + ], ) for term in payment_schedule: invoice_key = (term.payment_term, ref.reference_name) invoice_paid_amount_map.setdefault(invoice_key, {}) invoice_paid_amount_map[invoice_key]["outstanding"] = term.outstanding - invoice_paid_amount_map[invoice_key]["discounted_amt"] = ref.total_amount * ( - term.discount / 100 - ) + if not (term.discount_type and term.discount): + continue + + if term.discount_type == "Percentage": + invoice_paid_amount_map[invoice_key]["discounted_amt"] = ref.total_amount * ( + term.discount / 100 + ) + else: + invoice_paid_amount_map[invoice_key]["discounted_amt"] = term.discount for idx, (key, allocated_amount) in enumerate(invoice_payment_amount_map.items(), 1): if not invoice_paid_amount_map.get(key): From c217bb201878327fb6dfa341fbf65c19761916a5 Mon Sep 17 00:00:00 2001 From: marination Date: Mon, 6 Mar 2023 15:02:32 +0530 Subject: [PATCH 05/20] test: PE from SI with early payment discount amount & PE assertions in discount % test --- .../payment_entry/test_payment_entry.py | 67 ++++++++++++++++--- 1 file changed, 59 insertions(+), 8 deletions(-) diff --git a/erpnext/accounts/doctype/payment_entry/test_payment_entry.py b/erpnext/accounts/doctype/payment_entry/test_payment_entry.py index 123b5dfd51..fe80bad3d1 100644 --- a/erpnext/accounts/doctype/payment_entry/test_payment_entry.py +++ b/erpnext/accounts/doctype/payment_entry/test_payment_entry.py @@ -260,6 +260,14 @@ class TestPaymentEntry(FrappeTestCase): si.submit() pe = get_payment_entry("Sales Invoice", si.name, bank_account="_Test Cash - _TC") + + self.assertEqual(pe.references[0].payment_term, "30 Credit Days with 10% Discount") + self.assertEqual(pe.references[0].allocated_amount, 236.0) + self.assertEqual(pe.paid_amount, 212.4) + self.assertEqual(pe.deductions[0].amount, 20.0) # Loss on Income + self.assertEqual(pe.deductions[1].amount, 3.6) # Loss on Tax + self.assertEqual(pe.deductions[1].account, "_Test Account Service Tax - _TC") + pe.submit() si.load_from_db() @@ -269,6 +277,46 @@ class TestPaymentEntry(FrappeTestCase): self.assertEqual(si.payment_schedule[0].outstanding, 0) self.assertEqual(si.payment_schedule[0].discounted_amount, 23.6) + def test_payment_entry_against_payment_terms_with_discount_amount(self): + si = create_sales_invoice(do_not_save=1, qty=1, rate=200) + + si.payment_terms_template = "Test Discount Amount Template" + create_payment_terms_template_with_discount( + name="30 Credit Days with Rs.50 Discount", + discount_type="Amount", + discount=50, + template_name="Test Discount Amount Template", + ) + frappe.db.set_value("Company", si.company, "default_discount_account", "Write Off - _TC") + + si.append( + "taxes", + { + "charge_type": "On Net Total", + "account_head": "_Test Account Service Tax - _TC", + "cost_center": "_Test Cost Center - _TC", + "description": "Service Tax", + "rate": 18, + }, + ) + si.save() + si.submit() + + pe = get_payment_entry("Sales Invoice", si.name, bank_account="_Test Cash - _TC") + self.assertEqual(pe.references[0].allocated_amount, 236.0) + self.assertEqual(pe.paid_amount, 186) + self.assertEqual(pe.deductions[0].amount, 42.37) # Loss on Income + self.assertEqual(pe.deductions[1].amount, 7.63) # Loss on Tax + self.assertEqual(pe.deductions[1].account, "_Test Account Service Tax - _TC") + + pe.submit() + si.load_from_db() + + self.assertEqual(si.payment_schedule[0].payment_amount, 236.0) + self.assertEqual(si.payment_schedule[0].paid_amount, 186) + self.assertEqual(si.payment_schedule[0].outstanding, 0) + self.assertEqual(si.payment_schedule[0].discounted_amount, 50) + def test_payment_against_purchase_invoice_to_check_status(self): pi = make_purchase_invoice( supplier="_Test Supplier USD", @@ -839,24 +887,27 @@ def create_payment_terms_template(): ).insert() -def create_payment_terms_template_with_discount(): +def create_payment_terms_template_with_discount( + name=None, discount_type=None, discount=None, template_name=None +): + create_payment_term(name or "30 Credit Days with 10% Discount") + template_name = template_name or "Test Discount Template" - create_payment_term("30 Credit Days with 10% Discount") - - if not frappe.db.exists("Payment Terms Template", "Test Discount Template"): - payment_term_template = frappe.get_doc( + if not frappe.db.exists("Payment Terms Template", template_name): + frappe.get_doc( { "doctype": "Payment Terms Template", - "template_name": "Test Discount Template", + "template_name": template_name, "allocate_payment_based_on_payment_terms": 1, "terms": [ { "doctype": "Payment Terms Template Detail", - "payment_term": "30 Credit Days with 10% Discount", + "payment_term": name or "30 Credit Days with 10% Discount", "invoice_portion": 100, "credit_days_based_on": "Day(s) after invoice date", "credit_days": 2, - "discount": 10, + "discount_type": discount_type or "Percentage", + "discount": discount or 10, "discount_validity_based_on": "Day(s) after invoice date", "discount_validity": 1, } From 7f2e7badffab44355a4525369eb1a044b2f9e5c1 Mon Sep 17 00:00:00 2001 From: marination Date: Mon, 6 Mar 2023 17:43:40 +0530 Subject: [PATCH 06/20] fix: Set deduction amount in company currency on Doctype - Even via JS, deductions amount is always in company currency - Since there is nothing dynamic about this field, set it in the doctype spec itself - fixed: Inconsistency between label currency and field currency formatted value --- .../doctype/payment_entry/payment_entry.js | 2 -- .../payment_entry_deduction.json | 29 +++++++------------ 2 files changed, 11 insertions(+), 20 deletions(-) diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.js b/erpnext/accounts/doctype/payment_entry/payment_entry.js index 91374ae217..5a56a6b004 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry.js +++ b/erpnext/accounts/doctype/payment_entry/payment_entry.js @@ -245,8 +245,6 @@ frappe.ui.form.on('Payment Entry', { frm.set_currency_labels(["total_amount", "outstanding_amount", "allocated_amount"], party_account_currency, "references"); - frm.set_currency_labels(["amount"], company_currency, "deductions"); - cur_frm.set_df_property("source_exchange_rate", "description", ("1 " + frm.doc.paid_from_account_currency + " = [?] " + company_currency)); diff --git a/erpnext/accounts/doctype/payment_entry_deduction/payment_entry_deduction.json b/erpnext/accounts/doctype/payment_entry_deduction/payment_entry_deduction.json index 61a1462dd7..1c31829f0e 100644 --- a/erpnext/accounts/doctype/payment_entry_deduction/payment_entry_deduction.json +++ b/erpnext/accounts/doctype/payment_entry_deduction/payment_entry_deduction.json @@ -3,6 +3,7 @@ "creation": "2016-06-15 15:56:30.815503", "doctype": "DocType", "editable_grid": 1, + "engine": "InnoDB", "field_order": [ "account", "cost_center", @@ -17,9 +18,7 @@ "in_list_view": 1, "label": "Account", "options": "Account", - "reqd": 1, - "show_days": 1, - "show_seconds": 1 + "reqd": 1 }, { "fieldname": "cost_center", @@ -28,37 +27,30 @@ "label": "Cost Center", "options": "Cost Center", "print_hide": 1, - "reqd": 1, - "show_days": 1, - "show_seconds": 1 + "reqd": 1 }, { "fieldname": "amount", "fieldtype": "Currency", "in_list_view": 1, - "label": "Amount", - "reqd": 1, - "show_days": 1, - "show_seconds": 1 + "label": "Amount (Company Currency)", + "options": "Company:company:default_currency", + "reqd": 1 }, { "fieldname": "column_break_2", - "fieldtype": "Column Break", - "show_days": 1, - "show_seconds": 1 + "fieldtype": "Column Break" }, { "fieldname": "description", "fieldtype": "Small Text", - "label": "Description", - "show_days": 1, - "show_seconds": 1 + "label": "Description" } ], "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2020-09-12 20:38:08.110674", + "modified": "2023-03-06 07:11:57.739619", "modified_by": "Administrator", "module": "Accounts", "name": "Payment Entry Deduction", @@ -66,5 +58,6 @@ "permissions": [], "quick_entry": 1, "sort_field": "modified", - "sort_order": "DESC" + "sort_order": "DESC", + "states": [] } \ No newline at end of file From f02fc8acf0d50fcc178b713a1385595a40cb19f0 Mon Sep 17 00:00:00 2001 From: marination Date: Tue, 7 Mar 2023 12:12:45 +0530 Subject: [PATCH 07/20] fix: Don't add to deductions if amount is 0 - misc: better docstring --- erpnext/accounts/doctype/payment_entry/payment_entry.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.py b/erpnext/accounts/doctype/payment_entry/payment_entry.py index 6e612eeee7..f927b1b887 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry.py +++ b/erpnext/accounts/doctype/payment_entry/payment_entry.py @@ -1941,7 +1941,7 @@ def apply_early_payment_discount(paid_amount, received_amount, doc): def set_early_payment_discount_loss(pe, doc, valid_discounts, discount_amount): - """Split early bird discount deductions into Income Loss & Tax Loss.""" + """Split early payment discount into Income Loss & Tax Loss.""" if not (discount_amount and valid_discounts): return discount_amount @@ -2013,12 +2013,16 @@ def add_tax_discount_loss(pe, doc, total_discount_percenatage) -> float: for account, loss in tax_discount_loss.items(): total_tax_loss += loss + amount = flt(loss * doc.get("conversion_rate", 1), precision) + if amount == 0.0: + continue + pe.append( "deductions", { "account": account, "cost_center": pe.cost_center or frappe.get_cached_value("Company", pe.company, "cost_center"), - "amount": flt(loss * doc.get("conversion_rate", 1), precision), + "amount": amount, }, ) From 761f68d7bf0b8539f26a79993245c8ffcbcde5f1 Mon Sep 17 00:00:00 2001 From: marination Date: Wed, 8 Mar 2023 17:20:48 +0530 Subject: [PATCH 08/20] fix: Paid amount must be discounted considering accounting currency - Accounting is in the same currency if party currency and company currency is the same - If accounting is in the same currency, paid and recvd amount is in the base currency - Then, discount amount must also be in the base currency as it is deducted from paid amount - Received amount must be in base currency if not multi currency - cleanup: Deductions setting broken into smaller functions --- .../doctype/payment_entry/payment_entry.py | 58 ++++++++++++------- 1 file changed, 37 insertions(+), 21 deletions(-) diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.py b/erpnext/accounts/doctype/payment_entry/payment_entry.py index f927b1b887..152ec4df79 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry.py +++ b/erpnext/accounts/doctype/payment_entry/payment_entry.py @@ -1683,7 +1683,7 @@ def get_payment_entry( ) paid_amount, received_amount, discount_amount, valid_discounts = apply_early_payment_discount( - paid_amount, received_amount, doc + paid_amount, received_amount, doc, party_account_currency ) pe = frappe.new_doc("Payment Entry") @@ -1783,17 +1783,10 @@ def get_payment_entry( pe.set_exchange_rate(ref_doc=reference_doc) pe.set_amounts() - discount_amount = set_early_payment_discount_loss(pe, doc, valid_discounts, discount_amount) - if discount_amount > 0: - # Set pending base discount amount in deductions - positive_negative = -1 if payment_type == "Pay" else 1 - pe.set_gain_or_loss( - account_details={ - "account": frappe.get_cached_value("Company", pe.company, "default_discount_account"), - "cost_center": pe.cost_center - or frappe.get_cached_value("Company", pe.company, "cost_center"), - "amount": discount_amount * positive_negative * doc.get("conversion_rate", 1), - } + if discount_amount: + base_total_discount_loss = set_early_payment_discount_loss(pe, doc, valid_discounts) + set_pending_discount_loss( + pe, doc, discount_amount, base_total_discount_loss, party_account_currency ) pe.set_difference_amount() @@ -1907,7 +1900,7 @@ def set_paid_amount_and_received_amount( return paid_amount, received_amount -def apply_early_payment_discount(paid_amount, received_amount, doc): +def apply_early_payment_discount(paid_amount, received_amount, doc, party_account_currency): total_discount = 0 valid_discounts = [] eligible_for_payments = ["Sales Order", "Sales Invoice", "Purchase Order", "Purchase Invoice"] @@ -1916,12 +1909,17 @@ def apply_early_payment_discount(paid_amount, received_amount, doc): if doc.doctype in eligible_for_payments and has_payment_schedule: for term in doc.payment_schedule: if not term.discounted_amount and term.discount and getdate(nowdate()) <= term.discount_date: + is_multi_currency = party_account_currency != doc.company_currency + if term.discount_type == "Percentage": - discount_amount = flt(doc.get("grand_total")) * (term.discount / 100) + grand_total = doc.get("grand_total") if is_multi_currency else doc.get("base_grand_total") + discount_amount = flt(grand_total) * (term.discount / 100) else: discount_amount = term.discount - discount_amount_in_foreign_currency = discount_amount * doc.get("conversion_rate", 1) + # if accounting is done in the same currency, paid_amount = received_amount + conversion_rate = doc.get("conversion_rate", 1) if is_multi_currency else 1 + discount_amount_in_foreign_currency = discount_amount * conversion_rate if doc.doctype == "Sales Invoice": paid_amount -= discount_amount @@ -1940,20 +1938,38 @@ def apply_early_payment_discount(paid_amount, received_amount, doc): return paid_amount, received_amount, total_discount, valid_discounts -def set_early_payment_discount_loss(pe, doc, valid_discounts, discount_amount): - """Split early payment discount into Income Loss & Tax Loss.""" - if not (discount_amount and valid_discounts): - return discount_amount +def set_pending_discount_loss( + pe, doc, discount_amount, base_total_discount_loss, party_account_currency +): + # If multi-currency, get base discount amount to adjust with base currency deductions/losses + if party_account_currency != doc.company_currency: + discount_amount = discount_amount * doc.get("conversion_rate", 1) + discount_amount -= base_total_discount_loss + + # If pending base discount amount, set it in deductions + if discount_amount > 0.0: + positive_negative = -1 if pe.payment_type == "Pay" else 1 + pe.set_gain_or_loss( + account_details={ + "account": frappe.get_cached_value("Company", pe.company, "default_discount_account"), + "cost_center": pe.cost_center or frappe.get_cached_value("Company", pe.company, "cost_center"), + "amount": discount_amount * positive_negative, + } + ) + + +def set_early_payment_discount_loss(pe, doc, valid_discounts) -> float: + """Split early payment discount into Income Loss & Tax Loss.""" total_discount_percent = get_total_discount_percent(doc, valid_discounts) if not total_discount_percent: - return discount_amount + return 0.0 loss_on_income = add_income_discount_loss(pe, doc, total_discount_percent) loss_on_taxes = add_tax_discount_loss(pe, doc, total_discount_percent) - return flt(discount_amount - (loss_on_income + loss_on_taxes)) + return flt(loss_on_income + loss_on_taxes) def get_total_discount_percent(doc, valid_discounts) -> float: From b09c2381ca144c63098d0fedf79c92fa5f7b929a Mon Sep 17 00:00:00 2001 From: marination Date: Thu, 9 Mar 2023 16:25:45 +0530 Subject: [PATCH 09/20] fix: Multi-currency SI with base currency PE - Return total discount loss in base currency - Allocate payment based on terms: Set allocated amount in references table in base currency if accounting is in that currency - Allocate payment based on terms: While back updating set paid amount (payment schedule) in transaction currency always - minor: discount msgprint in correct currency --- .../doctype/payment_entry/payment_entry.py | 87 ++++++++++++++----- 1 file changed, 65 insertions(+), 22 deletions(-) diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.py b/erpnext/accounts/doctype/payment_entry/payment_entry.py index 152ec4df79..9dfc674ed7 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry.py +++ b/erpnext/accounts/doctype/payment_entry/payment_entry.py @@ -416,7 +416,7 @@ class PaymentEntry(AccountsController): for ref in self.get("references"): if ref.payment_term and ref.reference_name: - key = (ref.payment_term, ref.reference_name) + key = (ref.payment_term, ref.reference_name, ref.reference_doctype) invoice_payment_amount_map.setdefault(key, 0.0) invoice_payment_amount_map[key] += ref.allocated_amount @@ -434,7 +434,7 @@ class PaymentEntry(AccountsController): ], ) for term in payment_schedule: - invoice_key = (term.payment_term, ref.reference_name) + invoice_key = (term.payment_term, ref.reference_name, ref.reference_doctype) invoice_paid_amount_map.setdefault(invoice_key, {}) invoice_paid_amount_map[invoice_key]["outstanding"] = term.outstanding if not (term.discount_type and term.discount): @@ -451,6 +451,10 @@ class PaymentEntry(AccountsController): if not invoice_paid_amount_map.get(key): frappe.throw(_("Payment term {0} not used in {1}").format(key[0], key[1])) + allocated_amount = self.get_allocated_amount_in_transaction_currency( + allocated_amount, key[2], key[1] + ) + outstanding = flt(invoice_paid_amount_map.get(key, {}).get("outstanding")) discounted_amt = flt(invoice_paid_amount_map.get(key, {}).get("discounted_amt")) @@ -485,6 +489,33 @@ class PaymentEntry(AccountsController): (allocated_amount - discounted_amt, discounted_amt, allocated_amount, key[1], key[0]), ) + def get_allocated_amount_in_transaction_currency( + self, allocated_amount, reference_doctype, reference_docname + ): + """ + Payment Entry could be in base currency while reference's payment schedule + is always in transaction currency. + E.g. + * SI with base=INR and currency=USD + * SI with payment schedule in USD + * PE in INR (accounting done in base currency) + """ + ref_currency, ref_exchange_rate = frappe.db.get_value( + reference_doctype, reference_docname, ["currency", "conversion_rate"] + ) + is_single_currency = self.paid_from_account_currency == self.paid_to_account_currency + # PE in different currency + reference_is_multi_currency = self.paid_from_account_currency != ref_currency + + if not (is_single_currency and reference_is_multi_currency): + return allocated_amount + + allocated_amount = flt( + allocated_amount / ref_exchange_rate, self.precision("total_allocated_amount") + ) + + return allocated_amount + def set_status(self): if self.docstatus == 2: self.status = "Cancelled" @@ -1731,7 +1762,7 @@ def get_payment_entry( ): for reference in get_reference_as_per_payment_terms( - doc.payment_schedule, dt, dn, doc, grand_total, outstanding_amount + doc.payment_schedule, dt, dn, doc, grand_total, outstanding_amount, party_account_currency ): pe.append("references", reference) else: @@ -1905,11 +1936,11 @@ def apply_early_payment_discount(paid_amount, received_amount, doc, party_accoun valid_discounts = [] eligible_for_payments = ["Sales Order", "Sales Invoice", "Purchase Order", "Purchase Invoice"] has_payment_schedule = hasattr(doc, "payment_schedule") and doc.payment_schedule + is_multi_currency = party_account_currency != doc.company_currency if doc.doctype in eligible_for_payments and has_payment_schedule: for term in doc.payment_schedule: if not term.discounted_amount and term.discount and getdate(nowdate()) <= term.discount_date: - is_multi_currency = party_account_currency != doc.company_currency if term.discount_type == "Percentage": grand_total = doc.get("grand_total") if is_multi_currency else doc.get("base_grand_total") @@ -1932,7 +1963,8 @@ def apply_early_payment_discount(paid_amount, received_amount, doc, party_accoun total_discount += discount_amount if total_discount: - money = frappe.utils.fmt_money(total_discount, currency=doc.get("currency")) + currency = doc.get("currency") if is_multi_currency else doc.company_currency + money = frappe.utils.fmt_money(total_discount, currency=currency) frappe.msgprint(_("Discount of {} applied as per Payment Term").format(money), alert=1) return paid_amount, received_amount, total_discount, valid_discounts @@ -1952,7 +1984,6 @@ def set_pending_discount_loss( positive_negative = -1 if pe.payment_type == "Pay" else 1 pe.set_gain_or_loss( account_details={ - "account": frappe.get_cached_value("Company", pe.company, "default_discount_account"), "cost_center": pe.cost_center or frappe.get_cached_value("Company", pe.company, "cost_center"), "amount": discount_amount * positive_negative, } @@ -1966,10 +1997,10 @@ def set_early_payment_discount_loss(pe, doc, valid_discounts) -> float: if not total_discount_percent: return 0.0 - loss_on_income = add_income_discount_loss(pe, doc, total_discount_percent) - loss_on_taxes = add_tax_discount_loss(pe, doc, total_discount_percent) + base_loss_on_income = add_income_discount_loss(pe, doc, total_discount_percent) + base_loss_on_taxes = add_tax_discount_loss(pe, doc, total_discount_percent) - return flt(loss_on_income + loss_on_taxes) + return flt(base_loss_on_income + base_loss_on_taxes) def get_total_discount_percent(doc, valid_discounts) -> float: @@ -1999,38 +2030,41 @@ def add_income_discount_loss(pe, doc, total_discount_percent) -> float: """Add loss on income discount in base currency.""" precision = doc.precision("total") loss_on_income = flt(doc.get("total") * (total_discount_percent / 100), precision) + base_loss_on_income = flt(loss_on_income * doc.get("conversion_rate", 1), precision) + pe.append( "deductions", { "account": frappe.get_cached_value("Company", pe.company, "default_discount_account"), "cost_center": pe.cost_center or frappe.get_cached_value("Company", pe.company, "cost_center"), - "amount": flt(loss_on_income * doc.get("conversion_rate", 1), precision), + "amount": base_loss_on_income, }, ) - return loss_on_income + return base_loss_on_income -def add_tax_discount_loss(pe, doc, total_discount_percenatage) -> float: +def add_tax_discount_loss(pe, doc, total_discount_percentage) -> float: """Add loss on tax discount in base currency.""" tax_discount_loss = {} - total_tax_loss = 0 + base_total_tax_loss = 0 precision = doc.precision("tax_amount_after_discount_amount", "taxes") # The same account head could be used more than once for tax in doc.get("taxes", []): tax_loss = flt( - tax.get("tax_amount_after_discount_amount") * (total_discount_percenatage / 100), precision + tax.get("tax_amount_after_discount_amount") * (total_discount_percentage / 100), precision ) + base_tax_loss = flt(tax_loss * doc.get("conversion_rate", 1), precision) + account = tax.get("account_head") if not tax_discount_loss.get(account): - tax_discount_loss[account] = tax_loss + tax_discount_loss[account] = base_tax_loss else: - tax_discount_loss[account] += tax_loss + tax_discount_loss[account] += base_tax_loss for account, loss in tax_discount_loss.items(): - total_tax_loss += loss - amount = flt(loss * doc.get("conversion_rate", 1), precision) - if amount == 0.0: + base_total_tax_loss += loss + if loss == 0.0: continue pe.append( @@ -2038,21 +2072,30 @@ def add_tax_discount_loss(pe, doc, total_discount_percenatage) -> float: { "account": account, "cost_center": pe.cost_center or frappe.get_cached_value("Company", pe.company, "cost_center"), - "amount": amount, + "amount": loss, }, ) - return total_tax_loss + return base_total_tax_loss def get_reference_as_per_payment_terms( - payment_schedule, dt, dn, doc, grand_total, outstanding_amount + payment_schedule, dt, dn, doc, grand_total, outstanding_amount, party_account_currency ): references = [] + is_multi_currency_acc = (doc.currency != doc.company_currency) and ( + party_account_currency != doc.company_currency + ) + for payment_term in payment_schedule: payment_term_outstanding = flt( payment_term.payment_amount - payment_term.paid_amount, payment_term.precision("payment_amount") ) + if not is_multi_currency_acc: + # If accounting is done in company currency for multi-currency transaction + payment_term_outstanding = flt( + payment_term_outstanding * doc.get("conversion_rate"), payment_term.precision("payment_amount") + ) if payment_term_outstanding: references.append( From 9abf0ef615d38d806e27b0c2fcce48125fd75fa1 Mon Sep 17 00:00:00 2001 From: marination Date: Thu, 9 Mar 2023 16:32:36 +0530 Subject: [PATCH 10/20] test: Multi currency SI with multi-currency accounting and single currency accounting + Early payment discount --- .../payment_entry/test_payment_entry.py | 128 +++++++++++++++++- 1 file changed, 127 insertions(+), 1 deletion(-) diff --git a/erpnext/accounts/doctype/payment_entry/test_payment_entry.py b/erpnext/accounts/doctype/payment_entry/test_payment_entry.py index fe80bad3d1..6e5c25ee99 100644 --- a/erpnext/accounts/doctype/payment_entry/test_payment_entry.py +++ b/erpnext/accounts/doctype/payment_entry/test_payment_entry.py @@ -5,7 +5,7 @@ import unittest import frappe from frappe import qb -from frappe.tests.utils import FrappeTestCase +from frappe.tests.utils import FrappeTestCase, change_settings from frappe.utils import flt, nowdate from erpnext.accounts.doctype.payment_entry.payment_entry import ( @@ -317,6 +317,132 @@ class TestPaymentEntry(FrappeTestCase): self.assertEqual(si.payment_schedule[0].outstanding, 0) self.assertEqual(si.payment_schedule[0].discounted_amount, 50) + @change_settings( + "Accounts Settings", + {"allow_multi_currency_invoices_against_single_party_account": 1}, + ) + def test_payment_entry_multicurrency_si_with_base_currency_accounting_early_payment_discount( + self, + ): + """ + 1. Multi-currency SI with single currency accounting (company currency) + 2. PE with early payment discount + 3. Test if Paid Amount is calculated in company currency + 4. Test if deductions are calculated in company currency + + SI is in USD to document agreed amounts that are in USD, but the accounting is in base currency. + """ + si = create_sales_invoice( + customer="_Test Customer", + currency="USD", + conversion_rate=50, + do_not_save=1, + ) + create_payment_terms_template_with_discount() + si.payment_terms_template = "Test Discount Template" + + frappe.db.set_value("Company", si.company, "default_discount_account", "Write Off - _TC") + si.save() + si.submit() + + pe = get_payment_entry( + "Sales Invoice", + si.name, + bank_account="_Test Bank - _TC", + ) + pe.reference_no = si.name + pe.reference_date = nowdate() + + # Early payment discount loss on income + self.assertEqual(pe.paid_amount, 4500.0) # Amount in company currency + self.assertEqual(pe.received_amount, 4500.0) + self.assertEqual(pe.deductions[0].amount, 500.0) + self.assertEqual(pe.deductions[0].account, "Write Off - _TC") + self.assertEqual(pe.difference_amount, 0.0) + + pe.insert() + pe.submit() + + expected_gle = dict( + (d[0], d) + for d in [ + ["Debtors - _TC", 0, 5000, si.name], + ["_Test Bank - _TC", 4500, 0, None], + ["Write Off - _TC", 500.0, 0, None], + ] + ) + + self.validate_gl_entries(pe.name, expected_gle) + + outstanding_amount = flt(frappe.db.get_value("Sales Invoice", si.name, "outstanding_amount")) + self.assertEqual(outstanding_amount, 0) + + def test_payment_entry_multicurrency_accounting_si_with_early_payment_discount(self): + """ + 1. Multi-currency SI with multi-currency accounting + 2. PE with early payment discount and also exchange loss + 3. Test if Paid Amount is calculated in transaction currency + 4. Test if deductions are calculated in base/company currency + 5. Test if exchange loss is reflected in difference + """ + si = create_sales_invoice( + customer="_Test Customer USD", + debit_to="_Test Receivable USD - _TC", + currency="USD", + conversion_rate=50, + do_not_save=1, + ) + create_payment_terms_template_with_discount() + si.payment_terms_template = "Test Discount Template" + + frappe.db.set_value("Company", si.company, "default_discount_account", "Write Off - _TC") + si.save() + si.submit() + + pe = get_payment_entry( + "Sales Invoice", si.name, bank_account="_Test Bank - _TC", bank_amount=4700 + ) + pe.reference_no = si.name + pe.reference_date = nowdate() + + # Early payment discount loss on income + self.assertEqual(pe.paid_amount, 90.0) + self.assertEqual(pe.received_amount, 4200.0) # 5000 - 500 (discount) - 300 (exchange loss) + self.assertEqual(pe.deductions[0].amount, 500.0) + self.assertEqual(pe.deductions[0].account, "Write Off - _TC") + + # Exchange loss + self.assertEqual(pe.difference_amount, 300.0) + + pe.append( + "deductions", + { + "account": "_Test Exchange Gain/Loss - _TC", + "cost_center": "_Test Cost Center - _TC", + "amount": 300.0, + }, + ) + + pe.insert() + pe.submit() + + self.assertEqual(pe.difference_amount, 0.0) + + expected_gle = dict( + (d[0], d) + for d in [ + ["_Test Receivable USD - _TC", 0, 5000, si.name], + ["_Test Bank - _TC", 4200, 0, None], + ["Write Off - _TC", 500.0, 0, None], + ["_Test Exchange Gain/Loss - _TC", 300.0, 0, None], + ] + ) + + self.validate_gl_entries(pe.name, expected_gle) + + outstanding_amount = flt(frappe.db.get_value("Sales Invoice", si.name, "outstanding_amount")) + self.assertEqual(outstanding_amount, 0) + def test_payment_against_purchase_invoice_to_check_status(self): pi = make_purchase_invoice( supplier="_Test Supplier USD", From caa1a3dccf66b8ff379a4482841e8309f4a7fa6d Mon Sep 17 00:00:00 2001 From: marination Date: Mon, 13 Mar 2023 13:55:55 +0530 Subject: [PATCH 11/20] fix: Handle rounding more gracefully - Round off pending discount loss to avoid miniscule losses rounded to 0.0 that are added in deductions - Use base amounts to calculate base losses instead of using conversion factor which increases rounding error - Round of total base loss instead of individual income and tax losses to reduce rounding error - Use default round off account for pending rounding loss in deductions --- .../doctype/payment_entry/payment_entry.py | 26 ++++++++++--------- 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.py b/erpnext/accounts/doctype/payment_entry/payment_entry.py index 9dfc674ed7..8e47063002 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry.py +++ b/erpnext/accounts/doctype/payment_entry/payment_entry.py @@ -1977,13 +1977,15 @@ def set_pending_discount_loss( if party_account_currency != doc.company_currency: discount_amount = discount_amount * doc.get("conversion_rate", 1) - discount_amount -= base_total_discount_loss + # Avoid considering miniscule losses + discount_amount = flt(discount_amount - base_total_discount_loss, doc.precision("grand_total")) - # If pending base discount amount, set it in deductions + # If pending base discount amount (mostly rounding loss), set it in deductions if discount_amount > 0.0: positive_negative = -1 if pe.payment_type == "Pay" else 1 pe.set_gain_or_loss( account_details={ + "account": frappe.get_cached_value("Company", pe.company, "round_off_account"), "cost_center": pe.cost_center or frappe.get_cached_value("Company", pe.company, "cost_center"), "amount": discount_amount * positive_negative, } @@ -2000,7 +2002,8 @@ def set_early_payment_discount_loss(pe, doc, valid_discounts) -> float: base_loss_on_income = add_income_discount_loss(pe, doc, total_discount_percent) base_loss_on_taxes = add_tax_discount_loss(pe, doc, total_discount_percent) - return flt(base_loss_on_income + base_loss_on_taxes) + # Round off total loss rather than individual losses to reduce rounding error + return flt(base_loss_on_income + base_loss_on_taxes, doc.precision("grand_total")) def get_total_discount_percent(doc, valid_discounts) -> float: @@ -2029,18 +2032,18 @@ def get_total_discount_percent(doc, valid_discounts) -> float: def add_income_discount_loss(pe, doc, total_discount_percent) -> float: """Add loss on income discount in base currency.""" precision = doc.precision("total") - loss_on_income = flt(doc.get("total") * (total_discount_percent / 100), precision) - base_loss_on_income = flt(loss_on_income * doc.get("conversion_rate", 1), precision) + base_loss_on_income = doc.get("base_total") * (total_discount_percent / 100) pe.append( "deductions", { "account": frappe.get_cached_value("Company", pe.company, "default_discount_account"), "cost_center": pe.cost_center or frappe.get_cached_value("Company", pe.company, "cost_center"), - "amount": base_loss_on_income, + "amount": flt(base_loss_on_income, precision), }, ) - return base_loss_on_income + + return base_loss_on_income # Return loss without rounding def add_tax_discount_loss(pe, doc, total_discount_percentage) -> float: @@ -2051,10 +2054,9 @@ def add_tax_discount_loss(pe, doc, total_discount_percentage) -> float: # The same account head could be used more than once for tax in doc.get("taxes", []): - tax_loss = flt( - tax.get("tax_amount_after_discount_amount") * (total_discount_percentage / 100), precision + base_tax_loss = tax.get("base_tax_amount_after_discount_amount") * ( + total_discount_percentage / 100 ) - base_tax_loss = flt(tax_loss * doc.get("conversion_rate", 1), precision) account = tax.get("account_head") if not tax_discount_loss.get(account): @@ -2072,11 +2074,11 @@ def add_tax_discount_loss(pe, doc, total_discount_percentage) -> float: { "account": account, "cost_center": pe.cost_center or frappe.get_cached_value("Company", pe.company, "cost_center"), - "amount": loss, + "amount": flt(loss, precision), }, ) - return base_total_tax_loss + return base_total_tax_loss # Return loss without rounding def get_reference_as_per_payment_terms( From d6d0163514882a9d7ae16a61be54b7776c001e94 Mon Sep 17 00:00:00 2001 From: marination Date: Tue, 14 Mar 2023 13:40:55 +0530 Subject: [PATCH 12/20] fix: Provision to apply early payment discount if payment is recorded late - Party could have paid on time but payment is recorded late - Prompt for reference date so that discount is applied while mapping - Prompt only if discount in payment schedule of valid doctypes - test: Reference date and impact on PE - `make_payment_entry` (JS) must be able to access `this` --- .../doctype/payment_entry/payment_entry.py | 19 +++++-- .../payment_entry/test_payment_entry.py | 9 ++++ .../purchase_invoice/purchase_invoice.js | 6 ++- .../doctype/sales_invoice/sales_invoice.js | 9 ++-- .../doctype/purchase_order/purchase_order.js | 6 ++- erpnext/public/js/controllers/transaction.js | 52 ++++++++++++++++--- 6 files changed, 86 insertions(+), 15 deletions(-) diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.py b/erpnext/accounts/doctype/payment_entry/payment_entry.py index 8e47063002..15fd0c61eb 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry.py +++ b/erpnext/accounts/doctype/payment_entry/payment_entry.py @@ -1686,7 +1686,14 @@ def get_reference_details(reference_doctype, reference_name, party_account_curre @frappe.whitelist() def get_payment_entry( - dt, dn, party_amount=None, bank_account=None, bank_amount=None, party_type=None, payment_type=None + dt, + dn, + party_amount=None, + bank_account=None, + bank_amount=None, + party_type=None, + payment_type=None, + reference_date=None, ): reference_doc = None doc = frappe.get_doc(dt, dn) @@ -1713,8 +1720,9 @@ def get_payment_entry( dt, party_account_currency, bank, outstanding_amount, payment_type, bank_amount, doc ) + reference_date = getdate(reference_date) paid_amount, received_amount, discount_amount, valid_discounts = apply_early_payment_discount( - paid_amount, received_amount, doc, party_account_currency + paid_amount, received_amount, doc, party_account_currency, reference_date ) pe = frappe.new_doc("Payment Entry") @@ -1722,6 +1730,7 @@ def get_payment_entry( pe.company = doc.company pe.cost_center = doc.get("cost_center") pe.posting_date = nowdate() + pe.reference_date = reference_date pe.mode_of_payment = doc.get("mode_of_payment") pe.party_type = party_type pe.party = doc.get(scrub(party_type)) @@ -1931,7 +1940,9 @@ def set_paid_amount_and_received_amount( return paid_amount, received_amount -def apply_early_payment_discount(paid_amount, received_amount, doc, party_account_currency): +def apply_early_payment_discount( + paid_amount, received_amount, doc, party_account_currency, reference_date +): total_discount = 0 valid_discounts = [] eligible_for_payments = ["Sales Order", "Sales Invoice", "Purchase Order", "Purchase Invoice"] @@ -1940,7 +1951,7 @@ def apply_early_payment_discount(paid_amount, received_amount, doc, party_accoun if doc.doctype in eligible_for_payments and has_payment_schedule: for term in doc.payment_schedule: - if not term.discounted_amount and term.discount and getdate(nowdate()) <= term.discount_date: + if not term.discounted_amount and term.discount and reference_date <= term.discount_date: if term.discount_type == "Percentage": grand_total = doc.get("grand_total") if is_multi_currency else doc.get("base_grand_total") diff --git a/erpnext/accounts/doctype/payment_entry/test_payment_entry.py b/erpnext/accounts/doctype/payment_entry/test_payment_entry.py index 6e5c25ee99..ef57c99bda 100644 --- a/erpnext/accounts/doctype/payment_entry/test_payment_entry.py +++ b/erpnext/accounts/doctype/payment_entry/test_payment_entry.py @@ -302,6 +302,15 @@ class TestPaymentEntry(FrappeTestCase): si.save() si.submit() + # Set reference date past discount cut off date + pe_1 = get_payment_entry( + "Sales Invoice", + si.name, + bank_account="_Test Cash - _TC", + reference_date=frappe.utils.add_days(si.posting_date, 2), + ) + self.assertEqual(pe_1.paid_amount, 236.0) # discount not applied + pe = get_payment_entry("Sales Invoice", si.name, bank_account="_Test Cash - _TC") self.assertEqual(pe.references[0].allocated_amount, 236.0) self.assertEqual(pe.paid_amount, 186) diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js index e2b4a1ad5b..5c9168bf9c 100644 --- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js +++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js @@ -82,7 +82,11 @@ erpnext.accounts.PurchaseInvoice = class PurchaseInvoice extends erpnext.buying. if(doc.docstatus == 1 && doc.outstanding_amount != 0 && !(doc.is_return && doc.return_against) && !doc.on_hold) { - this.frm.add_custom_button(__('Payment'), this.make_payment_entry, __('Create')); + this.frm.add_custom_button( + __('Payment'), + () => this.make_payment_entry(), + __('Create') + ); cur_frm.page.set_inner_btn_group_as_primary(__('Create')); } diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.js b/erpnext/accounts/doctype/sales_invoice/sales_invoice.js index 47e3f9b935..56e412b297 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.js +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.js @@ -93,9 +93,12 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends e if (doc.docstatus == 1 && doc.outstanding_amount!=0 && !(cint(doc.is_return) && doc.return_against)) { - cur_frm.add_custom_button(__('Payment'), - this.make_payment_entry, __('Create')); - cur_frm.page.set_inner_btn_group_as_primary(__('Create')); + this.frm.add_custom_button( + __('Payment'), + () => this.make_payment_entry(), + __('Create') + ); + this.frm.page.set_inner_btn_group_as_primary(__('Create')); } if(doc.docstatus==1 && !doc.is_return) { diff --git a/erpnext/buying/doctype/purchase_order/purchase_order.js b/erpnext/buying/doctype/purchase_order/purchase_order.js index 47089f7d85..c6c9f1f98a 100644 --- a/erpnext/buying/doctype/purchase_order/purchase_order.js +++ b/erpnext/buying/doctype/purchase_order/purchase_order.js @@ -236,7 +236,11 @@ erpnext.buying.PurchaseOrderController = class PurchaseOrderController extends e this.make_purchase_invoice, __('Create')); if(flt(doc.per_billed) < 100 && doc.status != "Delivered") { - cur_frm.add_custom_button(__('Payment'), cur_frm.cscript.make_payment_entry, __('Create')); + this.frm.add_custom_button( + __('Payment'), + () => this.make_payment_entry(), + __('Create') + ); } if(flt(doc.per_billed) < 100) { diff --git a/erpnext/public/js/controllers/transaction.js b/erpnext/public/js/controllers/transaction.js index 8d69ea0c99..0bd4d91c6f 100644 --- a/erpnext/public/js/controllers/transaction.js +++ b/erpnext/public/js/controllers/transaction.js @@ -1897,20 +1897,60 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe } make_payment_entry() { + let via_journal_entry = this.frm.doc.__onload && this.frm.doc.__onload.make_payment_via_journal_entry; + if(this.has_discount_in_schedule() && !via_journal_entry) { + // If early payment discount is applied, ask user for reference date + this.prompt_user_for_reference_date(); + } else { + this.make_mapped_payment_entry(); + } + } + + make_mapped_payment_entry(args) { + var me = this; + args = args || { "dt": this.frm.doc.doctype, "dn": this.frm.doc.name }; return frappe.call({ - method: cur_frm.cscript.get_method_for_payment(), - args: { - "dt": cur_frm.doc.doctype, - "dn": cur_frm.doc.name - }, + method: me.get_method_for_payment(), + args: args, callback: function(r) { var doclist = frappe.model.sync(r.message); frappe.set_route("Form", doclist[0].doctype, doclist[0].name); - // cur_frm.refresh_fields() } }); } + prompt_user_for_reference_date(){ + var me = this; + frappe.prompt({ + label: __("Cheque/Reference Date"), + fieldname: "reference_date", + fieldtype: "Date", + reqd: 1, + }, (values) => { + let args = { + "dt": me.frm.doc.doctype, + "dn": me.frm.doc.name, + "reference_date": values.reference_date + } + me.make_mapped_payment_entry(args); + }, + __("Reference Date for Early Payment Discount"), + __("Continue") + ); + } + + has_discount_in_schedule() { + let is_eligible = in_list( + ["Sales Order", "Sales Invoice", "Purchase Order", "Purchase Invoice"], + this.frm.doctype + ); + let has_payment_schedule = this.frm.doc.payment_schedule && this.frm.doc.payment_schedule.length; + if(!is_eligible || !has_payment_schedule) return false; + + let has_discount = this.frm.doc.payment_schedule.some(row => row.discount_date); + return has_discount; + } + make_quality_inspection() { let data = []; const fields = [ From 216a46bd6615aab47a30ff79ddf78503080121c1 Mon Sep 17 00:00:00 2001 From: marination Date: Mon, 27 Mar 2023 16:11:00 +0530 Subject: [PATCH 13/20] feat: Make Tax loss booking optional - Checkbox in Accounts Settings - Apply checkbox in PE deductions setting logic - Adjust tests --- .../accounts_settings/accounts_settings.json | 10 +++++- .../doctype/payment_entry/payment_entry.py | 17 +++++++--- .../payment_entry/test_payment_entry.py | 34 ++++++++++++++----- 3 files changed, 47 insertions(+), 14 deletions(-) diff --git a/erpnext/accounts/doctype/accounts_settings/accounts_settings.json b/erpnext/accounts/doctype/accounts_settings/accounts_settings.json index 3f985b640b..c0eed18ad1 100644 --- a/erpnext/accounts/doctype/accounts_settings/accounts_settings.json +++ b/erpnext/accounts/doctype/accounts_settings/accounts_settings.json @@ -31,6 +31,7 @@ "determine_address_tax_category_from", "column_break_19", "add_taxes_from_item_tax_template", + "book_tax_discount_loss", "print_settings", "show_inclusive_tax_in_print", "column_break_12", @@ -360,6 +361,13 @@ "fieldname": "show_balance_in_coa", "fieldtype": "Check", "label": "Show Balances in Chart Of Accounts" + }, + { + "default": "0", + "description": "Split Early Payment Discount Loss into Income and Tax Loss", + "fieldname": "book_tax_discount_loss", + "fieldtype": "Check", + "label": "Book Tax Loss on Early Payment Discount" } ], "icon": "icon-cog", @@ -367,7 +375,7 @@ "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2023-01-02 12:07:42.434214", + "modified": "2023-03-28 09:50:20.375233", "modified_by": "Administrator", "module": "Accounts", "name": "Accounts Settings", diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.py b/erpnext/accounts/doctype/payment_entry/payment_entry.py index 15fd0c61eb..c34bddd77e 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry.py +++ b/erpnext/accounts/doctype/payment_entry/payment_entry.py @@ -1824,7 +1824,10 @@ def get_payment_entry( pe.set_amounts() if discount_amount: - base_total_discount_loss = set_early_payment_discount_loss(pe, doc, valid_discounts) + base_total_discount_loss = 0 + if frappe.db.get_single_value("Accounts Settings", "book_tax_discount_loss"): + base_total_discount_loss = split_early_payment_discount_loss(pe, doc, valid_discounts) + set_pending_discount_loss( pe, doc, discount_amount, base_total_discount_loss, party_account_currency ) @@ -1991,19 +1994,25 @@ def set_pending_discount_loss( # Avoid considering miniscule losses discount_amount = flt(discount_amount - base_total_discount_loss, doc.precision("grand_total")) - # If pending base discount amount (mostly rounding loss), set it in deductions + # Set base discount amount (discount loss/pending rounding loss) in deductions if discount_amount > 0.0: positive_negative = -1 if pe.payment_type == "Pay" else 1 + + # If tax loss booking is enabled, pending loss will be rounding loss. + # Otherwise it will be the total discount loss. + book_tax_loss = frappe.db.get_single_value("Accounts Settings", "book_tax_discount_loss") + account_type = "round_off_account" if book_tax_loss else "default_discount_account" + pe.set_gain_or_loss( account_details={ - "account": frappe.get_cached_value("Company", pe.company, "round_off_account"), + "account": frappe.get_cached_value("Company", pe.company, account_type), "cost_center": pe.cost_center or frappe.get_cached_value("Company", pe.company, "cost_center"), "amount": discount_amount * positive_negative, } ) -def set_early_payment_discount_loss(pe, doc, valid_discounts) -> float: +def split_early_payment_discount_loss(pe, doc, valid_discounts) -> float: """Split early payment discount into Income Loss & Tax Loss.""" total_discount_percent = get_total_discount_percent(doc, valid_discounts) diff --git a/erpnext/accounts/doctype/payment_entry/test_payment_entry.py b/erpnext/accounts/doctype/payment_entry/test_payment_entry.py index ef57c99bda..67049c47ad 100644 --- a/erpnext/accounts/doctype/payment_entry/test_payment_entry.py +++ b/erpnext/accounts/doctype/payment_entry/test_payment_entry.py @@ -256,17 +256,24 @@ class TestPaymentEntry(FrappeTestCase): }, ) si.save() - si.submit() + frappe.db.set_single_value("Accounts Settings", "book_tax_discount_loss", 1) + pe_with_tax_loss = get_payment_entry("Sales Invoice", si.name, bank_account="_Test Cash - _TC") + + self.assertEqual(pe_with_tax_loss.references[0].payment_term, "30 Credit Days with 10% Discount") + self.assertEqual(pe_with_tax_loss.references[0].allocated_amount, 236.0) + self.assertEqual(pe_with_tax_loss.paid_amount, 212.4) + self.assertEqual(pe_with_tax_loss.deductions[0].amount, 20.0) # Loss on Income + self.assertEqual(pe_with_tax_loss.deductions[1].amount, 3.6) # Loss on Tax + self.assertEqual(pe_with_tax_loss.deductions[1].account, "_Test Account Service Tax - _TC") + + frappe.db.set_single_value("Accounts Settings", "book_tax_discount_loss", 0) pe = get_payment_entry("Sales Invoice", si.name, bank_account="_Test Cash - _TC") - self.assertEqual(pe.references[0].payment_term, "30 Credit Days with 10% Discount") self.assertEqual(pe.references[0].allocated_amount, 236.0) self.assertEqual(pe.paid_amount, 212.4) - self.assertEqual(pe.deductions[0].amount, 20.0) # Loss on Income - self.assertEqual(pe.deductions[1].amount, 3.6) # Loss on Tax - self.assertEqual(pe.deductions[1].account, "_Test Account Service Tax - _TC") + self.assertEqual(pe.deductions[0].amount, 23.6) pe.submit() si.load_from_db() @@ -311,12 +318,18 @@ class TestPaymentEntry(FrappeTestCase): ) self.assertEqual(pe_1.paid_amount, 236.0) # discount not applied + # Test if tax loss is booked on enabling configuration + frappe.db.set_single_value("Accounts Settings", "book_tax_discount_loss", 1) + pe_with_tax_loss = get_payment_entry("Sales Invoice", si.name, bank_account="_Test Cash - _TC") + self.assertEqual(pe_with_tax_loss.deductions[0].amount, 42.37) # Loss on Income + self.assertEqual(pe_with_tax_loss.deductions[1].amount, 7.63) # Loss on Tax + self.assertEqual(pe_with_tax_loss.deductions[1].account, "_Test Account Service Tax - _TC") + + frappe.db.set_single_value("Accounts Settings", "book_tax_discount_loss", 0) pe = get_payment_entry("Sales Invoice", si.name, bank_account="_Test Cash - _TC") self.assertEqual(pe.references[0].allocated_amount, 236.0) self.assertEqual(pe.paid_amount, 186) - self.assertEqual(pe.deductions[0].amount, 42.37) # Loss on Income - self.assertEqual(pe.deductions[1].amount, 7.63) # Loss on Tax - self.assertEqual(pe.deductions[1].account, "_Test Account Service Tax - _TC") + self.assertEqual(pe.deductions[0].amount, 50.0) pe.submit() si.load_from_db() @@ -328,7 +341,10 @@ class TestPaymentEntry(FrappeTestCase): @change_settings( "Accounts Settings", - {"allow_multi_currency_invoices_against_single_party_account": 1}, + { + "allow_multi_currency_invoices_against_single_party_account": 1, + "book_tax_discount_loss": 1, + }, ) def test_payment_entry_multicurrency_si_with_base_currency_accounting_early_payment_discount( self, From a4112c75c5975b53e46ea5bab47daf1c4d8d7e7e Mon Sep 17 00:00:00 2001 From: s-aga-r Date: Thu, 30 Mar 2023 13:46:50 +0530 Subject: [PATCH 14/20] fix: BOM Update Cost, when no actual qty --- erpnext/manufacturing/doctype/bom/bom.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/erpnext/manufacturing/doctype/bom/bom.py b/erpnext/manufacturing/doctype/bom/bom.py index 619a415c8b..a085af859a 100644 --- a/erpnext/manufacturing/doctype/bom/bom.py +++ b/erpnext/manufacturing/doctype/bom/bom.py @@ -943,7 +943,8 @@ def get_valuation_rate(data): 2) If no value, get last valuation rate from SLE 3) If no value, get valuation rate from Item """ - from frappe.query_builder.functions import Sum + from frappe.query_builder.functions import Count, IfNull, Sum + from pypika import Case item_code, company = data.get("item_code"), data.get("company") valuation_rate = 0.0 @@ -954,7 +955,14 @@ def get_valuation_rate(data): frappe.qb.from_(bin_table) .join(wh_table) .on(bin_table.warehouse == wh_table.name) - .select((Sum(bin_table.stock_value) / Sum(bin_table.actual_qty)).as_("valuation_rate")) + .select( + Case() + .when( + Count(bin_table.name) > 0, IfNull(Sum(bin_table.stock_value) / Sum(bin_table.actual_qty), 0.0) + ) + .else_(None) + .as_("valuation_rate") + ) .where((bin_table.item_code == item_code) & (wh_table.company == company)) ).run(as_dict=True)[0] From 706be2a4155209abd3065fa1225f441b3c759740 Mon Sep 17 00:00:00 2001 From: s-aga-r Date: Fri, 31 Mar 2023 10:32:49 +0530 Subject: [PATCH 15/20] chore: make `Production Plan Item Reference` table hidden in Production Plan --- .../doctype/production_plan/production_plan.json | 3 ++- .../production_plan_item_reference.json | 5 +++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/erpnext/manufacturing/doctype/production_plan/production_plan.json b/erpnext/manufacturing/doctype/production_plan/production_plan.json index 2624daa41e..fdaa4a2a1d 100644 --- a/erpnext/manufacturing/doctype/production_plan/production_plan.json +++ b/erpnext/manufacturing/doctype/production_plan/production_plan.json @@ -344,6 +344,7 @@ { "fieldname": "prod_plan_references", "fieldtype": "Table", + "hidden": 1, "label": "Production Plan Item Reference", "options": "Production Plan Item Reference" }, @@ -397,7 +398,7 @@ "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2022-11-26 14:51:08.774372", + "modified": "2023-03-31 10:30:48.118932", "modified_by": "Administrator", "module": "Manufacturing", "name": "Production Plan", diff --git a/erpnext/manufacturing/doctype/production_plan_item_reference/production_plan_item_reference.json b/erpnext/manufacturing/doctype/production_plan_item_reference/production_plan_item_reference.json index 84dee4ad28..15ef20794c 100644 --- a/erpnext/manufacturing/doctype/production_plan_item_reference/production_plan_item_reference.json +++ b/erpnext/manufacturing/doctype/production_plan_item_reference/production_plan_item_reference.json @@ -28,7 +28,7 @@ "fieldname": "qty", "fieldtype": "Data", "in_list_view": 1, - "label": "qty" + "label": "Qty" }, { "fieldname": "item_reference", @@ -40,7 +40,7 @@ "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2021-05-07 17:03:49.707487", + "modified": "2023-03-31 10:30:14.604051", "modified_by": "Administrator", "module": "Manufacturing", "name": "Production Plan Item Reference", @@ -48,5 +48,6 @@ "permissions": [], "sort_field": "modified", "sort_order": "DESC", + "states": [], "track_changes": 1 } \ No newline at end of file From 576575c22798a011a092196fdd8c7523d238c682 Mon Sep 17 00:00:00 2001 From: Deepesh Garg Date: Fri, 31 Mar 2023 11:10:50 +0530 Subject: [PATCH 16/20] fix: Column value mismatch in COA blank template (#34658) --- .../chart_of_accounts_importer.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/erpnext/accounts/doctype/chart_of_accounts_importer/chart_of_accounts_importer.py b/erpnext/accounts/doctype/chart_of_accounts_importer/chart_of_accounts_importer.py index cb7da17901..d6e1be4123 100644 --- a/erpnext/accounts/doctype/chart_of_accounts_importer/chart_of_accounts_importer.py +++ b/erpnext/accounts/doctype/chart_of_accounts_importer/chart_of_accounts_importer.py @@ -325,14 +325,14 @@ def get_template(template_type): if template_type == "Blank Template": for root_type in get_root_types(): - writer.writerow(["", "", "", 1, "", root_type]) + writer.writerow(["", "", "", "", 1, "", root_type]) for account in get_mandatory_group_accounts(): - writer.writerow(["", "", "", 1, account, "Asset"]) + writer.writerow(["", "", "", "", 1, account, "Asset"]) for account_type in get_mandatory_account_types(): writer.writerow( - ["", "", "", 0, account_type.get("account_type"), account_type.get("root_type")] + ["", "", "", "", 0, account_type.get("account_type"), account_type.get("root_type")] ) else: writer = get_sample_template(writer) From 986daa65784fde3a2334599b58be222f030d79f6 Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Fri, 31 Mar 2023 12:09:57 +0530 Subject: [PATCH 17/20] fix: enclose ternary operator in parentheses --- erpnext/public/js/controllers/taxes_and_totals.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/public/js/controllers/taxes_and_totals.js b/erpnext/public/js/controllers/taxes_and_totals.js index 8e57ebd367..8efc47d18e 100644 --- a/erpnext/public/js/controllers/taxes_and_totals.js +++ b/erpnext/public/js/controllers/taxes_and_totals.js @@ -135,7 +135,7 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments { } else { // allow for '0' qty on Credit/Debit notes - let qty = item.qty || me.frm.doc.is_debit_note ? 1 : -1; + let qty = item.qty || (me.frm.doc.is_debit_note ? 1 : -1); item.net_amount = item.amount = flt(item.rate * qty, precision("amount", item)); } From 32a4ca6b6c65939e9d2db8b281bbc62cc886b883 Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Fri, 31 Mar 2023 14:57:49 +0530 Subject: [PATCH 18/20] fix: recalculate difference amount on allocation change --- .../payment_reconciliation.js | 28 +++++++++++++++++++ .../payment_reconciliation.py | 9 ++++++ 2 files changed, 37 insertions(+) diff --git a/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.js b/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.js index d986f32066..caffac5354 100644 --- a/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.js +++ b/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.js @@ -272,4 +272,32 @@ erpnext.accounts.PaymentReconciliationController = class PaymentReconciliationCo } }; +frappe.ui.form.on('Payment Reconciliation Allocation', { + allocated_amount: function(frm, cdt, cdn) { + let row = locals[cdt][cdn]; + // filter invoice + let invoice = frm.doc.invoices.filter((x) => (x.invoice_number == row.invoice_number)); + // filter payment + let payment = frm.doc.payments.filter((x) => (x.reference_name == row.reference_name)); + + frm.call({ + doc: frm.doc, + method: 'calculate_difference_on_allocation_change', + args: { + payment_entry: payment, + invoice: invoice, + allocated_amount: row.allocated_amount + }, + callback: (r) => { + if (r.message) { + row.difference_amount = r.message; + frm.refresh(); + } + } + }); + } +}); + + + extend_cscript(cur_frm.cscript, new erpnext.accounts.PaymentReconciliationController({frm: cur_frm})); diff --git a/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py b/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py index c9e3998ac8..d8082d058f 100644 --- a/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py +++ b/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py @@ -233,6 +233,15 @@ class PaymentReconciliation(Document): return difference_amount + @frappe.whitelist() + 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")) + new_difference_amount = self.get_difference_amount( + payment_entry[0], invoice[0], allocated_amount + ) + return new_difference_amount + @frappe.whitelist() def allocate_entries(self, args): self.validate_entries() From 74b29eb5e22c15a8055322f4143c9f401cabaa7f Mon Sep 17 00:00:00 2001 From: "Kitti U. @ Ecosoft" Date: Sat, 1 Apr 2023 20:20:30 +0700 Subject: [PATCH 19/20] fix: Bank clearance for case loan (disburstment/repayment) (#34586) --- .../doctype/bank_clearance/bank_clearance.py | 16 +++-- .../bank_clearance_summary.py | 64 ++++++++++++++++++- 2 files changed, 75 insertions(+), 5 deletions(-) diff --git a/erpnext/accounts/doctype/bank_clearance/bank_clearance.py b/erpnext/accounts/doctype/bank_clearance/bank_clearance.py index 80878ac506..081718726b 100644 --- a/erpnext/accounts/doctype/bank_clearance/bank_clearance.py +++ b/erpnext/accounts/doctype/bank_clearance/bank_clearance.py @@ -81,7 +81,7 @@ class BankClearance(Document): loan_disbursement = frappe.qb.DocType("Loan Disbursement") - loan_disbursements = ( + query = ( frappe.qb.from_(loan_disbursement) .select( ConstantColumn("Loan Disbursement").as_("payment_document"), @@ -90,17 +90,22 @@ class BankClearance(Document): ConstantColumn(0).as_("debit"), loan_disbursement.reference_number.as_("cheque_number"), loan_disbursement.reference_date.as_("cheque_date"), + loan_disbursement.clearance_date.as_("clearance_date"), loan_disbursement.disbursement_date.as_("posting_date"), loan_disbursement.applicant.as_("against_account"), ) .where(loan_disbursement.docstatus == 1) .where(loan_disbursement.disbursement_date >= self.from_date) .where(loan_disbursement.disbursement_date <= self.to_date) - .where(loan_disbursement.clearance_date.isnull()) .where(loan_disbursement.disbursement_account.isin([self.bank_account, self.account])) .orderby(loan_disbursement.disbursement_date) .orderby(loan_disbursement.name, order=frappe.qb.desc) - ).run(as_dict=1) + ) + + if not self.include_reconciled_entries: + query = query.where(loan_disbursement.clearance_date.isnull()) + + loan_disbursements = query.run(as_dict=1) loan_repayment = frappe.qb.DocType("Loan Repayment") @@ -113,16 +118,19 @@ class BankClearance(Document): ConstantColumn(0).as_("credit"), loan_repayment.reference_number.as_("cheque_number"), loan_repayment.reference_date.as_("cheque_date"), + loan_repayment.clearance_date.as_("clearance_date"), loan_repayment.applicant.as_("against_account"), loan_repayment.posting_date, ) .where(loan_repayment.docstatus == 1) - .where(loan_repayment.clearance_date.isnull()) .where(loan_repayment.posting_date >= self.from_date) .where(loan_repayment.posting_date <= self.to_date) .where(loan_repayment.payment_account.isin([self.bank_account, self.account])) ) + if not self.include_reconciled_entries: + query = query.where(loan_repayment.clearance_date.isnull()) + if frappe.db.has_column("Loan Repayment", "repay_from_salary"): query = query.where((loan_repayment.repay_from_salary == 0)) diff --git a/erpnext/accounts/report/bank_clearance_summary/bank_clearance_summary.py b/erpnext/accounts/report/bank_clearance_summary/bank_clearance_summary.py index 449ebdcd92..306af722ba 100644 --- a/erpnext/accounts/report/bank_clearance_summary/bank_clearance_summary.py +++ b/erpnext/accounts/report/bank_clearance_summary/bank_clearance_summary.py @@ -4,6 +4,7 @@ import frappe from frappe import _ +from frappe.query_builder.custom import ConstantColumn from frappe.utils import getdate, nowdate @@ -91,4 +92,65 @@ def get_entries(filters): as_list=1, ) - return sorted(journal_entries + payment_entries, key=lambda k: k[2] or getdate(nowdate())) + # Loan Disbursement + loan_disbursement = frappe.qb.DocType("Loan Disbursement") + + query = ( + frappe.qb.from_(loan_disbursement) + .select( + ConstantColumn("Loan Disbursement").as_("payment_document_type"), + loan_disbursement.name.as_("payment_entry"), + loan_disbursement.disbursement_date.as_("posting_date"), + loan_disbursement.reference_number.as_("cheque_no"), + loan_disbursement.clearance_date.as_("clearance_date"), + loan_disbursement.applicant.as_("against"), + -loan_disbursement.disbursed_amount.as_("amount"), + ) + .where(loan_disbursement.docstatus == 1) + .where(loan_disbursement.disbursement_date >= filters["from_date"]) + .where(loan_disbursement.disbursement_date <= filters["to_date"]) + .where(loan_disbursement.disbursement_account == filters["account"]) + .orderby(loan_disbursement.disbursement_date, order=frappe.qb.desc) + .orderby(loan_disbursement.name, order=frappe.qb.desc) + ) + + if filters.get("from_date"): + query = query.where(loan_disbursement.disbursement_date >= filters["from_date"]) + if filters.get("to_date"): + query = query.where(loan_disbursement.disbursement_date <= filters["to_date"]) + + loan_disbursements = query.run(as_list=1) + + # Loan Repayment + loan_repayment = frappe.qb.DocType("Loan Repayment") + + query = ( + frappe.qb.from_(loan_repayment) + .select( + ConstantColumn("Loan Repayment").as_("payment_document_type"), + loan_repayment.name.as_("payment_entry"), + loan_repayment.posting_date.as_("posting_date"), + loan_repayment.reference_number.as_("cheque_no"), + loan_repayment.clearance_date.as_("clearance_date"), + loan_repayment.applicant.as_("against"), + loan_repayment.amount_paid.as_("amount"), + ) + .where(loan_repayment.docstatus == 1) + .where(loan_repayment.posting_date >= filters["from_date"]) + .where(loan_repayment.posting_date <= filters["to_date"]) + .where(loan_repayment.payment_account == filters["account"]) + .orderby(loan_repayment.posting_date, order=frappe.qb.desc) + .orderby(loan_repayment.name, order=frappe.qb.desc) + ) + + if filters.get("from_date"): + query = query.where(loan_repayment.posting_date >= filters["from_date"]) + if filters.get("to_date"): + query = query.where(loan_repayment.posting_date <= filters["to_date"]) + + loan_repayments = query.run(as_list=1) + + return sorted( + journal_entries + payment_entries + loan_disbursements + loan_repayments, + key=lambda k: k[2] or getdate(nowdate()), + ) From 4c61ee30bbfb56aec18d3cac5770d786f635931b Mon Sep 17 00:00:00 2001 From: Deepesh Garg Date: Sun, 2 Apr 2023 09:35:27 +0530 Subject: [PATCH 20/20] fix: Multiple issues in purchase invoice submission (#34600) * fix: Multiple issues in purchase invoice submission * fix: Base grand total calculation * chore: Calculate base grand total separately only in multi currency docs * fix: Add gl entry for round off --- .../purchase_invoice/purchase_invoice.py | 27 +++++++++- .../doctype/sales_invoice/sales_invoice.py | 2 +- erpnext/controllers/accounts_controller.py | 39 ++++++++++++--- erpnext/stock/get_item_details.py | 23 +++++++-- erpnext/utilities/transaction_base.py | 49 ++++++++++++++----- 5 files changed, 114 insertions(+), 26 deletions(-) diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py index b79af71bef..a617447856 100644 --- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py +++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py @@ -117,7 +117,7 @@ class PurchaseInvoice(BuyingController): self.validate_expense_account() self.set_against_expense_account() self.validate_write_off_account() - self.validate_multiple_billing("Purchase Receipt", "pr_detail", "amount", "items") + self.validate_multiple_billing("Purchase Receipt", "pr_detail", "amount") self.create_remarks() self.set_status() self.validate_purchase_receipt_if_update_stock() @@ -232,7 +232,7 @@ class PurchaseInvoice(BuyingController): ) if ( - cint(frappe.db.get_single_value("Buying Settings", "maintain_same_rate")) + cint(frappe.get_cached_value("Buying Settings", "None", "maintain_same_rate")) and not self.is_return and not self.is_internal_supplier ): @@ -581,6 +581,7 @@ class PurchaseInvoice(BuyingController): self.make_supplier_gl_entry(gl_entries) self.make_item_gl_entries(gl_entries) + self.make_precision_loss_gl_entry(gl_entries) if self.check_asset_cwip_enabled(): self.get_asset_gl_entry(gl_entries) @@ -975,6 +976,28 @@ 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 + ) + + 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": 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 5cda276087..db619950e1 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py @@ -145,7 +145,7 @@ class SalesInvoice(SellingController): self.set_against_income_account() self.validate_time_sheets_are_submitted() - self.validate_multiple_billing("Delivery Note", "dn_detail", "amount", "items") + self.validate_multiple_billing("Delivery Note", "dn_detail", "amount") if not self.is_return: self.validate_serial_numbers() else: diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index 3705fcf499..390af0deb2 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -515,6 +515,8 @@ class AccountsController(TransactionBase): parent_dict.update({"customer": parent_dict.get("party_name")}) self.pricing_rules = [] + basic_item_details_map = {} + for item in self.get("items"): if item.get("item_code"): args = parent_dict.copy() @@ -533,7 +535,17 @@ class AccountsController(TransactionBase): if self.get("is_subcontracted"): args["is_subcontracted"] = self.is_subcontracted - ret = get_item_details(args, self, for_validate=True, overwrite_warehouse=False) + basic_details = basic_item_details_map.get(item.item_code) + ret, basic_item_details = get_item_details( + args, + self, + for_validate=True, + overwrite_warehouse=False, + return_basic_details=True, + basic_details=basic_details, + ) + + basic_item_details_map.setdefault(item.item_code, basic_item_details) for fieldname, value in ret.items(): if item.meta.get_field(fieldname) and value is not None: @@ -1232,7 +1244,7 @@ class AccountsController(TransactionBase): ) ) - def validate_multiple_billing(self, ref_dt, item_ref_dn, based_on, parentfield): + def validate_multiple_billing(self, ref_dt, item_ref_dn, based_on): from erpnext.controllers.status_updater import get_allowance_for item_allowance = {} @@ -1245,17 +1257,20 @@ class AccountsController(TransactionBase): total_overbilled_amt = 0.0 + reference_names = [d.get(item_ref_dn) for d in self.get("items") if d.get(item_ref_dn)] + reference_details = self.get_billing_reference_details( + reference_names, ref_dt + " Item", based_on + ) + for item in self.get("items"): if not item.get(item_ref_dn): continue - ref_amt = flt( - frappe.db.get_value(ref_dt + " Item", item.get(item_ref_dn), based_on), - self.precision(based_on, item), - ) + ref_amt = flt(reference_details.get(item.get(item_ref_dn)), self.precision(based_on, item)) + if not ref_amt: frappe.msgprint( - _("System will not check overbilling since amount for Item {0} in {1} is zero").format( + _("System will not check over billing since amount for Item {0} in {1} is zero").format( item.item_code, ref_dt ), title=_("Warning"), @@ -1302,6 +1317,16 @@ class AccountsController(TransactionBase): alert=True, ) + def get_billing_reference_details(self, reference_names, reference_doctype, based_on): + return frappe._dict( + frappe.get_all( + reference_doctype, + filters={"name": ("in", reference_names)}, + fields=["name", based_on], + as_list=1, + ) + ) + def get_billed_amount_for_item(self, item, item_ref_dn, based_on): """ Returns Sum of Amount of diff --git a/erpnext/stock/get_item_details.py b/erpnext/stock/get_item_details.py index 489ec6ebec..2df39c8183 100644 --- a/erpnext/stock/get_item_details.py +++ b/erpnext/stock/get_item_details.py @@ -35,7 +35,14 @@ purchase_doctypes = [ @frappe.whitelist() -def get_item_details(args, doc=None, for_validate=False, overwrite_warehouse=True): +def get_item_details( + args, + doc=None, + for_validate=False, + overwrite_warehouse=True, + return_basic_details=False, + basic_details=None, +): """ args = { "item_code": "", @@ -73,7 +80,13 @@ def get_item_details(args, doc=None, for_validate=False, overwrite_warehouse=Tru if doc.get("doctype") == "Purchase Invoice": args["bill_date"] = doc.get("bill_date") - out = get_basic_details(args, item, overwrite_warehouse) + if not basic_details: + out = get_basic_details(args, item, overwrite_warehouse) + else: + out = basic_details + + basic_details = out.copy() + get_item_tax_template(args, item, out) out["item_tax_rate"] = get_item_tax_map( args.company, @@ -141,7 +154,11 @@ def get_item_details(args, doc=None, for_validate=False, overwrite_warehouse=Tru out.amount = flt(args.qty) * flt(out.rate) out = remove_standard_fields(out) - return out + + if return_basic_details: + return out, basic_details + else: + return out def remove_standard_fields(details): diff --git a/erpnext/utilities/transaction_base.py b/erpnext/utilities/transaction_base.py index 21a0a551b6..fc2054533d 100644 --- a/erpnext/utilities/transaction_base.py +++ b/erpnext/utilities/transaction_base.py @@ -58,11 +58,11 @@ class TransactionBase(StatusUpdater): def compare_values(self, ref_doc, fields, doc=None): for reference_doctype, ref_dn_list in ref_doc.items(): + prev_doc_detail_map = self.get_prev_doc_reference_details( + ref_dn_list, reference_doctype, fields + ) for reference_name in ref_dn_list: - prevdoc_values = frappe.db.get_value( - reference_doctype, reference_name, [d[0] for d in fields], as_dict=1 - ) - + prevdoc_values = prev_doc_detail_map.get(reference_name) if not prevdoc_values: frappe.throw(_("Invalid reference {0} {1}").format(reference_doctype, reference_name)) @@ -70,6 +70,19 @@ class TransactionBase(StatusUpdater): if prevdoc_values[field] is not None and field not in self.exclude_fields: self.validate_value(field, condition, prevdoc_values[field], doc) + def get_prev_doc_reference_details(self, reference_names, reference_doctype, fields): + prev_doc_detail_map = {} + details = frappe.get_all( + reference_doctype, + filters={"name": ("in", reference_names)}, + fields=["name"] + [d[0] for d in fields], + ) + + for d in details: + prev_doc_detail_map.setdefault(d.name, d) + + return prev_doc_detail_map + def validate_rate_with_reference_doc(self, ref_details): if self.get("is_internal_supplier"): return @@ -77,23 +90,23 @@ class TransactionBase(StatusUpdater): buying_doctypes = ["Purchase Order", "Purchase Invoice", "Purchase Receipt"] if self.doctype in buying_doctypes: - action = frappe.db.get_single_value("Buying Settings", "maintain_same_rate_action") - settings_doc = "Buying Settings" + action, role_allowed_to_override = frappe.get_cached_value( + "Buying Settings", "None", ["maintain_same_rate_action", "role_to_override_stop_action"] + ) else: - action = frappe.db.get_single_value("Selling Settings", "maintain_same_rate_action") - settings_doc = "Selling Settings" + action, role_allowed_to_override = frappe.get_cached_value( + "Selling Settings", "None", ["maintain_same_rate_action", "role_to_override_stop_action"] + ) for ref_dt, ref_dn_field, ref_link_field in ref_details: + reference_names = [d.get(ref_link_field) for d in self.get("items") if d.get(ref_link_field)] + reference_details = self.get_reference_details(reference_names, ref_dt + " Item") for d in self.get("items"): if d.get(ref_link_field): - ref_rate = frappe.db.get_value(ref_dt + " Item", d.get(ref_link_field), "rate") + ref_rate = reference_details.get(d.get(ref_link_field)) if abs(flt(d.rate - ref_rate, d.precision("rate"))) >= 0.01: if action == "Stop": - role_allowed_to_override = frappe.db.get_single_value( - settings_doc, "role_to_override_stop_action" - ) - if role_allowed_to_override not in frappe.get_roles(): frappe.throw( _("Row #{0}: Rate must be same as {1}: {2} ({3} / {4})").format( @@ -109,6 +122,16 @@ class TransactionBase(StatusUpdater): indicator="orange", ) + def get_reference_details(self, reference_names, reference_doctype): + return frappe._dict( + frappe.get_all( + reference_doctype, + filters={"name": ("in", reference_names)}, + fields=["name", "rate"], + as_list=1, + ) + ) + def get_link_filters(self, for_doctype): if hasattr(self, "prev_link_mapper") and self.prev_link_mapper.get(for_doctype): fieldname = self.prev_link_mapper[for_doctype]["fieldname"]