Browse Source

feat: Dunning (#22559)

* feat: Dunning

* fix: Replaces spaces with tab

Co-authored-by: Nabin Hait <nabinhait@gmail.com>
develop
KanchanChauhan 4 years ago
committed by GitHub
parent
commit
2826fd3c20
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 0
      erpnext/accounts/doctype/dunning/__init__.py
  2. 149
      erpnext/accounts/doctype/dunning/dunning.js
  3. 370
      erpnext/accounts/doctype/dunning/dunning.json
  4. 119
      erpnext/accounts/doctype/dunning/dunning.py
  5. 9
      erpnext/accounts/doctype/dunning/dunning_list.js
  6. 100
      erpnext/accounts/doctype/dunning/test_dunning.py
  7. 0
      erpnext/accounts/doctype/dunning_letter_text/__init__.py
  8. 70
      erpnext/accounts/doctype/dunning_letter_text/dunning_letter_text.json
  9. 10
      erpnext/accounts/doctype/dunning_letter_text/dunning_letter_text.py
  10. 0
      erpnext/accounts/doctype/dunning_type/__init__.py
  11. 8
      erpnext/accounts/doctype/dunning_type/dunning_type.js
  12. 129
      erpnext/accounts/doctype/dunning_type/dunning_type.json
  13. 10
      erpnext/accounts/doctype/dunning_type/dunning_type.py
  14. 10
      erpnext/accounts/doctype/dunning_type/test_dunning_type.py
  15. 8
      erpnext/accounts/doctype/payment_entry/payment_entry.js
  16. 57
      erpnext/accounts/doctype/payment_entry/payment_entry.py
  17. 12
      erpnext/accounts/doctype/sales_invoice/sales_invoice.js
  18. 34
      erpnext/accounts/doctype/sales_invoice/sales_invoice.py
  19. 0
      erpnext/accounts/print_format/dunning_letter/__init__.py
  20. 25
      erpnext/accounts/print_format/dunning_letter/dunning_letter.json
  21. 4
      erpnext/hooks.py

0
erpnext/accounts/doctype/dunning/__init__.py

149
erpnext/accounts/doctype/dunning/dunning.js

@ -0,0 +1,149 @@
// Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors
// For license information, please see license.txt
frappe.ui.form.on("Dunning", {
setup: function (frm) {
frm.set_query("sales_invoice", () => {
return {
filters: {
docstatus: 1,
company: frm.doc.company,
outstanding_amount: [">", 0],
status: "Overdue"
},
};
});
frm.set_query("income_account", () => {
return {
filters: {
company: frm.doc.company,
root_type: "Income",
is_group: 0
}
};
});
},
refresh: function (frm) {
frm.set_df_property("company", "read_only", frm.doc.__islocal ? 0 : 1);
frm.set_df_property(
"sales_invoice",
"read_only",
frm.doc.__islocal ? 0 : 1
);
if (frm.doc.docstatus === 1 && frm.doc.status === "Unresolved") {
frm.add_custom_button(__("Resolve"), () => {
frm.set_value("status", "Resolved");
});
}
if (frm.doc.docstatus === 1 && frm.doc.status !== "Resolved") {
frm.add_custom_button(
__("Payment"),
function () {
frm.events.make_payment_entry(frm);
},__("Create")
);
frm.page.set_inner_btn_group_as_primary(__("Create"));
}
},
overdue_days: function (frm) {
frappe.db.get_value(
"Dunning Type",
{
start_day: ["<", frm.doc.overdue_days],
end_day: [">=", frm.doc.overdue_days],
},
"dunning_type",
(r) => {
if (r) {
frm.set_value("dunning_type", r.dunning_type);
} else {
frm.set_value("dunning_type", "");
frm.set_value("rate_of_interest", "");
frm.set_value("dunning_fee", "");
}
}
);
},
dunning_type: function (frm) {
frm.trigger("get_dunning_letter_text");
},
language: function (frm) {
frm.trigger("get_dunning_letter_text");
},
get_dunning_letter_text: function (frm) {
if (frm.doc.dunning_type) {
frappe.call({
method:
"erpnext.accounts.doctype.dunning.dunning.get_dunning_letter_text",
args: {
dunning_type: frm.doc.dunning_type,
language: frm.doc.language,
doc: frm.doc,
},
callback: function (r) {
if (r.message) {
frm.set_value("body_text", r.message.body_text);
frm.set_value("closing_text", r.message.closing_text);
frm.set_value("language", r.message.language);
} else {
frm.set_value("body_text", "");
frm.set_value("closing_text", "");
}
},
});
}
},
due_date: function (frm) {
frm.trigger("calculate_overdue_days");
},
posting_date: function (frm) {
frm.trigger("calculate_overdue_days");
},
rate_of_interest: function (frm) {
frm.trigger("calculate_interest_and_amount");
},
outstanding_amount: function (frm) {
frm.trigger("calculate_interest_and_amount");
},
interest_amount: function (frm) {
frm.trigger("calculate_interest_and_amount");
},
dunning_fee: function (frm) {
frm.trigger("calculate_interest_and_amount");
},
sales_invoice: function (frm) {
frm.trigger("calculate_overdue_days");
},
calculate_overdue_days: function (frm) {
if (frm.doc.posting_date && frm.doc.due_date) {
const overdue_days = moment(frm.doc.posting_date).diff(
frm.doc.due_date,
"days"
);
frm.set_value("overdue_days", overdue_days);
}
},
calculate_interest_and_amount: function (frm) {
const interest_per_year = frm.doc.outstanding_amount * frm.doc.rate_of_interest / 100;
const interest_amount = interest_per_year / 365 * frm.doc.overdue_days || 0;
const dunning_amount = interest_amount + frm.doc.dunning_fee;
const grand_total = frm.doc.outstanding_amount + dunning_amount;
frm.set_value("interest_amount", interest_amount);
frm.set_value("dunning_amount", dunning_amount);
frm.set_value("grand_total", grand_total);
},
make_payment_entry: function (frm) {
return frappe.call({
method:
"erpnext.accounts.doctype.payment_entry.payment_entry.get_payment_entry",
args: {
dt: frm.doc.doctype,
dn: frm.doc.name,
},
callback: function (r) {
var doc = frappe.model.sync(r.message);
frappe.set_route("Form", doc[0].doctype, doc[0].name);
},
});
},
});

370
erpnext/accounts/doctype/dunning/dunning.json

@ -0,0 +1,370 @@
{
"actions": [],
"allow_events_in_timeline": 1,
"autoname": "naming_series:",
"creation": "2019-07-05 16:34:31.013238",
"doctype": "DocType",
"engine": "InnoDB",
"field_order": [
"title",
"naming_series",
"sales_invoice",
"customer",
"customer_name",
"outstanding_amount",
"currency",
"conversion_rate",
"column_break_3",
"company",
"posting_date",
"posting_time",
"due_date",
"overdue_days",
"address_and_contact_section",
"address_display",
"contact_display",
"contact_mobile",
"contact_email",
"column_break_18",
"company_address_display",
"section_break_6",
"dunning_type",
"interest_amount",
"column_break_8",
"rate_of_interest",
"dunning_fee",
"section_break_12",
"dunning_amount",
"grand_total",
"income_account",
"column_break_17",
"status",
"printing_setting_section",
"language",
"body_text",
"column_break_22",
"letter_head",
"closing_text",
"amended_from"
],
"fields": [
{
"fieldname": "company",
"fieldtype": "Link",
"label": "Company",
"options": "Company",
"reqd": 1
},
{
"default": "DUNN-.MM.-.YY.-",
"fieldname": "naming_series",
"fieldtype": "Select",
"label": "Series",
"options": "DUNN-.MM.-.YY.-"
},
{
"fieldname": "sales_invoice",
"fieldtype": "Link",
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Sales Invoice",
"options": "Sales Invoice",
"reqd": 1
},
{
"fetch_from": "sales_invoice.customer_name",
"fieldname": "customer_name",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Customer Name",
"read_only": 1
},
{
"fetch_from": "sales_invoice.outstanding_amount",
"fieldname": "outstanding_amount",
"fieldtype": "Currency",
"label": "Outstanding Amount",
"read_only": 1
},
{
"fieldname": "column_break_3",
"fieldtype": "Column Break"
},
{
"default": "Today",
"fieldname": "posting_date",
"fieldtype": "Date",
"label": "Date"
},
{
"fieldname": "overdue_days",
"fieldtype": "Int",
"label": "Overdue Days",
"read_only": 1
},
{
"fieldname": "section_break_6",
"fieldtype": "Section Break"
},
{
"fieldname": "dunning_type",
"fieldtype": "Link",
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Dunning Type",
"options": "Dunning Type",
"reqd": 1
},
{
"default": "0",
"fieldname": "interest_amount",
"fieldtype": "Currency",
"label": "Interest Amount",
"precision": "2",
"read_only": 1
},
{
"fieldname": "column_break_8",
"fieldtype": "Column Break"
},
{
"default": "0",
"fetch_from": "dunning_type.dunning_fee",
"fetch_if_empty": 1,
"fieldname": "dunning_fee",
"fieldtype": "Currency",
"label": "Dunning Fee",
"precision": "2"
},
{
"fieldname": "section_break_12",
"fieldtype": "Section Break"
},
{
"fieldname": "column_break_17",
"fieldtype": "Column Break"
},
{
"fieldname": "printing_setting_section",
"fieldtype": "Section Break",
"label": "Printing Setting"
},
{
"fieldname": "language",
"fieldtype": "Link",
"label": "Print Language",
"options": "Language"
},
{
"fieldname": "letter_head",
"fieldtype": "Link",
"label": "Letter Head",
"options": "Letter Head"
},
{
"fieldname": "column_break_22",
"fieldtype": "Column Break"
},
{
"fetch_from": "sales_invoice.currency",
"fieldname": "currency",
"fieldtype": "Link",
"hidden": 1,
"label": "Currency",
"options": "Currency",
"read_only": 1
},
{
"fieldname": "amended_from",
"fieldtype": "Link",
"label": "Amended From",
"no_copy": 1,
"options": "Dunning",
"print_hide": 1,
"read_only": 1
},
{
"allow_on_submit": 1,
"default": "{customer_name}",
"fieldname": "title",
"fieldtype": "Data",
"hidden": 1,
"label": "Title"
},
{
"fieldname": "body_text",
"fieldtype": "Text Editor",
"label": "Body Text"
},
{
"fieldname": "closing_text",
"fieldtype": "Text Editor",
"label": "Closing Text"
},
{
"fetch_from": "sales_invoice.due_date",
"fieldname": "due_date",
"fieldtype": "Date",
"label": "Due Date",
"read_only": 1
},
{
"fieldname": "posting_time",
"fieldtype": "Time",
"label": "Posting Time"
},
{
"default": "0",
"fetch_from": "dunning_type.interest_rate",
"fetch_if_empty": 1,
"fieldname": "rate_of_interest",
"fieldtype": "Float",
"label": "Rate of Interest (%) Yearly"
},
{
"fieldname": "address_and_contact_section",
"fieldtype": "Section Break",
"label": "Address and Contact"
},
{
"fetch_from": "sales_invoice.address_display",
"fieldname": "address_display",
"fieldtype": "Small Text",
"label": "Address",
"read_only": 1
},
{
"fetch_from": "sales_invoice.contact_display",
"fieldname": "contact_display",
"fieldtype": "Small Text",
"label": "Contact",
"read_only": 1
},
{
"fetch_from": "sales_invoice.contact_mobile",
"fieldname": "contact_mobile",
"fieldtype": "Small Text",
"label": "Mobile No",
"read_only": 1
},
{
"fieldname": "column_break_18",
"fieldtype": "Column Break"
},
{
"fetch_from": "sales_invoice.company_address_display",
"fieldname": "company_address_display",
"fieldtype": "Small Text",
"label": "Company Address",
"read_only": 1
},
{
"fetch_from": "sales_invoice.contact_email",
"fieldname": "contact_email",
"fieldtype": "Data",
"label": "Contact Email",
"options": "Email",
"read_only": 1
},
{
"fetch_from": "sales_invoice.customer",
"fieldname": "customer",
"fieldtype": "Link",
"label": "Customer",
"options": "Customer",
"read_only": 1
},
{
"default": "0",
"fieldname": "grand_total",
"fieldtype": "Currency",
"label": "Grand Total",
"precision": "2",
"read_only": 1
},
{
"allow_on_submit": 1,
"default": "Unresolved",
"fieldname": "status",
"fieldtype": "Select",
"in_standard_filter": 1,
"label": "Status",
"options": "Draft\nResolved\nUnresolved\nCancelled"
},
{
"fieldname": "dunning_amount",
"fieldtype": "Currency",
"hidden": 1,
"label": "Dunning Amount",
"read_only": 1
},
{
"fieldname": "income_account",
"fieldtype": "Link",
"label": "Income Account",
"options": "Account"
},
{
"fetch_from": "sales_invoice.conversion_rate",
"fieldname": "conversion_rate",
"fieldtype": "Float",
"hidden": 1,
"label": "Conversion Rate",
"read_only": 1
}
],
"is_submittable": 1,
"links": [],
"modified": "2020-07-21 18:20:23.512151",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Dunning",
"owner": "Administrator",
"permissions": [
{
"amend": 1,
"cancel": 1,
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "System Manager",
"share": 1,
"submit": 1,
"write": 1
},
{
"cancel": 1,
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Accounts Manager",
"share": 1,
"submit": 1,
"write": 1
},
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Accounts User",
"share": 1,
"write": 1
}
],
"sort_field": "modified",
"sort_order": "ASC",
"title_field": "customer_name",
"track_changes": 1
}

