Merge branch 'develop' into early-payment-loss
diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.js b/erpnext/accounts/doctype/payment_entry/payment_entry.js
index 2a8e2527..f8969b8 100644
--- a/erpnext/accounts/doctype/payment_entry/payment_entry.js
+++ b/erpnext/accounts/doctype/payment_entry/payment_entry.js
@@ -244,8 +244,6 @@
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/payment_entry.py b/erpnext/accounts/doctype/payment_entry/payment_entry.py
index cd5b6d5..8e47063 100644
--- a/erpnext/accounts/doctype/payment_entry/payment_entry.py
+++ b/erpnext/accounts/doctype/payment_entry/payment_entry.py
@@ -416,7 +416,7 @@
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
@@ -424,20 +424,37 @@
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_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
- 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):
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"))
@@ -472,6 +489,33 @@
(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"
@@ -1669,8 +1713,8 @@
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, doc
+ paid_amount, received_amount, discount_amount, valid_discounts = apply_early_payment_discount(
+ paid_amount, received_amount, doc, party_account_currency
)
pe = frappe.new_doc("Payment Entry")
@@ -1718,7 +1762,7 @@
):
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:
@@ -1769,16 +1813,14 @@
if party_account and bank:
pe.set_exchange_rate(ref_doc=reference_doc)
pe.set_amounts()
+
if discount_amount:
- 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),
- }
+ 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()
+
+ pe.set_difference_amount()
return pe
@@ -1889,20 +1931,26 @@
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"]
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:
+
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
@@ -1911,23 +1959,145 @@
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"))
+ 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
+ return paid_amount, received_amount, total_discount, valid_discounts
+
+
+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)
+
+ # 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
+ 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,
+ }
+ )
+
+
+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 0.0
+
+ 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)
+
+ # 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:
+ """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:
+ """Add loss on income discount in base currency."""
+ precision = doc.precision("total")
+ 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": flt(base_loss_on_income, precision),
+ },
+ )
+
+ return base_loss_on_income # Return loss without rounding
+
+
+def add_tax_discount_loss(pe, doc, total_discount_percentage) -> float:
+ """Add loss on tax discount in base currency."""
+ tax_discount_loss = {}
+ 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", []):
+ base_tax_loss = tax.get("base_tax_amount_after_discount_amount") * (
+ total_discount_percentage / 100
+ )
+
+ account = tax.get("account_head")
+ if not tax_discount_loss.get(account):
+ tax_discount_loss[account] = base_tax_loss
+ else:
+ tax_discount_loss[account] += base_tax_loss
+
+ for account, loss in tax_discount_loss.items():
+ base_total_tax_loss += loss
+ if loss == 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, precision),
+ },
+ )
+
+ return base_total_tax_loss # Return loss without rounding
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(
diff --git a/erpnext/accounts/doctype/payment_entry/test_payment_entry.py b/erpnext/accounts/doctype/payment_entry/test_payment_entry.py
index 123b5df..6e5c25e 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 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 (
@@ -260,6 +260,14 @@
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,172 @@
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)
+
+ @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",
@@ -839,24 +1013,27 @@
).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,
}
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 61a1462..1c31829 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