fix: Allocate tax loss to tax account head on early payment discount (#34287)

* 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

(cherry picked from commit 768c3a4927)

* fix: Recalculate difference amount after setting deductions

(cherry picked from commit 75ec0a0a85)

* fix: Set deductions in base currency

- Use field precision to get more accurate values

(cherry picked from commit dc2998f544)

* fix: Back update discounted amount in Invoice based on discount type

- Discount value was always trated as a percentage on back updation

(cherry picked from commit 2ae5834290)

* test: PE from SI with early payment discount amount & PE assertions in discount % test

(cherry picked from commit c217bb2018)

* 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

(cherry picked from commit 7f2e7badff)

* fix: Don't add to deductions if amount is 0

- misc: better docstring

(cherry picked from commit f02fc8acf0)

* 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

(cherry picked from commit 761f68d7bf)

* 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

(cherry picked from commit b09c2381ca)

* test: Multi currency SI with multi-currency accounting and single currency accounting + Early payment discount

(cherry picked from commit 9abf0ef615)

# Conflicts:
#	erpnext/accounts/doctype/payment_entry/test_payment_entry.py

* 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

(cherry picked from commit caa1a3dccf)

* 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`

(cherry picked from commit d6d0163514)

# Conflicts:
#	erpnext/accounts/doctype/payment_entry/payment_entry.py
#	erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js
#	erpnext/buying/doctype/purchase_order/purchase_order.js
#	erpnext/public/js/controllers/transaction.js

* feat: Make Tax loss booking optional

- Checkbox in Accounts Settings
- Apply checkbox in PE deductions setting logic
- Adjust tests

(cherry picked from commit 216a46bd66)

# Conflicts:
#	erpnext/accounts/doctype/accounts_settings/accounts_settings.json

* fix: Merge conflicts

* fix: 'Donation' does not have `company_currency` field

- Make sure check uses this field only for eligible documents

---------

Co-authored-by: marination <maricadsouza221197@gmail.com>
This commit is contained in:
mergify[bot]
2023-04-05 11:55:22 +05:30
committed by GitHub
parent fed43aeb85
commit 92a26dda3c
9 changed files with 515 additions and 70 deletions

View File

@@ -26,6 +26,7 @@
"determine_address_tax_category_from", "determine_address_tax_category_from",
"column_break_19", "column_break_19",
"add_taxes_from_item_tax_template", "add_taxes_from_item_tax_template",
"book_tax_discount_loss",
"period_closing_settings_section", "period_closing_settings_section",
"acc_frozen_upto", "acc_frozen_upto",
"frozen_accounts_modifier", "frozen_accounts_modifier",
@@ -284,6 +285,13 @@
"fieldname": "allow_multi_currency_invoices_against_single_party_account", "fieldname": "allow_multi_currency_invoices_against_single_party_account",
"fieldtype": "Check", "fieldtype": "Check",
"label": "Allow multi-currency invoices against single party account" "label": "Allow multi-currency invoices against single party account"
},
{
"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", "icon": "icon-cog",
@@ -291,7 +299,7 @@
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"issingle": 1, "issingle": 1,
"links": [], "links": [],
"modified": "2022-07-11 13:37:50.605141", "modified": "2023-03-28 09:50:20.375233",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Accounts", "module": "Accounts",
"name": "Accounts Settings", "name": "Accounts Settings",

View File

@@ -256,8 +256,6 @@ frappe.ui.form.on('Payment Entry', {
frm.set_currency_labels(["total_amount", "outstanding_amount", "allocated_amount"], frm.set_currency_labels(["total_amount", "outstanding_amount", "allocated_amount"],
party_account_currency, "references"); party_account_currency, "references");
frm.set_currency_labels(["amount"], company_currency, "deductions");
cur_frm.set_df_property("source_exchange_rate", "description", cur_frm.set_df_property("source_exchange_rate", "description",
("1 " + frm.doc.paid_from_account_currency + " = [?] " + company_currency)); ("1 " + frm.doc.paid_from_account_currency + " = [?] " + company_currency));

View File

@@ -440,7 +440,7 @@ class PaymentEntry(AccountsController):
for ref in self.get("references"): for ref in self.get("references"):
if ref.payment_term and ref.reference_name: 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.setdefault(key, 0.0)
invoice_payment_amount_map[key] += ref.allocated_amount invoice_payment_amount_map[key] += ref.allocated_amount
@@ -448,20 +448,37 @@ class PaymentEntry(AccountsController):
payment_schedule = frappe.get_all( payment_schedule = frappe.get_all(
"Payment Schedule", "Payment Schedule",
filters={"parent": ref.reference_name}, 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: 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.setdefault(invoice_key, {})
invoice_paid_amount_map[invoice_key]["outstanding"] = term.outstanding invoice_paid_amount_map[invoice_key]["outstanding"] = term.outstanding
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 * ( invoice_paid_amount_map[invoice_key]["discounted_amt"] = ref.total_amount * (
term.discount / 100 term.discount / 100
) )
else:
invoice_paid_amount_map[invoice_key]["discounted_amt"] = term.discount
for idx, (key, allocated_amount) in enumerate(iteritems(invoice_payment_amount_map), 1): for idx, (key, allocated_amount) in enumerate(iteritems(invoice_payment_amount_map), 1):
if not invoice_paid_amount_map.get(key): if not invoice_paid_amount_map.get(key):
frappe.throw(_("Payment term {0} not used in {1}").format(key[0], key[1])) 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")) outstanding = flt(invoice_paid_amount_map.get(key, {}).get("outstanding"))
discounted_amt = flt(invoice_paid_amount_map.get(key, {}).get("discounted_amt")) discounted_amt = flt(invoice_paid_amount_map.get(key, {}).get("discounted_amt"))
@@ -496,6 +513,33 @@ class PaymentEntry(AccountsController):
(allocated_amount - discounted_amt, discounted_amt, allocated_amount, key[1], key[0]), (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): def set_status(self):
if self.docstatus == 2: if self.docstatus == 2:
self.status = "Cancelled" self.status = "Cancelled"
@@ -1801,7 +1845,14 @@ def get_bill_no_and_update_amounts(
@frappe.whitelist() @frappe.whitelist()
def get_payment_entry(dt, dn, party_amount=None, bank_account=None, bank_amount=None): def get_payment_entry(
dt,
dn,
party_amount=None,
bank_account=None,
bank_amount=None,
reference_date=None,
):
reference_doc = None reference_doc = None
doc = frappe.get_doc(dt, dn) doc = frappe.get_doc(dt, dn)
if dt in ("Sales Order", "Purchase Order") and flt(doc.per_billed, 2) > 0: if dt in ("Sales Order", "Purchase Order") and flt(doc.per_billed, 2) > 0:
@@ -1822,8 +1873,9 @@ def get_payment_entry(dt, dn, party_amount=None, bank_account=None, bank_amount=
dt, party_account_currency, bank, outstanding_amount, payment_type, bank_amount, doc dt, party_account_currency, bank, outstanding_amount, payment_type, bank_amount, doc
) )
paid_amount, received_amount, discount_amount = apply_early_payment_discount( reference_date = getdate(reference_date)
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, reference_date
) )
pe = frappe.new_doc("Payment Entry") pe = frappe.new_doc("Payment Entry")
@@ -1831,6 +1883,7 @@ def get_payment_entry(dt, dn, party_amount=None, bank_account=None, bank_amount=
pe.company = doc.company pe.company = doc.company
pe.cost_center = doc.get("cost_center") pe.cost_center = doc.get("cost_center")
pe.posting_date = nowdate() pe.posting_date = nowdate()
pe.reference_date = reference_date
pe.mode_of_payment = doc.get("mode_of_payment") pe.mode_of_payment = doc.get("mode_of_payment")
pe.party_type = party_type pe.party_type = party_type
pe.party = doc.get(scrub(party_type)) pe.party = doc.get(scrub(party_type))
@@ -1871,7 +1924,7 @@ def get_payment_entry(dt, dn, party_amount=None, bank_account=None, bank_amount=
): ):
for reference in get_reference_as_per_payment_terms( 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) pe.append("references", reference)
else: else:
@@ -1922,15 +1975,16 @@ def get_payment_entry(dt, dn, party_amount=None, bank_account=None, bank_amount=
reference_doc = doc reference_doc = doc
pe.set_exchange_rate(ref_doc=reference_doc) pe.set_exchange_rate(ref_doc=reference_doc)
pe.set_amounts() pe.set_amounts()
if discount_amount: if discount_amount:
pe.set_gain_or_loss( base_total_discount_loss = 0
account_details={ if frappe.db.get_single_value("Accounts Settings", "book_tax_discount_loss"):
"account": frappe.get_cached_value("Company", pe.company, "default_discount_account"), base_total_discount_loss = split_early_payment_discount_loss(pe, doc, valid_discounts)
"cost_center": pe.cost_center
or frappe.get_cached_value("Company", pe.company, "cost_center"), set_pending_discount_loss(
"amount": discount_amount * (-1 if payment_type == "Pay" else 1), pe, doc, discount_amount, base_total_discount_loss, party_account_currency
}
) )
pe.set_difference_amount() pe.set_difference_amount()
return pe return pe
@@ -2067,20 +2121,30 @@ def set_paid_amount_and_received_amount(
return paid_amount, 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, reference_date
):
total_discount = 0 total_discount = 0
valid_discounts = []
eligible_for_payments = ["Sales Order", "Sales Invoice", "Purchase Order", "Purchase Invoice"] eligible_for_payments = ["Sales Order", "Sales Invoice", "Purchase Order", "Purchase Invoice"]
has_payment_schedule = hasattr(doc, "payment_schedule") and doc.payment_schedule has_payment_schedule = hasattr(doc, "payment_schedule") and doc.payment_schedule
if doc.doctype in eligible_for_payments and has_payment_schedule: if doc.doctype in eligible_for_payments and has_payment_schedule:
# Non eligible documents may not have `company_currency` field
is_multi_currency = party_account_currency != doc.company_currency
for term in doc.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": 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: else:
discount_amount = term.discount 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": if doc.doctype == "Sales Invoice":
paid_amount -= discount_amount paid_amount -= discount_amount
@@ -2089,23 +2153,151 @@ def apply_early_payment_discount(paid_amount, received_amount, doc):
received_amount -= discount_amount received_amount -= discount_amount
paid_amount -= discount_amount_in_foreign_currency paid_amount -= discount_amount_in_foreign_currency
valid_discounts.append({"type": term.discount_type, "discount": term.discount})
total_discount += discount_amount total_discount += discount_amount
if total_discount: 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) 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"))
# 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, account_type),
"cost_center": pe.cost_center or frappe.get_cached_value("Company", pe.company, "cost_center"),
"amount": discount_amount * positive_negative,
}
)
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)
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( 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 = [] references = []
is_multi_currency_acc = (doc.currency != doc.company_currency) and (
party_account_currency != doc.company_currency
)
for payment_term in payment_schedule: for payment_term in payment_schedule:
payment_term_outstanding = flt( payment_term_outstanding = flt(
payment_term.payment_amount - payment_term.paid_amount, payment_term.precision("payment_amount") 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: if payment_term_outstanding:
references.append( references.append(

View File

@@ -5,6 +5,7 @@ import unittest
import frappe import frappe
from frappe import qb from frappe import qb
from frappe.tests.utils import change_settings
from frappe.utils import flt, nowdate from frappe.utils import flt, nowdate
from erpnext.accounts.doctype.payment_entry.payment_entry import ( from erpnext.accounts.doctype.payment_entry.payment_entry import (
@@ -252,10 +253,25 @@ class TestPaymentEntry(unittest.TestCase):
}, },
) )
si.save() si.save()
si.submit() 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") 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, 212.4)
self.assertEqual(pe.deductions[0].amount, 23.6)
pe.submit() pe.submit()
si.load_from_db() si.load_from_db()
@@ -265,6 +281,190 @@ class TestPaymentEntry(unittest.TestCase):
self.assertEqual(si.payment_schedule[0].outstanding, 0) self.assertEqual(si.payment_schedule[0].outstanding, 0)
self.assertEqual(si.payment_schedule[0].discounted_amount, 23.6) 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()
# 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
# 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, 50.0)
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,
"book_tax_discount_loss": 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): def test_payment_against_purchase_invoice_to_check_status(self):
pi = make_purchase_invoice( pi = make_purchase_invoice(
supplier="_Test Supplier USD", supplier="_Test Supplier USD",
@@ -856,24 +1056,27 @@ def create_payment_terms_template():
).insert() ).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", template_name):
frappe.get_doc(
if not frappe.db.exists("Payment Terms Template", "Test Discount Template"):
payment_term_template = frappe.get_doc(
{ {
"doctype": "Payment Terms Template", "doctype": "Payment Terms Template",
"template_name": "Test Discount Template", "template_name": template_name,
"allocate_payment_based_on_payment_terms": 1, "allocate_payment_based_on_payment_terms": 1,
"terms": [ "terms": [
{ {
"doctype": "Payment Terms Template Detail", "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, "invoice_portion": 100,
"credit_days_based_on": "Day(s) after invoice date", "credit_days_based_on": "Day(s) after invoice date",
"credit_days": 2, "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_based_on": "Day(s) after invoice date",
"discount_validity": 1, "discount_validity": 1,
} }

View File

@@ -3,6 +3,7 @@
"creation": "2016-06-15 15:56:30.815503", "creation": "2016-06-15 15:56:30.815503",
"doctype": "DocType", "doctype": "DocType",
"editable_grid": 1, "editable_grid": 1,
"engine": "InnoDB",
"field_order": [ "field_order": [
"account", "account",
"cost_center", "cost_center",
@@ -17,9 +18,7 @@
"in_list_view": 1, "in_list_view": 1,
"label": "Account", "label": "Account",
"options": "Account", "options": "Account",
"reqd": 1, "reqd": 1
"show_days": 1,
"show_seconds": 1
}, },
{ {
"fieldname": "cost_center", "fieldname": "cost_center",
@@ -28,37 +27,30 @@
"label": "Cost Center", "label": "Cost Center",
"options": "Cost Center", "options": "Cost Center",
"print_hide": 1, "print_hide": 1,
"reqd": 1, "reqd": 1
"show_days": 1,
"show_seconds": 1
}, },
{ {
"fieldname": "amount", "fieldname": "amount",
"fieldtype": "Currency", "fieldtype": "Currency",
"in_list_view": 1, "in_list_view": 1,
"label": "Amount", "label": "Amount (Company Currency)",
"reqd": 1, "options": "Company:company:default_currency",
"show_days": 1, "reqd": 1
"show_seconds": 1
}, },
{ {
"fieldname": "column_break_2", "fieldname": "column_break_2",
"fieldtype": "Column Break", "fieldtype": "Column Break"
"show_days": 1,
"show_seconds": 1
}, },
{ {
"fieldname": "description", "fieldname": "description",
"fieldtype": "Small Text", "fieldtype": "Small Text",
"label": "Description", "label": "Description"
"show_days": 1,
"show_seconds": 1
} }
], ],
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"istable": 1, "istable": 1,
"links": [], "links": [],
"modified": "2020-09-12 20:38:08.110674", "modified": "2023-03-06 07:11:57.739619",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Accounts", "module": "Accounts",
"name": "Payment Entry Deduction", "name": "Payment Entry Deduction",
@@ -66,5 +58,6 @@
"permissions": [], "permissions": [],
"quick_entry": 1, "quick_entry": 1,
"sort_field": "modified", "sort_field": "modified",
"sort_order": "DESC" "sort_order": "DESC",
"states": []
} }

View File

@@ -81,8 +81,12 @@ erpnext.accounts.PurchaseInvoice = erpnext.buying.BuyingController.extend({
} }
if(doc.docstatus == 1 && doc.outstanding_amount != 0 if(doc.docstatus == 1 && doc.outstanding_amount != 0
&& !(doc.is_return && doc.return_against)) { && !(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')); cur_frm.page.set_inner_btn_group_as_primary(__('Create'));
} }

View File

@@ -75,9 +75,12 @@ erpnext.accounts.SalesInvoiceController = erpnext.selling.SellingController.exte
if (doc.docstatus == 1 && doc.outstanding_amount!=0 if (doc.docstatus == 1 && doc.outstanding_amount!=0
&& !(cint(doc.is_return) && doc.return_against)) { && !(cint(doc.is_return) && doc.return_against)) {
cur_frm.add_custom_button(__('Payment'), this.frm.add_custom_button(
this.make_payment_entry, __('Create')); __('Payment'),
cur_frm.page.set_inner_btn_group_as_primary(__('Create')); () => this.make_payment_entry(),
__('Create')
);
this.frm.page.set_inner_btn_group_as_primary(__('Create'));
} }
if(doc.docstatus==1 && !doc.is_return) { if(doc.docstatus==1 && !doc.is_return) {

View File

@@ -191,8 +191,12 @@ erpnext.buying.PurchaseOrderController = erpnext.buying.BuyingController.extend(
cur_frm.add_custom_button(__('Purchase Invoice'), cur_frm.add_custom_button(__('Purchase Invoice'),
this.make_purchase_invoice, __('Create')); this.make_purchase_invoice, __('Create'));
if(flt(doc.per_billed)==0 && doc.status != "Delivered") { 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)==0) { if(flt(doc.per_billed)==0) {

View File

@@ -1987,22 +1987,62 @@ erpnext.TransactionController = erpnext.taxes_and_totals.extend({
} }
}, },
make_payment_entry: function() { make_payment_entry() {
return frappe.call({ let via_journal_entry = this.frm.doc.__onload && this.frm.doc.__onload.make_payment_via_journal_entry;
method: cur_frm.cscript.get_method_for_payment(), if(this.has_discount_in_schedule() && !via_journal_entry) {
args: { // If early payment discount is applied, ask user for reference date
"dt": cur_frm.doc.doctype, this.prompt_user_for_reference_date();
"dn": cur_frm.doc.name } 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: me.get_method_for_payment(),
args: args,
callback: function(r) { callback: function(r) {
var doclist = frappe.model.sync(r.message); var doclist = frappe.model.sync(r.message);
frappe.set_route("Form", doclist[0].doctype, doclist[0].name); frappe.set_route("Form", doclist[0].doctype, doclist[0].name);
// cur_frm.refresh_fields()
} }
}); });
}, },
make_quality_inspection: function () { 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 = []; let data = [];
const fields = [ const fields = [
{ {