119
erpnext/accounts/doctype/dunning/dunning.py

@ -0,0 +1,119 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
from __future__ import unicode_literals
import frappe
import json
from six import string_types
from frappe.utils import getdate, get_datetime, rounded, flt
from erpnext.loan_management.doctype.loan_interest_accrual.loan_interest_accrual import days_in_year
from erpnext.accounts.general_ledger import make_gl_entries, make_reverse_gl_entries
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import get_accounting_dimensions
from erpnext.controllers.accounts_controller import AccountsController
class Dunning(AccountsController):
def validate(self):
self.validate_overdue_days()
self.validate_amount()
if not self.income_account:
self.income_account = frappe.db.get_value('Company', self.company, 'default_income_account')
def validate_overdue_days(self):
self.overdue_days = (getdate(self.posting_date) - getdate(self.due_date)).days or 0
def validate_amount(self):
amounts = calculate_interest_and_amount(
self.posting_date, self.outstanding_amount, self.rate_of_interest, self.dunning_fee, self.overdue_days)
if self.interest_amount != amounts.get('interest_amount'):
self.interest_amount = amounts.get('interest_amount')
if self.dunning_amount != amounts.get('dunning_amount'):
self.dunning_amount = amounts.get('dunning_amount')
if self.grand_total != amounts.get('grand_total'):
self.grand_total = amounts.get('grand_total')
def on_submit(self):
self.make_gl_entries()
def on_cancel(self):
if self.dunning_amount:
self.ignore_linked_doctypes = ('GL Entry', 'Stock Ledger Entry')
make_reverse_gl_entries(voucher_type=self.doctype, voucher_no=self.name)
def make_gl_entries(self):
if not self.dunning_amount:
return
gl_entries = []
invoice_fields = ["project", "cost_center", "debit_to", "party_account_currency", "conversion_rate", "cost_center"]
inv = frappe.db.get_value("Sales Invoice", self.sales_invoice, invoice_fields, as_dict=1)
accounting_dimensions = get_accounting_dimensions()
invoice_fields.extend(accounting_dimensions)
dunning_in_company_currency = flt(self.dunning_amount * inv.conversion_rate)
default_cost_center = frappe.get_cached_value('Company', self.company, 'cost_center')
gl_entries.append(
self.get_gl_dict({
"account": inv.debit_to,
"party_type": "Customer",
"party": self.customer,
"due_date": self.due_date,
"against": self.income_account,
"debit": dunning_in_company_currency,
"debit_in_account_currency": self.dunning_amount,
"against_voucher": self.name,
"against_voucher_type": "Dunning",
"cost_center": inv.cost_center or default_cost_center,
"project": inv.project
}, inv.party_account_currency, item=inv)
)
gl_entries.append(
self.get_gl_dict({
"account": self.income_account,
"against": self.customer,
"credit": dunning_in_company_currency,
"cost_center": inv.cost_center or default_cost_center,
"credit_in_account_currency": self.dunning_amount,
"project": inv.project
}, item=inv)
)
make_gl_entries(gl_entries, cancel=(self.docstatus == 2), update_outstanding="No", merge_entries=False)
def resolve_dunning(doc, state):
for reference in doc.references:
if reference.reference_doctype == 'Sales Invoice' and reference.outstanding_amount <= 0:
dunnings = frappe.get_list('Dunning', filters={
'sales_invoice': reference.reference_name, 'status': ('!=', 'Resolved')})
for dunning in dunnings:
frappe.db.set_value("Dunning", dunning.name, "status", 'Resolved')
def calculate_interest_and_amount(posting_date, outstanding_amount, rate_of_interest, dunning_fee, overdue_days):
interest_amount = 0
if rate_of_interest:
interest_per_year = rounded(flt(outstanding_amount) * flt(rate_of_interest))/100
interest_amount = (
interest_per_year / days_in_year(get_datetime(posting_date).year)) * int(overdue_days)
grand_total = flt(outstanding_amount) + flt(interest_amount) + flt(dunning_fee)
dunning_amount = flt(interest_amount) + flt(dunning_fee)
return {
'interest_amount': interest_amount,
'grand_total': grand_total,
'dunning_amount': dunning_amount}
@frappe.whitelist()
def get_dunning_letter_text(dunning_type, doc, language=None):
if isinstance(doc, string_types):
doc = json.loads(doc)
if language:
filters = {'parent': dunning_type, 'language': language}
else:
filters = {'parent': dunning_type, 'is_default_language': 1}
letter_text = frappe.db.get_value('Dunning Letter Text', filters,
['body_text', 'closing_text', 'language'], as_dict=1)
if letter_text:
return {
'body_text': frappe.render_template(letter_text.body_text, doc),
'closing_text': frappe.render_template(letter_text.closing_text, doc),
'language': letter_text.language
}

