diff --git a/erpnext/accounts/doctype/dunning/dunning.js b/erpnext/accounts/doctype/dunning/dunning.js index 9909c6c2ab..1ac909e745 100644 --- a/erpnext/accounts/doctype/dunning/dunning.js +++ b/erpnext/accounts/doctype/dunning/dunning.js @@ -1,13 +1,14 @@ -// Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors +// Copyright (c) 2023, 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", () => { + frm.set_query("sales_invoice", "overdue_payments", () => { return { filters: { docstatus: 1, company: frm.doc.company, + customer: frm.doc.customer, outstanding_amount: [">", 0], status: "Overdue" }, @@ -22,14 +23,24 @@ frappe.ui.form.on("Dunning", { } }; }); + frm.set_query("cost_center", () => { + return { + filters: { + company: frm.doc.company, + is_group: 0 + } + }; + }); + + frm.set_query("contact_person", erpnext.queries.contact_query); + frm.set_query("customer_address", erpnext.queries.address_query); + frm.set_query("company_address", erpnext.queries.company_address_query); + + // cannot add rows manually, only via button "Fetch Overdue Payments" + frm.set_df_property("overdue_payments", "cannot_add_rows", true); }, 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"); @@ -40,42 +51,111 @@ frappe.ui.form.on("Dunning", { __("Payment"), function () { frm.events.make_payment_entry(frm); - },__("Create") + }, __("Create") ); frm.page.set_inner_btn_group_as_primary(__("Create")); } - if(frm.doc.docstatus > 0) { - frm.add_custom_button(__('Ledger'), function() { - frappe.route_options = { - "voucher_no": frm.doc.name, - "from_date": frm.doc.posting_date, - "to_date": frm.doc.posting_date, - "company": frm.doc.company, - "show_cancelled_entries": frm.doc.docstatus === 2 - }; - frappe.set_route("query-report", "General Ledger"); - }, __('View')); + if (frm.doc.docstatus === 0) { + frm.add_custom_button(__("Fetch Overdue Payments"), () => { + erpnext.utils.map_current_doc({ + method: "erpnext.accounts.doctype.sales_invoice.sales_invoice.create_dunning", + source_doctype: "Sales Invoice", + date_field: "due_date", + target: frm, + setters: { + customer: frm.doc.customer || undefined, + }, + get_query_filters: { + docstatus: 1, + status: "Overdue", + company: frm.doc.company + }, + allow_child_item_selection: true, + child_fieldname: "payment_schedule", + child_columns: ["due_date", "outstanding"], + }); + }); } + + frappe.dynamic_link = { doc: frm.doc, fieldname: 'customer', doctype: 'Customer' }; + + frm.toggle_display("customer_name", (frm.doc.customer_name && frm.doc.customer_name !== frm.doc.customer)); }, - 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", ""); + // When multiple companies are set up. in case company name is changed set default company address + company: function (frm) { + if (frm.doc.company) { + frappe.call({ + method: "erpnext.setup.doctype.company.company.get_default_company_address", + args: { name: frm.doc.company, existing_address: frm.doc.company_address || "" }, + debounce: 2000, + callback: function (r) { + frm.set_value("company_address", r && r.message || ""); + } + }); + + if (frm.fields_dict.currency) { + const company_currency = erpnext.get_currency(frm.doc.company); + + if (!frm.doc.currency) { + frm.set_value("currency", company_currency); + } + + if (frm.doc.currency == company_currency) { + frm.set_value("conversion_rate", 1.0); } } - ); + + const company_doc = frappe.get_doc(":Company", frm.doc.company); + if (company_doc.default_letter_head) { + if (frm.fields_dict.letter_head) { + frm.set_value("letter_head", company_doc.default_letter_head); + } + } + } + }, + currency: function (frm) { + // this.set_dynamic_labels(); + const company_currency = erpnext.get_currency(frm.doc.company); + // Added `ignore_pricing_rule` to determine if document is loading after mapping from another doc + if (frm.doc.currency && frm.doc.currency !== company_currency) { + frappe.call({ + method: "erpnext.setup.utils.get_exchange_rate", + args: { + transaction_date: frm.doc.posting_date, + from_currency: frm.doc.currency, + to_currency: company_currency, + args: "for_selling" + }, + freeze: true, + freeze_message: __("Fetching exchange rates ..."), + callback: function(r) { + const exchange_rate = flt(r.message); + if (exchange_rate != frm.doc.conversion_rate) { + frm.set_value("conversion_rate", exchange_rate); + } + } + }); + } else { + frm.trigger("conversion_rate"); + } + }, + customer: (frm) => { + erpnext.utils.get_party_details(frm); + }, + conversion_rate: function (frm) { + if (frm.doc.currency === erpnext.get_currency(frm.doc.company)) { + frm.set_value("conversion_rate", 1.0); + } + + // Make read only if Accounts Settings doesn't allow stale rates + frm.set_df_property("conversion_rate", "read_only", erpnext.stale_rate_allowed() ? 0 : 1); + }, + customer_address: function (frm) { + erpnext.utils.get_address_display(frm, "customer_address"); + }, + company_address: function (frm) { + erpnext.utils.get_address_display(frm, "company_address"); }, dunning_type: function (frm) { frm.trigger("get_dunning_letter_text"); @@ -87,7 +167,7 @@ frappe.ui.form.on("Dunning", { if (frm.doc.dunning_type) { frappe.call({ method: - "erpnext.accounts.doctype.dunning.dunning.get_dunning_letter_text", + "erpnext.accounts.doctype.dunning.dunning.get_dunning_letter_text", args: { dunning_type: frm.doc.dunning_type, language: frm.doc.language, @@ -106,49 +186,62 @@ frappe.ui.form.on("Dunning", { }); } }, - 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"); + frm.trigger("calculate_interest"); }, dunning_fee: function (frm) { - frm.trigger("calculate_interest_and_amount"); + frm.trigger("calculate_totals"); }, - sales_invoice: function (frm) { - frm.trigger("calculate_overdue_days"); + overdue_payments_add: function (frm) { + frm.trigger("calculate_totals"); + }, + overdue_payments_remove: function (frm) { + frm.trigger("calculate_totals"); }, 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); - } + frm.doc.overdue_payments.forEach((row) => { + if (frm.doc.posting_date && row.due_date) { + const overdue_days = moment(frm.doc.posting_date).diff( + row.due_date, + "days" + ); + frappe.model.set_value(row.doctype, row.name, "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 = flt((interest_per_year * cint(frm.doc.overdue_days)) / 365 || 0, precision('interest_amount')); - const dunning_amount = flt(interest_amount + frm.doc.dunning_fee, precision('dunning_amount')); - const grand_total = flt(frm.doc.outstanding_amount + dunning_amount, precision('grand_total')); - frm.set_value("interest_amount", interest_amount); - frm.set_value("dunning_amount", dunning_amount); - frm.set_value("grand_total", grand_total); + calculate_interest: function (frm) { + frm.doc.overdue_payments.forEach((row) => { + const interest_per_day = frm.doc.rate_of_interest / 100 / 365; + const interest = flt((interest_per_day * row.overdue_days * row.outstanding), precision("interest", row)); + frappe.model.set_value(row.doctype, row.name, "interest", interest); + }); + }, + calculate_totals: function (frm) { + const total_interest = frm.doc.overdue_payments + .reduce((prev, cur) => prev + cur.interest, 0); + const total_outstanding = frm.doc.overdue_payments + .reduce((prev, cur) => prev + cur.outstanding, 0); + const dunning_amount = total_interest + frm.doc.dunning_fee; + const base_dunning_amount = dunning_amount * frm.doc.conversion_rate; + const grand_total = total_outstanding + dunning_amount; + + function setWithPrecison(field, value) { + frm.set_value(field, flt(value, precision(field))); + } + + setWithPrecison("total_outstanding", total_outstanding); + setWithPrecison("total_interest", total_interest); + setWithPrecison("dunning_amount", dunning_amount); + setWithPrecison("base_dunning_amount", base_dunning_amount); + setWithPrecison("grand_total", grand_total); }, make_payment_entry: function (frm) { return frappe.call({ method: - "erpnext.accounts.doctype.payment_entry.payment_entry.get_payment_entry", + "erpnext.accounts.doctype.payment_entry.payment_entry.get_payment_entry", args: { dt: frm.doc.doctype, dn: frm.doc.name, @@ -160,3 +253,9 @@ frappe.ui.form.on("Dunning", { }); }, }); + +frappe.ui.form.on("Overdue Payment", { + interest: function (frm) { + frm.trigger("calculate_totals"); + } +}); \ No newline at end of file diff --git a/erpnext/accounts/doctype/dunning/dunning.json b/erpnext/accounts/doctype/dunning/dunning.json index 2a32b99f42..b7e8aeaaaf 100644 --- a/erpnext/accounts/doctype/dunning/dunning.json +++ b/erpnext/accounts/doctype/dunning/dunning.json @@ -2,49 +2,60 @@ "actions": [], "allow_events_in_timeline": 1, "autoname": "naming_series:", + "beta": 1, "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", + "status", + "section_break_9", + "currency", + "column_break_11", + "conversion_rate", "address_and_contact_section", + "customer_address", "address_display", + "contact_person", "contact_display", + "column_break_16", + "company_address", + "company_address_display", "contact_mobile", "contact_email", - "column_break_18", - "company_address_display", "section_break_6", "dunning_type", - "dunning_fee", "column_break_8", "rate_of_interest", - "interest_amount", "section_break_12", - "dunning_amount", - "grand_total", - "income_account", + "overdue_payments", + "section_break_28", + "total_interest", + "dunning_fee", "column_break_17", - "status", - "printing_setting_section", + "dunning_amount", + "base_dunning_amount", + "section_break_32", + "spacer", + "column_break_33", + "total_outstanding", + "grand_total", + "printing_settings_section", "language", "body_text", "column_break_22", "letter_head", "closing_text", + "accounting_details_section", + "income_account", + "column_break_48", + "cost_center", "amended_from" ], "fields": [ @@ -60,32 +71,17 @@ "fieldname": "naming_series", "fieldtype": "Select", "label": "Series", - "options": "DUNN-.MM.-.YY.-" + "options": "DUNN-.MM.-.YY.-", + "print_hide": 1 }, { - "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", + "fetch_from": "customer.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" @@ -94,13 +90,8 @@ "default": "Today", "fieldname": "posting_date", "fieldtype": "Date", - "label": "Date" - }, - { - "fieldname": "overdue_days", - "fieldtype": "Int", - "label": "Overdue Days", - "read_only": 1 + "label": "Date", + "reqd": 1 }, { "fieldname": "section_break_6", @@ -112,16 +103,7 @@ "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 + "options": "Dunning Type" }, { "fieldname": "column_break_8", @@ -134,6 +116,7 @@ "fieldname": "dunning_fee", "fieldtype": "Currency", "label": "Dunning Fee", + "options": "currency", "precision": "2" }, { @@ -144,36 +127,24 @@ "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" + "options": "Language", + "print_hide": 1 }, { "fieldname": "letter_head", "fieldtype": "Link", "label": "Letter Head", - "options": "Letter Head" + "options": "Letter Head", + "print_hide": 1 }, { "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", @@ -183,14 +154,6 @@ "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", @@ -201,13 +164,6 @@ "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", @@ -222,26 +178,24 @@ "label": "Rate of Interest (%) Yearly" }, { + "collapsible": 1, "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", @@ -249,18 +203,12 @@ "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", + "label": "Company Address Display", "read_only": 1 }, { - "fetch_from": "sales_invoice.contact_email", "fieldname": "contact_email", "fieldtype": "Data", "label": "Contact Email", @@ -268,18 +216,18 @@ "read_only": 1 }, { - "fetch_from": "sales_invoice.customer", "fieldname": "customer", "fieldtype": "Link", "label": "Customer", "options": "Customer", - "read_only": 1 + "reqd": 1 }, { "default": "0", "fieldname": "grand_total", "fieldtype": "Currency", "label": "Grand Total", + "options": "currency", "precision": "2", "read_only": 1 }, @@ -290,33 +238,150 @@ "fieldtype": "Select", "in_standard_filter": 1, "label": "Status", - "options": "Draft\nResolved\nUnresolved\nCancelled" - }, - { - "fieldname": "dunning_amount", - "fieldtype": "Currency", - "hidden": 1, - "label": "Dunning Amount", + "options": "Draft\nResolved\nUnresolved\nCancelled", "read_only": 1 }, { + "description": "For dunning fee and interest", + "fetch_from": "dunning_type.income_account", "fieldname": "income_account", "fieldtype": "Link", "label": "Income Account", - "options": "Account" + "options": "Account", + "print_hide": 1 + }, + { + "fieldname": "overdue_payments", + "fieldtype": "Table", + "label": "Overdue Payments", + "options": "Overdue Payment" + }, + { + "fieldname": "section_break_28", + "fieldtype": "Section Break" + }, + { + "default": "0", + "fieldname": "total_interest", + "fieldtype": "Currency", + "label": "Total Interest", + "options": "currency", + "precision": "2", + "read_only": 1 + }, + { + "fieldname": "total_outstanding", + "fieldtype": "Currency", + "label": "Total Outstanding", + "options": "currency", + "read_only": 1 + }, + { + "fieldname": "customer_address", + "fieldtype": "Link", + "label": "Customer Address", + "options": "Address", + "print_hide": 1 + }, + { + "fieldname": "contact_person", + "fieldtype": "Link", + "label": "Contact Person", + "options": "Contact", + "print_hide": 1 + }, + { + "default": "0", + "fieldname": "dunning_amount", + "fieldtype": "Currency", + "label": "Dunning Amount", + "options": "currency", + "read_only": 1 + }, + { + "collapsible": 1, + "fieldname": "accounting_details_section", + "fieldtype": "Section Break", + "label": "Accounting Details" + }, + { + "fetch_from": "dunning_type.cost_center", + "fieldname": "cost_center", + "fieldtype": "Link", + "label": "Cost Center", + "options": "Cost Center", + "print_hide": 1 + }, + { + "collapsible": 1, + "fieldname": "printing_settings_section", + "fieldtype": "Section Break", + "label": "Printing Settings" + }, + { + "fieldname": "section_break_32", + "fieldtype": "Section Break" + }, + { + "fieldname": "column_break_33", + "fieldtype": "Column Break" + }, + { + "fieldname": "spacer", + "fieldtype": "Data", + "hidden": 1, + "label": "Spacer", + "print_hide": 1, + "read_only": 1, + "report_hide": 1 + }, + { + "fieldname": "column_break_16", + "fieldtype": "Column Break" + }, + { + "fieldname": "company_address", + "fieldtype": "Link", + "label": "Company Address", + "options": "Address", + "print_hide": 1 + }, + { + "fieldname": "section_break_9", + "fieldtype": "Section Break", + "label": "Currency" + }, + { + "fieldname": "currency", + "fieldtype": "Link", + "label": "Currency", + "options": "Currency" + }, + { + "fieldname": "column_break_11", + "fieldtype": "Column Break" }, { - "fetch_from": "sales_invoice.conversion_rate", "fieldname": "conversion_rate", "fieldtype": "Float", - "hidden": 1, - "label": "Conversion Rate", + "label": "Conversion Rate" + }, + { + "default": "0", + "fieldname": "base_dunning_amount", + "fieldtype": "Currency", + "label": "Dunning Amount (Company Currency)", + "options": "Company:company:default_currency", "read_only": 1 + }, + { + "fieldname": "column_break_48", + "fieldtype": "Column Break" } ], "is_submittable": 1, "links": [], - "modified": "2023-06-03 16:24:01.677026", + "modified": "2023-06-15 15:46:53.865712", "modified_by": "Administrator", "module": "Accounts", "name": "Dunning", diff --git a/erpnext/accounts/doctype/dunning/dunning.py b/erpnext/accounts/doctype/dunning/dunning.py index b4df0a5270..9d0d36b970 100644 --- a/erpnext/accounts/doctype/dunning/dunning.py +++ b/erpnext/accounts/doctype/dunning/dunning.py @@ -1,131 +1,150 @@ -# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors +# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors # For license information, please see license.txt +""" +# Accounting +1. Payment of outstanding invoices with dunning amount + - Debit full amount to bank + - Credit invoiced amount to receivables + - Credit dunning amount to interest and similar revenue + + -> Resolves dunning automatically +""" import json import frappe -from frappe.utils import cint, flt, getdate +from frappe import _ +from frappe.contacts.doctype.address.address import get_address_display +from frappe.utils import getdate -from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import ( - get_accounting_dimensions, -) -from erpnext.accounts.general_ledger import make_gl_entries, make_reverse_gl_entries 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.get_cached_value("Company", self.company, "default_income_account") + self.validate_same_currency() + self.validate_overdue_payments() + self.validate_totals() + self.set_party_details() + self.set_dunning_level() - def validate_overdue_days(self): - self.overdue_days = (getdate(self.posting_date) - getdate(self.due_date)).days or 0 + def validate_same_currency(self): + """ + Throw an error if invoice currency differs from dunning currency. + """ + for row in self.overdue_payments: + invoice_currency = frappe.get_value("Sales Invoice", row.sales_invoice, "currency") + if invoice_currency != self.currency: + frappe.throw( + _( + "The currency of invoice {} ({}) is different from the currency of this dunning ({})." + ).format(row.sales_invoice, invoice_currency, self.currency) + ) - def validate_amount(self): - amounts = calculate_interest_and_amount( - self.outstanding_amount, self.rate_of_interest, self.dunning_fee, self.overdue_days + def validate_overdue_payments(self): + daily_interest = self.rate_of_interest / 100 / 365 + + for row in self.overdue_payments: + row.overdue_days = (getdate(self.posting_date) - getdate(row.due_date)).days or 0 + row.interest = row.outstanding * daily_interest * row.overdue_days + + def validate_totals(self): + self.total_outstanding = sum(row.outstanding for row in self.overdue_payments) + self.total_interest = sum(row.interest for row in self.overdue_payments) + self.dunning_amount = self.total_interest + self.dunning_fee + self.base_dunning_amount = self.dunning_amount * self.conversion_rate + self.grand_total = self.total_outstanding + self.dunning_amount + + def set_party_details(self): + from erpnext.accounts.party import _get_party_details + + party_details = _get_party_details( + self.customer, + ignore_permissions=self.flags.ignore_permissions, + doctype=self.doctype, + company=self.company, + posting_date=self.get("posting_date"), + fetch_payment_terms_template=False, + party_address=self.customer_address, + company_address=self.get("company_address"), ) - if self.interest_amount != amounts.get("interest_amount"): - self.interest_amount = flt(amounts.get("interest_amount"), self.precision("interest_amount")) - if self.dunning_amount != amounts.get("dunning_amount"): - self.dunning_amount = flt(amounts.get("dunning_amount"), self.precision("dunning_amount")) - if self.grand_total != amounts.get("grand_total"): - self.grand_total = flt(amounts.get("grand_total"), self.precision("grand_total")) + for field in [ + "customer_address", + "address_display", + "company_address", + "contact_person", + "contact_display", + "contact_mobile", + ]: + self.set(field, party_details.get(field)) - def on_submit(self): - self.make_gl_entries() + self.set("company_address_display", get_address_display(self.company_address)) - def on_cancel(self): - if self.dunning_amount: - self.ignore_linked_doctypes = ("GL Entry", "Stock Ledger Entry", "Payment 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, + def set_dunning_level(self): + for row in self.overdue_payments: + past_dunnings = frappe.get_all( + "Overdue Payment", + filters={ + "payment_schedule": row.payment_schedule, + "parent": ("!=", row.parent), + "docstatus": 1, }, - 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 - ) + row.dunning_level = len(past_dunnings) + 1 def resolve_dunning(doc, state): + """ + Check if all payments have been made and resolve dunning, if yes. Called + when a Payment Entry is submitted. + """ 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")}, - ignore_permissions=True, - ) + # Consider partial and full payments: + # Submitting full payment: outstanding_amount will be 0 + # Submitting 1st partial payment: outstanding_amount will be the pending installment + # Cancelling full payment: outstanding_amount will revert to total amount + # Cancelling last partial payment: outstanding_amount will revert to pending amount + submit_condition = reference.outstanding_amount < reference.total_amount + cancel_condition = reference.outstanding_amount <= reference.total_amount + + if reference.reference_doctype == "Sales Invoice" and ( + submit_condition if doc.docstatus == 1 else cancel_condition + ): + state = "Resolved" if doc.docstatus == 2 else "Unresolved" + dunnings = get_linked_dunnings_as_per_state(reference.reference_name, state) for dunning in dunnings: - frappe.db.set_value("Dunning", dunning.name, "status", "Resolved") + resolve = True + dunning = frappe.get_doc("Dunning", dunning.get("name")) + for overdue_payment in dunning.overdue_payments: + outstanding_inv = frappe.get_value( + "Sales Invoice", overdue_payment.sales_invoice, "outstanding_amount" + ) + outstanding_ps = frappe.get_value( + "Payment Schedule", overdue_payment.payment_schedule, "outstanding" + ) + resolve = False if (outstanding_ps > 0 and outstanding_inv > 0) else True + + dunning.status = "Resolved" if resolve else "Unresolved" + dunning.save() -def calculate_interest_and_amount(outstanding_amount, rate_of_interest, dunning_fee, overdue_days): - interest_amount = 0 - grand_total = flt(outstanding_amount) + flt(dunning_fee) - if rate_of_interest: - interest_per_year = flt(outstanding_amount) * flt(rate_of_interest) / 100 - interest_amount = (interest_per_year * cint(overdue_days)) / 365 - grand_total += flt(interest_amount) - dunning_amount = flt(interest_amount) + flt(dunning_fee) - return { - "interest_amount": interest_amount, - "grand_total": grand_total, - "dunning_amount": dunning_amount, - } +def get_linked_dunnings_as_per_state(sales_invoice, state): + dunning = frappe.qb.DocType("Dunning") + overdue_payment = frappe.qb.DocType("Overdue Payment") + + return ( + frappe.qb.from_(dunning) + .join(overdue_payment) + .on(overdue_payment.parent == dunning.name) + .select(dunning.name) + .where( + (dunning.status == state) + & (dunning.docstatus != 2) + & (overdue_payment.sales_invoice == sales_invoice) + ) + ).run(as_dict=True) @frappe.whitelist() diff --git a/erpnext/accounts/doctype/dunning/dunning_dashboard.py b/erpnext/accounts/doctype/dunning/dunning_dashboard.py deleted file mode 100644 index d1d4031410..0000000000 --- a/erpnext/accounts/doctype/dunning/dunning_dashboard.py +++ /dev/null @@ -1,12 +0,0 @@ -from frappe import _ - - -def get_data(): - return { - "fieldname": "dunning", - "non_standard_fieldnames": { - "Journal Entry": "reference_name", - "Payment Entry": "reference_name", - }, - "transactions": [{"label": _("Payment"), "items": ["Payment Entry", "Journal Entry"]}], - } diff --git a/erpnext/accounts/doctype/dunning/test_dunning.py b/erpnext/accounts/doctype/dunning/test_dunning.py index e1fd1e984f..b29ace275f 100644 --- a/erpnext/accounts/doctype/dunning/test_dunning.py +++ b/erpnext/accounts/doctype/dunning/test_dunning.py @@ -1,162 +1,197 @@ -# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors +# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors # See license.txt - -import unittest - import frappe +from frappe.tests.utils import FrappeTestCase from frappe.utils import add_days, nowdate, today -from erpnext.accounts.doctype.dunning.dunning import calculate_interest_and_amount +from erpnext import get_default_cost_center from erpnext.accounts.doctype.payment_entry.test_payment_entry import get_payment_entry from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import ( unlink_payment_on_cancel_of_invoice, ) +from erpnext.accounts.doctype.sales_invoice.sales_invoice import ( + create_dunning as create_dunning_from_sales_invoice, +) from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import ( create_sales_invoice_against_cost_center, ) +test_dependencies = ["Company", "Cost Center"] -class TestDunning(unittest.TestCase): + +class TestDunning(FrappeTestCase): @classmethod - def setUpClass(self): - create_dunning_type() - create_dunning_type_with_zero_interest_rate() + def setUpClass(cls): + super().setUpClass() + create_dunning_type("First Notice", fee=0.0, interest=0.0, is_default=1) + create_dunning_type("Second Notice", fee=10.0, interest=10.0, is_default=0) unlink_payment_on_cancel_of_invoice() @classmethod - def tearDownClass(self): + def tearDownClass(cls): unlink_payment_on_cancel_of_invoice(0) + super().tearDownClass() - def test_dunning(self): - dunning = create_dunning() - amounts = calculate_interest_and_amount( - 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_dunning_without_fees(self): + dunning = create_dunning(overdue_days=20) - def test_dunning_with_zero_interest_rate(self): - dunning = create_dunning_with_zero_interest_rate() - amounts = calculate_interest_and_amount( - dunning.outstanding_amount, dunning.rate_of_interest, dunning.dunning_fee, dunning.overdue_days - ) - self.assertEqual(round(amounts.get("interest_amount"), 2), 0) - self.assertEqual(round(amounts.get("dunning_amount"), 2), 20) - self.assertEqual(round(amounts.get("grand_total"), 2), 120) + self.assertEqual(round(dunning.total_outstanding, 2), 100.00) + self.assertEqual(round(dunning.total_interest, 2), 0.00) + self.assertEqual(round(dunning.dunning_fee, 2), 0.00) + self.assertEqual(round(dunning.dunning_amount, 2), 0.00) + self.assertEqual(round(dunning.grand_total, 2), 100.00) - 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.assertEqual(expected_values[gle.account][0], gle.account) - self.assertEqual(expected_values[gle.account][1], gle.debit) - self.assertEqual(expected_values[gle.account][2], gle.credit) + def test_dunning_with_fees_and_interest(self): + dunning = create_dunning(overdue_days=15, dunning_type_name="Second Notice - _TC") - def test_payment_entry(self): - dunning = create_dunning() + self.assertEqual(round(dunning.total_outstanding, 2), 100.00) + self.assertEqual(round(dunning.total_interest, 2), 0.41) + self.assertEqual(round(dunning.dunning_fee, 2), 10.00) + self.assertEqual(round(dunning.dunning_amount, 2), 10.41) + self.assertEqual(round(dunning.grand_total, 2), 110.41) + + def test_dunning_with_payment_entry(self): + dunning = create_dunning(overdue_days=15, dunning_type_name="Second Notice - _TC") 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) + + for overdue_payment in dunning.overdue_payments: + outstanding_amount = frappe.get_value( + "Sales Invoice", overdue_payment.sales_invoice, "outstanding_amount" + ) + self.assertEqual(outstanding_amount, 0) + + dunning.reload() + self.assertEqual(dunning.status, "Resolved") + + def test_dunning_and_payment_against_partially_due_invoice(self): + """ + Create SI with first installment overdue. Check impact of Dunning and Payment Entry. + """ + create_payment_terms_template_for_dunning() + sales_invoice = create_sales_invoice_against_cost_center( + posting_date=add_days(today(), -1 * 6), + qty=1, + rate=100, + do_not_submit=True, + ) + sales_invoice.payment_terms_template = "_Test 50-50 for Dunning" + sales_invoice.submit() + dunning = create_dunning_from_sales_invoice(sales_invoice.name) + + self.assertEqual(len(dunning.overdue_payments), 1) + self.assertEqual(dunning.overdue_payments[0].payment_term, "_Test Payment Term 1 for Dunning") + + dunning.submit() + pe = get_payment_entry("Dunning", dunning.name) + pe.reference_no, pe.reference_date = "2", nowdate() + pe.insert() + pe.submit() + sales_invoice.load_from_db() + dunning.load_from_db() + + self.assertEqual(sales_invoice.status, "Partly Paid") + self.assertEqual(sales_invoice.payment_schedule[0].outstanding, 0) + self.assertEqual(dunning.status, "Resolved") + + # Test impact on cancellation of PE + pe.cancel() + sales_invoice.reload() + dunning.reload() + + self.assertEqual(sales_invoice.status, "Overdue") + self.assertEqual(dunning.status, "Unresolved") -def create_dunning(): - posting_date = add_days(today(), -20) - due_date = add_days(today(), -15) +def create_dunning(overdue_days, dunning_type_name=None): + posting_date = add_days(today(), -1 * overdue_days) sales_invoice = create_sales_invoice_against_cost_center( - posting_date=posting_date, due_date=due_date, status="Overdue" + posting_date=posting_date, qty=1, rate=100 ) - 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 + dunning = create_dunning_from_sales_invoice(sales_invoice.name) + + if dunning_type_name: + dunning_type = frappe.get_doc("Dunning Type", dunning_type_name) + dunning.dunning_type = dunning_type.name + dunning.rate_of_interest = dunning_type.rate_of_interest + dunning.dunning_fee = dunning_type.dunning_fee + dunning.income_account = dunning_type.income_account + dunning.cost_center = dunning_type.cost_center + + return dunning.save() -def create_dunning_with_zero_interest_rate(): - 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 with 0% Rate of Interest") - 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 with 0% Rate of Interest" - dunning.rate_of_interest = dunning_type.rate_of_interest - dunning.dunning_fee = dunning_type.dunning_fee - dunning.save() - return dunning +def create_dunning_type(title, fee, interest, is_default): + company = "_Test Company" + if frappe.db.exists("Dunning Type", f"{title} - _TC"): + return - -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.dunning_type = title + dunning_type.company = company + dunning_type.is_default = is_default + dunning_type.dunning_fee = fee + dunning_type.rate_of_interest = interest + dunning_type.income_account = get_income_account(company) + dunning_type.cost_center = get_default_cost_center(company) dunning_type.append( "dunning_letter_text", { "language": "en", - "body_text": "We have still not received payment for our invoice ", + "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() + dunning_type.insert() -def create_dunning_type_with_zero_interest_rate(): - dunning_type = frappe.new_doc("Dunning Type") - dunning_type.dunning_type = "First Notice with 0% Rate of Interest" - dunning_type.start_day = 10 - dunning_type.end_day = 20 - dunning_type.dunning_fee = 20 - dunning_type.rate_of_interest = 0 - 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, and late fees.", - }, +def get_income_account(company): + return ( + frappe.get_value("Company", company, "default_income_account") + or frappe.get_all( + "Account", + filters={"is_group": 0, "company": company}, + or_filters={ + "report_type": "Profit and Loss", + "account_type": ("in", ("Income Account", "Temporary")), + }, + limit=1, + pluck="name", + )[0] ) - dunning_type.save() + + +def create_payment_terms_template_for_dunning(): + from erpnext.accounts.doctype.payment_entry.test_payment_entry import create_payment_term + + create_payment_term("_Test Payment Term 1 for Dunning") + create_payment_term("_Test Payment Term 2 for Dunning") + + if not frappe.db.exists("Payment Terms Template", "_Test 50-50 for Dunning"): + frappe.get_doc( + { + "doctype": "Payment Terms Template", + "template_name": "_Test 50-50 for Dunning", + "allocate_payment_based_on_payment_terms": 1, + "terms": [ + { + "doctype": "Payment Terms Template Detail", + "payment_term": "_Test Payment Term 1 for Dunning", + "invoice_portion": 50.00, + "credit_days_based_on": "Day(s) after invoice date", + "credit_days": 5, + }, + { + "doctype": "Payment Terms Template Detail", + "payment_term": "_Test Payment Term 2 for Dunning", + "invoice_portion": 50.00, + "credit_days_based_on": "Day(s) after invoice date", + "credit_days": 10, + }, + ], + } + ).insert() diff --git a/erpnext/accounts/doctype/dunning_type/dunning_type.js b/erpnext/accounts/doctype/dunning_type/dunning_type.js index 54156b488d..b2c08c1c7f 100644 --- a/erpnext/accounts/doctype/dunning_type/dunning_type.js +++ b/erpnext/accounts/doctype/dunning_type/dunning_type.js @@ -1,8 +1,24 @@ // 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) { - - // } +frappe.ui.form.on("Dunning Type", { + setup: function (frm) { + frm.set_query("income_account", () => { + return { + filters: { + root_type: "Income", + is_group: 0, + company: frm.doc.company, + }, + }; + }); + frm.set_query("cost_center", () => { + return { + filters: { + is_group: 0, + company: frm.doc.company, + }, + }; + }); + }, }); diff --git a/erpnext/accounts/doctype/dunning_type/dunning_type.json b/erpnext/accounts/doctype/dunning_type/dunning_type.json index da43664472..5e39769735 100644 --- a/erpnext/accounts/doctype/dunning_type/dunning_type.json +++ b/erpnext/accounts/doctype/dunning_type/dunning_type.json @@ -1,23 +1,26 @@ { "actions": [], "allow_rename": 1, - "autoname": "field:dunning_type", + "beta": 1, "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", + "is_default", + "column_break_3", + "company", "section_break_6", "dunning_fee", "column_break_8", "rate_of_interest", "text_block_section", - "dunning_letter_text" + "dunning_letter_text", + "section_break_9", + "income_account", + "column_break_13", + "cost_center" ], "fields": [ { @@ -45,10 +48,6 @@ "fieldtype": "Table", "options": "Dunning Letter Text" }, - { - "fieldname": "column_break_4", - "fieldtype": "Column Break" - }, { "fieldname": "section_break_6", "fieldtype": "Section Break" @@ -57,33 +56,62 @@ "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" + }, + { + "default": "0", + "fieldname": "is_default", + "fieldtype": "Check", + "label": "Is Default" + }, + { + "fieldname": "section_break_9", + "fieldtype": "Section Break", + "label": "Accounting Details" + }, + { + "fieldname": "income_account", + "fieldtype": "Link", + "label": "Income Account", + "options": "Account" + }, + { + "fieldname": "cost_center", + "fieldtype": "Link", + "label": "Cost Center", + "options": "Cost Center" + }, + { + "fieldname": "column_break_3", + "fieldtype": "Column Break" + }, + { + "fieldname": "company", + "fieldtype": "Link", + "label": "Company", + "options": "Company", + "reqd": 1 + }, + { + "fieldname": "column_break_13", + "fieldtype": "Column Break" } ], - "links": [], - "modified": "2020-07-15 17:14:17.835074", + "links": [ + { + "link_doctype": "Dunning", + "link_fieldname": "dunning_type" + } + ], + "modified": "2021-11-13 00:25:35.659283", "modified_by": "Administrator", "module": "Accounts", "name": "Dunning Type", + "naming_rule": "By script", "owner": "Administrator", "permissions": [ { diff --git a/erpnext/accounts/doctype/dunning_type/dunning_type.py b/erpnext/accounts/doctype/dunning_type/dunning_type.py index 1b9bb9c032..226e159a3b 100644 --- a/erpnext/accounts/doctype/dunning_type/dunning_type.py +++ b/erpnext/accounts/doctype/dunning_type/dunning_type.py @@ -2,9 +2,11 @@ # For license information, please see license.txt -# import frappe +import frappe from frappe.model.document import Document class DunningType(Document): - pass + def autoname(self): + company_abbr = frappe.get_value("Company", self.company, "abbr") + self.name = f"{self.dunning_type} - {company_abbr}" diff --git a/erpnext/accounts/doctype/dunning_type/test_records.json b/erpnext/accounts/doctype/dunning_type/test_records.json new file mode 100644 index 0000000000..7f28aab873 --- /dev/null +++ b/erpnext/accounts/doctype/dunning_type/test_records.json @@ -0,0 +1,36 @@ +[ + { + "doctype": "Dunning Type", + "dunning_type": "_Test First Notice", + "company": "_Test Company", + "is_default": 1, + "dunning_fee": 0.0, + "rate_of_interest": 0.0, + "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." + } + ], + "income_account": "Sales - _TC", + "cost_center": "_Test Cost Center - _TC" + }, + { + "doctype": "Dunning Type", + "dunning_type": "_Test Second Notice", + "company": "_Test Company", + "is_default": 0, + "dunning_fee": 10.0, + "rate_of_interest": 10.0, + "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." + } + ], + "income_account": "Sales - _TC", + "cost_center": "_Test Cost Center - _TC" + } +] diff --git a/erpnext/accounts/doctype/overdue_payment/__init__.py b/erpnext/accounts/doctype/overdue_payment/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/erpnext/accounts/doctype/overdue_payment/overdue_payment.json b/erpnext/accounts/doctype/overdue_payment/overdue_payment.json new file mode 100644 index 0000000000..99e16469d0 --- /dev/null +++ b/erpnext/accounts/doctype/overdue_payment/overdue_payment.json @@ -0,0 +1,170 @@ +{ + "actions": [], + "creation": "2021-09-15 18:34:27.172906", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "sales_invoice", + "payment_schedule", + "dunning_level", + "payment_term", + "section_break_15", + "description", + "section_break_4", + "due_date", + "overdue_days", + "mode_of_payment", + "column_break_5", + "invoice_portion", + "section_break_16", + "payment_amount", + "outstanding", + "paid_amount", + "discounted_amount", + "interest" + ], + "fields": [ + { + "columns": 2, + "fieldname": "payment_term", + "fieldtype": "Link", + "label": "Payment Term", + "options": "Payment Term", + "print_hide": 1, + "read_only": 1 + }, + { + "collapsible": 1, + "fieldname": "section_break_15", + "fieldtype": "Section Break", + "label": "Description" + }, + { + "columns": 2, + "fetch_from": "payment_term.description", + "fieldname": "description", + "fieldtype": "Small Text", + "label": "Description", + "read_only": 1 + }, + { + "fieldname": "section_break_4", + "fieldtype": "Section Break" + }, + { + "columns": 2, + "fieldname": "due_date", + "fieldtype": "Date", + "label": "Due Date", + "read_only": 1 + }, + { + "fieldname": "mode_of_payment", + "fieldtype": "Link", + "label": "Mode of Payment", + "options": "Mode of Payment", + "read_only": 1 + }, + { + "fieldname": "column_break_5", + "fieldtype": "Column Break" + }, + { + "columns": 2, + "fieldname": "invoice_portion", + "fieldtype": "Percent", + "label": "Invoice Portion", + "read_only": 1 + }, + { + "columns": 2, + "fieldname": "payment_amount", + "fieldtype": "Currency", + "label": "Payment Amount", + "options": "currency", + "read_only": 1 + }, + { + "fetch_from": "payment_amount", + "fieldname": "outstanding", + "fieldtype": "Currency", + "in_list_view": 1, + "label": "Outstanding", + "options": "currency", + "read_only": 1 + }, + { + "depends_on": "paid_amount", + "fieldname": "paid_amount", + "fieldtype": "Currency", + "label": "Paid Amount", + "options": "currency" + }, + { + "default": "0", + "depends_on": "discounted_amount", + "fieldname": "discounted_amount", + "fieldtype": "Currency", + "label": "Discounted Amount", + "print_hide": 1, + "read_only": 1 + }, + { + "fieldname": "sales_invoice", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Sales Invoice", + "options": "Sales Invoice", + "read_only": 1, + "reqd": 1 + }, + { + "fieldname": "payment_schedule", + "fieldtype": "Data", + "label": "Payment Schedule", + "print_hide": 1, + "read_only": 1 + }, + { + "fieldname": "overdue_days", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Overdue Days", + "read_only": 1 + }, + { + "default": "1", + "fieldname": "dunning_level", + "fieldtype": "Int", + "in_list_view": 1, + "label": "Dunning Level", + "read_only": 1 + }, + { + "fieldname": "section_break_16", + "fieldtype": "Section Break" + }, + { + "fieldname": "interest", + "fieldtype": "Currency", + "in_list_view": 1, + "label": "Interest", + "options": "currency", + "read_only": 1 + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2021-09-23 13:48:27.898830", + "modified_by": "Administrator", + "module": "Accounts", + "name": "Overdue Payment", + "owner": "Administrator", + "permissions": [], + "quick_entry": 1, + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} \ No newline at end of file diff --git a/erpnext/accounts/doctype/overdue_payment/overdue_payment.py b/erpnext/accounts/doctype/overdue_payment/overdue_payment.py new file mode 100644 index 0000000000..6a543ad467 --- /dev/null +++ b/erpnext/accounts/doctype/overdue_payment/overdue_payment.py @@ -0,0 +1,9 @@ +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class OverduePayment(Document): + pass diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.py b/erpnext/accounts/doctype/payment_entry/payment_entry.py index e9a3b79acb..7542babe92 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry.py +++ b/erpnext/accounts/doctype/payment_entry/payment_entry.py @@ -2054,28 +2054,27 @@ def get_payment_entry( pe.append("references", reference) else: if dt == "Dunning": + for overdue_payment in doc.overdue_payments: + pe.append( + "references", + { + "reference_doctype": "Sales Invoice", + "reference_name": overdue_payment.sales_invoice, + "payment_term": overdue_payment.payment_term, + "due_date": overdue_payment.due_date, + "total_amount": overdue_payment.outstanding, + "outstanding_amount": overdue_payment.outstanding, + "allocated_amount": overdue_payment.outstanding, + }, + ) + pe.append( - "references", + "deductions", { - "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"), + "account": doc.income_account, + "cost_center": doc.cost_center, + "amount": -1 * doc.dunning_amount, + "description": _("Interest and/or dunning fee"), }, ) else: @@ -2169,8 +2168,10 @@ def set_party_account_currency(dt, party_account, doc): def set_payment_type(dt, doc): if ( - dt == "Sales Order" or (dt in ("Sales Invoice", "Dunning") and doc.outstanding_amount > 0) - ) or (dt == "Purchase Invoice" and doc.outstanding_amount < 0): + (dt == "Sales Order" or (dt == "Sales Invoice" and doc.outstanding_amount > 0)) + or (dt == "Purchase Invoice" and doc.outstanding_amount < 0) + or dt == "Dunning" + ): payment_type = "Receive" else: payment_type = "Pay" diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.js b/erpnext/accounts/doctype/sales_invoice/sales_invoice.js index 8753ebc3ba..4ec103c9f2 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.js +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.js @@ -142,9 +142,15 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends e 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); + const payment_is_overdue = doc.payment_schedule.map( + row => Date.parse(row.due_date) < Date.now() + ).reduce( + (prev, current) => prev || current + ); + + if (payment_is_overdue) { + this.frm.add_custom_button(__('Dunning'), () => { + this.frm.events.create_dunning(this.frm); }, __('Create')); } } diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py index 7ab1c89397..b3212b5a7b 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py @@ -2516,55 +2516,49 @@ def get_mode_of_payment_info(mode_of_payment, company): @frappe.whitelist() -def create_dunning(source_name, target_doc=None): +def create_dunning(source_name, target_doc=None, ignore_permissions=False): from frappe.model.mapper import get_mapped_doc - from erpnext.accounts.doctype.dunning.dunning import ( - calculate_interest_and_amount, - get_dunning_letter_text, - ) + def postprocess_dunning(source, target): + from erpnext.accounts.doctype.dunning.dunning import get_dunning_letter_text - 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]} - ) + dunning_type = frappe.db.exists("Dunning Type", {"is_default": 1, "company": source.company}) + if dunning_type: + dunning_type = frappe.get_doc("Dunning Type", dunning_type) 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()) + target.income_account = dunning_type.income_account + target.cost_center = dunning_type.cost_center + letter_text = get_dunning_letter_text( + dunning_type=dunning_type.name, doc=target.as_dict(), language=source.language + ) + 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.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, - { + target.validate() + + return get_mapped_doc( + from_doctype="Sales Invoice", + from_docname=source_name, + target_doc=target_doc, + table_maps={ "Sales Invoice": { "doctype": "Dunning", - } + "field_map": {"customer_address": "customer_address", "parent": "sales_invoice"}, + }, + "Payment Schedule": { + "doctype": "Overdue Payment", + "field_map": {"name": "payment_schedule", "parent": "sales_invoice"}, + "condition": lambda doc: doc.outstanding > 0 and getdate(doc.due_date) < getdate(), + }, }, - target_doc, - set_missing_values, + postprocess=postprocess_dunning, + ignore_permissions=ignore_permissions, ) - return doclist def check_if_return_invoice_linked_with_payment_entry(self): diff --git a/erpnext/accounts/print_format/dunning_letter/dunning_letter.json b/erpnext/accounts/print_format/dunning_letter/dunning_letter.json index a7eac70b65..c48e1cf35b 100644 --- a/erpnext/accounts/print_format/dunning_letter/dunning_letter.json +++ b/erpnext/accounts/print_format/dunning_letter/dunning_letter.json @@ -1,4 +1,5 @@ { + "absolute_value": 0, "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", @@ -9,10 +10,10 @@ "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