diff --git a/erpnext/accounts/doctype/dunning/__init__.py b/erpnext/accounts/doctype/dunning/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/erpnext/accounts/doctype/dunning/dunning.js b/erpnext/accounts/doctype/dunning/dunning.js new file mode 100644 index 0000000000..c563368894 --- /dev/null +++ b/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); + }, + }); + }, +}); diff --git a/erpnext/accounts/doctype/dunning/dunning.json b/erpnext/accounts/doctype/dunning/dunning.json new file mode 100644 index 0000000000..b3eddf5f22 --- /dev/null +++ b/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 +} \ No newline at end of file diff --git a/erpnext/accounts/doctype/dunning/dunning.py b/erpnext/accounts/doctype/dunning/dunning.py new file mode 100644 index 0000000000..0be6a480c9 --- /dev/null +++ b/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 + } diff --git a/erpnext/accounts/doctype/dunning/dunning_list.js b/erpnext/accounts/doctype/dunning/dunning_list.js new file mode 100644 index 0000000000..8dc0a8c857 --- /dev/null +++ b/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"]; + } + }, +}; diff --git a/erpnext/accounts/doctype/dunning/test_dunning.py b/erpnext/accounts/doctype/dunning/test_dunning.py new file mode 100644 index 0000000000..cb18309e3c --- /dev/null +++ b/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() diff --git a/erpnext/accounts/doctype/dunning_letter_text/__init__.py b/erpnext/accounts/doctype/dunning_letter_text/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/erpnext/accounts/doctype/dunning_letter_text/dunning_letter_text.json b/erpnext/accounts/doctype/dunning_letter_text/dunning_letter_text.json new file mode 100644 index 0000000000..5ede3a1071 --- /dev/null +++ b/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": "
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 > Customize Form View and selecting the document type (e.g. Sales Invoice)
\n\nTemplates are compiled using the Jinja Templating Language. To learn more about Jinja, read this documentation.
" + } + ], + "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 +} \ No newline at end of file diff --git a/erpnext/accounts/doctype/dunning_letter_text/dunning_letter_text.py b/erpnext/accounts/doctype/dunning_letter_text/dunning_letter_text.py new file mode 100644 index 0000000000..426497b607 --- /dev/null +++ b/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 diff --git a/erpnext/accounts/doctype/dunning_type/__init__.py b/erpnext/accounts/doctype/dunning_type/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/erpnext/accounts/doctype/dunning_type/dunning_type.js b/erpnext/accounts/doctype/dunning_type/dunning_type.js new file mode 100644 index 0000000000..54156b488d --- /dev/null +++ b/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) { + + // } +}); diff --git a/erpnext/accounts/doctype/dunning_type/dunning_type.json b/erpnext/accounts/doctype/dunning_type/dunning_type.json new file mode 100644 index 0000000000..da43664472 --- /dev/null +++ b/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 +} \ No newline at end of file diff --git a/erpnext/accounts/doctype/dunning_type/dunning_type.py b/erpnext/accounts/doctype/dunning_type/dunning_type.py new file mode 100644 index 0000000000..8708748428 --- /dev/null +++ b/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 diff --git a/erpnext/accounts/doctype/dunning_type/test_dunning_type.py b/erpnext/accounts/doctype/dunning_type/test_dunning_type.py new file mode 100644 index 0000000000..b2fb26f34a --- /dev/null +++ b/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 diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.js b/erpnext/accounts/doctype/payment_entry/payment_entry.js index 42c9fdeba4..4bbf63bdd9 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry.js +++ b/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; } diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.py b/erpnext/accounts/doctype/payment_entry/payment_entry.py index 1cecab74ef..f9db14b90f 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry.py +++ b/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 \ No newline at end of file diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.js b/erpnext/accounts/doctype/sales_invoice/sales_invoice.js index df0c3d2299..061ce1cbb9 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.js +++ b/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 + }); } }) diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py index bab5208370..89843484f9 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py +++ b/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 \ No newline at end of file diff --git a/erpnext/accounts/print_format/dunning_letter/__init__.py b/erpnext/accounts/print_format/dunning_letter/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/erpnext/accounts/print_format/dunning_letter/dunning_letter.json b/erpnext/accounts/print_format/dunning_letter/dunning_letter.json new file mode 100644 index 0000000000..a7eac70b65 --- /dev/null +++ b/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\": \"\"}, {\"fieldtype\": \"Section Break\", \"label\": \"\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldname\": \"_custom_html\", \"print_hide\": 0, \"label\": \"Custom HTML\", \"fieldtype\": \"HTML\", \"options\": \"{{doc.customer_name}}| {{_(\\\"Description\\\")}} | \\n\\t{{_(\\\"Amount\\\")}} | \\n
|---|---|
| \\n {{_(\\\"Outstanding Amount\\\")}}\\n | \\n\\n {{doc.get_formatted(\\\"outstanding_amount\\\")}}\\n | \\n
| \\n {{_(\\\"Interest \\\")}} {{doc.rate_of_interest}}% p.a. ({{doc.overdue_days}} {{_(\\\"days\\\")}})\\n | \\n\\n {{doc.get_formatted(\\\"interest_amount\\\")}}\\n | \\n
| \\n {{_(\\\"Dunning Fee\\\")}}\\n | \\n\\n {{doc.get_formatted(\\\"dunning_fee\\\")}}\\n | \\n