9
erpnext/accounts/doctype/dunning/dunning_list.js

@ -0,0 +1,9 @@
frappe.listview_settings["Dunning"] = {
get_indicator: function (doc) {
if (doc.status === "Resolved") {
return [__("Resolved"), "green", "status,=,Resolved"];
} else {
return [__("Unresolved"), "red", "status,=,Unresolved"];
}
},
};

100
erpnext/accounts/doctype/dunning/test_dunning.py

@ -0,0 +1,100 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
from __future__ import unicode_literals
import frappe
import unittest
from frappe.utils import add_days, today, nowdate
from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import unlink_payment_on_cancel_of_invoice
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice_against_cost_center
from erpnext.accounts.doctype.dunning.dunning import calculate_interest_and_amount
from erpnext.accounts.doctype.payment_entry.test_payment_entry import get_payment_entry
class TestDunning(unittest.TestCase):
@classmethod
def setUpClass(self):
create_dunning_type()
unlink_payment_on_cancel_of_invoice()
@classmethod
def tearDownClass(self):
unlink_payment_on_cancel_of_invoice(0)
def test_dunning(self):
dunning = create_dunning()
amounts = calculate_interest_and_amount(
dunning.posting_date, dunning.outstanding_amount, dunning.rate_of_interest, dunning.dunning_fee, dunning.overdue_days)
self.assertEqual(round(amounts.get('interest_amount'), 2), 0.44)
self.assertEqual(round(amounts.get('dunning_amount'), 2), 20.44)
self.assertEqual(round(amounts.get('grand_total'), 2), 120.44)
def test_gl_entries(self):
dunning = create_dunning()
dunning.submit()
gl_entries = frappe.db.sql("""select account, debit, credit
from `tabGL Entry` where voucher_type='Dunning' and voucher_no=%s
order by account asc""", dunning.name, as_dict=1)
self.assertTrue(gl_entries)
expected_values = dict((d[0], d) for d in [
['Debtors - _TC', 20.44, 0.0],
['Sales - _TC', 0.0, 20.44]
])
for gle in gl_entries:
self.assertEquals(expected_values[gle.account][0], gle.account)
self.assertEquals(expected_values[gle.account][1], gle.debit)
self.assertEquals(expected_values[gle.account][2], gle.credit)
def test_payment_entry(self):
dunning = create_dunning()
dunning.submit()
pe = get_payment_entry("Dunning", dunning.name)
pe.reference_no = "1"
pe.reference_date = nowdate()
pe.paid_from_account_currency = dunning.currency
pe.paid_to_account_currency = dunning.currency
pe.source_exchange_rate = 1
pe.target_exchange_rate = 1
pe.insert()
pe.submit()
si_doc = frappe.get_doc('Sales Invoice', dunning.sales_invoice)
self.assertEqual(si_doc.outstanding_amount, 0)
def create_dunning():
posting_date = add_days(today(), -20)
due_date = add_days(today(), -15)
sales_invoice = create_sales_invoice_against_cost_center(
posting_date=posting_date, due_date=due_date, status='Overdue')
dunning_type = frappe.get_doc("Dunning Type", 'First Notice')
dunning = frappe.new_doc("Dunning")
dunning.sales_invoice = sales_invoice.name
dunning.customer_name = sales_invoice.customer_name
dunning.outstanding_amount = sales_invoice.outstanding_amount
dunning.debit_to = sales_invoice.debit_to
dunning.currency = sales_invoice.currency
dunning.company = sales_invoice.company
dunning.posting_date = nowdate()
dunning.due_date = sales_invoice.due_date
dunning.dunning_type = 'First Notice'
dunning.rate_of_interest = dunning_type.rate_of_interest
dunning.dunning_fee = dunning_type.dunning_fee
dunning.save()
return dunning
def create_dunning_type():
dunning_type = frappe.new_doc("Dunning Type")
dunning_type.dunning_type = 'First Notice'
dunning_type.start_day = 10
dunning_type.end_day = 20
dunning_type.dunning_fee = 20
dunning_type.rate_of_interest = 8
dunning_type.append(
"dunning_letter_text", {
'language': 'en',
'body_text': 'We have still not received payment for our invoice ',
'closing_text': 'We kindly request that you pay the outstanding amount immediately, including interest and late fees.'
}
)
dunning_type.save()

0
erpnext/accounts/doctype/dunning_letter_text/__init__.py

70
erpnext/accounts/doctype/dunning_letter_text/dunning_letter_text.json

@ -0,0 +1,70 @@
{
"actions": [],
"creation": "2019-12-06 04:25:40.215625",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"language",
"is_default_language",
"section_break_4",
"body_text",
"closing_text",
"section_break_7",
"body_and_closing_text_help"
],
"fields": [
{
"fieldname": "language",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Language",
"options": "Language"
},
{
"default": "0",
"fieldname": "is_default_language",
"fieldtype": "Check",
"label": "Is Default Language"
},
{
"fieldname": "section_break_4",
"fieldtype": "Section Break"
},
{
"description": "Letter or Email Body Text",
"fieldname": "body_text",
"fieldtype": "Text Editor",
"in_list_view": 1,
"label": "Body Text"
},
{
"description": "Letter or Email Closing Text",
"fieldname": "closing_text",
"fieldtype": "Text Editor",
"in_list_view": 1,
"label": "Closing Text"
},
{
"fieldname": "section_break_7",
"fieldtype": "Section Break"
},
{
"fieldname": "body_and_closing_text_help",
"fieldtype": "HTML",
"label": "Body and Closing Text Help",
"options": "<h4>Body Text and Closing Text Example</h4>\n\n<div>We have noticed that you have not yet paid invoice {{sales_invoice}} for {{frappe.db.get_value(\"Currency\", currency, \"symbol\")}} {{outstanding_amount}}. This is a friendly reminder that the invoice was due on {{due_date}}. Please pay the amount due immediately to avoid any further dunning cost.</div>\n\n<h4>How to get fieldnames</h4>\n\n<p>The fieldnames you can use in your template are the fields in the document. You can find out the fields of any documents via Setup &gt; Customize Form View and selecting the document type (e.g. Sales Invoice)</p>\n\n<h4>Templating</h4>\n\n<p>Templates are compiled using the Jinja Templating Language. To learn more about Jinja, <a class=\"strong\" href=\"http://jinja.pocoo.org/docs/dev/templates/\">read this documentation.</a></p>"
}
],
"istable": 1,
"links": [],
"modified": "2020-07-14 18:02:35.988958",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Dunning Letter Text",
"owner": "Administrator",
"permissions": [],
"sort_field": "modified",
"sort_order": "DESC",
"track_changes": 1
}

10
erpnext/accounts/doctype/dunning_letter_text/dunning_letter_text.py

@ -0,0 +1,10 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
from __future__ import unicode_literals
# import frappe
from frappe.model.document import Document
class DunningLetterText(Document):
pass

0
erpnext/accounts/doctype/dunning_type/__init__.py

8
erpnext/accounts/doctype/dunning_type/dunning_type.js

@ -0,0 +1,8 @@
// Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors
// For license information, please see license.txt
frappe.ui.form.on('Dunning Type', {
// refresh: function(frm) {
// }
});

129
erpnext/accounts/doctype/dunning_type/dunning_type.json

@ -0,0 +1,129 @@
{
"actions": [],
"allow_rename": 1,
"autoname": "field:dunning_type",
"creation": "2019-12-04 04:59:08.003664",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"dunning_type",
"overdue_interval_section",
"start_day",
"column_break_4",
"end_day",
"section_break_6",
"dunning_fee",
"column_break_8",
"rate_of_interest",
"text_block_section",
"dunning_letter_text"
],
"fields": [
{
"fieldname": "dunning_type",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Dunning Type",
"reqd": 1,
"unique": 1
},
{
"fieldname": "dunning_fee",
"fieldtype": "Currency",
"in_list_view": 1,
"label": "Dunning Fee"
},
{
"description": "This section allows the user to set the Body and Closing text of the Dunning Letter for the Dunning Type based on language, which can be used in Print.",
"fieldname": "text_block_section",
"fieldtype": "Section Break",
"label": "Dunning Letter"
},
{
"fieldname": "dunning_letter_text",
"fieldtype": "Table",
"options": "Dunning Letter Text"
},
{
"fieldname": "column_break_4",
"fieldtype": "Column Break"
},
{
"fieldname": "section_break_6",
"fieldtype": "Section Break"
},
{
"fieldname": "column_break_8",
"fieldtype": "Column Break"
},
{
"fieldname": "overdue_interval_section",
"fieldtype": "Section Break",
"label": "Overdue Interval"
},
{
"fieldname": "start_day",
"fieldtype": "Int",
"label": "Start Day"
},
{
"fieldname": "end_day",
"fieldtype": "Int",
"label": "End Day"
},
{
"fieldname": "rate_of_interest",
"fieldtype": "Float",
"in_list_view": 1,
"label": "Rate of Interest (%) Yearly"
}
],
"links": [],
"modified": "2020-07-15 17:14:17.835074",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Dunning Type",
"owner": "Administrator",
"permissions": [
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "System Manager",
"share": 1,
"write": 1
},
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Accounts Manager",
"share": 1,
"write": 1
},
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Administrator",
"share": 1,
"write": 1
}
],
"sort_field": "modified",
"sort_order": "DESC",
"track_changes": 1
}

10
erpnext/accounts/doctype/dunning_type/dunning_type.py

@ -0,0 +1,10 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
from __future__ import unicode_literals
# import frappe
from frappe.model.document import Document
class DunningType(Document):
pass

10
erpnext/accounts/doctype/dunning_type/test_dunning_type.py

@ -0,0 +1,10 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
from __future__ import unicode_literals
# import frappe
import unittest
class TestDunningType(unittest.TestCase):
pass

8
erpnext/accounts/doctype/payment_entry/payment_entry.js

@ -90,7 +90,7 @@ frappe.ui.form.on('Payment Entry', {
frm.set_query("reference_doctype", "references", function() {
if (frm.doc.party_type=="Customer") {
var doctypes = ["Sales Order", "Sales Invoice", "Journal Entry"];
var doctypes = ["Sales Order", "Sales Invoice", "Journal Entry", "Dunning"];
} else if (frm.doc.party_type=="Supplier") {
var doctypes = ["Purchase Order", "Purchase Invoice", "Journal Entry"];
} else if (frm.doc.party_type=="Employee") {
@ -125,7 +125,7 @@ frappe.ui.form.on('Payment Entry', {
const child = locals[cdt][cdn];
const filters = {"docstatus": 1, "company": doc.company};
const party_type_doctypes = ['Sales Invoice', 'Sales Order', 'Purchase Invoice',
'Purchase Order', 'Expense Claim', 'Fees'];
'Purchase Order', 'Expense Claim', 'Fees', 'Dunning'];
if (in_list(party_type_doctypes, child.reference_doctype)) {
filters[doc.party_type.toLowerCase()] = doc.party;
@ -863,10 +863,10 @@ frappe.ui.form.on('Payment Entry', {
}
if(frm.doc.party_type=="Customer" &&
!in_list(["Sales Order", "Sales Invoice", "Journal Entry"], row.reference_doctype)
!in_list(["Sales Order", "Sales Invoice", "Journal Entry", "Dunning"], row.reference_doctype)
) {
frappe.model.set_value(row.doctype, row.name, "reference_doctype", null);
frappe.msgprint(__("Row #{0}: Reference Document Type must be one of Sales Order, Sales Invoice or Journal Entry", [row.idx]));
frappe.msgprint(__("Row #{0}: Reference Document Type must be one of Sales Order, Sales Invoice, Journal Entry or Dunning", [row.idx]));
return false;
}

57
erpnext/accounts/doctype/payment_entry/payment_entry.py

@ -199,8 +199,8 @@ class PaymentEntry(AccountsController):
def validate_account_type(self, account, account_types):
account_type = frappe.db.get_value("Account", account, "account_type")
if account_type not in account_types:
frappe.throw(_("Account Type for {0} must be {1}").format(account, comma_or(account_types)))
# if account_type not in account_types:
# frappe.throw(_("Account Type for {0} must be {1}").format(account, comma_or(account_types)))
def set_exchange_rate(self):
if self.paid_from and not self.source_exchange_rate:
@ -223,7 +223,7 @@ class PaymentEntry(AccountsController):
if self.party_type == "Student":
valid_reference_doctypes = ("Fees")
elif self.party_type == "Customer":
valid_reference_doctypes = ("Sales Order", "Sales Invoice", "Journal Entry")
valid_reference_doctypes = ("Sales Order", "Sales Invoice", "Journal Entry", "Dunning")
elif self.party_type == "Supplier":
valid_reference_doctypes = ("Purchase Order", "Purchase Invoice", "Journal Entry")
elif self.party_type == "Employee":
@ -897,6 +897,10 @@ def get_reference_details(reference_doctype, reference_name, party_account_curre
total_amount = ref_doc.get("grand_total")
exchange_rate = 1
outstanding_amount = ref_doc.get("outstanding_amount")
if reference_doctype == "Dunning":
total_amount = ref_doc.get("dunning_amount")
exchange_rate = 1
outstanding_amount = ref_doc.get("dunning_amount")
elif reference_doctype == "Journal Entry" and ref_doc.docstatus == 1:
total_amount = ref_doc.get("total_amount")
if ref_doc.multi_currency:
@ -951,7 +955,7 @@ def get_payment_entry(dt, dn, party_amount=None, bank_account=None, bank_amount=
if dt in ("Sales Order", "Purchase Order") and flt(doc.per_billed, 2) > 0:
frappe.throw(_("Can only make payment against unbilled {0}").format(dt))
if dt in ("Sales Invoice", "Sales Order"):
if dt in ("Sales Invoice", "Sales Order", "Dunning"):
party_type = "Customer"
elif dt in ("Purchase Invoice", "Purchase Order"):
party_type = "Supplier"
@ -980,7 +984,7 @@ def get_payment_entry(dt, dn, party_amount=None, bank_account=None, bank_amount=
party_account_currency = doc.get("party_account_currency") or get_account_currency(party_account)
# payment type
if (dt == "Sales Order" or (dt in ("Sales Invoice", "Fees") and doc.outstanding_amount > 0)) \
if (dt == "Sales Order" or (dt in ("Sales Invoice", "Fees", "Dunning") and doc.outstanding_amount > 0)) \
or (dt=="Purchase Invoice" and doc.outstanding_amount < 0):
payment_type = "Receive"
else:
@ -1006,6 +1010,9 @@ def get_payment_entry(dt, dn, party_amount=None, bank_account=None, bank_amount=
elif dt == "Fees":
grand_total = doc.grand_total
outstanding_amount = doc.outstanding_amount
elif dt == "Dunning":
grand_total = doc.grand_total
outstanding_amount = doc.grand_total
else:
if party_account_currency == doc.company_currency:
grand_total = flt(doc.get("base_rounded_total") or doc.base_grand_total)
@ -1075,15 +1082,35 @@ def get_payment_entry(dt, dn, party_amount=None, bank_account=None, bank_amount=
for reference in get_reference_as_per_payment_terms(doc.payment_schedule, dt, dn, doc, grand_total, outstanding_amount):
pe.append('references', reference)
else:
pe.append("references", {
'reference_doctype': dt,
'reference_name': dn,
"bill_no": doc.get("bill_no"),
"due_date": doc.get("due_date"),
'total_amount': grand_total,
'outstanding_amount': outstanding_amount,
'allocated_amount': outstanding_amount
})
if dt == "Dunning":
pe.append("references", {
'reference_doctype': 'Sales Invoice',
'reference_name': doc.get('sales_invoice'),
"bill_no": doc.get("bill_no"),
"due_date": doc.get("due_date"),
'total_amount': doc.get('outstanding_amount'),
'outstanding_amount': doc.get('outstanding_amount'),
'allocated_amount': doc.get('outstanding_amount')
})
pe.append("references", {
'reference_doctype': dt,
'reference_name': dn,
"bill_no": doc.get("bill_no"),
"due_date": doc.get("due_date"),
'total_amount': doc.get('dunning_amount'),
'outstanding_amount': doc.get('dunning_amount'),
'allocated_amount': doc.get('dunning_amount')
})
else:
pe.append("references", {
'reference_doctype': dt,
'reference_name': dn,
"bill_no": doc.get("bill_no"),
"due_date": doc.get("due_date"),
'total_amount': grand_total,
'outstanding_amount': outstanding_amount,
'allocated_amount': outstanding_amount
})
pe.setup_party_account_field()
pe.set_missing_values()
@ -1172,4 +1199,4 @@ def make_payment_order(source_name, target_doc=None):
}, target_doc, set_missing_values)
return doclist
return doclist

12
erpnext/accounts/doctype/sales_invoice/sales_invoice.js

@ -96,6 +96,12 @@ erpnext.accounts.SalesInvoiceController = erpnext.selling.SellingController.exte
cur_frm.add_custom_button(__('Invoice Discounting'), function() {
cur_frm.events.create_invoice_discounting(cur_frm);
}, __('Create'));
if (doc.due_date < frappe.datetime.get_today()) {
cur_frm.add_custom_button(__('Dunning'), function() {
cur_frm.events.create_dunning(cur_frm);
}, __('Create'));
}
}
if (doc.docstatus === 1) {
@ -824,6 +830,12 @@ frappe.ui.form.on('Sales Invoice', {
method: "erpnext.accounts.doctype.sales_invoice.sales_invoice.create_invoice_discounting",
frm: frm
});
},
create_dunning: function(frm) {
frappe.model.open_mapped_doc({
method: "erpnext.accounts.doctype.sales_invoice.sales_invoice.create_dunning",
frm: frm
});
}
})

34
erpnext/accounts/doctype/sales_invoice/sales_invoice.py

@ -1602,3 +1602,37 @@ def create_invoice_discounting(source_name, target_doc=None):
})
return invoice_discounting
@frappe.whitelist()
def create_dunning(source_name, target_doc=None):
from frappe.model.mapper import get_mapped_doc
from erpnext.accounts.doctype.dunning.dunning import get_dunning_letter_text, calculate_interest_and_amount
def set_missing_values(source, target):
target.sales_invoice = source_name
target.outstanding_amount = source.outstanding_amount
overdue_days = (getdate(target.posting_date) - getdate(source.due_date)).days
target.overdue_days = overdue_days
if frappe.db.exists('Dunning Type', {'start_day': [
'<', overdue_days], 'end_day': ['>=', overdue_days]}):
dunning_type = frappe.get_doc('Dunning Type', {'start_day': [
'<', overdue_days], 'end_day': ['>=', overdue_days]})
target.dunning_type = dunning_type.name
target.rate_of_interest = dunning_type.rate_of_interest
target.dunning_fee = dunning_type.dunning_fee
letter_text = get_dunning_letter_text(dunning_type = dunning_type.name, doc = target.as_dict())
if letter_text:
target.body_text = letter_text.get('body_text')
target.closing_text = letter_text.get('closing_text')
target.language = letter_text.get('language')
amounts = calculate_interest_and_amount(target.posting_date, target.outstanding_amount,
target.rate_of_interest, target.dunning_fee, target.overdue_days)
target.interest_amount = amounts.get('interest_amount')
target.dunning_amount = amounts.get('dunning_amount')
target.grand_total = amounts.get('grand_total')
doclist = get_mapped_doc("Sales Invoice", source_name, {
"Sales Invoice": {
"doctype": "Dunning",
}
}, target_doc, set_missing_values)
return doclist

0
erpnext/accounts/print_format/dunning_letter/__init__.py

25
erpnext/accounts/print_format/dunning_letter/dunning_letter.json

@ -0,0 +1,25 @@
{
"align_labels_right": 0,
"creation": "2019-12-11 04:37:14.012805",
"css": ".print-format th {\n background-color: transparent !important;\n border-bottom: 1px solid !important;\n border-top: none !important;\n}\n.print-format .ql-editor {\n padding-left: 0px;\n padding-right: 0px;\n}\n\n.print-format table {\n margin-bottom: 0px;\n }\n.print-format .table-data tr:last-child { \n border-bottom: 1px solid !important;\n}\n\n.print-format .table-inner tr:last-child {\n border-bottom:none !important;\n}\n.print-format .table-inner {\n margin: 0px 0px;\n}\n\n.print-format .table-data ul li { \n color:#787878 !important;\n}\n\n.no-top-border {\n border-top:none !important;\n}\n\n.table-inner td {\n padding-left: 0px !important; \n padding-top: 1px !important;\n padding-bottom: 1px !important;\n color:#787878 !important;\n}\n\n.total {\n background-color: lightgrey !important;\n padding-top: 4px !important;\n padding-bottom: 4px !important;\n}\n",
"custom_format": 0,
"default_print_language": "en",
"disabled": 0,
"doc_type": "Dunning",
"docstatus": 0,
"doctype": "Print Format",
"font": "Arial",
"format_data": "[{\"fieldname\": \"print_heading_template\", \"fieldtype\": \"Custom HTML\", \"options\": \"<div></div>\"}, {\"fieldtype\": \"Section Break\", \"label\": \"\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldname\": \"_custom_html\", \"print_hide\": 0, \"label\": \"Custom HTML\", \"fieldtype\": \"HTML\", \"options\": \"<b>{{doc.customer_name}}</b> <br />\\n{{doc.address_display}}\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldname\": \"_custom_html\", \"print_hide\": 0, \"label\": \"Custom HTML\", \"fieldtype\": \"HTML\", \"options\": \"<div style=\\\"text-align:left;\\\">\\n<div style=\\\"font-size:24px; text-transform:uppercase;\\\">{{_(doc.dunning_type)}}</div>\\n<div style=\\\"font-size:16px;padding-bottom:5px;\\\">{{ doc.name }}</div>\\n</div>\"}, {\"fieldname\": \"posting_date\", \"print_hide\": 0, \"label\": \"Date\"}, {\"fieldname\": \"sales_invoice\", \"print_hide\": 0, \"label\": \"Sales Invoice\"}, {\"fieldname\": \"due_date\", \"print_hide\": 0, \"label\": \"Due Date\"}, {\"fieldtype\": \"Section Break\", \"label\": \"\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldname\": \"body_text\", \"print_hide\": 0, \"label\": \"Body Text\"}, {\"fieldtype\": \"Section Break\", \"label\": \"\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldname\": \"_custom_html\", \"print_hide\": 0, \"label\": \"Custom HTML\", \"fieldtype\": \"HTML\", \"options\": \"<table class=\\\"table table-borderless table-data\\\">\\n <tbody>\\n <tr>\\n <th>{{_(\\\"Description\\\")}}</th>\\n\\t <th style=\\\"text-align: right;\\\">{{_(\\\"Amount\\\")}}</th>\\n </tr>\\n <tr>\\n <td>\\n {{_(\\\"Outstanding Amount\\\")}}\\n </td>\\n <td style=\\\"text-align: right;\\\">\\n {{doc.get_formatted(\\\"outstanding_amount\\\")}}\\n </td>\\n </tr>\\n {%if doc.rate_of_interest > 0%}\\n <tr>\\n <td>\\n {{_(\\\"Interest \\\")}} {{doc.rate_of_interest}}% p.a. ({{doc.overdue_days}} {{_(\\\"days\\\")}})\\n </td>\\n <td style=\\\"text-align: right;\\\">\\n {{doc.get_formatted(\\\"interest_amount\\\")}}\\n </td>\\n </tr>\\n {% endif %}\\n {%if doc.dunning_fee > 0%}\\n <tr>\\n <td>\\n {{_(\\\"Dunning Fee\\\")}}\\n </td>\\n <td style=\\\"text-align: right;\\\">\\n {{doc.get_formatted(\\\"dunning_fee\\\")}}\\n </td>\\n </tr>\\n {% endif %}\\n </tbody>\\n</table>\"}, {\"fieldtype\": \"Section Break\", \"label\": \"\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldname\": \"_custom_html\", \"print_hide\": 0, \"label\": \"Custom HTML\", \"fieldtype\": \"HTML\", \"options\": \"\\n<div class=\\\"row total\\\" style =\\\"margin-right: 0px;\\\">\\n\\t\\t<div class=\\\"col-xs-5\\\">\\n\\t\\t\\t<b>{{_(\\\"Grand Total\\\")}}</b></div>\\n\\t\\t<div class=\\\"col-xs-7 text-right\\\" style=\\\"padding-right: 4px;\\\">\\n\\t\\t\\t<b>{{doc.get_formatted(\\\"grand_total\\\")}}</b>\\n\\t\\t</div>\\n</div>\\n\\n\"}, {\"fieldtype\": \"Section Break\", \"label\": \"\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldname\": \"closing_text\", \"print_hide\": 0, \"label\": \"Closing Text\"}]",
"idx": 0,
"line_breaks": 0,
"modified": "2020-07-14 18:25:44.348207",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Dunning Letter",
"owner": "Administrator",
"print_format_builder": 0,
"print_format_type": "Jinja",
"raw_printing": 0,
"show_section_headings": 0,
"standard": "Yes"
}

4
erpnext/hooks.py

@ -249,7 +249,7 @@ doc_events = {
"validate": "erpnext.regional.india.utils.update_grand_total_for_rcm"
},
"Payment Entry": {
"on_submit": ["erpnext.regional.create_transaction_log", "erpnext.accounts.doctype.payment_request.payment_request.update_payment_req_status"],
"on_submit": ["erpnext.regional.create_transaction_log", "erpnext.accounts.doctype.payment_request.payment_request.update_payment_req_status", "erpnext.accounts.doctype.dunning.dunning.resolve_dunning"],
"on_trash": "erpnext.regional.check_deletion_permission"
},
'Address': {
@ -552,4 +552,4 @@ global_search_doctypes = {
{'doctype': 'Hotel Room Package', 'index': 3},
{'doctype': 'Hotel Room Type', 'index': 4}
]
}
}
Loading…
Cancel
Save